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

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

Ingvar Stepanyan
Ingvar Stepanyan

WebAssembly iş parçacığı desteği, WebAssembly'e eklenen en önemli performans özelliklerinden biridir. Bu özellik, kodunuzun bölümlerini ayrı çekirdeklerde paralel olarak veya aynı kodu giriş verilerinin bağımsız bölümlerinde çalıştırmanıza olanak tanır. Böylece, kodu kullanıcının sahip olduğu çekirdek sayısına ölçeklendirebilir ve genel yürütme süresini önemli ölçüde azaltabilirsiniz.

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

WebAssembly iş parçacıkları ayrı bir özellik değil, WebAssembly uygulamalarının web'de 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 işçilerdir. WebAssembly iş parçacıkları, yeni temel iş parçacıkları oluşturmak için new Worker kurucusunu kullanır. Her iş parçacığı bir JavaScript yapıştırıcısı yükler ve ardından ana iş parçacığı, derlenmiş WebAssembly.Module ile paylaşılan bir WebAssembly.Memory (aşağıya bakın) öğesini diğer iş parçacıklarıyla paylaşmak için Worker#postMessage yöntemini kullanır. Bu işlem, iletişim kurar ve tüm bu iş parçacıklarının JavaScript'i tekrar kullanmadan aynı paylaşılan bellekte aynı WebAssembly kodunu çalıştırmasına olanak tanır.

Web işçileri on yıldan uzun süredir kullanılıyor, yaygın olarak destekleniyor ve özel işaretler gerektirmiyor.

SharedArrayBuffer

WebAssembly belleği, JavaScript API'sinde bir WebAssembly.Memory nesnesi ile temsil edilir. Varsayılan olarak WebAssembly.Memory, yalnızca tek bir iş parçacığı tarafından erişilebilen ham bayt arabelleği olan bir ArrayBuffer sarmalayıcısıdır.

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

Çoklu iş parçacığı desteği için WebAssembly.Memory, paylaşılan bir varyant da kazandı. JavaScript API aracılığıyla veya WebAssembly ikili dosyası tarafından shared işaretiyle oluşturulduğunda bunun yerine SharedArrayBuffer etrafında bir sarmalayıcı olur. Diğer ileti dizileriyle paylaşılabilen ve her iki taraftan da aynı anda okunabilen veya değiştirilebilen ArrayBuffer'ün bir varyasyonudur.

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

Normalde ana iş parçacığı ile web işçileri arasında iletişim için kullanılan postMessage'in aksine SharedArrayBuffer, verilerin kopyalanmasını veya hatta etkinlik döngüsünün mesaj gönderip almasını beklemeyi gerektirmez. Bunun yerine, tüm değişiklikler tüm iş parçacıkları tarafından neredeyse anında görülebilir. Bu da geleneksel senkronizasyon ilkelleri için çok daha iyi bir derleme hedefi olmasını sağlar.

SharedArrayBuffer karmaşık bir geçmişe sahip. İlk olarak 2017'nin ortalarında çeşitli tarayıcılarda kullanıma sunuldu ancak Spectre güvenlik açıklarının bulunması nedeniyle 2018'in başlarında devre dışı bırakıldı. Bunun nedeni, Spectre'deki veri ayıklamanın zamanlama saldırılarına (belirli bir kod parçasının yürütme süresini ölçme) dayalı olması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 azalttı. Ancak ayrı bir iş parçacığında çalışan basit bir sayaç döngüsü ile birlikte kullanılan paylaşılan bellek, yüksek hassasiyetli zamanlama elde etmek için de çok güvenilir bir yöntemdir ve çalışma zamanı performansını önemli ölçüde azaltmadan azaltılması çok daha zordur.

