WebAssembly-Threads aus C, C++ und Rust verwenden

Hier erfahren Sie, wie Sie Multithread-Anwendungen, die in anderen Sprachen geschrieben sind, in WebAssembly einbinden.

Die Unterstützung von WebAssembly-Threads ist eine der wichtigsten Leistungsergänzungen zu WebAssembly. Sie können Teile Ihres Codes entweder parallel auf separaten Kernen ausführen oder denselben Code über unabhängige Teile der Eingabedaten ausführen. Dadurch wird er auf so viele Kerne wie der Nutzer skaliert und die Gesamtausführungszeit kann dadurch erheblich reduziert werden.

In diesem Artikel erfahren Sie, wie Sie mithilfe von WebAssembly-Threads Multithread-Anwendungen in Sprachen wie C, C++ und Rust im Web bereitstellen.

Funktionsweise von WebAssembly-Threads

WebAssembly-Threads sind keine separate Funktion, sondern eine Kombination aus mehreren Komponenten, die es WebAssembly-Anwendungen ermöglicht, traditionelle Multithreading-Paradigmen im Web zu verwenden.

Web Worker

Die erste Komponente sind die regulären Worker, die Sie von JavaScript kennen und schätzen. WebAssembly-Threads verwenden den Konstruktor new Worker, um neue zugrunde liegende Threads zu erstellen. In jedem Thread wird ein JavaScript-Glue geladen. Anschließend verwendet der Hauptthread die Methode Worker#postMessage, um den kompilierten WebAssembly.Module sowie ein freigegebenes WebAssembly.Memory (siehe unten) für die anderen Threads freizugeben. Dadurch wird die Kommunikation hergestellt und alle Threads können denselben WebAssembly-Code im selben gemeinsamen Speicher ausführen, ohne noch einmal JavaScript verwenden zu müssen.

Web Worker gibt es bereits seit über einem Jahrzehnt, werden weitgehend unterstützt und benötigen keine speziellen Flags.

SharedArrayBuffer

Der WebAssembly-Arbeitsspeicher wird in der JavaScript API durch ein WebAssembly.Memory-Objekt dargestellt. Standardmäßig ist WebAssembly.Memory ein Wrapper um einen ArrayBuffer – einen Rohbyte-Zwischenspeicher, auf den nur von einem einzelnen Thread zugegriffen werden kann.

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

Zur Unterstützung von Multithreading hat WebAssembly.Memory ebenfalls eine freigegebene Variante abgerufen. Wenn er mit dem Flag shared über die JavaScript API oder von der WebAssembly-Binärdatei erstellt wird, wird er zu einem Wrapper für eine SharedArrayBuffer. Es ist eine Variante von ArrayBuffer, die für andere Threads freigegeben und gleichzeitig von beiden Seiten gelesen oder geändert werden kann.

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

Im Gegensatz zu postMessage, das normalerweise für die Kommunikation zwischen Hauptthread und Web Workers verwendet wird, muss für SharedArrayBuffer weder Daten kopiert noch auf die Ereignisschleife gewartet werden, um Nachrichten zu senden und zu empfangen. Stattdessen werden Änderungen nahezu sofort von allen Threads erkannt, was es zu einem viel besseren Kompilierungsziel für traditionelle Synchronisierungsprimitive macht.

Die Geschichte von SharedArrayBuffer ist kompliziert. Es wurde Mitte 2017 in mehreren Browsern ausgeliefert, musste dann aber Anfang 2018 aufgrund von Spectre-Sicherheitslücken deaktiviert werden. Der besondere Grund war, dass die Datenextraktion in Spectre auf Timing-Angriffen basiert, die die Ausführungszeit eines bestimmten Code-Snippets messen. Um diese Art von Angriff zu erschweren, haben Browser die Genauigkeit von Standard-Timing-APIs wie Date.now und performance.now reduziert. Gemeinsamer Arbeitsspeicher ist jedoch in Kombination mit einer einfachen Zählerschleife, die in einem separaten Thread ausgeführt wird, auch eine sehr zuverlässige Möglichkeit für eine hochpräzise Zeitangabe. Außerdem ist es viel schwieriger, das Problem zu beheben, ohne die Laufzeitleistung erheblich zu gedrosseln.

