C, C++ ve Rust'tan WebAssembly iş parçacıklarını kullanma

Başka dillerde yazılmış çok iş parçacıklı uygulamaları WebAssembly'ye nasıl taşıyacağınızı öğrenin.

Ingvar Stepanyan
Ingvar Stepanyan

WebAssembly iş parçacıkları desteği, WebAssembly'ye yapılan en önemli performans eklemelerinden biridir. Bu özellik, kodunuzun parçalarını ayrı çekirdeklerde paralel olarak çalıştırmanıza veya aynı kodu giriş verilerinin bağımsız kısımlarında çalıştırmanıza, böylece kullanıcının sahip olduğu sayıda çekirdeğe ölçeklendirmenize ve toplam yürütme süresini önemli ölçüde azaltmanıza olanak tanır.

Bu makalede C, C++ ve Rust gibi dillerde yazılmış çok iş parçacıklı uygulamaları web'e taşımak için WebAssembly iş parçacıklarını nasıl kullanacağınızı öğreneceksiniz.

WebAssembly iş parçacıklarının işleyiş şekli

WebAssembly iş parçacıkları ayrı bir özellik değildir ancak WebAssembly uygulamalarının web'deki geleneksel çoklu iş parçacığı paradigmalarını kullanmasına olanak tanıyan çeşitli bileşenlerin bir birleşimidir.

Web İşçileri

İlk bileşen, JavaScript’ten bildiğiniz ve sevdiğiniz normal Çalışanlar’dır. WebAssembly iş parçacıkları, yeni temel iş parçacıkları oluşturmak için new Worker oluşturucuyu kullanır. Her iş parçacığı bir JavaScript tutkalı yükler, ardından ana iş parçacığı derlenmiş WebAssembly.Module ve paylaşılan WebAssembly.Memory öğesini (aşağıya bakın) bu diğer iş parçacıklarıyla paylaşmak için Worker#postMessage yöntemini kullanır. Bu, iletişim kurar ve tüm bu iş parçacıklarının, JavaScript'e başvurmadan aynı paylaşılan bellek üzerinde aynı WebAssembly kodunu çalıştırmasına olanak tanır.

Web İşçileri on yılı aşkın süredir faaliyet göstermektedir, yaygın şekilde desteklenmektedir ve herhangi bir özel işarete ihtiyaç duymazlar.

SharedArrayBuffer

WebAssembly belleği, JavaScript API'de bir WebAssembly.Memory nesnesiyle temsil edilir. Varsayılan olarak WebAssembly.Memory, yalnızca tek bir iş parçacığı tarafından erişilebilen ArrayBuffer öğesinin etrafındaki bir sarmalayıcıdır.

> new WebAssembly.Memory({ initial:1, maximum:10 }).buffer
ArrayBuffer { … }

WebAssembly.Memory, çoklu iş parçacıklarını desteklemek için de paylaşılan bir varyant kazandı. JavaScript API aracılığıyla veya WebAssembly ikili programının kendisi tarafından shared işaretiyle oluşturulduğunda, bunun yerine SharedArrayBuffer etrafında sarmalayıcı haline gelir. Bu, diğer ileti dizileriyle paylaşılabilen ve her iki taraftan da eşzamanlı olarak okunabilen veya değiştirilebilen bir ArrayBuffer varyasyonudur.

> new WebAssembly.Memory({ initial:1, maximum:10, shared:true }).buffer
SharedArrayBuffer { … }

Normalde ana iş parçacığı ile web çalışanları arasındaki iletişim için kullanılan postMessage'in aksine SharedArrayBuffer, verilerin kopyalanmasını, hatta etkinlik döngüsünün mesaj gönderip almasını beklemeyi gerektirmez. Bunun yerine, değişiklikler tüm iş parçacıkları tarafından neredeyse anında görülür. Böylece, geleneksel senkronizasyon temel öğeleri için çok daha iyi bir derleme hedefi olur.

