Echte Parallelverarbeitung in JavaScript
JavaScript ist von Natur aus single-threaded – aber mit SharedArrayBuffer und Atomics gibt es echten Shared Memory zwischen Threads. Damit lassen sich Szenarien lösen, die mit postMessage nicht effizient zu realisieren waren: schnelle Bildverarbeitung, Parser, WASM-Integration und Lock-freie Datenstrukturen.
Inhaltsverzeichnis
- 1. Warum Shared Memory in JavaScript?
- 2. SharedArrayBuffer: Speicher zwischen Threads teilen
- 3. Sicherheitsanforderungen: COOP und COEP Header
- 4. Atomics: atomare Operationen ohne Race Conditions
- 5. Atomics.wait() und Atomics.notify() – Thread-Synchronisation
- 6. Mutex-Implementierung mit Atomics.compareExchange()
- 7. Atomics und WebAssembly: WASM-Integration
- 8. Praktische Anwendungsfälle im Frontend
- 9. Atomics vs. postMessage: Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Shared Memory in JavaScript?
JavaScript wurde für single-threaded Ausführung entworfen. Die Event-Loop verarbeitet einen Callback nach dem anderen, ohne echte Parallelität. Web Workers haben dieses Modell erweitert: Sie laufen in separaten Threads mit eigenem Heap. Die Kommunikation zwischen Workers und dem Hauptthread erfolgt über postMessage, das Daten serialisiert, überträgt und auf der Empfängerseite deserialisiert. Für kleine Datenmenge ist das ausreichend – für große Binärdaten, Bilder oder kontinuierliche Datenströme ist die Serialisierung der Engpass.
SharedArrayBuffer löst dieses Problem fundamental: Statt Daten zwischen Threads zu kopieren, teilen sich alle Threads denselben Speicherbereich. Ein Web Worker kann direkt in denselben Buffer schreiben, aus dem der Hauptthread liest – ohne Kopieren, ohne Serialisierung, ohne Overhead. Das ermöglicht Szenarien, die vorher nicht in JavaScript umsetzbar waren: echte Lock-freie Datenstrukturen, effiziente Bildpipelines, Parser die große Dateien ohne Kopierschritte verarbeiten und WASM-Module, die mit JavaScript-Code Speicher teilen.
2. SharedArrayBuffer: Speicher zwischen Threads teilen
Ein SharedArrayBuffer ist ein Rohspeicher-Objekt, ähnlich wie ArrayBuffer, aber mit dem entscheidenden Unterschied: Er kann an mehrere Worker übergeben werden, ohne kopiert zu werden. Wo ein normaler ArrayBuffer bei der Übertragung via postMessage transferiert wird (der Sender verliert den Zugriff), kann ein SharedArrayBuffer von beliebig vielen Threads gleichzeitig gelesen und geschrieben werden. Der Speicher bleibt bestehen, bis alle Referenzen auf ihn aufgelöst sind.
Auf den SharedArrayBuffer wird über typisierte Arrays zugegriffen: Int32Array, Uint8Array, Float64Array und andere. Diese Views zeigen auf denselben Speicherbereich und ermöglichen effizient typisierten Zugriff. Schreibt ein Worker in den Buffer via Int32Array, sieht der andere Worker diese Änderung sofort – ohne Message-Passing, ohne Verzögerung. Das ist echte Shared-Memory-Kommunikation, wie sie von C++ und Java-Programmierern seit Jahrzehnten genutzt wird, nun auch in JavaScript verfügbar.
// Main thread: create shared buffer and send to worker
const sharedBuffer = new SharedArrayBuffer(4096); // 4 KB shared memory
const sharedArray = new Int32Array(sharedBuffer);
// Initialize with values
sharedArray[0] = 0; // status flag
sharedArray[1] = 0; // counter
const worker = new Worker('worker.js');
// Transfer reference — no copy, both share the same memory
worker.postMessage({ sharedBuffer });
// Read from main thread — sees worker writes immediately
setTimeout(() => {
console.log('Counter from worker:', sharedArray[1]);
}, 1000);
// --- worker.js ---
self.onmessage = ({ data: { sharedBuffer } }) => {
const sharedArray = new Int32Array(sharedBuffer);
// Write directly to shared memory
for (let i = 0; i < 1000; i++) {
sharedArray[1]++; // increment shared counter
}
self.postMessage('done');
};
3. Sicherheitsanforderungen: COOP und COEP Header
Nach dem Spectre-Angriff 2018 wurde SharedArrayBuffer in Browsern temporär deaktiviert. Der Grund: Shared Memory mit hoher zeitlicher Auflösung ermöglicht Timing-Angriffe, die CPU-Cache-Seitenkanäle ausnutzen. Die Lösung war nicht die Abschaffung von SharedArrayBuffer, sondern die Einführung von Site-Isolation-Anforderungen. Seit 2020 ist SharedArrayBuffer wieder verfügbar – aber nur in sogenannten "cross-origin isolated" Kontexten.
Konkret bedeutet das: Der Server muss zwei HTTP-Header senden. Cross-Origin-Opener-Policy: same-origin (COOP) verhindert, dass andere Seiten das Browserfenster referenzieren können. Cross-Origin-Embedder-Policy: require-corp (COEP) stellt sicher, dass alle Ressourcen auf der Seite explizit den Cross-Origin-Zugriff erlauben. Beide Header zusammen aktivieren den "cross-origin isolated"-Modus, der für SharedArrayBuffer und hohe Zeitauflösung in performance.now() erforderlich ist. Wer diese Header nicht setzt, bekommt eine SecurityError-Exception beim Erstellen eines SharedArrayBuffer.
4. Atomics: atomare Operationen ohne Race Conditions
Wenn mehrere Threads gleichzeitig in denselben Speicherbereich schreiben, entsteht ohne Synchronisation eine Race Condition. Das klassische Problem: Thread A liest einen Wert (z.B. 5), Thread B liest denselben Wert (5), Thread A schreibt 6 zurück, Thread B schreibt ebenfalls 6 zurück – obwohl beide einmal inkrementiert haben, steht im Speicher nur 6, nicht 7. Das nennt man ein "lost update". Das Atomics-Objekt in JavaScript löst dieses Problem durch atomare Operationen: Lesen-Modifizieren-Schreiben in einem einzigen, unteilbaren Schritt.
Atomics.add(array, index, value) inkrementiert den Wert an einer Position atomisch – kein anderer Thread kann dazwischenkommen. Atomics.load(array, index) liest einen Wert mit garantierter Sichtbarkeit der neuesten Schreiboperation anderer Threads. Atomics.store(array, index, value) schreibt einen Wert, der sofort für alle anderen Threads sichtbar ist. Atomics arbeitet ausschließlich mit Int8Array, Int16Array, Int32Array, BigInt64Array und deren unsigned Varianten – alle über einem SharedArrayBuffer.
// Atomic operations — safe concurrent access without race conditions
const sab = new SharedArrayBuffer(16);
const int32 = new Int32Array(sab);
// Atomic increment — returns OLD value, no lost updates
const oldValue = Atomics.add(int32, 0, 1);
console.log('Was:', oldValue, 'Now:', int32[0]);
// Atomic compare-and-exchange: only writes if current value matches expected
// Returns the value BEFORE the exchange (whether it succeeded or not)
const wasExpected = Atomics.compareExchange(
int32, // typed array
0, // index
5, // expected value — only swap if current === 5
10, // replacement value
);
if (wasExpected === 5) {
console.log('Exchange succeeded — now 10');
} else {
console.log('Exchange failed — was', wasExpected, 'not 5');
}
// Atomic AND, OR, XOR for bit manipulation
Atomics.or(int32, 1, 0b00001000); // set bit 3 atomically
Atomics.and(int32, 1, 0b11110111); // clear bit 3 atomically
// Fence: ensure all prior stores are visible before continuing
Atomics.store(int32, 2, 1); // write sentinel value
// Any Atomics.load() after this on another thread will see the store above
5. Atomics.wait() und Atomics.notify() – Thread-Synchronisation
Atomics.wait() und Atomics.notify() implementieren das klassische Condvar-Muster (Condition Variable) aus der systemnahen Programmierung. Ein Thread ruft Atomics.wait(array, index, expectedValue) auf und blockiert, bis der Wert an der angegebenen Position nicht mehr expectedValue ist – oder bis ein Timeout abläuft. Ein anderer Thread ändert den Wert und ruft Atomics.notify(array, index, count) auf, um wartende Threads aufzuwecken. Das ist die Grundlage für Mutexe, Semaphore und Producer-Consumer-Queues in JavaScript.
Ein wichtiger Unterschied: Atomics.wait() kann nur in Worker-Threads aufgerufen werden, nicht im Hauptthread des Browsers (der darf nicht blockieren). Im Hauptthread gibt es seit 2024 Atomics.waitAsync(), das einen Promise zurückgibt statt zu blockieren. In Node.js ist Atomics.wait() im Hauptthread erlaubt, weil Node.js für I/O-intensive Workloads anders konzipiert ist. Atomics.notify() hingegen kann von überall aufgerufen werden.
6. Mutex-Implementierung mit Atomics.compareExchange()
Ein Mutex (Mutual Exclusion Lock) ist die grundlegendste Synchronisationsprimitive für geteilten Speicher. Mit Atomics.compareExchange() lässt sich ein Spinlock implementieren: Der Lock-Wert ist 0 (unlocked) oder 1 (locked). Ein Thread versucht, mit compareExchange von 0 auf 1 zu wechseln. Wenn der Rückgabewert 0 ist, hat er erfolgreich gelockt. Wenn nicht, war der Mutex bereits gesperrt, und der Thread muss warten. Mit Atomics.wait() kann er effizienter warten als mit einem Busy-Wait-Loop.
Der kritische Abschnitt (Critical Section) zwischen Lock und Unlock wird von maximal einem Thread gleichzeitig ausgeführt – alle anderen warten. Beim Entsperren setzt der Thread den Wert zurück auf 0 und ruft Atomics.notify() auf, um wartende Threads zu wecken. Dieses Pattern ist die Grundlage für alle höheren Synchronisationsprimitiven: Semaphore, Read-Write-Locks und Condition Variables. In der Praxis sind solche Strukturen die Basis für performante Parser und Encoder, die große Datenmengen in Chunks parallelisiert verarbeiten.
// Mutex implementation using Atomics.compareExchange()
// Index 0 in the Int32Array is the lock: 0=unlocked, 1=locked
class SharedMutex {
constructor(sab, byteOffset = 0) {
this.lock = new Int32Array(sab, byteOffset, 1);
}
acquire() {
// Spin until lock is acquired
while (true) {
// Try to change 0 → 1; returns old value
const prev = Atomics.compareExchange(this.lock, 0, 0, 1);
if (prev === 0) return; // acquired successfully
// Lock was taken — wait until notified (efficient block)
Atomics.wait(this.lock, 0, 1);
}
}
release() {
Atomics.store(this.lock, 0, 0); // release lock
Atomics.notify(this.lock, 0, 1); // wake one waiting thread
}
}
// Usage in worker:
self.onmessage = ({ data: { sab } }) => {
const mutex = new SharedMutex(sab, 0);
const counter = new Int32Array(sab, 4, 1); // counter at offset 4
for (let i = 0; i < 10000; i++) {
mutex.acquire();
counter[0]++; // critical section — safe with mutex
mutex.release();
}
};
7. Atomics und WebAssembly: WASM-Integration
Einer der wichtigsten Anwendungsfälle von SharedArrayBuffer und Atomics ist die Integration mit WebAssembly. WASM-Module, die aus C++, Rust oder Go kompiliert wurden, erwarten oft Shared Memory – genau das, was SharedArrayBuffer bietet. Emscripten-kompilierter C++-Code, der POSIX-Threads verwendet, nutzt intern SharedArrayBuffer für den Thread-Stack und Atomics für Pthread-Synchronisation. Ohne diese APIs wäre Threading in WASM im Browser nicht möglich.
Für JavaScript-Entwickler bedeutet das: Wenn man ein rechenintensives WASM-Modul (z.B. einen Bildfilter, Codec oder Physik-Simulator) in einem Worker laufen lässt und Ergebnisse effizient zurückbekommen will, ist SharedArrayBuffer der richtige Kanal. Statt das gesamte Ergebnis zu serialisieren und via postMessage zu übertragen, schreibt das WASM-Modul direkt in den geteilten Buffer, und der Hauptthread liest die Daten sofort. Das kann bei großen Puffern (z.B. einem Full-HD-Videobild: 8 MB) mehrere Millisekunden Latenz einsparen.
8. Praktische Anwendungsfälle im Frontend
Die drei häufigsten Anwendungsfälle für SharedArrayBuffer und Atomics im Frontend sind Bildverarbeitung, Audio-Verarbeitung und strukturierte Parallelarbeit. Bei der Bildverarbeitung arbeiten mehrere Worker auf verschiedenen Teilen eines Bildes gleichzeitig – z.B. teilt man ein 4K-Bild in vier horizontale Streifen, jeder Worker verarbeitet einen Streifen im selben SharedArrayBuffer, und das Ergebnis liegt ohne Kopierschritt im Hauptthread bereit. Das ermöglicht Echtzeitfilter, die ohne Shared Memory zu langsam wären.
Für Audio bietet die Web Audio API seit neuem den AudioWorkletProcessor, der in einem eigenen Thread läuft. Mit SharedArrayBuffer kann ein Analyse-Worker permanent Audio-Samples aus dem Worklet-Thread lesen, ohne dass jedes Sample via postMessage übertragen werden muss. Das eliminiert die Jitter-Probleme, die bei Message-basiertem Audio-Streaming entstehen. Als dritter Anwendungsfall: JSON-Parser und Kompressionsalgorithmen, die large Inputs parallel in Chunks verarbeiten – jeder Worker nimmt sich einen Chunk aus einem geteilten Input-Buffer und schreibt das Ergebnis in einen geteilten Output-Buffer.
9. Atomics vs. postMessage: Vergleich
Die Wahl zwischen Atomics/SharedArrayBuffer und postMessage hängt vom Datenvolumen und der Kommunikationsfrequenz ab. Für seltene, kleine Nachrichten ist postMessage einfacher und ausreichend. Für hochfrequente Kommunikation oder große Datenmengen ist Shared Memory klar überlegen. Die Entscheidungsgrundlage liegt in der Messung: postMessage mit einem 1 MB ArrayBuffer kostet typischerweise 1-5 ms für die Serialisierung und den Transfer; der gleiche Zugriff via SharedArrayBuffer kostet Nanosekunden.
| Kriterium | postMessage | SharedArrayBuffer + Atomics | Empfehlung |
|---|---|---|---|
| Serialisierungsaufwand | O(n) – strukturiertes Klonen | Keiner – Direktzugriff | Shared Memory bei >10 KB |
| Synchronisation | Automatisch (Kopie) | Manuell (Atomics) | postMessage für einfache Fälle |
| Setup-Komplexität | Gering | Hoch (COOP/COEP Header) | Shared Memory nur wenn nötig |
| Latenz bei 1 MB | 1–5 ms | <1 µs | Shared Memory bei großen Daten |
| Debugging | Einfach | Schwer (Race Conditions) | Mutex-Abstraktion verwenden |
Die Grundregel: SharedArrayBuffer und Atomics sind Werkzeuge für Hochleistungsszenarien, nicht für allgemeine Worker-Kommunikation. Sie bringen echte Komplexität mit – Race Conditions, Deadlock-Risiken, COOP/COEP-Anforderungen – und sollten nur eingesetzt werden, wenn postMessage messbar zum Bottleneck wird. In der Praxis ist das selten der Fall, aber wenn es der Fall ist, gibt es keine gleichwertige Alternative im Browser-Ökosystem.
Mironsoft
JavaScript-Performance, Web Workers und WebAssembly-Integration
Hochleistungs-JavaScript mit Shared Memory implementieren?
Wir helfen bei der Konzeption und Implementierung von parallelen JavaScript-Architekturen mit SharedArrayBuffer, Atomics und WebAssembly-Integration.
Performance-Audit
Analyse ob SharedArrayBuffer ein echter Bottleneck-Löser ist oder postMessage ausreicht
Architektur
Worker-Pool-Design, Mutex-Abstraktionen und sichere Shared-Memory-Protokolle
WASM-Integration
WebAssembly mit SharedArrayBuffer verbinden und Threading für WASM-Module aktivieren
10. Zusammenfassung
SharedArrayBuffer ermöglicht echtes Shared Memory zwischen JavaScript-Threads – ohne Serialisierungsaufwand, ohne postMessage-Overhead. Atomics stellt die Synchronisationsprimitiven bereit, die für sicheren concurrent Zugriff auf diesen geteilten Speicher notwendig sind: atomare Lese-/Schreiboperationen, Compare-and-Exchange für Lock-freie Algorithmen, und wait/notify für effizientes Warten. Zusammen ermöglichen sie Parallelverarbeitungsszenarien, die vorher im Browser nicht umsetzbar waren.
Die Eintrittshürde ist bewusst hoch: COOP/COEP-Header müssen konfiguriert sein, Race Conditions müssen verstanden und vermieden werden, und die Debugging-Erfahrung ist komplexer als bei postMessage. Atomics und SharedArrayBuffer sind kein Ersatz für postMessage in normalen Worker-Szenarien – sie sind die richtige Wahl für Hochleistungsszenarien mit großen Datenmengen, WASM-Integration oder rechenintensive Bildverarbeitung. Wer diese Werkzeuge beherrscht, hat Zugang zu einer Klasse von Optimierungen, die im JavaScript-Ökosystem einzigartig ist.
Atomics und SharedArrayBuffer — Das Wichtigste auf einen Blick
SharedArrayBuffer
Geteilter Rohspeicher zwischen Threads ohne Kopieraufwand. Anforderung: COOP + COEP HTTP-Header für cross-origin isolation.
Atomics-Operationen
add, sub, and, or, xor, load, store als atomare Operationen. compareExchange für Lock-freie Algorithmen und Mutex-Implementierungen.
Thread-Synchronisation
Atomics.wait() blockiert Worker-Threads effizient. Atomics.notify() weckt wartende Threads. waitAsync() für den nicht-blockierenden Hauptthread.
Wann einsetzen
Nur wenn postMessage messbar zum Bottleneck wird. Bildverarbeitung, Audio-Worklets, WASM-Integration und große Binärdaten sind die richtigen Szenarien.