Używanie wątków WebAssembly z C, C++ i Rust

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ątki WebAssembly nie są oddzielną funkcją, ale połączeniem kilku komponentów, które pozwalają aplikacjom WebAssembly na korzystanie z tradycyjnych paradygmatów wielowątkowości w sieci.

Skrypty Web Worker

Pierwszym z nich 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.Moduleoraz współdzielony WebAssembly.Memory (patrz poniżej) innym wątkom. Ułatwia to komunikację i umożliwia uruchamianie tego samego kodu WebAssembly we wszystkich wątkach w tej samej pamięci współdzielonej bez konieczności ponownego używania JavaScriptu.

Web Workers istnieją od ponad 10 lat, 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 przeciwieństwie do postMessage, który jest zwykle używany do komunikacji między wątkiem głównym a narzędziami Web Worker, SharedArrayBuffer nie wymaga kopiowania danych ani nawet czekania na wysyłanie i odbieranie wiadomości przez pętlę zdarzeń. 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.

Historia tego miejsca (SharedArrayBuffer) jest skomplikowana. 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.nowperformance.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 wyrażeniu zgody uzyskasz dostęp do usługi SharedArrayBuffer (w tym do WebAssembly.Memory wspieranej przez SharedArrayBuffer), precyzyjnych liczników czasu, pomiaru pamięci i innych interfejsów API, które ze względów bezpieczeństwa wymagają izolowanego źródła. Więcej informacji znajdziesz w artykule na temat odizolowania witryny od zasobów z innych domen przy użyciu elementów COOP i COEP.

Jednostki atomowe 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). Dzięki temu żadne 2 wątki nie odczytują ani nie zapisują danych w tej samej komórce w tym samym czasie, co zapobiega takim 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 upewnić się, że wszyscy użytkownicy mogą wczytać aplikację, musisz wdrożyć progresywne ulepszenie, tworząc 2 różne wersje Wasm – jedną z obsługą wielowątkowości i 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 przyjrzyjmy się, 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. Zobaczysz też kilka kluczowych funkcji 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ść czasochłonnych interfejsów API dostępnych w internecie jest asynchronicznych i wymagają do wykonania 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 potem 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. Po wywołaniu funkcji pthread_create może ona pobrać z puli gotową do użycia instancję roboczą, uruchomić podane wywołanie zwrotne w wątku w tle i zwrócić instancję roboczą z powrotem 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 liczby stałej 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ć funkcji -s PTHREAD_POOL_SIZE=1:

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

Tym razem wszystko działa, jak należy:

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 wykonywana w wywołaniu zwrotnym wątku, czyli poza wątkiem głównym, więc nie powinno być problemów. No cóż.

Gdy wywoływana jest funkcja pthread_join, musi ona poczekać na zakończenie 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 chcesz tylko wykonać niektóre zadania 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. Przy okazji korzystania z tej opcji nie musisz też wstępnie tworzyć puli wątków. Zamiast tego Emscripten może wykorzystać wątek główny do utworzenia nowych bazowych instancji roboczych, a następnie zablokować wątek pomocniczy w pthread_join bez ich blokowania.

Po trzecie, jeśli pracujesz nad biblioteką i nadal musisz blokować, możesz utworzyć własnego pracownika, zaimportować kod wygenerowany przez Emscripten 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 ograniczenia i zasady logiki są stosowane w taki sam sposób w C++. Jedyną nowością jest dostęp do interfejsów API wyższego poziomu, takich jak std::thread i std::async, które korzystają z omówionej wcześniej biblioteki pthread.

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 API JavaScriptu pozostawia się bibliotekom i narzędziom zewnętrznym, 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ę wątków 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 opisie wcześniej Emscripten i należy go zainicjować przed głównym kodem, aby uniknąć zakleszczeń:

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, ale aby zachować bezpieczeństwo, silniki przeglądarek aktywnie zapobiegają blokowaniu głównego wątku, a taki kod spowoduje wyjątek. 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:

Rzeczywiste przypadki użycia

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 inna godna uwagi usługa, która w swojej wersji internetowej wykorzystuje wątki WebAssembly.

FFMPEG.WASM to wersja WebAssembly popularnego zestawu narzędzi multimedialnych 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 uruchom w sieci własne wielowątkowe aplikacje i biblioteki.