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

Informationen zum Umwandeln von in anderen Sprachen geschriebenen Multithread-Anwendungen in WebAssembly.

Die Unterstützung von WebAssembly-Threads ist eine der wichtigsten Leistungsverbesserungen für WebAssembly. Sie können damit entweder Teile Ihres Codes parallel auf separaten Kernen oder denselben Code auf unabhängigen Teilen der Eingabedaten ausführen. So wird die Ausführung auf so viele Kerne wie möglich skaliert und die Gesamtausführungszeit erheblich reduziert.

In diesem Artikel erfahren Sie, wie Sie mit WebAssembly-Threads mehrstufige Anwendungen, die in Sprachen wie C, C++ und Rust geschrieben wurden, im Web nutzen.

Funktionsweise von WebAssembly-Threads

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

Web Worker

Die erste Komponente sind die regulären Worker, die Sie aus JavaScript kennen und lieben. WebAssembly-Threads verwenden den Konstruktor new Worker, um neue zugrunde liegende Threads zu erstellen. Jeder Thread lädt einen JavaScript-Glue und der Hauptthread verwendet dann die Methode Worker#postMessage, um den kompilierten WebAssembly.Module sowie einen freigegebenen WebAssembly.Memory (siehe unten) für die anderen Threads freizugeben. Dadurch wird die Kommunikation hergestellt und alle diese Threads können denselben WebAssembly-Code im selben gemeinsamen Speicher ausführen, ohne JavaScript noch einmal durchlaufen zu müssen.

Webworker gibt es seit über einem Jahrzehnt, sie werden weithin unterstützt und erfordern keine speziellen Flags.

SharedArrayBuffer

Der WebAssembly-Speicher wird in der JavaScript API durch ein WebAssembly.Memory-Objekt dargestellt. Standardmäßig ist WebAssembly.Memory ein Wrapper um einen ArrayBuffer, einen Rohbyte-Puffer, auf den nur ein einzelner Thread zugreifen kann.

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

Zur Unterstützung von Multithreading wurde WebAssembly.Memory auch eine freigegebene Variante hinzugefügt. Wenn es über die JavaScript API oder das WebAssembly-Binär selbst mit einem shared-Flag erstellt wird, wird es stattdessen zu einem Wrapper um einen SharedArrayBuffer. Es ist eine Variante von ArrayBuffer, die mit anderen Threads geteilt und von beiden Seiten gleichzeitig 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 dem Haupt- und dem Webworker verwendet wird, müssen bei SharedArrayBuffer keine Daten kopiert werden und es muss auch nicht auf die Ereignisschleife gewartet werden, um Nachrichten zu senden und zu empfangen. Stattdessen sind alle Änderungen für alle Threads fast sofort sichtbar, was es zu einem viel besseren Kompilierungsziel für traditionelle Synchronisierungsprimitive macht.

SharedArrayBuffer hat eine komplizierte Geschichte. Sie wurde Mitte 2017 in mehreren Browsern eingeführt, musste aber Anfang 2018 aufgrund der Entdeckung von Spectre-Sicherheitslücken deaktiviert werden. Der Grund dafür war, dass die Datenextraktion bei Spectre auf Timing-Angriffen basiert, bei denen die Ausführungszeit eines bestimmten Code-Snippets gemessen wird. Um diese Art von Angriff zu erschweren, haben Browser die Genauigkeit der Standard-Timing-APIs wie Date.now und performance.now reduziert. Allerdings ist gemeinsam genutzter Speicher in Kombination mit einer einfachen Zählerschleife, die in einem separaten Thread ausgeführt wird, auch eine sehr zuverlässige Methode, um ein hochpräzises Timing zu erzielen. Außerdem ist es viel schwieriger, diese Methode zu kompensieren, ohne die Laufzeitleistung erheblich zu beeinträchtigen.

Stattdessen wurde SharedArrayBuffer in Chrome 68 (Mitte 2018) wieder aktiviert. Dazu wurde die Website-Isolierung verwendet. Bei dieser Funktion werden unterschiedliche Websites in unterschiedliche Prozesse verschoben, was die Nutzung von Seitenkanalangriffen wie Spectre erschwert. Diese Abhilfe war jedoch nur auf Chrome für Computer beschränkt, da die Website-Isolierung eine relativ teure Funktion ist und nicht standardmäßig für alle Websites auf Mobilgeräten mit wenig Arbeitsspeicher aktiviert werden konnte. Außerdem wurde sie noch nicht von anderen Anbietern implementiert.