Bunun yerine Chrome 68 (2018'in ortaları), farklı web sitelerini farklı işlemlere yerleştiren ve Spectre gibi yan kanal saldırılarının kullanılmasını çok daha zorlaştıran bir özellik olan Site İzolasyonundan yararlanarak SharedArrayBuffer'ü yeniden etkinleştirdi. Ancak Site İzolasyonu oldukça pahalı bir özellik olduğundan ve belleği düşük mobil cihazlardaki tüm siteler için varsayılan olarak etkinleştirilemediğinden ya da henüz diğer tedarikçiler tarafından uygulanmadığından bu azaltma yöntemi yalnızca Chrome masaüstüyle sınırlıydı.

2020'ye geldiğimizde hem Chrome hem de Firefox'ta Site İzolasyonu'nun uygulandığını ve web sitelerinin COOP ve COEP üstbilgileriyle bu özelliği etkinleştirmesinin standart bir yolunun olduğunu görüyoruz. Etkinleştirme mekanizması, tüm web siteleri için etkinleştirmenin çok pahalı olacağı düşük güçlü cihazlarda bile Site İzolasyon özelliğinin kullanılmasını sağlar. Bu özelliği etkinleştirmek 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

Bu özelliği etkinleştirdiğinizde 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şebilirsiniz. Daha fazla bilgi için COOP ve COEP'yi kullanarak web sitenizi "kaynaklar arası izole" hale getirme başlıklı makaleyi inceleyin.

WebAssembly atomik işlemleri

SharedArrayBuffer, her iş parçacığının aynı belleği okumasına ve yazmasına izin verse de doğru iletişim için iş parçacıklarının aynı anda çakışan işlemler yapmadığından emin olmanız gerekir. Örneğin, bir ileti dizisinin paylaşılan bir adresten veri okumaya başlaması, başka bir ileti dizisinin bu adrese yazmaya başlaması mümkündür. Bu durumda, ilk ileti dizisi bozuk bir sonuç alır. Bu hata kategorisi yarış koşulları olarak bilinir. Yarışma koşullarını önlemek için bu erişimleri bir şekilde senkronize etmeniz gerekir. Bu noktada atomik işlemler devreye girer.

WebAssembly atomik işlemleri, WebAssembly talimat setinin küçük veri hücrelerini (genellikle 32 ve 64 bit tam sayılar) "atomik" olarak okumasına ve yazmasına olanak tanıyan bir uzantısıdır. Yani, iki iş parçacığının aynı anda aynı hücreyi okumasını veya hücreye yazmasını garanti edecek şekilde, bu tür çakışmaların düşük düzeyde önlenmesini sağlar. Ayrıca WebAssembly atomikleri, bir iş parçacığının paylaşılan bellekteki belirli bir adreste "bekle" komutu ile uykuya dalmasına ve başka bir iş parçacığının "bildir" komutu ile onu uyandırmasına olanak tanıyan iki talimat türü daha içerir.

Kanallar, mutex'ler ve okuma/yazma kilitleri dahil olmak üzere tüm üst düzey senkronizasyon primitifleri bu talimatları temel alır.

WebAssembly iş parçacıklarını kullanma

Özellik algılama

WebAssembly atomikleri ve SharedArrayBuffer nispeten yeni özelliklerdir ve henüz WebAssembly desteği olan tüm tarayıcılarda kullanılamaz. Yeni WebAssembly özelliklerini destekleyen tarayıcıları webassembly.org yol haritasında bulabilirsiniz.

Tüm kullanıcıların uygulamanızı yükleyebilmesini sağlamak için Wasm'in iki farklı sürümünü (biri çoklu iş parçacığı desteğiyle, diğeri olmadan) oluşturarak aşamalı iyileştirmeyi uygulamanız gerekir. Ardından, özellik algılama sonuçlarına bağlı olarak desteklenen sürümü yükleyin. WebAssembly iş parçacığı desteğini çalışma zamanında algılamak için wasm-feature-detect kitaplığını 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ı bir sürümünün nasıl oluşturulacağına bakalım.

C

C'de, özellikle Unix benzeri sistemlerde, iş parçacıklarını kullanmanın yaygın yolu pthread kitaplığı tarafından sağlanan POSIX iş parçacıkları'dır. Emscripten, aynı kodun web'de değişiklik yapılmadan çalışabilmesi için Web İşleyiciler, paylaşılan bellek ve atomik işlemlerin üzerine inşa edilmiş pthread kitaplığının API uyumlu bir uygulamasını sağlar.

Bir örnek inceleyelim:

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 başlıkları pthread.h aracılığıyla dahil edilmiştir. Ayrıca, ileti dizileriyle ilgili birkaç önemli işlevi de görebilirsiniz.

pthread_create, arka planda bir mesaj dizisi oluşturur. Bir mesaj dizisi adını depolamak için bir hedef, bazı mesaj dizisi oluşturma özellikleri (burada hiçbir özellik iletilmediği için yalnızca NULL), yeni mesaj dizisinde yürütülecek geri çağırma işlevi (burada thread_callback) ve ana mesaj dizisinden bazı verileri paylaşmak isterseniz bu geri çağırma işlevine iletilecek isteğe bağlı bir bağımsız değişken işaretçisi alır. Bu örnekte, arg değişkenine ait bir işaretçi paylaşıyoruz.

pthread_join, iş parçacığının yürütmeyi tamamlamasını beklemek ve geri çağırma işlevinden döndürülen sonucu almak için daha sonra herhangi bir zamanda çağrılabilir. Önceden atanan ileti dizisi adını ve sonucu depolayacak bir işaretçiyi kabul eder. Bu durumda, sonuç olmadığı için işlev bağımsız değişken olarak NULL alır.

Emscripten ile iş parçacıklarını kullanarak kod derlemek için emcc'i çağırmanız ve aynı kodu diğer platformlarda Clang veya GCC ile derlerken olduğu gibi bir -pthread parametresi iletmeniz gerekir:

emcc -pthread example.c -o example.js

Ancak bir tarayıcıda veya Node.js'de çalıştırmaya çalıştığınızda bir uyarı görürsünüz ve ardından program kilitlenir:

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, web'deki zaman alan API'lerin çoğunun asenkron olması ve yürütülmek için etkinlik döngüsüne ihtiyaç duymasıdır. Bu sınırlama, uygulamaların normalde G/Ç'yi senkronize ve engelleme şeklinde çalıştırdığı geleneksel ortamlara kıyasla önemli bir farktır. Daha fazla bilgi edinmek isterseniz WebAssembly'den asenkron web API'lerini kullanma başlıklı blog yayınına göz atın.

Bu durumda kod, arka plan iş parçacığı oluşturmak için pthread_create işlevini senkronize olarak çağırır ve ardından arka plan iş parçacığının yürütmeyi tamamlamasını bekleyen pthread_join işlevine başka bir senkronize çağrı yapar. Ancak bu kod Emscripten ile derlenirken arka planda kullanılan Web İşleyiciler eşzamanlı değildir. Bu nedenle, pthread_create yalnızca bir sonraki etkinlik döngüsü çalıştırıldığında oluşturulacak yeni bir İşçi iş parçacığı planlar, ancak pthread_join bu İşçi'yi beklemek için etkinlik döngüsünü hemen engeller ve böylece İşçi'nin oluşturulmasını engeller. Bu, kilitlenme durumunun klasik bir örneğidir.

Bu sorunu çözmenin bir yolu, program başlamadan önce, önceden bir İşçi havuzu oluşturmaktır. pthread_create çağrıldığında havuzdan hazır bir çalışan alabilir, sağlanan geri çağırma işlevini arka plan iş parçacığında çalıştırabilir ve çalışanı havuza geri döndürebilir. Tüm bunlar eşzamanlı olarak yapılabilir. Bu nedenle, havuz yeterince büyük olduğu sürece kilitlenme yaşanmaz.

Emscripten, -s PTHREAD_POOL_SIZE=... seçeneğiyle tam olarak bunu sağlar. Bu işlev, CPU'daki çekirdek sayısı kadar iş parçacığı oluşturmak için bir dizi iş parçacığı (sabit bir sayı veya navigator.hardwareConcurrency gibi bir JavaScript ifadesi) belirtmenize olanak tanır. İkinci seçenek, kodunuz rastgele sayıda iş parçacığına ölçeklenebiliyorsa yararlıdır.

Yukarıdaki örnekte yalnızca bir iş parçacığı oluşturulduğundan tüm çekirdekleri ayırmak yerine -s PTHREAD_POOL_SIZE=1 kullanmak yeterlidir:

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

Bu sefer komutu yürüttüğünüzde işler başarıyla gerçekleşir:

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

Ancak başka bir sorun var: Kod örneğindeki sleep(1) ne anlama geliyor? İş parçacığı geri çağırmasında yürütülür. Yani ana iş parçacığından bağımsızdır. Bu nedenle sorun olmayacaktır, değil mi? Hayır, değil.

pthread_join çağrıldığında, iş parçacığı yürütmesinin tamamlanmasını beklemesi gerekir. Yani oluşturulan iş parçacığı uzun süren görevler (bu durumda 1 saniye bekleme) gerçekleştiriyorsa ana iş parçacığının da sonuçlar gelene kadar aynı süre boyunca engellemesi gerekir. Bu JS tarayıcıda yürütüldüğünde, iş parçacığı geri çağırma işlevi döndürülene kadar kullanıcı arayüzü iş parçacığı 1 saniye boyunca engellenir. Bu durum, kullanıcı deneyimini olumsuz yönde etkiler.

Bunun birkaç çözümü vardır:

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

pthread_detach

Öncelikle, yalnızca ana iş parçacığında bazı görevleri çalıştırmanız gerekiyorsa ancak sonuçları beklemeniz gerekmiyorsa pthread_join yerine pthread_detach kullanabilirsiniz. Bu işlem, ileti dizisi geri çağırma işlevinin arka planda çalışmasını sağlar. Bu seçeneği kullanıyorsanız -s PTHREAD_POOL_SIZE_STRICT=0 simgesini kullanarak uyarıyı devre dışı bırakabilirsiniz.

PROXY_TO_PTHREAD

İkinci olarak, kitaplık yerine C uygulaması derliyorsanız -s PROXY_TO_PTHREAD seçeneğini kullanabilirsiniz. Bu seçenek, uygulamanın kendisi tarafından oluşturulan iç içe yerleştirilmiş ileti dizilerine ek olarak ana uygulama kodunu ayrı bir ileti dizisine aktarır. Bu sayede ana kod, kullanıcı arayüzünü dondurmadan herhangi bir zamanda güvenli bir şekilde engelleyebilir. Bu seçeneği kullanırken iş parçacığı havuzunu önceden oluşturmanız da gerekmez. Bunun yerine Emscripten, yeni temel işleyiciler oluşturmak için ana iş parçacığından yararlanabilir ve ardından kilitlenme olmadan pthread_join'teki yardımcı iş parçacığını engelleyebilir.

Üçüncü olarak, bir kitap üzerinde çalışıyorsanız ve yine de engellemeniz gerekiyorsa kendi İşleyicinizi oluşturabilir, Emscripten tarafından oluşturulan kodu içe aktarabilir ve Comlink ile ana iş parçacığına gösterebilirsiniz. Ana iş parçacığı, dışa aktarılan tüm yöntemleri eşzamansız işlevler olarak çağırabilir. Bu sayede kullanıcı arayüzünün engellenmesi de önlenir.

Ö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++

C++ için de aynı uyarılar ve mantık geçerlidir. Elinize geçen tek yeni şey, daha önce bahsedilen pthread kitaplığını kullanan std::thread ve std::async gibi üst düzey API'lere erişimdir.

Bu nedenle, yukarıdaki örnek daha doğal bir C++ dilinde ş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 çalıştırıldığında 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'in aksine Rust'ta özel bir uçtan uca web hedefi yoktur. Bunun yerine, genel WebAssembly çıkışı için genel bir wasm32-unknown-unknown hedefi sağlanır.

Wasm'in bir web ortamında kullanılması amaçlanıyorsa JavaScript API'leriyle olan tüm etkileşimler wasm-bindgen ve wasm-pack gibi harici kitaplıklara ve araçlara bırakılır. Maalesef bu durum, standart kitaplığın Web Çalışanları'ndan haberdar olmadığı ve std::thread gibi standart API'lerin WebAssembly'e derlendiğinde çalışmadığı anlamına gelir.

Neyse ki ekosistemin büyük kısmı, çoklu iş parçacıklılığı üstlenmek için üst düzey kitaplıklara güveniyor. Bu düzeyde, tüm platform farklılıklarını soyutlamak çok daha kolaydır.

Özellikle Rayon, Rust'ta veri paralelliği için en popüler seçenektir. Normal iteratörlerdeki yöntem zincirlerini alıp genellikle tek bir satır değişikliğiyle bunları sırayla yerine tüm mevcut iş parçaları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 kod, giriş verilerini böler, x * x ve kısmi toplamları paralel iş parçacıklarında hesaplar ve sonunda bu kısmi sonuçları toplar.

std::thread'ün çalışmadığı platformlara uyum sağlamak için Rayon, iş parçacıkları oluşturma ve iş parçacıklarından çıkma için özel mantık tanımlamaya olanak tanıyan kancalar sağlar.

wasm-bindgen-rayon, WebAssembly iş parçacıklarını Web Çalışanları olarak oluşturmak için bu kancalardan yararlanır. Bu paketi kullanmak için bağımlı olarak eklemeniz ve dokümanlarda açıklanan yapılandırma adımlarını uygulamanız gerekir. Yukarıdaki örnek şu şekilde görünür:

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 ek bir initThreadPool işlevi dışa aktarır. Bu işlev, bir çalışan havuzu oluşturur ve bu çalışanları, Rayon tarafından yapılan çok iş parçacıklı işlemler için programın ömrü boyunca yeniden kullanır.

Bu havuz mekanizması, daha önce açıklanan Emscripten'deki -s PTHREAD_POOL_SIZE=... seçeneğine benzer. Ayrıca kilitlenmelerin önüne geçmek 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 ihtiyat tedbirlerinin burada da geçerli olduğunu unutmayın. sum_of_squares örneğinde bile, diğer iş parçacıklarından gelen kısmi sonuçları beklemek için ana iş parçacığının engellenmesi gerekir.

Bekleme süresi, iteratörlerin karmaşıklığına ve kullanılabilir iş parçacıklarının sayısına bağlı olarak çok kısa veya uzun olabilir. Ancak, güvenli tarafta olmak için tarayıcı motorları, ana iş parçacığının tamamen engellenmesini aktif olarak önler ve bu tür kodlar hata verir. Bunun yerine bir İşleyici oluşturmalı, wasm-bindgen tarafından oluşturulan kodu buraya içe aktarmalı ve API'sini Comlink gibi bir kitaplıkla ana iş parçacığına göstermelisiniz.

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

Gerçek hayattan kullanım alanları

Müşteri tarafında görüntü sıkıştırma için Squoosh.app'de WebAssembly iş parçacıklarını etkin bir şekilde kullanıyoruz. Özellikle AVIF (C++), JPEG-XL (C++), OxiPNG (Rust) ve WebP v2 (C++) gibi biçimler için bu yöntemi tercih ediyoruz. Yalnızca çoklu iş parçacığı sayesinde 1,5 ila 3 kat arasında tutarlı bir hız artışı elde ettik (tam oran codec'e göre değişir). WebAssembly iş parçacıklarını WebAssembly SIMD ile birleştirerek bu sayıları daha da artırmayı başardık.

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ı kullanan daha birçok heyecan verici örnek vardır. Demoları inceleyip kendi çok iş parçacıklı uygulamalarınızı ve kitaplıklarınızı web'e ekleyin.