Web Worker ueber SharedArrayBuffer synchronisieren, ohne den Thread einzufrieren
Mehrere Web Worker, die sich denselben SharedArrayBuffer teilen, brauchen eine Art, aufeinander zu warten, ohne staendig in einer Schleife zu pollen. Atomics.wait() bietet genau das, blockiert dabei aber den kompletten Thread und ist deshalb im Main Thread verboten. Atomics.waitAsync() loest dasselbe Problem non-blockierend und funktioniert dadurch auch dort, wo Blockieren keine Option ist.
Inhaltsverzeichnis
- 1. Das Koordinationsproblem zwischen mehreren Web Workern
- 2. Grundlage: SharedArrayBuffer und Cross-Origin-Isolation
- 3. Atomics.wait(): blockierendes Warten, nur in Workern erlaubt
- 4. Atomics.waitAsync(): dieselbe Wartelogik, ohne den Thread zu blockieren
- 5. Atomics.notify(): wartende Threads gezielt aufwecken
- 6. Praxisbeispiel: ein einfacher asynchroner Mutex ueber mehrere Worker
- 7. Main Thread und Worker: warum waitAsync ueberall funktioniert
- 8. Performance: warum Spin-Locks und Polling teurer sind
- 9. Grenzen, Browser-Support und Vergleich der Mechanismen
- 10. Zusammenfassung
- 11. FAQ
1. Das Koordinationsproblem zwischen mehreren Web Workern
Sobald mehrere Web Worker denselben Speicherbereich lesen und schreiben, entsteht dasselbe Grundproblem wie bei jeder klassischen Multithreading-Umgebung: Ohne Koordination kann ein Worker einen Wert lesen, waehrend ein anderer ihn gerade mitten in der Aktualisierung hat, was zu inkonsistenten oder unbrauchbaren Zwischenzustaenden fuehrt. Reine postMessage-Kommunikation loest das nur bedingt, weil jede Nachricht kopiert oder zumindest serialisiert werden muss und damit fuer feingranulare, haeufige Synchronisation zu langsam ist.
JavaScript bietet dafuer mit SharedArrayBuffer und den Atomics-Operationen echte, speichergestuetzte Synchronisationsprimitiven, wie man sie aus Sprachen mit direktem Thread-Zugriff kennt. Damit lassen sich Mutexe, Semaphoren oder einfache Signalisierungs-Flags bauen, die mehrere Worker koordinieren, ohne bei jeder Aktualisierung eine komplette Nachricht ueber den Message-Kanal schicken zu muessen.
2. Grundlage: SharedArrayBuffer und Cross-Origin-Isolation
Ein SharedArrayBuffer verhaelt sich aehnlich wie ein gewoehnlicher ArrayBuffer, wird aber beim Uebergeben an einen Worker per postMessage nicht kopiert, sondern tatsaechlich geteilt: Beide Seiten greifen auf denselben physischen Speicherbereich zu. Ueblicherweise wird ein Int32Array als typisierte Sicht auf den SharedArrayBuffer gelegt, weil die Atomics-Operationen auf Ganzzahl-Arrays arbeiten.
Aus Sicherheitsgruenden, insbesondere wegen Seitenkanalangriffen wie Spectre, verlangt der Browser fuer SharedArrayBuffer eine Cross-Origin-Isolation der Seite: Die HTTP-Header Cross-Origin-Opener-Policy und Cross-Origin-Embedder-Policy muessen gesetzt sein. Fehlen diese Header, ist der SharedArrayBuffer-Konstruktor schlicht nicht verfuegbar, was in der Praxis der haeufigste Stolperstein beim ersten Einsatz ist.
3. Atomics.wait(): blockierendes Warten, nur in Workern erlaubt
Atomics.wait(typedArray, index, value, timeout) prueft, ob der Wert an der angegebenen Position noch dem erwarteten value entspricht, und blockiert andernfalls den aufrufenden Thread synchron, bis sich der Wert aendert oder das Timeout ablaeuft. Diese Blockade ist eine echte Thread-Pause auf Betriebssystemebene, waehrend der keinerlei anderer Code auf demselben Thread ausgefuehrt wird, auch keine Events oder Timer.
Genau deshalb wirft der Aufruf von Atomics.wait() auf dem Main Thread einen TypeError, der Browser verweigert die Ausfuehrung von vornherein. Wuerde der Main Thread blockieren, fror die gesamte Seite ein, kein Scrollen, kein Klicken, keine Animation waere mehr moeglich, bis das Warten beendet ist. In dedizierten Workern ohne UI-Verantwortung ist dieses blockierende Verhalten dagegen unproblematisch und teils sogar erwuenscht.
// Nur in einem Worker erlaubt, blockiert den Worker-Thread
const status = Atomics.wait(sharedInt32, 0, 0, 5000);
// status ist 'ok', 'not-equal' oder 'timed-out'
if (status === 'ok') {
verarbeiteFreigegebenenBereich();
}
4. Atomics.waitAsync(): dieselbe Wartelogik, ohne den Thread zu blockieren
Atomics.waitAsync(typedArray, index, value, timeout) hat dieselbe Signatur wie Atomics.wait(), verhaelt sich aber grundlegend anders: Statt den Thread anzuhalten, gibt die Methode sofort ein Objekt mit den Eigenschaften async und value zurueck. Ist async gleich false, war der Wert bereits abweichend oder das Ergebnis stand sofort fest, value enthaelt dann direkt das Ergebnis-String wie bei Atomics.wait().
Ist async gleich true, enthaelt value stattdessen ein Promise, das erst aufgeloest wird, wenn sich der Wert tatsaechlich aendert, das Timeout ablaeuft oder Atomics.notify() aufgerufen wird. Waehrend dieses Promise aussteht, laeuft der Thread ganz normal weiter, verarbeitet Events, fuehrt andere Tasks aus und friert an keiner Stelle ein, was der zentrale Unterschied zu Atomics.wait() ist.
const ergebnis = Atomics.waitAsync(sharedInt32, 0, 0, 5000);
if (ergebnis.async) {
ergebnis.value.then((status) => {
console.log('Aufgeweckt mit Status:', status); // 'ok' oder 'timed-out'
});
} else {
console.log('Sofortiges Ergebnis:', ergebnis.value); // z. B. 'not-equal'
}
5. Atomics.notify(): wartende Threads gezielt aufwecken
Atomics.notify(typedArray, index, count) weckt bis zu count wartende Threads auf, die an der angegebenen Speicherposition auf Atomics.wait() oder Atomics.waitAsync() warten. Ein Aufruf mit count gleich 1 weckt genau einen wartenden Thread, praktisch fuer Mutex-Szenarien mit exklusivem Zugriff, waehrend Infinity alle wartenden Threads gleichzeitig aufweckt, etwa fuer ein Broadcast-Signal an alle Worker eines Pools.
Wichtig ist, den geteilten Wert vor dem Aufruf von notify tatsaechlich zu aendern, meist per Atomics.store(), weil notify selbst keinen Wert setzt, sondern lediglich wartende Threads informiert, dass sich am Speicher etwas getan haben koennte. Die aufgeweckten Threads pruefen den Wert danach selbst erneut, was gelegentliche unnoetige Aufweckungen ohne tatsaechliche Zustandsaenderung einschliesst.
// Wert setzen und genau einen wartenden Thread aufwecken
Atomics.store(sharedInt32, 0, 1);
Atomics.notify(sharedInt32, 0, 1);
6. Praxisbeispiel: ein einfacher asynchroner Mutex ueber mehrere Worker
Ein minimaler Mutex laesst sich mit einem einzelnen Int32Array-Slot realisieren: Der Wert 0 bedeutet frei, der Wert 1 bedeutet gesperrt. Ein Worker, der die Sperre erwerben will, versucht per Atomics.compareExchange() atomar von 0 auf 1 zu wechseln. Gelingt das, besitzt er die Sperre, schlaegt es fehl, wartet er per Atomics.waitAsync() auf die naechste Freigabe, statt in einer Busy-Waiting-Schleife CPU-Zeit zu verschwenden.
Beim Freigeben setzt der Worker den Wert per Atomics.store() zurueck auf 0 und ruft anschliessend Atomics.notify() auf, um genau einen wartenden Konkurrenten aufzuwecken. Dieses Muster ist deutlich effizienter als wiederholtes Pollen per setInterval, weil kein Worker CPU-Zyklen verbraucht, waehrend er tatsaechlich nichts zu tun hat.
async function sperreErwerben(sharedInt32) {
while (Atomics.compareExchange(sharedInt32, 0, 0, 1) !== 0) {
const ergebnis = Atomics.waitAsync(sharedInt32, 0, 1);
if (ergebnis.async) await ergebnis.value;
}
}
function sperreFreigeben(sharedInt32) {
Atomics.store(sharedInt32, 0, 0);
Atomics.notify(sharedInt32, 0, 1);
}
7. Main Thread und Worker: warum waitAsync ueberall funktioniert
Waehrend Atomics.wait() ausschliesslich in dedizierten Workern nutzbar ist, funktioniert Atomics.waitAsync() ausdruecklich auch auf dem Main Thread, eben weil es niemals blockiert. Das erlaubt Szenarien, in denen der Main Thread selbst auf einen Zustand wartet, den ein Worker-Pool ueber SharedArrayBuffer meldet, etwa den Abschluss einer parallelen Berechnung, ohne dabei die Reaktionsfaehigkeit der Seite zu gefaehrden.
Das macht waitAsync besonders wertvoll fuer Architekturen, in denen ein zentraler Koordinator im Main Thread den Fortschritt mehrerer Worker ueberwacht: Statt fuer jede Fortschrittsmeldung eine eigene postMessage-Nachricht zu verschicken, koennen die Worker einfach einen gemeinsamen Zaehler per Atomics.add() erhoehen, und der Main Thread wacht per waitAsync ueber Grenzwerte auf, ganz ohne staendiges Polling.
8. Performance: warum Spin-Locks und Polling teurer sind
Eine naive Alternative zu waitAsync waere ein Spin-Lock, bei dem ein Worker in einer engen Schleife wiederholt den Wert prueft, bis er sich aendert. Das verbraucht durchgehend CPU-Zeit, selbst waehrend nichts Sinnvolles passiert, und konkurriert dabei aktiv mit anderen Threads um Rechenzeit, was auf Geraeten mit wenigen CPU-Kernen die Gesamtleistung der Seite spuerbar verschlechtert.
Atomics.waitAsync() dagegen registriert den wartenden Thread beim Betriebssystem-Scheduler und gibt die Kontrolle vollstaendig zurueck, bis eine tatsaechliche Zustandsaenderung eintritt. Der Browser muss dabei kein Promise-Polling in Millisekunden-Intervallen durchfuehren, sondern wird von der zugrunde liegenden Thread-Synchronisationsprimitive direkt benachrichtigt, was den Ressourcenverbrauch nahezu auf null reduziert, solange kein Ereignis eintritt.
9. Grenzen, Browser-Support und Vergleich der Mechanismen
Atomics.waitAsync() setzt wie SharedArrayBuffer selbst eine cross-origin-isolierte Umgebung voraus und ist in modernen Chromium- und Firefox-Versionen verfuegbar, in aelteren Browsern jedoch nicht. Fuer Projekte, die auf breite Kompatibilitaet angewiesen sind, bleibt eine MessageChannel-basierte Signalisierung als Fallback noetig, auch wenn diese fuer sehr feingranulare, haeufige Synchronisation deutlich mehr Overhead pro Nachricht verursacht.
In der Praxis lohnt sich Atomics.waitAsync() vor allem dort, wo mehrere Worker haeufig und mit geringer Latenz auf gemeinsamen Speicher synchronisieren muessen, etwa bei paralleler Bild- oder Audioverarbeitung. Fuer seltene, grobkoernige Koordination zwischen Worker und Main Thread ist eine einfache postMessage-Nachricht oft weiterhin die einfachere und ausreichend performante Wahl.
| Mechanismus | Blockiert Thread | Nutzbar im Main Thread | Typischer Einsatz |
|---|---|---|---|
| Atomics.wait() | Ja, synchron bis Aufwachen | Nein, wirft TypeError | Dedizierter Worker mit strenger Reihenfolge |
| Atomics.waitAsync() | Nein | Ja | Mutex/Semaphore zwischen mehreren Workern |
| postMessage / MessageChannel | Nein | Ja | Seltene, grobkoernige Koordination |
| Busy-Spin-Loop (manuell) | Nein, aber CPU-intensiv | Technisch ja, aber unerwuenscht | Nicht empfohlen, nur zu Testzwecken |
Mironsoft
Moderne Browser-APIs, Performance und wartbares JavaScript
JavaScript, das im echten Browser robust bleibt, nicht nur im Tutorial?
Wir prüfen bestehenden Frontend-Code auf veraltete Patterns, unnötige Bibliotheken und Performance-Fallen und ersetzen sie durch moderne, native Browser-APIs, die weniger Bundle-Gewicht und weniger Wartungslast bedeuten.
Code-Review
Veraltete Patterns, unnötige Dependencies und Memory Leaks systematisch aufspüren.
Performance-Optimierung
Bundle-Größe, Ladezeit und Runtime-Performance mit modernen APIs verbessern.
Modernisierung
Native Browser-APIs statt schwerer Bibliotheken gezielt einführen.
10. Zusammenfassung
Atomics.waitAsync(): Das Wichtigste auf einen Blick
Kernidee
Atomics.waitAsync() wartet auf eine Speicheraenderung, ohne den ausfuehrenden Thread zu blockieren.
Unterschied zu wait()
Atomics.wait() blockiert synchron und ist im Main Thread verboten, waitAsync funktioniert ueberall.
Zusammenspiel
Atomics.notify() weckt wartende Threads gezielt auf, meist nach einem Atomics.store().
Voraussetzung
SharedArrayBuffer und damit auch waitAsync erfordern eine cross-origin-isolierte Seite.