Im Jahr 2020 haben sowohl Chrome als auch Firefox die Website-Isolierung implementiert. Websites können die Funktion mit COOP- und COEP-Headern standardmäßig aktivieren. Mit einem Opt-in-Mechanismus kann die Website-Isolierung auch auf Geräten mit geringer Leistung verwendet werden, bei denen die Aktivierung für alle Websites zu teuer wäre. Fügen Sie dazu dem Hauptdokument in Ihrer Serverkonfiguration die folgenden Header hinzu:

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

Nach der Aktivierung erhalten Sie Zugriff auf SharedArrayBuffer (einschließlich WebAssembly.Memory, die von einer SharedArrayBuffer unterstützt wird), präzise Timer, Speichermessung und andere APIs, die aus Sicherheitsgründen einen abgeschirmten Ursprung erfordern. Weitere Informationen finden Sie unter Websites mit COOP und COEP plattformübergreifend isolieren.

WebAssembly-Atome

Mit SharedArrayBuffer kann jeder Thread im selben Arbeitsspeicher lesen und schreiben. Für eine korrekte Kommunikation sollten Sie jedoch darauf achten, dass keine Threads gleichzeitig in Konflikt stehende Vorgänge ausführen. So kann es beispielsweise vorkommen, dass ein Thread mit dem Lesen von Daten aus einer freigegebenen Adresse beginnt, während ein anderer Thread darauf schreibt. Der erste Thread erhält dann ein beschädigtes Ergebnis. Diese Kategorie von Fehlern wird als Race Condition bezeichnet. Um Race-Zustände zu vermeiden, müssen Sie diese Zugriffe irgendwie synchronisieren. Hier kommen atomarer Vorgänge ins Spiel.

WebAssembly-Atome sind eine Erweiterung des WebAssembly-Instruction-Sets, mit der kleine Datenzellen (in der Regel 32‑ und 64‑Bit-Ganzzahlen) „atomar“ gelesen und geschrieben werden können. Das bedeutet, dass sichergestellt wird, dass nicht zwei Threads gleichzeitig in dieselbe Zelle lesen oder schreiben, um solche Konflikte auf niedriger Ebene zu vermeiden. Außerdem enthalten WebAssembly-Atome zwei weitere Arten von Anweisungen: „wait“ und „notify“. Mit diesen Anweisungen kann ein Thread an einer bestimmten Adresse im gemeinsamen Speicher inaktiv („wait“) bleiben, bis ein anderer Thread ihn über „notify“ aufweckt.

Alle Synchronisationsprimitiven höherer Ebene, einschließlich Kanäle, Mutexe und Lese-/Schreibsperren, basieren auf diesen Anweisungen.

WebAssembly-Threads verwenden

Funktionserkennung

WebAssembly-Atome und SharedArrayBuffer sind relativ neue Funktionen und sind noch nicht in allen Browsern mit WebAssembly-Unterstützung verfügbar. Welche Browser neue WebAssembly-Funktionen unterstützen, erfahren Sie in der Roadmap von webassembly.org.

Damit alle Nutzer Ihre Anwendung laden können, müssen Sie progressive Verbesserungen implementieren, indem Sie zwei verschiedene Versionen von Wasm erstellen – eine mit und eine ohne Multithreading-Unterstützung. Laden Sie dann je nach Ergebnis der Funktionserkennung die unterstützte Version. Wenn Sie die Unterstützung von WebAssembly-Threads während der Laufzeit erkennen möchten, 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 eine mehrstufige Version des WebAssembly-Moduls erstellt wird.

C

In C, insbesondere auf Unix-ähnlichen Systemen, werden Threads häufig über POSIX-Threads verwendet, die von der pthread-Bibliothek bereitgestellt werden. Emscripten bietet eine API-kompatible Implementierung der pthread-Bibliothek, die auf Webworkern, gemeinsam genutztem Arbeitsspeicher und Atomen basiert, sodass derselbe Code ohne Änderungen im Web verwendet werden 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. Außerdem sehen Sie einige wichtige Funktionen für den Umgang mit Threads.