SharedArrayBuffer karmaşık bir geçmişe sahip. 2017'nin ortalarında birden fazla tarayıcıda kullanıma sunulmuş olsa da Spectre güvenlik açıklarının tespit edilmesi nedeniyle 2018'in başlarında devre dışı bırakılması gerekti. Bunun belirli bir nedeni, Spectre'da veri ayıklamanın, belirli bir kod parçasının yürütülme süresini ölçmek üzere zamanlama saldırılarına dayanmasıydı. Tarayıcılar bu tür saldırıları zorlaştırmak için Date.now ve performance.now gibi standart zamanlama API'lerinin hassasiyetini azaltır. Bununla birlikte, paylaşılan bellek, ayrı bir iş parçacığında çalışan basit bir sayaç döngüsüyle birlikte, yüksek hassasiyetli zamanlamalar elde etmenin çok güvenilir bir yoludur ve çalışma zamanı performansını önemli ölçüde azaltmadan azaltılması çok daha zordur.

Bunun yerine, Chrome 68 (2018'in ortaları) Site İzolasyonu'ndan yararlanarak SharedArrayBuffer özelliğini tekrar etkinleştirdi. Bu özellik farklı web sitelerini farklı süreçlere sokar ve Spectre gibi yan kanal saldırılarının kullanımını çok daha zor hale getiren bir özelliktir. Ancak Site İzolasyonu oldukça pahalı bir özellik olduğundan ve düşük bellek kapasiteli mobil cihazlardaki tüm siteler için varsayılan olarak etkinleştirilemediğinden ve henüz diğer satıcılar tarafından uygulanmadığından bu çözüm hâlâ yalnızca Chrome masaüstüyle sınırlıydı.

2020 yılına kadar hem Chrome hem Firefox'ta Site İzolasyonu uygulamaları ve web sitelerinin COOP ve COEP başlıkları olan bu özelliği etkinleştirmesi için standart bir yöntem bulunuyor. Etkinleştirme mekanizması, Site İzolasyonu'nu tüm web siteleri için etkinleştirmenin çok pahalıya mal olacağı düşük güç tüketen cihazlarda bile kullanılmasına olanak tanır. Kaydolmak için sunucu yapılandırmanızdaki ana dokümana aşağıdaki üstbilgileri ekleyin:

Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin

Kaydolduktan sonra SharedArrayBuffer (SharedArrayBuffer tarafından desteklenen WebAssembly.Memory dahil), hassas zamanlayıcılar, bellek ölçümü ve güvenlik nedeniyle izole bir kaynak gerektiren diğer API'lere erişim elde edersiniz. Daha fazla bilgi için COOP ve COEP kullanarak web sitenizi "kaynaklar arası erişime kapalı" hale getirme konulu makaleye göz atın.

WebAssembly atomik

SharedArrayBuffer, her iş parçacığının aynı bellekte okuma ve yazma yapmasına izin verir. Ancak doğru iletişim için bu iş parçacıklarının aynı anda çakışan işlemler yapmadığından emin olmanız gerekir. Örneğin, bir iş parçacığı paylaşılan bir adresten veri okumaya başlarken başka bir ileti dizisi bu adrese yazıyor olabilir. Bu nedenle ilk ileti dizisi artık bozuk bir sonuç alır. Bu hata kategorisi, yarış durumu olarak bilinir. Yarış koşullarını önlemek için bu erişimleri bir şekilde senkronize etmeniz gerekir. Atom işlemleri tam da bu noktada devreye girer.

WebAssembly atomics, WebAssembly talimat grubunun bir uzantısıdır. Küçük veri hücrelerini (genellikle 32 ve 64 bitlik tam sayılar) "atomik" olarak okuyup yazmaya olanak tanır. Diğer bir deyişle, iki iş parçacığının aynı anda aynı hücrede okuma veya yazma işlemi yapmamasını garanti ederek bu tür çakışmaları düşük bir düzeyde önler. Buna ek olarak WebAssembly atomikleri, başka bir iş parçacığının "bilgilendir" aracılığıyla uyandırılıncaya kadar paylaşılan bellekteki belirli bir adres üzerinde bir iş parçacığının uykuya geçmesine ("bekleme") izin veren iki komut türü daha içerir: "bekleme" ve "bildirim".

Kanallar, karşılıklı dışlamalar ve okuma-yazma kilitleri de dahil olmak üzere tüm üst düzey senkronizasyon öğeleri bu talimatları temel alır.

WebAssembly iş parçacıkları nasıl kullanılır?

Özellik algılama

WebAssembly atomics ve SharedArrayBuffer nispeten yeni özellikler olup henüz WebAssembly destekli tüm tarayıcılarda kullanılamamaktadır. Hangi tarayıcıların yeni WebAssembly özelliklerini desteklediğini webassembly.org yol haritasında bulabilirsiniz.

Tüm kullanıcıların uygulamanızı yükleyebildiğinden emin olmak için biri çoklu iş parçacığı desteği diğeri ise olmayan iki farklı Wasm sürümü oluşturarak progresif geliştirme uygulamanız gerekir. Ardından özellik algılama sonuçlarına bağlı olarak desteklenen sürümü yükleyin. Çalışma zamanında WebAssembly iş parçacıkları desteğini algılamak için wasm-feature-detectlibrary özelliğini kullanın ve modülü şu şekilde yükleyin:

import { threads } from 'wasm-feature-detect';

const hasThreads = await threads();

const module = await (
  hasThreads
    ? import('./module-with-threads.js')
    : import('./module-without-threads.js')
);

// …now use `module` as you normally would

Şimdi WebAssembly modülünün çok iş parçacıklı sürümünü nasıl oluşturacağınıza göz atalım.

C

C'de, özellikle de Unix benzeri sistemlerde iş parçacıklarını kullanmanın yaygın yolu, pthread kitaplığı tarafından sağlanan POSIX İş Parçacıkları'dır. Emscripten, Web İşçileri, paylaşılan bellek ve atomik üzerine kurulu pthread kitaplığının API ile uyumlu bir uygulamasını sunar. Böylece aynı kod herhangi bir değişiklik olmadan web'de çalışabilir.

Bir örnekle açıklayalım:

example.c:

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

void *thread_callback(void *arg)
{
    sleep(1);
    printf("Inside the thread: %d\n", *(int *)arg);
    return NULL;
}

int main()
{
    puts("Before the thread");

    pthread_t thread_id;
    int arg = 42;
    pthread_create(&thread_id, NULL, thread_callback, &arg);

    pthread_join(thread_id, NULL);

    puts("After the thread");

    return 0;
}

Burada, pthread kitaplığının üstbilgileri pthread.h aracılığıyla eklenmiştir. İş parçacıklarıyla ilgilenmek için birkaç önemli işlev de görebilirsiniz.

pthread_create bir arka plan iş parçacığı oluşturur. Bir iş parçacığı işleyicisinin içinde depolanması için bir hedef, bazı iş parçacığı oluşturma özellikleri (burada herhangi biri iletilmiyor, bu nedenle yalnızca NULL vardır), yeni iş parçacığında yürütülecek geri çağırma (burada thread_callback) ve ana iş parçacığından bazı verileri paylaşmak istemeniz durumunda bu geri çağırmaya iletilecek isteğe bağlı bir bağımsız değişken işaretçisi gerekir. Bu örnekte arg değişkenine bir işaretçi paylaşıyoruz.

İş parçacığının yürütmeyi tamamlamasını beklemek ve geri çağırmanın döndürdüğü sonucu almak için pthread_join daha sonra istenildiği zaman çağrılabilir. Sonucu kaydetmek için bir işaretçinin yanı sıra daha önce atanmış iş parçacığı tutamacını da kabul eder. Bu durumda hiç sonuç olmadığından işlev bir NULL değerini bağımsız değişken olarak alır.

İş parçacıklarını Emscripten ile kullanarak kod derlemek için emcc yöntemini çağırmanız ve aynı kodu başka platformlarda Clang veya GCC ile derlerken bir -pthread parametresi iletmeniz gerekir:

emcc -pthread example.c -o example.js

Ancak bunu bir tarayıcıda veya Node.js'de çalıştırmayı denediğinizde bir uyarı görürsünüz ve program askıya alınır:

Before the thread
Tried to spawn a new thread, but the thread pool is exhausted.
This might result in a deadlock unless some threads eventually exit or the code
explicitly breaks out to the event loop.
If you want to increase the pool size, use setting `-s PTHREAD_POOL_SIZE=...`.
If you want to throw an explicit error instead of the risk of deadlocking in those
cases, use setting `-s PTHREAD_POOL_SIZE_STRICT=2`.
[…hangs here…]

Ne oldu? Sorun şu ki, web'de zaman alan API'lerin çoğu eşzamansızdır ve yürütülmesi için olay döngüsüne dayanır. Bu sınırlama, uygulamaların normalde G/Ç'yi eşzamanlı ve engelleme modunda çalıştığı geleneksel ortamlara kıyasla önemli bir farktır. Daha fazla bilgi edinmek için WebAssembly'den eşzamansız web API'lerini kullanma konulu blog yayınına göz atın.

Bu durumda kod, eşzamanlı olarak pthread_create yöntemini çağırarak bir arka plan iş parçacığı oluşturur ve ardından arka plan iş parçacığının yürütülmesini bekleyen başka bir pthread_join eşzamanlı çağrısını izler. Ancak bu kod Emscripten ile derlendiğinde perde arkasında kullanılan Web İşçileri eşzamansızdır. Sonuç olarak, pthread_create yalnızca bir sonraki etkinlik döngüsü çalıştırıldığında oluşturulacak yeni bir Worker iş parçacığını planlar ancak pthread_join, etkinlik döngüsünü hemen engelleyerek bu Çalışanı beklemesini sağlar ve böylece hiçbir zaman oluşturulmasını önler. Bu, klasik bir kırışıklık örneğidir.

Bu sorunu çözmenin bir yolu, daha program başlamadan önce bir Çalışan havuzu oluşturmaktır. pthread_create çağrıldığında, havuzdan kullanıma hazır bir Çalışan alabilir, sağlanan geri çağırmayı arka plan iş parçacığında çalıştırabilir ve Çalışanı havuza geri döndürebilir. Bunların tümü eşzamanlı olarak gerçekleştirilebilir. Böylece havuz yeterince büyük olduğu sürece kilitlenme olmaz.

Emscripten, -s PTHREAD_POOL_SIZE=... seçeneğiyle tam olarak bunu sağlar. CPU'da bulunan çekirdek sayısı kadar iş parçacığı oluşturmak için iş parçacıklarının (sabit bir sayı veya navigator.hardwareConcurrency gibi bir JavaScript ifadesi) belirtilmesini sağlar. İkinci seçenek, kodunuz isteğe bağlı sayıda iş parçacığına ölçeklendirilebiliyorsa kullanışlıdır.

Yukarıdaki örnekte yalnızca bir iş parçacığı oluşturulmaktadır. Bu nedenle, tüm çekirdekleri ayırmak yerine -s PTHREAD_POOL_SIZE=1 kullanmanız yeterlidir:

emcc -pthread -s PTHREAD_POOL_SIZE=1 example.c -o example.js

Bu sefer kodu yürüttüğünüzde, süreç başarıyla ilerleyecektir:

Before the thread
Inside the thread: 42
After the thread
Pthread 0x701510 exited.

Ancak başka bir sorun var: Kod örneğinde sleep(1) öğesini görüyor musunuz? İş parçacığı geri çağırmasında, yani ana iş parçacığının altında yürütülür. Bu durumda sorun olmaz, değil mi? Aslında öyle değil.

pthread_join çağrıldığında, iş parçacığının yürütülmesinin bitmesini beklemesi gerekir. Yani, oluşturulan iş parçacığı uzun süreli görevler gerçekleştiriyorsa (bu durumda 1 saniye uyuyarak) ana iş parçacığının da sonuçlar geri dönene kadar aynı süre boyunca engelleme yapması gerekir. Bu JS tarayıcıda yürütüldüğünde, iş parçacığı geri çağırması tekrarlanana kadar kullanıcı arayüzü iş parçacığını 1 saniye süreyle engeller. Bu durum, kötü bir kullanıcı deneyimine yol açar.

Bu sorunun birkaç çözümü vardır:

  • pthread_detach
  • -s PROXY_TO_PTHREAD
  • Özel Çalışan ve Comlink

pthread_detach

Öncelikle, bazı görevleri ana iş parçacığının dışında çalıştırmanız gerekiyorsa ancak sonuçları beklemeniz gerekmiyorsa pthread_join yerine pthread_detach işlevini kullanabilirsiniz. Bu işlem, ileti dizisi geri çağırma işlevini arka planda çalışır durumda bırakacaktır. Bu seçeneği kullanıyorsanız -s PTHREAD_POOL_SIZE_STRICT=0 ile uyarıyı kapatabilirsiniz.

PROXY_TO_PTHREAD

İkinci olarak, kitaplık yerine C uygulaması derliyorsanız -s PROXY_TO_PTHREAD seçeneğini kullanabilirsiniz. Bu seçenek, ana uygulama kodunu, uygulamanın kendisi tarafından oluşturulan iç içe yerleştirilmiş iş parçacıklarına ek olarak ayrı bir iş parçacığına boşaltır. Bu şekilde, ana kod her zaman kullanıcı arayüzünü dondurmadan güvenli bir şekilde engelleme yapabilir. Bu seçeneği kullanırken iş parçacığı havuzunu önceden oluşturmanız da gerekmez. Bunun yerine Emscripten, yeni temel Çalışanlar oluşturmak için ana iş parçacığından yararlanabilir ve ardından kilitlenmeyi aşmadan pthread_join ürününde yardımcı iş parçacığını engelleyebilir.

Üçüncü olarak, bir kitaplık üzerinde çalışmanıza rağmen yine de engelleme yapmanız gerekiyorsa kendi Çalışanınızı oluşturabilir, Emscripten tarafından oluşturulan kodu içe aktarabilir ve Comlink ile ana iş parçacığına sunabilirsiniz. Ana iş parçacığı dışa aktarılan tüm yöntemleri eşzamansız işlevler olarak çağırabilecek ve bu şekilde kullanıcı arayüzünün engellenmesini de önleyecektir.

Önceki örnek gibi basit bir uygulamada -s PROXY_TO_PTHREAD en iyi seçenektir:

emcc -pthread -s PROXY_TO_PTHREAD example.c -o example.js

C++

Aynı uyarılar ve mantık C++ için de aynı şekilde geçerlidir. Elde edeceğiniz tek yeni şey, daha önce bahsettiğimiz pthread kitaplığını kullanan std::thread ve std::async gibi üst düzey API'lere erişim olacak.

Dolayısıyla, yukarıdaki örnek daha deyimsel C++ ile şu şekilde yeniden yazılabilir:

example.cpp:

#include <iostream>
#include <thread>
#include <chrono>

int main()
{
    puts("Before the thread");

    int arg = 42;
    std::thread thread([&]() {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        std::cout << "Inside the thread: " << arg << std::endl;
    });

    thread.join();

    std::cout << "After the thread" << std::endl;

    return 0;
}

Benzer parametrelerle derlenip yürütüldüğünde C örneğiyle aynı şekilde davranır:

emcc -std=c++11 -pthread -s PROXY_TO_PTHREAD example.cpp -o example.js

Çıkış:

Before the thread
Inside the thread: 42
Pthread 0xc06190 exited.
After the thread
Proxied main thread 0xa05c18 finished with return code 0. EXIT_RUNTIME=0 set, so
keeping main thread alive for asynchronous event operations.
Pthread 0xa05c18 exited.

Rust

Emscripten'ın aksine Rust'ın özel bir uçtan uca web hedefi yoktur. Bunun yerine genel WebAssembly çıktısı için genel bir wasm32-unknown-unknown hedefi sağlar.

Wasm'ın bir web ortamında kullanılması amaçlanıyorsa JavaScript API'leriyle her etkileşim, harici kitaplıklara ve wasm-bindgen ve wasm-pack gibi araçlara bırakılır. Ne yazık ki bu durum, standart kitaplığın Web Çalışanlarını algılamadığı ve std::thread gibi standart API'lerin WebAssembly'ye derlendiğinde çalışmayacağı anlamına gelir.

Neyse ki ekosistemin büyük bir kısmı, çoklu iş parçacığı işlemeyi sağlamak için üst düzey kitaplıklara bağımlıdır. Bu düzeyde, tüm platform farklılıklarını soyutlamak çok daha kolaydır.

Özellikle Rayon, Rust'taki veri paralelliği için en popüler seçimdir. Normal yinelemelerde yöntem zincirleri almanıza ve bunları genellikle tek bir satırlık değişiklikle, sıralı olarak değil, kullanılabilir tüm iş parçacıklarında paralel olarak çalışacak şekilde dönüştürmenize olanak tanır. Örneğin:

pub fn sum_of_squares(numbers: &[i32]) -> i32 {
  numbers
  .iter()
  .par_iter()
  .map(|x| x * x)
  .sum()
}

Bu küçük değişiklikle birlikte kod, giriş verilerini böler, x * x ve kısmi toplamları paralel iş parçacıkları halinde hesaplar ve son olarak bu kısmi sonuçları toplar.

std::thread çalışmayan platformlara uyum sağlamak amacıyla Rayon, üreme ve çıkış iş parçacıkları için özel mantık tanımlamayı sağlayan kancalar sağlar.

wasm-bindgen-rayon, WebAssembly iş parçacıklarını Web İşçileri olarak oluşturmak için bu kancalardan yararlanır. Kullanabilmek için bunu bir bağımlılık olarak eklemeniz ve docs açıklanan yapılandırma adımlarını uygulamanız gerekir. Yukarıdaki örnek şöyle görünecektir:

pub use wasm_bindgen_rayon::init_thread_pool;

#[wasm_bindgen]
pub fn sum_of_squares(numbers: &[i32]) -> i32 {
  numbers
  .par_iter()
  .map(|x| x * x)
  .sum()
}

İşlem tamamlandığında, oluşturulan JavaScript ekstra bir initThreadPool işlevini dışa aktarır. Bu işlev, bir Çalışan havuzu oluşturur ve Rayon tarafından yapılan tüm çok iş parçacıklı işlemler için programın kullanım ömrü boyunca bunları yeniden kullanır.

Bu havuz mekanizması, daha önce Emscripten'da açıklanan -s PTHREAD_POOL_SIZE=... seçeneğine benzer ve kilitlenmeleri önlemek için ana koddan önce başlatılması gerekir:

import init, { initThreadPool, sum_of_squares } from './pkg/index.js';

// Regular wasm-bindgen initialization.
await init();

// Thread pool initialization with the given number of threads
// (pass `navigator.hardwareConcurrency` if you want to use all cores).
await initThreadPool(navigator.hardwareConcurrency);

// ...now you can invoke any exported functions as you normally would
console.log(sum_of_squares(new Int32Array([1, 2, 3]))); // 14

Ana iş parçacığının engellenmesiyle ilgili uyarıların burada da geçerli olduğunu unutmayın. Diğer iş parçacıklarından kısmi sonuçları beklemek için sum_of_squares örneğinde bile ana iş parçacığını engellemesi gerekir.

Yinelemelerin karmaşıklığına ve mevcut iş parçacıklarının sayısına bağlı olarak çok kısa bir bekleme süresi veya uzun sürebilir. Ancak güvenliği sağlamak için tarayıcı motorları, ana iş parçacığının engellenmesini etkin bir şekilde önlüyor ve bu tür kodlar hata verir. Bunun yerine, bir Çalışan oluşturmalı, wasm-bindgen tarafından oluşturulan kodu oraya aktarmalı ve API'sini ana iş parçacığına Comlink gibi bir kitaplıkla göstermelisiniz.

Aşağıdakileri gösteren uçtan uca bir tanıtım için Wasm-bindgen-rayon örneğine göz atın:

Gerçek kullanım alanları

Squoosh.app'te WebAssembly iş parçacıklarını istemci tarafında resim sıkıştırmak için etkin bir şekilde kullanıyoruz. Özellikle AVIF (C++), JPEG-XL (C++), OxiPNG (Rust) ve WebP v2 (C++) gibi biçimler için WebAssembly iş parçacıklarının her ikisinde de tutarlı bir şekilde (1,5x-3 kat daha hızlı)

Google Earth, web sürümü için WebAssembly iş parçacıklarını kullanan bir diğer önemli hizmettir.

FFMPEG.WASM, videoları doğrudan tarayıcıda verimli bir şekilde kodlamak için WebAssembly iş parçacıklarını kullanan popüler bir FFmpeg multimedya araç zincirinin WebAssembly sürümüdür.

WebAssembly iş parçacıklarının kullanıldığı birçok başka heyecan verici örnek bulabilirsiniz. Demolara mutlaka göz atın ve kendi çoklu iş parçacıklı uygulamalarınızı ve kitaplıklarınızı web'e getirin!