Stattdessen wurde SharedArrayBuffer in Chrome 68 (Mitte 2018) durch die Website-Isolierung wieder aktiviert. Diese Funktion teilt verschiedene Websites in verschiedene Prozesse ein und erschwert die Ausnutzung von Seitenkanalangriffen wie Spectre. Diese Abhilfemaßnahme war jedoch nur auf Chrome-Desktops beschränkt, da die Website-Isolierung eine relativ teure Funktion ist und nicht standardmäßig für alle Websites auf Mobilgeräten mit geringem Arbeitsspeicher aktiviert werden konnte oder von anderen Anbietern implementiert wurde.

Bis 2020 bieten Chrome und Firefox sowohl Implementierungen der Website-Isolierung als auch eine Standardmethode für Websites zur Aktivierung dieser Funktion mit COOP- und COEP-Headern. Ein Opt-in-Mechanismus ermöglicht die Verwendung der Website-Isolierung auch auf Geräten mit geringer Leistung, da es zu teuer wäre, sie für alle Websites zu aktivieren. Zur Aktivierung fügen Sie dem Hauptdokument in Ihrer Serverkonfiguration die folgenden Header hinzu:

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

Sobald du zugestimmt hast, erhältst du Zugriff auf SharedArrayBuffer (einschließlich WebAssembly.Memory, unterstützt von einem SharedArrayBuffer), präzise Timer, Arbeitsspeichermessung und andere APIs, für die aus Sicherheitsgründen ein isolierter Ursprung erforderlich ist. Weitere Informationen finden Sie unter Website mit COOP und COEP „ursprungsübergreifend isoliert“ gestalten.

Atomarer WebAssembly

Mit SharedArrayBuffer kann jeder Thread im selben Speicher lesen und schreiben. Für eine korrekte Kommunikation sollten Sie jedoch dafür sorgen, dass nicht gleichzeitig in Konflikt stehende Vorgänge ausgeführt werden. Es ist beispielsweise möglich, dass ein Thread mit dem Lesen von Daten von einer gemeinsamen Adresse beginnt, während ein anderer Thread in ihn schreibt, sodass der erste Thread nun ein beschädigtes Ergebnis erhält. Diese Fehlerkategorie wird als Race-Bedingung bezeichnet. Um Race-Bedingungen zu vermeiden, müssen Sie diese Zugriffe synchronisieren. Hier kommen atomare Vorgänge ins Spiel.

WebAssembly Atomics ist eine Erweiterung des WebAssembly-Befehlssatzes, mit dem kleine Datenzellen (in der Regel 32- und 64-Bit-Zahlen) "atomar" gelesen und geschrieben werden können. Das heißt, in einer Weise, die garantiert, dass keine zwei Threads gleichzeitig in dieselbe Zelle lesen oder schreiben und solche Konflikte auf niedriger Ebene verhindern. Darüber hinaus enthalten WebAssembly-Atomics zwei weitere Anweisungsarten – "wait" und "notify". Diese ermöglichen es einem Thread, auf einer bestimmten Adresse in einem gemeinsamen Speicher in den Ruhezustand zu versetzen ("warte"), bis ein anderer Thread ihn durch "notify" aktiviert.

Alle übergeordneten Synchronisierungsgrundlagen, einschließlich Kanälen, Mutexen und Lese-/Schreibsperren, bauen auf diesen Anweisungen auf.

WebAssembly-Threads verwenden

Funktionserkennung

WebAssembly Atomics und SharedArrayBuffer sind relativ neue Funktionen und noch nicht in allen Browsern mit WebAssembly-Unterstützung verfügbar. Informationen dazu, welche Browser neue WebAssembly-Funktionen unterstützen, findest du in der Roadmap zu webassembly.org.

Damit alle Nutzer Ihre Anwendung laden können, müssen Sie die progressive Verbesserung implementieren. Erstellen Sie dazu zwei verschiedene Wasm-Versionen – eine mit Multithreading-Unterstützung und eine ohne. Laden Sie dann je nach den Ergebnissen der Featureerkennung die unterstützte Version. Um Unterstützung von WebAssembly-Threads zur Laufzeit zu erkennen, verwenden Sie die wasm-feature- detect-Bibliothek und laden Sie das Modul so:

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