Mit pthread_create wird ein Hintergrund-Thread erstellt. Es ist ein Ziel erforderlich, in dem ein Thread-Handle gespeichert werden soll, einige Attribute zur Threaderstellung (hier werden keine übergeben, daher nur NULL), der Callback, der im neuen Thread ausgeführt werden soll (hier thread_callback) und ein optionaler Argumentzeiger, der an diesen Callback übergeben wird, falls Sie Daten aus dem Hauptthread freigeben möchten. In diesem Beispiel geben wir einen Verweis auf eine Variable arg weiter.

pthread_join kann später jederzeit aufgerufen werden, um zu warten, bis der Thread die Ausführung beendet hat, und das vom Rückruf zurückgegebene Ergebnis abzurufen. Es akzeptiert den zuvor zugewiesenen Thread-Handle sowie einen Verweis zum Speichern des Ergebnisses. In diesem Fall gibt es keine Ergebnisse, sodass die Funktion NULL als Argument nimmt.

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

emcc -pthread example.c -o example.js

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

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

Was ist passiert? Das Problem ist, dass die meisten zeitaufwendigen APIs im Web asynchron sind und für die Ausführung auf den Ereignis-Loop angewiesen sind. Diese Einschränkung ist ein wichtiger Unterschied zu traditionellen Umgebungen, in denen Anwendungen die E/A normalerweise synchron und blockierend ausführen. Weitere Informationen finden Sie im Blogpost Asynchrone Web-APIs aus WebAssembly verwenden.

In diesem Fall ruft der Code pthread_create synchron auf, um einen Hintergrund-Thread zu erstellen, und folgt mit einem weiteren synchronen Aufruf von pthread_join, der darauf wartet, dass die Ausführung des Hintergrund-Threads abgeschlossen ist. Webworker, die im Hintergrund verwendet werden, wenn dieser Code mit Emscripten kompiliert wird, sind jedoch asynchron. pthread_create plant also nur, dass bei der nächsten Ausführung der Ereignisschleife ein neuer Worker-Thread erstellt wird. pthread_join blockiert dann aber sofort die Ereignisschleife, um auf diesen Worker zu warten, und verhindert so, dass er überhaupt erstellt wird. Dies ist ein klassisches Beispiel für eine Sperrung.

Eine Möglichkeit, dieses Problem zu lösen, besteht darin, einen Pool von Workern im Voraus zu erstellen, bevor das Programm gestartet wird. Wenn pthread_create aufgerufen wird, kann ein einsatzbereiter Worker aus dem Pool genommen, der bereitgestellte Callback in seinem Hintergrund-Thread ausgeführt und der Worker wieder an den Pool zurückgegeben werden. 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 es Kerne auf der CPU gibt. Die letzte Option ist hilfreich, wenn Ihr Code auf eine beliebige Anzahl von Threads skaliert werden kann.

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

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

Diesmal funktioniert die Ausführung:

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

Es gibt jedoch ein weiteres Problem: Sehen Sie sich das sleep(1) im Codebeispiel an? Er wird im Thread-Callback ausgeführt, also nicht im Hauptthread. Das sollte also in Ordnung sein, oder? Das ist nicht der Fall.

Wenn pthread_join aufgerufen wird, muss auf den Abschluss der Threadausführung gewartet werden. Wenn der erstellte Thread also langwierige Aufgaben ausführt, in diesem Fall eine 1-Sekunden-Pause, muss der Hauptthread ebenfalls für die gleiche Zeit blockieren, bis die Ergebnisse zurückgegeben werden. 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.

Es gibt einige Lösungen für dieses Problem:

  • pthread_detach
  • -s PROXY_TO_PTHREAD
  • Benutzerdefinierter Worker und Comlink

pthread_detach

Wenn Sie nur einige Aufgaben aus dem Hauptthread ausführen, aber nicht auf die Ergebnisse warten müssen, können Sie anstelle von pthread_join pthread_detach 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

Zweitens: Wenn Sie eine C-Anwendung und keine 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 werden, auf einen separaten Thread ausgelagert. So kann der Hauptcode jederzeit sicher blockiert werden, ohne dass die Benutzeroberfläche eingefroren wird. Wenn Sie diese Option verwenden, müssen Sie den Thread-Pool auch nicht vorab erstellen. Stattdessen kann Emscripten den Haupt-Thread zum Erstellen neuer zugrunde liegender Worker nutzen und dann den Hilfs-Thread in pthread_join blockieren, ohne zu einem Deadlock zu führen.

