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

Dowiedz się, jak przenieść do WebAssembly aplikacje wielowątkowe napisane w innych językach.

Obsługa wątków WebAssembly to jeden z najważniejszych ulepszeń w komponencie WebAssembly. Pozwala uruchamiać fragmenty kodu równolegle na osobnych rdzeniach lub używać tego samego kodu w niezależnych częściach danych wejściowych, skalując go do tyle rdzeni, ile ma użytkownik, i znacznie skrócić ogólny czas wykonywania.

Z tego artykułu dowiesz się, jak za pomocą wątków WebAssembly wprowadzać w internecie wielowątkowe aplikacje napisane w językach takich jak C, C++ czy Rust.

Jak działają wątki WebAssembly

Wątki WebAssembly nie są oddzielną funkcją, ale połączeniem kilku komponentów, dzięki którym aplikacje WebAssembly mogą korzystać z tradycyjnych paradygmatów wielowątkowości w internecie.

Procesy internetowe

Pierwszym komponentem są zwykłe pracowniki, których znasz i lubisz z JavaScriptu. Wątki WebAssembly używają konstruktora new Worker do tworzenia nowych wątków bazowych. Każdy wątek wczytuje klej JavaScript, a potem wątek główny używa metody Worker#postMessage do udostępniania tym wątkom skompilowanego WebAssembly.Module oraz udostępnionego WebAssembly.Memory (patrz poniżej). Zapewnia to komunikację i umożliwia wszystkim wątkom uruchamianie tego samego kodu WebAssembly w tej samej współdzielonej pamięci bez konieczności ponownego przechodzenia przez JavaScript.

Narzędzia internetowe działają już od ponad 10 lat, są powszechnie wspierane i nie wymagają żadnych specjalnych flag.

SharedArrayBuffer

Pamięć WebAssembly jest reprezentowana przez obiekt WebAssembly.Memory w JavaScript API. Domyślnie WebAssembly.Memory otacza ArrayBuffer – bufor nieprzetworzonych bajtów, do którego dostęp ma tylko 1 wątek.

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

Aby umożliwić obsługę wielowątkowości, aplikacja WebAssembly.Memory również uzyskała udostępniony wariant. Po utworzeniu za pomocą flagi shared za pomocą interfejsu JavaScript API lub samego pliku binarnego WebAssembly staje się on otoką wokół SharedArrayBuffer. To odmiana etykiety ArrayBuffer, którą można udostępniać w innych wątkach oraz czytać lub modyfikować jednocześnie z jednej strony.

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

W przeciwieństwie do narzędzia postMessage, zwykle używanego do komunikacji między wątkiem głównym a instancjami roboczymi, usługa SharedArrayBuffer nie wymaga kopiowania danych ani oczekiwania na wysyłanie i odbieranie wiadomości w pętli zdarzeń. Zamiast tego wszystkie zmiany są widoczne niemal natychmiast we wszystkich wątkach, co sprawia, że w przypadku tradycyjnych elementów podstawowych do synchronizacji jest to znacznie lepszy cel kompilacji.

SharedArrayBuffer ma skomplikowaną historię. Początkowo był dostępny w kilku przeglądarkach w połowie 2017 r., ale na początku 2018 r. musiał zostać wyłączony ze względu na odkrycie luk w zabezpieczeniach związanych z Spectre. Głównym powodem było to, że wyodrębnianie danych w Spectre polega na atakach czasowych, czyli mierzeniu czasu wykonywania konkretnego fragmentu kodu. Aby utrudnić ten typ ataku, przeglądarki zmniejszyły precyzję standardowych interfejsów API określających czas, takich jak Date.now i performance.now. Jednak pamięć współdzielona w połączeniu z prostą pętlą licznika działającą w oddzielnym wątku jest też bardzo niezawodnym sposobem na uzyskanie bardzo dokładnego czasu, a zmniejszenie wydajności jest znacznie trudniejsze bez znacznego ograniczania wydajności środowiska wykonawczego.

