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. Anschließend verwendet der Hauptthread die Methode Worker#postMessage, um den kompilierten WebAssembly.Module und einen freigegebenen WebAssembly.Memory (siehe unten) für diese 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.

Web Worker gibt es bereits seit über einem Jahrzehnt, werden weitgehend unterstützt und benötigen keine besonderen 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-Zwischenspeicher, 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, reduzierten Browser die Genauigkeit von Standardzeit-APIs wie Date.now und performance.now. Gemeinsamer Arbeitsspeicher in Kombination mit einer einfachen Zählerschleife, die in einem separaten Thread ausgeführt wird, ist jedoch auch eine sehr zuverlässige Möglichkeit, um eine hohe Genauigkeit zu erreichen. Außerdem lässt es sich viel schwerer abschwächen, ohne die Laufzeitleistung wesentlich zu verringern.

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 gibt es sowohl in Chrome als auch in Firefox eine Implementierung der Website-Isolierung und eine Standardmethode, mit der Websites die Funktion mit COOP- und COEP-Headern aktivieren können. 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 im Artikel Website mithilfe von COOP und COEP ursprungsübergreifend isoliert gestalten.

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-Bedingungen 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, bauen auf diesen Anweisungen auf.

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. In der Roadmap für webassembly.org kannst du nachlesen, welche Browser neue WebAssembly-Funktionen unterstützen.

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 die unterstützte Version abhängig von den Ergebnissen der Featureerkennung. 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 Multithread-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. Sie sehen auch einige wichtige Funktionen für den Umgang mit Threads.

pthread_create erstellt einen Hintergrundthread. Es benötigt ein Ziel, in dem ein Thread-Handle gespeichert werden soll, einige Attribute zur Threaderstellung (hier werden keine übergeben, daher nur NULL), den 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 freigeben möchten. In diesem Beispiel geben wir einen Zeiger auf eine Variable arg weiter.

pthread_join kann jederzeit später aufgerufen werden, um zu warten, bis der Thread die Ausführung abgeschlossen hat und das Ergebnis vom Callback zurückgegeben wird. Es akzeptiert den zuvor zugewiesenen Thread-Handle sowie einen Verweis zum Speichern des Ergebnisses. Da es in diesem Fall keine Ergebnisse gibt, verwendet die Funktion NULL als Argument.

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 in 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 er einen einsatzbereiten Worker aus dem Pool verwenden, den bereitgestellten Callback in seinem Hintergrundthread ausführen und den Worker wieder in den Pool zurücksenden. 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 obigen Beispiel wird nur ein Thread erstellt. Daher reicht es aus, nicht alle Kerne zu reservieren, sondern -s PTHREAD_POOL_SIZE=1 zu verwenden:

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 noch ein anderes 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? Nein.

Wenn pthread_join aufgerufen wird, muss gewartet werden, bis die Threadausführung abgeschlossen ist. Wenn der erstellte Thread also langlaufende 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.

Dafür gibt es verschiedene Lösungen:

  • 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. Übrigens: Wenn Sie diese Option verwenden, müssen Sie auch den Threadpool nicht vorab erstellen. Stattdessen kann Emscripten den Hauptthread verwenden, um neue zugrunde liegende Worker zu erstellen, und dann den Hilfsthread in pthread_join ohne Deadlocks blockieren.

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, was Sie gewinnen, ist der Zugriff auf APIs höherer Ebene wie std::thread und std::async, die die zuvor beschriebene pthread-Bibliothek verwenden.

Also kann das obige Beispiel wie folgt in idiomatischeres C++ umgeschrieben werden:

beispiel.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 ermöglicht es Ihnen, Methodenketten auf regelmäßigen Iterationen zu verwenden und diese in der Regel mit einer Änderung an einer Zeile so zu konvertieren, dass sie parallel auf allen verfügbaren Threads ausgeführt werden, anstatt sequenziell. 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 funktionierenden std::thread bietet Rayon Hooks, mit denen eine benutzerdefinierte Logik zum Erzeugen 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 sie während der gesamten Lebensdauer des Programms für alle von Rayon ausgeführten Multithreaded-Vorgänge.

Dieser Poolmechanismus ähnelt der oben in Emscripten erläuterten Option -s PTHREAD_POOL_SIZE=... und muss außerdem 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 Elementen:

Anwendungsfälle aus der Praxis

Wir nutzen aktiv 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 schon allein durch Multithreading konnten wir feststellen, dass WebAsbly-Zahlen mit 1,5- bis 3-fachen Geschwindigkeiten pro SIM-Dembly-Rate (Kombination von 1,5- bis 3-fachen Datenübertragungen pro SIM-Differenzen) konstant waren.

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 unbedingt die Demos an und stellen Sie Ihre eigenen Multithread-Anwendungen und Bibliotheken ins Web.