Sehen wir uns nun an, wie Sie eine Multithread-Version des WebAssembly-Moduls erstellen.

C

In C werden Threads, insbesondere auf Unix-ähnlichen Systemen, häufig über POSIX-Threads aus der pthread-Bibliothek verwendet. Emscripten bietet eine API-kompatible Implementierung der pthread-Bibliothek, die auf Web Workern, gemeinsamem Arbeitsspeicher und Atomelementen erstellt wurde, sodass derselbe Code im Web ohne Änderungen funktionieren kann.

Sehen wir uns ein Beispiel an:

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;
}

Hier werden die Header für die pthread-Bibliothek über pthread.h eingefügt. Sie sehen auch einige wichtige Funktionen für den Umgang mit Threads.

pthread_create erstellt einen Hintergrundthread. Es verwendet ein Ziel, um einen Thread-Handle zu speichern, einige Attribute zur Threaderstellung (hier werden keine übergeben, es sind nur NULL), der Callback, der im neuen Thread ausgeführt werden soll (hier thread_callback), und einen optionalen Argumentzeiger, der an diesen Callback übergeben wird, falls Sie Daten aus dem Hauptthread teilen möchten. In diesem Beispiel teilen wir einen Verweis auf eine Variable arg.

pthread_join kann jederzeit später aufgerufen werden, um zu warten, bis der Thread die Ausführung beendet hat, und um das vom Callback zurückgegebene Ergebnis zu erhalten. Sie akzeptiert das zuvor zugewiesene Thread-Handle sowie einen Zeiger zum Speichern des Ergebnisses. In diesem Fall gibt es keine Ergebnisse, sodass die Funktion NULL als Argument verwendet.

Um Code mithilfe von Threads mit Emscripten zu kompilieren, müssen Sie emcc aufrufen und einen -pthread-Parameter übergeben, wie beim Kompilieren desselben Codes mit Clang oder GCC auf anderen Plattformen:

emcc -pthread example.c -o example.js

Wenn Sie jedoch versuchen, es in einem Browser oder in Node.js auszuführen, wird eine Warnung angezeigt und das Programm hängt sich auf:

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

Woran liegt das? Das Problem ist, dass die meisten zeitaufwendigen APIs im Web asynchron sind und für die Ausführung der Ereignisschleife erforderlich sind. Diese Einschränkung stellt einen wichtigen Unterschied zu herkömmlichen Umgebungen dar, in denen Anwendungen E/A normalerweise synchron und blockierend ausführen. Weitere Informationen finden Sie im Blogpost zur Verwendung asynchroner Web APIs mit WebAssembly.

In diesem Fall ruft der Code synchron pthread_create auf, um einen Hintergrundthread zu erstellen. Danach folgt ein weiterer synchroner Aufruf von pthread_join, der darauf wartet, dass der Hintergrundthread beendet ist. Web Worker, die im Hintergrund verwendet werden, wenn dieser Code mit Emscripten kompiliert wird, sind jedoch asynchron. pthread_create plant also nur einen neuen Worker-Thread, der bei der nächsten Ausführung der Ereignisschleife erstellt wird. Dann blockiert pthread_join die Ereignisschleife aber sofort, um auf diesen Worker zu warten. Dadurch wird verhindert, dass ein neuer Worker-Thread erstellt wird. Es ist ein klassisches Beispiel für ein Deadlock.

Eine Möglichkeit, dieses Problem zu lösen, besteht darin, im Voraus einen Pool von Workern zu erstellen, bevor das Programm überhaupt gestartet wurde. Wenn pthread_create aufgerufen wird, kann er einen einsatzbereiten Worker aus dem Pool annehmen, den bereitgestellten Callback im Hintergrundthread ausführen und den Worker wieder an den Pool zurückgeben. All dies kann synchron erfolgen, sodass es keine Deadlocks gibt, solange der Pool ausreichend groß ist.

Genau das ermöglicht Emscripten mit der Option -s PTHREAD_POOL_SIZE=.... Sie können eine Anzahl von Threads angeben – entweder eine feste Anzahl oder einen JavaScript-Ausdruck wie navigator.hardwareConcurrency, um so viele Threads zu erstellen, wie sich Kerne auf der CPU befinden. Die letztere Option ist hilfreich, wenn Ihr Code auf eine beliebige Anzahl von Threads skaliert werden kann.