Zamiast tego Chrome 68 (w połowie 2018 r.) ponownie włączył SharedArrayBuffer, wykorzystując izolację witryn – funkcję, która łączy różne witryny z różnymi procesami i utrudnia korzystanie z ataków pobocznych, takich jak Spectre. Jednak rozwiązania te były nadal ograniczone tylko do Chrome na komputery, ponieważ izolacja witryn jest dość kosztowna i nie można było jej domyślnie włączyć we wszystkich witrynach korzystających z urządzeń mobilnych z małą ilością pamięci. Nie zaimplementowali go jeszcze inni dostawcy.

W Chrome i Firefoksie do 2020 roku wdrożyliśmy izolację witryn oraz standardowy sposób włączania tej funkcji przez witryny za pomocą nagłówków COOP i COEP. Mechanizm akceptowania umożliwia korzystanie z izolacji witryn nawet na urządzeniach o słabej mocy, gdy włączenie tej funkcji we wszystkich witrynach byłoby zbyt kosztowne. Aby je włączyć, dodaj następujące nagłówki do głównego dokumentu w konfiguracji serwera:

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

Gdy wyrazisz zgodę, uzyskasz dostęp do SharedArrayBuffer (w tym WebAssembly.Memory z funkcją SharedArrayBuffer), precyzyjnych liczników czasu, pomiaru pamięci i innych interfejsów API, które ze względów bezpieczeństwa wymagają oddzielnego źródła. Więcej informacji znajdziesz w artykule o zapewnieniu izolacji witryny od zasobów z innych domen za pomocą narzędzi COOP i COEP.

Atomowa konfiguracja WebAssembly

Chociaż funkcja SharedArrayBuffer umożliwia wszystkim wątkom odczytywanie i zapisywanie w tej samej pamięci, warto zadbać o to, aby nie powodowały one sprzecznych operacji w tym samym czasie, aby zapewnić prawidłową komunikację. Możliwe jest na przykład, że jeden wątek zacznie odczytywać dane z udostępnionego adresu, a inny pisze w nim, więc wynik pierwszego wątku jest uszkodzony. Ta kategoria błędów nosi nazwę rasy. Aby zapobiec sytuacjom wyścigu, musisz w jakiś sposób zsynchronizować te uprawnienia dostępu. Tutaj do akcji wkraczają operacje atomowe.

WebAssembly Atomics to rozszerzenie zestawu instrukcji WebAssembly, które umożliwia „atomowe” odczytywanie i zapisywanie małych komórek z danymi (zwykle 32- i 64-bitowych liczb całkowitych). Oznacza to, że żadne 2 wątki nie odczytują ani nie zapisują w tej samej komórce w tym samym czasie, co pozwala uniknąć takich konfliktów na niskim poziomie. Poza tym atomowa instancja WebAssembly zawiera 2 kolejne rodzaje instrukcji: „oczekuj” i „powiadomienie”, które umożliwiają uśpienie jednego wątku („oczekuj”) dla danego adresu we współdzielonej pamięci do momentu, gdy inny wątek uruchomi go za pomocą funkcji „powiadomienia”.

Wszystkie podstawowe elementy synchronizacji wyższego poziomu, w tym kanały, muteksy oraz blokady odczytu i zapisu, są tworzone na podstawie tych instrukcji.

Jak korzystać z wątków WebAssembly

Wykrywanie funkcji

WebAssembly atomics 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 w harmonogramie webassembly.org.

Aby mieć pewność, że wszyscy użytkownicy będą mogli wczytać aplikację, musisz wdrożyć stopniowe ulepszanie, tworząc 2 różne wersje Wasm – jedną z obsługą wielowątkowości, a drugą bez niej. Następnie wczytaj obsługiwaną wersję w zależności od wyników wykrywania funkcji. Aby wykryć obsługę wątków WebAssembly w czasie działania, użyj wasm-feature-detect Library i wczytaj 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

