Dowiedz się, jak przenosić wielowątkowe aplikacje napisane w innych językach do WebAssembly.
Obsługa wątków WebAssembly to jedno z najważniejszych ulepszeń wydajności WebAssembly. Umożliwia ona uruchamianie części kodu równolegle na osobnych rdzeniach lub ten sam kod na niezależnych częściach danych wejściowych, skalowanie go na tyle rdzeni, ile ma użytkownik, oraz znaczne skrócenie całkowitego czasu wykonywania.
Z tego artykułu dowiesz się, jak używać wątków WebAssembly do tworzenia wielowątkowych aplikacji napisanych w takich językach jak C, C++ i Rust i uruchamiania ich w przeglądarce.
Jak działają wątki WebAssembly
Wątek WebAssembly nie jest osobną funkcją, ale kombinacją kilku komponentów, które umożliwiają aplikacjom WebAssembly korzystanie z tradycyjnych paradygmatów wielowątkowości w internecie.
Skrypty Web Worker
Pierwszym komponentem są zwykłe Workery, które znasz i kochasz w JavaScript. Wątek WebAssembly używa konstruktora new Worker
do tworzenia nowych wątków. Każdy wątek wczytuje klej JavaScript, a potem wątek główny używa metody Worker#postMessage
, aby udostępnić skompilowany kod WebAssembly.Module
oraz udostępniony WebAssembly.Memory
(patrz poniżej) innym wątkom. Dzięki temu nawiązy się komunikacja i wszystkie wątki będą mogły uruchamiać ten sam kod WebAssembly w tej samej pamięci współdzielonej bez konieczności ponownego uruchamiania JavaScript.
Web Workers istnieją od ponad dekady, są szeroko obsługiwane i nie wymagają żadnych specjalnych flag.
SharedArrayBuffer
Pamięć WebAssembly jest reprezentowana przez obiekt WebAssembly.Memory
w interfejsie JavaScript API. Domyślnie WebAssembly.Memory
jest otoczką ArrayBuffer
—nieprzetworzonego bufora bajtów, do którego dostęp ma tylko jeden wątek.
> new WebAssembly.Memory({ initial:1, maximum:10 }).buffer
ArrayBuffer { … }
Aby obsługiwać wielowątkowość, WebAssembly.Memory
otrzymała również wariant współdzielony. Gdy zostanie utworzony za pomocą parametru shared
w interfejsie JavaScript API lub przez sam plik binarny WebAssembly, staje się opakowaniem dla SharedArrayBuffer
. Jest to odmiana ArrayBuffer
, którą można udostępniać w ramach innych wątków oraz czytać lub modyfikować jednocześnie z obu stron.
> new WebAssembly.Memory({ initial:1, maximum:10, shared:true }).buffer
SharedArrayBuffer { … }
W odróżnieniu od postMessage
, który jest zwykle używany do komunikacji między wątkiem głównym a elementami Web Workers, SharedArrayBuffer
nie wymaga kopiowania danych ani nawet oczekiwania na pętlę zdarzeń na potrzeby wysyłania i odbierania wiadomości.
Zamiast tego wszystkie zmiany są widoczne dla wszystkich wątków niemal natychmiast, co sprawia, że jest to znacznie lepszy obiekt kompilacji dla tradycyjnych prymitywów synchronizacji.
SharedArrayBuffer
ma skomplikowaną historię. Początkowo była ona dostępna w kilku przeglądarkach w połowie 2017 roku, ale na początku 2018 roku trzeba było ją wyłączyć z powodu odkrycia luków Spectre. Powodem było to, że wydobywanie danych w przypadku Spectre polega na atakach czasowych, czyli pomiarze czasu wykonywania konkretnego fragmentu kodu. Aby utrudnić tego typu ataki, przeglądarki zmniejszyły dokładność standardowych interfejsów API do pomiaru czasu, takich jak Date.now
i performance.now
. Jednak współdzielona pamięć w połączeniu z prostą pętlą licznika działającą w oddzielnym wątku to również bardzo niezawodny sposób na uzyskanie precyzyjnego pomiaru czasu, którego nie da się ograniczyć bez znacznego ograniczenia wydajności w czasie działania.
Zamiast tego w Chrome 68 (w połowie 2018 r.) ponownie włączono SharedArrayBuffer
, korzystając z izolacji witryn – funkcji, która umieszcza różne witryny w różnych procesach i znacznie utrudnia przeprowadzanie ataków typu side-channel, takich jak Spectre. Jednak to rozwiązanie było nadal ograniczone tylko do wersji Chrome na komputery, ponieważ izolacja witryn to dość kosztowna funkcja, której nie można było włączyć domyślnie dla wszystkich witryn na urządzeniach mobilnych z małą ilością pamięci ani zaimplementować u innych dostawców.
W 2020 r. zarówno Chrome, jak i Firefox mają implementacje izolacji witryn oraz standardowy sposób włączania tej funkcji w przypadku stron internetowych za pomocą nagłówków COOP i COEP. Mechanizm wyrażenia zgody umożliwia korzystanie z izolacji witryn nawet na urządzeniach o niskiej mocy, na których włączenie tej funkcji dla wszystkich witryn byłoby zbyt kosztowne. Aby włączyć tę funkcję, dodaj do głównego dokumentu w konfiguracji serwera te nagłówki:
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
Po włączeniu tej opcji uzyskasz dostęp do interfejsu SharedArrayBuffer
(w tym do interfejsu WebAssembly.Memory
obsługiwanego przez interfejs SharedArrayBuffer
), dokładnych zegarów, pomiaru pamięci i innych interfejsów API, które ze względów bezpieczeństwa wymagają odizolowanego źródła. Więcej informacji znajdziesz w artykule Uzyskiwanie dostępu do zasobów w ramach różnych źródeł za pomocą mechanizmów COOP i COEP.
Atomy WebAssembly
Funkcja SharedArrayBuffer
pozwala każdemu wątkowi odczytywać i zapisywać dane w tej samej pamięci, ale aby zapewnić prawidłową komunikację, musisz zadbać o to, aby wątki nie wykonywały jednocześnie sprzecznych operacji. Na przykład jeden wątek może zacząć odczytywać dane z wspólnego adresu, podczas gdy inny wątek będzie do niego zapisywać, przez co pierwszy wątek uzyska uszkodzony wynik. Ta kategoria błędów to warunki wyścig. Aby zapobiec warunkom wyścigu, musisz jakoś zsynchronizować te dostępy.
Właśnie w tym miejscu wkraczają do akcji operacje atomowe.
WebAssembly atomicsto rozszerzenie zestawu instrukcji WebAssembly, które umożliwia „atomistyczną” obsługę małych komórek danych (zwykle 32- i 64-bitowych liczb całkowitych). Oznacza to, że żadna z nich nie odczytuje ani nie zapisze tej samej komórki w tym samym czasie, co zapobiega konfliktom na niskim poziomie. Dodatkowo operacje atomowe WebAssembly zawierają 2 kolejne rodzaje instrukcji: „wait” (zaczekaj) i „notify” (powiadomienie), które umożliwiają jednemu wątkowi przejście w stan oczekiwania (zaczekaj) pod danym adresem w wspólnej pamięci, dopóki inny wątek nie obudzi go za pomocą instrukcji „notify”.
Wszystkie mechanizmy synchronizacji wyższego poziomu, w tym kanały, mutexy i blokady odczytu/zapisu, korzystają z tych instrukcji.
Jak korzystać z wątków WebAssembly
Wykrywanie cech
Atomy WebAssembly i SharedArrayBuffer
to stosunkowo nowe funkcje, które nie są jeszcze dostępne we wszystkich przeglądarkach obsługujących WebAssembly. Informacje o tym, które przeglądarki obsługują nowe funkcje WebAssembly, znajdziesz na stronie webassembly.org roadmap.
Aby zapewnić wszystkim użytkownikom możliwość wczytania aplikacji, musisz wdrożyć ulepszanie progresywne, tworząc 2 różne wersje Wasm – jedną z obsługą wielowątkowości, a drugą bez niej. Następnie pobierz obsługiwaną wersję w zależności od wyników wykrywania funkcji. Aby wykryć obsługę wątków WebAssembly w czasie wykonywania, użyj biblioteki wasm-feature-detect i załaduj moduł w ten sposób:
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
Teraz zobaczmy, jak utworzyć wielowątkową wersję modułu WebAssembly.
C
W języku C, zwłaszcza w systemach typu Unix, wątki są zwykle używane za pomocą interfejsu POSIX Threads udostępnianego przez bibliotekę pthread
. Emscripten zapewnia implementację zgodną z interfejsem API biblioteki pthread
opartej na elementach Web Workers, współdzielonej pamięci i elementach atomicznych, dzięki czemu ten sam kod może działać w internecie bez wprowadzania zmian.
Zobaczmy to na przykładzie:
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;
}
Tutaj nagłówki biblioteki pthread
są uwzględniane za pomocą pthread.h
. Możesz też zobaczyć kilka kluczowych funkcji do obsługi wątków.
pthread_create
utworzy wątek w tle. Wymaga miejsca do przechowywania uchwytu wątku, niektórych atrybutów tworzenia wątku (tutaj nie przekazujemy żadnych atrybutów, więc jest to tylko NULL
), wywołania zwrotnego do wykonania w nowym wątku (tutaj thread_callback
) oraz opcjonalnego wskaźnika argumentu do przekazania do tego wywołania zwrotnego na wypadek, gdybyś chciał udostępnić niektóre dane z głównego wątku. W tym przykładzie udostępniamy wskaźnik do zmiennej arg
.
pthread_join
można wywołać w dowolnym momencie, aby poczekać, aż wątek zakończy wykonywanie, i otrzymać wynik z funkcji zwracanej przez funkcję wywołania zwrotnego. Przyjmuje wcześniej przypisany identyfikator wątku oraz wskaźnik do przechowywania wyniku. W tym przypadku nie ma żadnych wyników, więc funkcja przyjmuje jako argument NULL
.
Aby skompilować kod za pomocą wątków za pomocą Emscripten, musisz wywołać emcc
i przekazać parametr -pthread
, tak jak podczas kompilowania tego samego kodu za pomocą Clang lub GCC na innych platformach:
emcc -pthread example.c -o example.js
Jeśli jednak spróbujesz uruchomić go w przeglądarce lub w Node.js, zobaczysz ostrzeżenie, a program się zawiesi:
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…]
Co się stało? Problem polega na tym, że większość interfejsów API, które w internecie zajmują dużo czasu, działają asynchronicznie i wymagają pętli zdarzeń. To ograniczenie jest ważną różnicą w porównaniu z tradycyjnymi środowiskami, w których aplikacje zwykle wykonują operacje wejścia/wyjścia w sposób synchroniczny, blokujący. Jeśli chcesz dowiedzieć się więcej, przeczytaj wpis na blogu na temat korzystanie z asynchronicznych interfejsów API dla stron internetowych z WebAssembly.
W tym przypadku kod wywołuje synchronicznie funkcję pthread_create
, aby utworzyć wątek w tle, a następnie wykonuje kolejne wywołanie synchroniczne funkcji pthread_join
, które czeka na zakończenie działania wątku w tle. Jednak web workery, które są używane w tle podczas kompilowania tego kodu za pomocą Emscripten, są asynchroniczne. pthread_create
tylko planuje utworzenie nowego wątku roboczego, który ma zostać utworzony w ramach następnego uruchomienia pętli zdarzeń, ale pthread_join
natychmiast blokuje pętlę zdarzeń, aby oczekiwać na ten wątek roboczy, co uniemożliwia jego utworzenie. Jest to klasyczny przykład blokady.
Jednym ze sposobów na rozwiązanie tego problemu jest utworzenie puli instancji roboczych z wyprzedzeniem, jeszcze przed rozpoczęciem programu. Gdy wywołana zostanie funkcja pthread_create
, może ona pobrać z puli gotowego do użycia Workera, uruchomić podany callback na wątku w tle i zwrócić Workera do puli. Wszystkie te operacje można wykonywać synchronicznie, więc nie będzie żadnych blokad, o ile pula jest wystarczająco duża.
Właśnie to umożliwia Emscripten za pomocą opcji -s
PTHREAD_POOL_SIZE=...
. Umożliwia określenie liczby wątków (jako stałej liczby lub wyrażenia JavaScript, np. navigator.hardwareConcurrency
) w celu utworzenia takiej liczby wątków, jaka odpowiada liczbie rdzeni procesora. Ta druga opcja jest przydatna, gdy kod może się skalować do dowolnej liczby wątków.
W powyższym przykładzie tworzony jest tylko 1 wątek, więc zamiast rezerwować wszystkie rdzenie wystarczy użyć -s PTHREAD_POOL_SIZE=1
:
emcc -pthread -s PTHREAD_POOL_SIZE=1 example.c -o example.js
Tym razem, gdy go wykonasz, wszystko będzie działać prawidłowo:
Before the thread
Inside the thread: 42
After the thread
Pthread 0x701510 exited.
Jest jednak jeszcze jeden problem: widzisz ten sleep(1)
w przykładowym kodzie? Jest wykonywany w wątku wywołania zwrotnego, czyli poza wątkiem głównym, więc powinien być OK, prawda? Nie.
Gdy wywoływana jest funkcja pthread_join
, musi ona zaczekać na zakończenie wykonywania wątku, co oznacza, że jeśli utworzony wątek wykonuje długotrwałe zadania (w tym przypadku czekanie przez 1 sekundę), wątek główny musi również blokować się przez taki sam czas, dopóki nie otrzyma wyników. Gdy ten kod JS zostanie wykonany w przeglądarce, zablokuje wątek interfejsu na 1 sekundę, dopóki nie wróci wywołanie zwrotne wątku. Pogarsza to wrażenia użytkownika.
Istnieje kilka rozwiązań tego problemu:
pthread_detach
-s PROXY_TO_PTHREAD
- Niestandardowa instancja robocza i Comlink
pthread_detach
Po pierwsze, jeśli musisz tylko wykonać kilka zadań poza wątkiem głównym, ale nie musisz czekać na wyniki, możesz użyć pthread_detach
zamiast pthread_join
. Dzięki temu wywołanie wątku będzie działać w tle. Jeśli korzystasz z tej opcji, możesz wyłączyć ostrzeżenie, klikając -s
PTHREAD_POOL_SIZE_STRICT=0
.
PROXY_TO_PTHREAD
Po drugie, jeśli kompilujesz aplikację w języku C, a nie bibliotekę, możesz użyć opcji -s
PROXY_TO_PTHREAD
, która przeniesie kod głównej aplikacji do osobnego wątku, oprócz wątków zagnieżdżonych utworzonych przez samą aplikację. Dzięki temu główny kod może w dowolnym momencie bezpiecznie zablokować interfejs bez jego zawieszania.
Korzystając z tej opcji, nie musisz też utworzyć puli wątków. Zamiast tego Emscripten może wykorzystać główny wątek do tworzenia nowych podrzędnych wątków Workera, a następnie zablokować pomocniczy wątek w funkcji pthread_join
, nie powodując blokady.
Comlink
Po trzecie, jeśli pracujesz nad biblioteką i nadal musisz blokować, możesz utworzyć własnego pracownika, zaimportować wygenerowany przez Emscripten kod i ujawnić go za pomocą Comlink w głównym wątku. Wątek główny będzie mógł wywoływać dowolne wyeksportowane metody jako funkcje asynchroniczne, co pozwoli uniknąć blokowania interfejsu użytkownika.
W przypadku prostej aplikacji, takiej jak w poprzednim przykładzie, najlepszą opcją jest -s PROXY_TO_PTHREAD
:
emcc -pthread -s PROXY_TO_PTHREAD example.c -o example.js
C++
Wszystkie te same zastrzeżenia i zasady mają zastosowanie w tym samym zakresie w przypadku C++. Jedyną nową rzeczą, którą zyskujesz, jest dostęp do interfejsów API wyższego poziomu, takich jak std::thread
i std::async
, które pod spodem korzystają z biblioteki pthread
, o której była mowa wcześniej.
Powyższy przykład można przepisać w bardziej idiomatycznej formie w języku C++, np. tak:
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;
}
Po skompilowaniu i wykonaniu z podobnymi parametrami będzie się zachowywać tak samo jak przykład w C:
emcc -std=c++11 -pthread -s PROXY_TO_PTHREAD example.cpp -o example.js
Dane wyjściowe:
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
W przeciwieństwie do Emscripten, Rust nie ma wyspecjalizowanego celu końcowego dla całego procesu w internecie, ale zapewnia ogólny cel wasm32-unknown-unknown
dla ogólnego wyjścia WebAssembly.
Jeśli Wasm ma być używany w środowisku internetowym, wszelkie interakcje z interfejsami JavaScript API są pozostawione zewnętrznym bibliotekom i narzędziom, takim jak wasm-bindgen i wasm-pack. Oznacza to, że biblioteka standardowa nie jest świadoma istnienia instancji roboczych w przeglądarce i że standardowe interfejsy API, takie jak std::thread
, nie będą działać po skompilowaniu do WebAssembly.
Na szczęście większość ekosystemu korzysta z bibliotek wyższego poziomu, które zajmują się wielowątkowością. Na tym poziomie łatwiej jest pominąć różnice między platformami.
W szczególności Rayon jest najpopularniejszym wyborem w przypadku równoległości danych w Rust. Umożliwia to tworzenie łańcuchów metod na zwykłych iteratorach i zwykle za pomocą jednej zmiany wiersza konwertowanie ich w taki sposób, aby działały równolegle na wszystkich dostępnych wątkach zamiast sekwencyjnie. Na przykład:
pub fn sum_of_squares(numbers: &[i32]) -> i32 {
numbers
.iter()
.par_iter()
.map(|x| x * x)
.sum()
}
Dzięki tej niewielkiej zmianie kod podzieli dane wejściowe, obliczy x * x
i częściowe sumy w odrębnych wątkach, a na końcu zsumuje te częściowe wyniki.
Aby uwzględnić platformy, na których nie działa std::thread
, Rayon udostępnia łapy, które umożliwiają definiowanie niestandardowej logiki dla tworzenia i zamykania wątków.
wasm-bindgen-rayon korzysta z tych funkcji, aby tworzyć wątki WebAssembly jako web workery. Aby go użyć, musisz go dodać jako zależność i postępować zgodnie z instrukcjami konfiguracji podanymi w dokumentacji. W przykładzie powyżej będzie to wyglądać tak:
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()
}
Po zakończeniu wygenerowany kod JavaScript wyeksportuje dodatkową funkcję initThreadPool
. Ta funkcja utworzy pulę instancji roboczych i będzie ich używać przez cały czas działania programu do wykonywania operacji wielowątkowych przez Rayon.
Ten mechanizm puli jest podobny do opcji -s PTHREAD_POOL_SIZE=...
w Emscripten opisanej wcześniej i także musi zostać zainicjowany przed kodem głównym, aby uniknąć blokad:
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
Pamiętaj, że w tym przypadku obowiązują te same zastrzeżenia, co w przypadku blokowania wątku głównego. Nawet przykład sum_of_squares
musi zablokować główny wątek, aby oczekiwać na częściowe wyniki z innych wątków.
Czas oczekiwania może być bardzo krótki lub długi, w zależności od złożoności iteratorów i liczby dostępnych wątków. Aby uniknąć problemów, silniki przeglądarek aktywnie zapobiegają blokowaniu głównego wątku, a taki kod spowoduje wyświetlenie błędu. Zamiast tego utwórz zadanie, zaimportuj do niego wygenerowany przez wasm-bindgen
kod i ujawnij jego interfejs API za pomocą biblioteki takiej jak Comlink w głównym wątku.
Aby zobaczyć kompleksową prezentację, zapoznaj się z przykładem wasm-bindgen-rayon, który pokazuje:
- Funkcja wykrywania wątków.
- Tworzenie wersji jedno- i wielowątkowych tej samej aplikacji Rust.
- Ładowanie w Workerze kodu JS+Wasm wygenerowanego przez wasm-bindgen.
- Inicjowanie puli wątków za pomocą narzędzia wasm-bindgen-rayon.
- Używanie funkcji Comlink do wyświetlania interfejsu API Workera w głównym wątku.
Przypadki użycia w praktyce
Aktywnie używamy wątków WebAssembly w Squoosh.app do kompresji obrazów po stronie klienta, zwłaszcza w przypadku formatów takich jak AVIF (C++), JPEG-XL (C++), OxiPNG (Rust) i WebP w wersji 2 (C++). Dzięki wielowątkowemu przetwarzaniu odnotowaliśmy stałe przyspieszenie kompresji o 1,5–3 razy (dokładne wartości zależą od kodeka), a nawet o więcej, gdy połączyliśmy wątki WebAssembly z WebAssembly SIMD.
Google Earth to kolejna ważna usługa, która w swojej wersji internetowej korzysta z wątków WebAssembly.
FFMPEG.WASM to wersja WebAssembly popularnego pakietu multimedialnego FFmpeg, który używa wątków WebAssembly do wydajnego kodowania filmów bezpośrednio w przeglądarce.
Istnieje wiele innych interesujących przykładów użycia wątków WebAssembly. Koniecznie obejrzyj prezentacje i wprowadź do sieci własne wielowątkowe aplikacje i biblioteki.