Im obigen Beispiel wird nur ein Thread erstellt. Daher reicht es aus, nicht alle Kerne zu reservieren, um -s PTHREAD_POOL_SIZE=1 zu verwenden:

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

Dieses Mal funktioniert alles erfolgreich, wenn Sie es ausführen:

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

Es gibt jedoch ein anderes Problem: Sehen Sie das sleep(1) im Codebeispiel? Er wird im Thread-Callback ausgeführt, also außerhalb des Hauptthreads, also sollte er in Ordnung sein, oder? Nun, das ist es nicht.

Wenn pthread_join aufgerufen wird, muss gewartet werden, bis die Thread-Ausführung abgeschlossen ist. Wenn also der erstellte Thread Aufgaben mit langer Ausführungszeit ausführt – in diesem Fall 1 Sekunde –, muss der Hauptthread ebenso lange blockiert werden, bis die Ergebnisse zurückgegeben sind. Wenn dieses JS im Browser ausgeführt wird, wird der UI-Thread für eine Sekunde blockiert, bis der Thread-Callback zurückgegeben wird. Dies führt zu einer schlechten Nutzererfahrung.

Dafür gibt es mehrere Lösungen:

  • pthread_detach
  • -s PROXY_TO_PTHREAD
  • Benutzerdefinierte Worker und Comlink

pthread_detach

Wenn Sie nur einige Aufgaben außerhalb des Hauptthreads ausführen müssen und nicht auf die Ergebnisse warten müssen, können Sie pthread_detach anstelle von pthread_join verwenden. Dadurch wird der Thread-Callback im Hintergrund ausgeführt. Wenn Sie diese Option verwenden, können Sie die Warnung mit -s PTHREAD_POOL_SIZE_STRICT=0 deaktivieren.

PROXY_TO_PTHREAD

Wenn Sie eine C-Anwendung anstelle einer Bibliothek kompilieren, können Sie die Option -s PROXY_TO_PTHREAD verwenden. Dadurch wird der Hauptanwendungscode zusätzlich zu allen verschachtelten Threads, die von der Anwendung selbst erstellt wurden, in einen separaten Thread verschoben. Auf diese Weise kann der Hauptcode jederzeit sicher blockiert werden, ohne die Benutzeroberfläche einzufrieren. Übrigens: Wenn Sie diese Option verwenden, müssen Sie auch den Threadpool nicht vorab erstellen. Stattdessen kann Emscripten den Hauptthread zum Erstellen neuer zugrunde liegender Worker verwenden und dann den Hilfsthread in pthread_join ohne Deadlock blockieren.

Wenn Sie an einer Bibliothek arbeiten, diese aber trotzdem blockieren müssen, können Sie Ihren eigenen Worker erstellen, den von Emscripten generierten Code importieren und mit Comlink für den Hauptthread verfügbar machen. Der Hauptthread kann alle exportierten Methoden als asynchrone Funktionen aufrufen. Auf diese Weise wird auch verhindert, dass die Benutzeroberfläche blockiert wird.

In einer einfachen Anwendung wie im vorherigen Beispiel ist -s PROXY_TO_PTHREAD die beste Option:

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

C++

Dieselben Einschränkungen und Logiken gelten genauso für C++. Sie erhalten nur Zugriff auf übergeordnete APIs wie std::thread und std::async, die die zuvor erläuterte pthread-Bibliothek im Hintergrund verwenden.

Das obige Beispiel kann also wie folgt in idiomatischer C++ umgeschrieben werden:

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;
}

Wenn sie mit ähnlichen Parametern kompiliert und ausgeführt wird, verhält sie sich genauso wie das C-Beispiel:

emcc -std=c++11 -pthread -s PROXY_TO_PTHREAD example.cpp -o example.js

Ausgabe:

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

Im Gegensatz zu Emscripten hat Rust kein spezielles End-to-End-Webziel, sondern stellt stattdessen ein allgemeines wasm32-unknown-unknown-Ziel für die generische WebAssembly-Ausgabe bereit.

