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ının işleyiş şekli

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 Çalışanlar'dır. 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 birlikte 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 bir 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 programı tarafından shared işaretiyle oluşturulduğunda, bunun yerine SharedArrayBuffer çevresinde bir sarmalayıcı haline gelir. Bu, diğer ileti dizileriyle paylaşılabilen ve her iki taraftan aynı anda 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 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ülür. Böylece, geleneksel senkronizasyon temel öğeleri için çok daha iyi bir derleme hedefi haline gelir.

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 özel nedeni, Spectre'da veri ayıklama işleminin zamanlama saldırılarına dayanmasıdır. Bu, belirli bir kod parçasının yürütme süresini ölçer. 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ı. Bununla birlikte, paylaşılan bellek, ayrı bir iş parçacığında çalışan basit bir sayaç döngüsüyle birlikte yüksek hassasiyetli zamanlama elde etmenin çok güvenilir bir yoludur ve çalışma zamanı performansını önemli ölçüde kısıtlamadan azaltmanın ç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

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

SharedArrayBuffer her iş parçacığının aynı bellekte okumasına ve yazmasına izin verir. Bununla birlikte, 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 okumaya ve yazmaya 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. Çalışma zamanında WebAssembly iş parçacıklarını algılamak için wasm-feature-detectlibrary öğesini 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 bir arka plan ileti dizisi oluşturur. Bir mesaj dizisi adını saklamak 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 öğesini çağırmanız ve aynı kodu diğer platformlarda Clang veya GCC ile derlerken -pthread parametresini 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 eşzamansız olması ve yürütülmesi için etkinlik döngüsünden yararlanması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 eşzamansız web API'lerini kullanma hakkındaki 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ütmesinin tamamlanması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. Yani pthread_create, bir sonraki etkinlik döngüsü çalıştırmasında yalnızca yeni bir Çalışan iş parçacığı planlar. Daha sonra pthread_join, etkinlik döngüsünü hemen engelleyerek ilgili Çalışan'ı beklemeye başlar ve böylece o işçinin oluşturulmasını önler. Bu, kilitlenme durumunun klasik bir örneğidir.

Bu sorunu çözmenin bir yolu, program başlamadan önce, önceden bir işleyici 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. Bunların tümü eşzamanlı olarak yapılabilir. Böylece, 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 kez, anahtar kelimeyi yürüttüğünüzde her şey başarılı bir şekilde çalışıyor:

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

Ancak başka bir sorun var: Kod örneğindeki sleep(1) değerini görüyor musunuz? İş parçacığı geri çağırmasında yürütülür. Yani ana iş parçacığından bağımsızdır. Dolayısıyla 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ğırması döndürülene kadar 1 saniye boyunca kullanıcı arayüzü iş parçacığını engeller. 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

İlk olarak, yalnızca ana iş parçacığında bazı görevleri çalıştırmanız ancak sonuçları beklemeniz gerekmiyorsa pthread_join yerine pthread_detach komutunu kullanabilirsiniz. Bu, iş parçacığı geri çağırmasının arka planda çalışmaya devam etmesini 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, ana uygulama kodunu uygulamanın kendisi tarafından oluşturulan iç içe yerleştirilmiş ileti dizilerine ek olarak 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 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 yöntemleri eşzamansız işlevler olarak çağırabilir ve bu şekilde, kullanıcı arayüzünün engellenmesini de önleyebilir.

Ö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++'ta da aynı şekilde geçerlidir. Elde edeceğiniz tek yeni şey, daha önce bahsettiğimiz pthread kitaplığını kullanan std::thread ve std::async gibi daha üst düzey API'lere erişmektir.

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 ç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 yinelemelerde yöntem zincirleri almanıza ve genellikle tek satırlık bir değişiklikte, bunları sıralı yerine mevcut tüm iş parçacıklarında paralel ç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ı

Squoosh.app'de WebAssembly iş parçacıklarını, istemci taraflı görüntü sıkıştırmak için, özellikle de AVIF (C++), JPEG-XL (C++), OxiPNG (Rust) ve WebP v2 (C++) gibi biçimler için aktif bir şekilde kullanıyoruz. Tek başına çoklu iş parçacıkları sayesinde, tek başına birden çok iş parçacığı (Squoosh.app ile) ile tutarlı 1,5x-3x iş parçacığı (tam olarak 3x) hızlarının birbirine paralel şekilde

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.