Przyjrzyjmy się teraz, jak utworzyć wielowątkową wersję modułu WebAssembly.

C

W języku C, zwłaszcza w systemach uniksowych, typowym sposobem korzystania z wątków jest użycie funkcji POSIX Threads z biblioteki pthread. Emscripten udostępnia zgodną z interfejsem API implementację biblioteki pthread opracowanej na podstawie zasobów Web Workers, współdzielonej pamięci i elementów atomowych, dzięki czemu ten sam kod może działać w internecie bez wprowadzania zmian.

Przeanalizujmy przykład:

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ą dołączone z pliku pthread.h. Poznasz też kilka kluczowych funkcji w radzeniu sobie z wątkami.

pthread_create utworzy wątek w tle. Wymaga miejsca docelowego do przechowywania uchwytu wątku, niektórych atrybutów tworzenia wątku (tutaj nie przekazujemy żadnych, dlatego jest to tylko NULL), wywołania zwrotnego do wykonania w nowym wątku (tutaj thread_callback) oraz opcjonalnego wskaźnika argumentu przekazywanego do tego wywołania zwrotnego na wypadek, gdyby trzeba było udostępnić jakieś dane z wątku głównego – w tym przykładzie udostępniamy wskaźnik do zmiennej arg.

Narzędzie pthread_join można wywołać później w dowolnym momencie, czekając na zakończenie wykonywania wątku i uzyskanie wyniku z wywołania zwrotnego. Akceptuje wcześniej przypisany uchwyt wątku, a także wskaźnik do zapisania wyniku. W tym przypadku nie ma żadnych wyników, więc funkcja przyjmuje jako argument NULL.

Aby skompilować kod z użyciem wątków w Emscripten, musisz wywołać emcc i przekazać parametr -pthread, tak jak w przypadku 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 środowisku Node.js, zobaczysz ostrzeżenie, a następnie program zawiesi się:

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…]

What happened? Problem polega na tym, że większość czasochłonnych interfejsów API w internecie jest asynchroniczna i korzysta z pętli zdarzeń. To ograniczenie stanowi ważną różnicę w porównaniu z tradycyjnymi środowiskami, w których aplikacje zwykle uruchamiają operacje wejścia-wyjścia synchroniczne, blokujące. Aby dowiedzieć się więcej, zapoznaj się z postem na temat używania asynchronicznych internetowych interfejsów API z WebAssembly.

W tym przypadku kod synchronicznie wywołuje pthread_create, aby utworzyć wątek w tle, a następnie przechodzi do kolejnego synchronicznego wywołania pthread_join, które czeka na zakończenie wykonania wątku w tle. Skrypty Web Worker używane w tle, gdy ten kod jest kompilowane w Emscripten, są jednak asynchroniczne. W efekcie pthread_create planuje tylko utworzenie nowego wątku instancji roboczej przy następnym uruchomieniu pętli zdarzeń, ale pthread_join natychmiast blokuje pętlę zdarzeń i czeka na tę instancję roboczą, co uniemożliwia jego utworzenie. To klasyczny przykład zakleszczenia.

Jednym ze sposobów rozwiązania tego problemu jest utworzenie puli pracowników z wyprzedzeniem, jeszcze przed rozpoczęciem programu. Po wywołaniu pthread_create może pobrać gotową do użycia instancję roboczą z puli, uruchomić podane wywołanie zwrotne w swoim wątku w tle i zwrócić tę instancję z powrotem do puli. Wszystkie te czynności można wykonywać synchronicznie, więc jeśli pula będzie wystarczająco duża, nie będzie żadnych blokad wzajemnych.

Na to właśnie zezwala Emscripten w przypadku opcji -s PTHREAD_POOL_SIZE=.... Pozwala określić liczbę wątków – stałą liczbę lub wyrażenie JavaScript, takie jak navigator.hardwareConcurrency, które pozwala utworzyć tyle wątków, ile jest rdzeni w procesorze. Ta druga opcja jest przydatna, gdy kod można skalować do dowolnej liczby wątków.