Wenn Wasm in einer Webumgebung verwendet werden soll, bleibt jede Interaktion mit JavaScript APIs externen Bibliotheken und Tools wie wasm-bindgen und wasm-pack überlassen. Leider bedeutet dies, dass die Standardbibliothek keine Web Worker kennt und Standard-APIs wie std::thread nicht funktionieren, wenn sie in WebAssembly kompiliert wurden.

Glücklicherweise ist der Großteil des Systems für das Multithreading von Bibliotheken auf höherer Ebene abhängig. Auf dieser Ebene ist es viel einfacher, alle Plattformunterschiede zu abstrahieren.

Rayon ist in Rust die beliebteste Wahl für Datenparallelität. Sie können Methodenketten auf regulären Iterationen verwenden und sie – in der Regel mit einer Änderung einer einzelnen Zeile – so konvertieren, dass sie parallel auf allen verfügbaren Threads ausgeführt werden, anstatt sequenziell zu laufen. Beispiel:

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

Mit dieser kleinen Änderung teilt der Code die Eingabedaten auf, berechnet x * x- und Teilsummen in parallelen Threads und summiert diese Teilergebnisse am Ende zusammen.

Um Plattformen ohne funktionierendes std::thread zu berücksichtigen, bietet Rayon Hooks, mit denen eine benutzerdefinierte Logik für das Erstellen und Beenden von Threads definiert werden kann.

wasm-bindgen-rayon nutzt diese Hooks, um WebAssembly-Threads als Web Worker zu erzeugen. Wenn Sie sie verwenden möchten, müssen Sie sie als Abhängigkeit hinzufügen und die in der docs beschriebenen Konfigurationsschritte ausführen. Das obige Beispiel sieht dann so aus:

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()
}

Anschließend exportiert das generierte JavaScript eine zusätzliche initThreadPool-Funktion. Diese Funktion erstellt einen Pool von Workern und verwendet diese während der gesamten Lebensdauer des Programms für alle von Rayon ausgeführten Multithread-Vorgänge.

Dieser Poolmechanismus ähnelt der zuvor erläuterten -s PTHREAD_POOL_SIZE=...-Option in Emscripten und muss vor dem Hauptcode initialisiert werden, um Deadlocks zu vermeiden:

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

Die selben Vorbehalte zum Blockieren des Hauptthreads gelten auch hier. Auch das sum_of_squares-Beispiel muss den Hauptthread weiterhin blockieren, um auf die Teilergebnisse von anderen Threads zu warten.

Je nach Komplexität der Iterationen und der Anzahl der verfügbaren Threads kann es eine sehr kurze oder lange Wartezeit sein. sicherheitshalber verhindern Browser-Engines jedoch, den Hauptthread vollständig zu blockieren, und ein solcher Code gibt einen Fehler aus. Stattdessen sollten Sie einen Worker erstellen, den von wasm-bindgen generierten Code dort importieren und seine API mit einer Bibliothek wie Comlink für den Hauptthread verfügbar machen.

Das Wasm-bindgen-rayon-Beispiel zeigt eine End-to-End-Demo, die Folgendes zeigt:

Anwendungsfälle aus der Praxis

Wir verwenden in Squoosh.app aktiv WebAssembly-Threads zur clientseitigen Bildkomprimierung. Dies gilt insbesondere für Formate wie AVIF (C++), JPEG-XL (C++), OxiPNG (Rust) und WebP v2 (C++). Alleine dank Multithreading konnten wir bei WebAssembly-Threads einen gleichmäßigen Code-Verhältnis von 1,5-3x – bei exakten SIMbem-Tests und sogar nochmalig-3x-simbembem-Code-Verhältnissen – 1,5-3x-Beschleunigungen pro Code

Auch Google Earth ist ein wichtiger Dienst, der WebAssembly-Threads für seine Webversion verwendet.

FFMPEG.WASM ist eine WebAssembly-Version einer beliebten Multimedia-Toolchain FFmpeg, die WebAssembly-Threads verwendet, um Videos direkt im Browser effizient zu codieren.

Es gibt noch viele weitere spannende Beispiele für die Verwendung von WebAssembly-Threads. Sehen Sie sich auch die Demos an und bringen Sie Ihre eigenen Multithread-Anwendungen und -Bibliotheken ins Web.