Drittens: Wenn Sie an einer Bibliothek arbeiten und trotzdem blockieren müssen, können Sie Ihren eigenen Worker erstellen, den von Emscripten generierten Code importieren und ihn mit Comlink für den Hauptthread freigeben. Der Hauptthread kann alle exportierten Methoden als asynchrone Funktionen aufrufen und so 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++

Alle Einschränkungen und die Logik gelten für C++ genau so. Das einzige Neue ist der Zugriff auf APIs höherer Ebene wie std::thread und std::async, die die zuvor beschriebene pthread-Bibliothek verwenden.

Das obige Beispiel kann also in idiomatischerem C++ so 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 es mit ähnlichen Parametern kompiliert und ausgeführt wird, verhält es 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 bietet Rust kein spezielles End-to-End-Web-Ziel, sondern ein generisches wasm32-unknown-unknown-Ziel für die generische WebAssembly-Ausgabe.

Wenn Wasm in einer Webumgebung verwendet werden soll, werden alle Interaktionen mit JavaScript APIs externen Bibliotheken und Tools wie wasm-bindgen und wasm-pack überlassen. Leider bedeutet das, dass die Standardbibliothek keine Webworker kennt und Standard-APIs wie std::thread nicht funktionieren, wenn sie in WebAssembly kompiliert werden.

Glücklicherweise nutzt der Großteil des Ökosystems Bibliotheken höherer Ebene, die für Multithreading sorgen. Auf dieser Ebene ist es viel einfacher, alle Plattformunterschiede zu abstrahieren.

Insbesondere Rayon ist die beliebteste Option für den Datenparallelismus in Rust. Sie können damit Methodenketten für reguläre Iteratoren in der Regel mit einer einzigen Zeilenänderung so konvertieren, dass sie nicht sequenziell, sondern parallel auf allen verfügbaren Threads ausgeführt werden. Beispiel:

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

Durch diese kleine Änderung wird der Code die Eingabedaten aufteilen, x * x und Teilsummen in parallelen Threads berechnen und am Ende diese Teilergebnisse zusammenzählen.

Für Plattformen ohne funktionierende std::thread bietet Rayon Hooks, mit denen benutzerdefinierte Logik zum Starten und Beenden von Threads definiert werden kann.

wasm-bindgen-rayon nutzt diese Hooks, um WebAssembly-Threads als Web Worker zu erstellen. Wenn Sie es verwenden möchten, müssen Sie es als Abhängigkeit hinzufügen und die in der Dokumentation 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 wird im generierten JavaScript eine zusätzliche initThreadPool-Funktion exportiert. Diese Funktion erstellt einen Pool von Workern und verwendet diese während der gesamten Lebensdauer des Programms für alle von Rayon ausgeführten mehrstufigen Vorgänge.

Dieser Poolmechanismus ähnelt der -s PTHREAD_POOL_SIZE=...-Option in Emscripten, die bereits oben erläutert wurde. Er muss ebenfalls 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

Dieselben Einschränkungen zum Blockieren des Hauptthreads gelten auch hier. Auch im Beispiel sum_of_squares muss der Hauptthread blockiert werden, um auf die Teilergebnisse anderer Threads zu warten.

Je nach Komplexität der Iteratoren und der Anzahl der verfügbaren Threads kann die Wartezeit sehr kurz oder sehr lang sein. Zur Sicherheit verhindern Browser-Engines jedoch aktiv, dass der Haupt-Thread vollständig blockiert wird. Bei entsprechendem Code wird ein Fehler ausgegeben. Stattdessen sollten Sie einen Worker erstellen, den von wasm-bindgen generierten Code dort importieren und die API mit einer Bibliothek wie Comlink für den Hauptthread freigeben.

Im Beispiel für wasm-bindgen-rayon finden Sie eine End-to-End-Demo mit folgenden Funktionen:

Anwendungsfälle aus der Praxis

Wir verwenden WebAssembly-Threads in Squoosh.app für die clientseitige Bildkomprimierung, insbesondere für Formate wie AVIF (C++), JPEG-XL (C++), OxiPNG (Rust) und WebP v2 (C++). Allein durch Multithreading konnten wir eine konstante Beschleunigung von 1,5- bis 3-fach erzielen (das genaue Verhältnis variiert je nach Codec). Durch die Kombination von WebAssembly-Threads mit WebAssembly SIMD konnten wir diese Zahlen noch weiter steigern.

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

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

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