W tym przykładzie tworzony jest tylko 1 wątek, więc zamiast rezerwować wszystkie rdzenie, wystarczy, że użyjesz -s PTHREAD_POOL_SIZE=1:

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

Tym razem, gdy go uruchomisz, wszystko będzie działać prawidłowo:

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

Jest jednak inny problem: widzisz fragment sleep(1) w przykładowym kodzie? Działa w wywołaniu zwrotnym wątku, czyli poza wątekem głównym, więc nie ma problemu. Cóż, nie jest.

Po wywołaniu funkcji pthread_join musi on czekać na zakończenie wykonywania wątku, co oznacza, że jeśli utworzony wątek wykonuje długotrwałe zadania (w tym przypadku jest uśpiony przez 1 sekundę), to wątek główny będzie musiał zostać zablokowany przez ten sam czas, aż do zwrócenia wyników. Gdy ten kod JavaScript zostanie wykonany w przeglądarce, zablokuje on wątek UI na 1 sekundę, dopóki nie nastąpi wywołanie zwrotne wątku. Pogarsza to komfort użytkowników.

Można to osiągnąć na kilka sposobów:

  • pthread_detach
  • -s PROXY_TO_PTHREAD
  • Niestandardowa instancja robocza i Comlink

pthread_detach

Po pierwsze, jeśli musisz uruchomić tylko niektóre zadania z wątku głównego, ale nie musisz czekać na wyniki, możesz użyć pthread_detach zamiast pthread_join. Wywołanie zwrotne wątku będzie nadal działać w tle. Jeśli używasz tej opcji, ostrzeżenie możesz wyłączyć, klikając -s PTHREAD_POOL_SIZE_STRICT=0.

PROXY_TO_PTHREAD

Po drugie, jeśli kompilujesz aplikację w C, a nie bibliotekę, możesz użyć opcji -s PROXY_TO_PTHREAD, która przeniesie główny kod aplikacji do osobnego wątku, a nie tylko z zagnieżdżonych wątków utworzonych przez samą aplikację. Dzięki temu główny kod może zostać bezpiecznie zablokowany w dowolnym momencie bez blokowania interfejsu użytkownika. W przypadku korzystania z tej opcji nie musisz też wstępnie tworzyć puli wątków. Zamiast tego usługa Emscripten może wykorzystać wątek główny do tworzenia nowych bazowych instancji roboczych, a następnie zablokować wątek pomocniczy w pthread_join bez blokady wzajemnych.

Po trzecie, jeśli pracujesz nad biblioteką i wciąż chcesz ją zablokować, możesz utworzyć własną instancję roboczą, zaimportować kod wygenerowany w Emscripten i udostępnić go w wątku głównym za pomocą narzędzia Comlink. Wątek główny będzie mógł wywoływać dowolne wyeksportowane metody jako funkcje asynchroniczne, co pozwoli uniknąć zablokowania interfejsu użytkownika.

W prostej aplikacji, takiej jak poprzedni przykład, -s PROXY_TO_PTHREAD jest najlepszą opcją:

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

C++

Te same zastrzeżenia i logika mają zastosowanie w C++ tak samo. Jedyną nową funkcją jest dostęp do interfejsów API wyższego poziomu, takich jak std::thread i std::async, które korzystają z omawianej wcześniej biblioteki pthread.

Powyższy przykład można więc napisać od nowa w bardziej idiomatycznym formacie C++:

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 zachowuje się tak samo jak w przykładzie 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 specjalistycznego, kompleksowego celu internetowego. Zamiast tego udostępnia ogólny cel wasm32-unknown-unknown dla ogólnych danych wyjściowych WebAssembly.

Jeśli Wasm ma być używany w środowisku internetowym, wszelkie interakcje z interfejsami API JavaScript pozostawia się bibliotekom i narzędziom zewnętrznym, takim jak wasm-bindgen czy wasm-pack. Oznacza to, że biblioteka standardowa nie wie o zasobach Web Worker, a standardowe interfejsy API, takie jak std::thread, nie będą działać po skompilowaniu do WebAssembly.

Na szczęście większość ekosystemu opiera się na bibliotekach wyższego poziomu, które obsługują wielowątkowość. Na tym poziomie o wiele łatwiej wyeliminować wszystkie różnice między platformami.

W szczególności w przypadku paralelizmu danych w Rust najpopularniejszym rozwiązaniem jest Rayon. Umożliwia przyjmowanie łańcuchów metod w zwykłych iteratorach i przekształcanie ich, zwykle w ramach jednej zmiany wiersza, w taki sposób, aby działały równolegle we wszystkich dostępnych wątkach, a nie po kolei. Na przykład:

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

Po tej niewielkiej zmianie kod podzieli dane wejściowe, obliczy x * x i sumy częściowe w wątkach równoległych, a na koniec zsumuje te częściowe wyniki.

Aby dostosować się do platform, które nie mają działającego elementu std::thread, Rayon udostępnia punkty zaczepienia, które umożliwiają zdefiniowanie niestandardowej logiki tworzenia i wychodzenia wątków.

wasm-bindgen-rayon korzysta z tych punktów zaczepienia, aby wygenerować wątki WebAssembly jako instancje Web Worker. Aby go używać, musisz dodać go jako zależność i wykonać czynności konfiguracyjne opisane w docs. Powyższy przykład będzie 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()
}

Gdy skończysz, wygenerowany JavaScript wyeksportuje dodatkową funkcję initThreadPool. Ta funkcja utworzy pulę instancji roboczych i będzie ich używać w trakcie działania programu we wszystkich operacjach wielowątkowych wykonywanych przez Rayon.

Ten mechanizm puli jest podobny do opcji -s PTHREAD_POOL_SIZE=... w Emscripten wyjaśnionej wcześniej. Musi też zostać zainicjowany przed głównym kodem, aby uniknąć zakleszczenia:

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 i w tym przypadku obowiązują te same zastrzeżenia dotyczące blokowania wątku głównego. Nawet przykład sum_of_squares nadal musi blokować wątek główny w oczekiwaniu 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 dla bezpieczeństwa wyszukiwarki całkowicie uniemożliwiają zablokowanie głównego wątku, a taki kod powoduje wystąpienie błędu. Zamiast tego utwórz instancję roboczą, zaimportuj tam kod wygenerowany w usłudze wasm-bindgen i udostępnij jej interfejs API za pomocą biblioteki takiej jak Comlink w wątku głównym.

Zapoznaj się z przykładem Wasm-bindgen-rayon, w którym znajdziesz kompleksową prezentację funkcji:

Praktyczne przypadki użycia

Aktywnie wykorzystujemy wątki WebAssembly w Squoosh.app do kompresowania obrazów po stronie klienta, w szczególności w przypadku formatów takich jak AVIF (C++), JPEG-XL (C++), OxiPNG (Rust) i WebP v2 (C++). Dzięki samej wielowątkowości i szybkościom zwracania się na poziomie 1,5x do 3

Kolejną ciekawą usługą jest Google Earth, która w wersji internetowej używa wątków WebAssembly.

FFMPEG.WASM to wersja popularnego łańcucha narzędzi multimedialnych FFmpeg w technologii WebAssembly, która korzysta z wątków WebAssembly do wydajnego kodowania filmów bezpośrednio w przeglądarce.

Istnieje o wiele więcej ciekawych przykładów z wykorzystaniem wątków WebAssembly. Zapoznaj się z wersjami demonstracyjnymi i użyj w internecie własnych wielowątkowych aplikacji oraz bibliotek.