Erschließen Sie echtes Multithreading in JavaScript. Dieser umfassende Leitfaden behandelt SharedArrayBuffer, Atomics, Web Workers und die Sicherheitsanforderungen für hochleistungsfähige Webanwendungen.
JavaScript SharedArrayBuffer: Ein tiefer Einblick in die nebenläufige Programmierung im Web
Seit Jahrzehnten ist die Single-Thread-Natur von JavaScript sowohl eine Quelle seiner Einfachheit als auch ein erheblicher Performance-Engpass. Das Event-Loop-Modell funktioniert wunderbar für die meisten UI-gesteuerten Aufgaben, stößt aber bei rechenintensiven Operationen an seine Grenzen. Langlaufende Berechnungen können den Browser blockieren und zu einer frustrierenden Benutzererfahrung führen. Während Web Workers eine Teillösung boten, indem sie Skripten erlaubten, im Hintergrund zu laufen, hatten sie ihre eigene große Einschränkung: ineffiziente Datenkommunikation.
Hier kommt SharedArrayBuffer
(SAB) ins Spiel, ein leistungsstarkes Feature, das die Spielregeln grundlegend ändert, indem es echte, Low-Level-Speicherfreigabe zwischen Threads im Web einführt. Gepaart mit dem Atomics
-Objekt eröffnet SAB eine neue Ära hochleistungsfähiger, nebenläufiger Anwendungen direkt im Browser. Doch mit großer Macht kommt große Verantwortung – und Komplexität.
Dieser Leitfaden nimmt Sie mit auf einen tiefen Einblick in die Welt der nebenläufigen Programmierung in JavaScript. Wir werden untersuchen, warum wir sie brauchen, wie SharedArrayBuffer
und Atomics
funktionieren, die kritischen Sicherheitsaspekte, die Sie beachten müssen, und praktische Beispiele, um Ihnen den Einstieg zu erleichtern.
Die alte Welt: JavaScripts Single-Thread-Modell und seine Grenzen
Bevor wir die Lösung würdigen können, müssen wir das Problem vollständig verstehen. Die JavaScript-Ausführung in einem Browser findet traditionell in einem einzigen Thread statt, der oft als „Main-Thread“ oder „UI-Thread“ bezeichnet wird.
Die Ereignisschleife (Event Loop)
Der Main-Thread ist für alles verantwortlich: die Ausführung Ihres JavaScript-Codes, das Rendern der Seite, die Reaktion auf Benutzerinteraktionen (wie Klicks und Scrolls) und die Ausführung von CSS-Animationen. Er verwaltet diese Aufgaben mithilfe einer Ereignisschleife, die kontinuierlich eine Warteschlange von Nachrichten (Tasks) verarbeitet. Wenn eine Aufgabe lange dauert, blockiert sie die gesamte Warteschlange. Nichts anderes kann passieren – die Benutzeroberfläche friert ein, Animationen stottern und die Seite reagiert nicht mehr.
Web Workers: Ein Schritt in die richtige Richtung
Web Workers wurden eingeführt, um dieses Problem zu entschärfen. Ein Web Worker ist im Wesentlichen ein Skript, das in einem separaten Hintergrund-Thread läuft. Sie können aufwendige Berechnungen an einen Worker auslagern und so den Main-Thread für die Verarbeitung der Benutzeroberfläche freihalten.
Die Kommunikation zwischen dem Main-Thread und einem Worker erfolgt über die postMessage()
-API. Wenn Sie Daten senden, werden diese durch den Structured-Clone-Algorithmus verarbeitet. Das bedeutet, die Daten werden serialisiert, kopiert und dann im Kontext des Workers deserialisiert. Obwohl effektiv, hat dieser Prozess bei großen Datensätzen erhebliche Nachteile:
- Performance-Overhead: Das Kopieren von Megabytes oder sogar Gigabytes an Daten zwischen Threads ist langsam und CPU-intensiv.
- Speicherverbrauch: Es wird ein Duplikat der Daten im Speicher erstellt, was bei Geräten mit begrenztem Speicher ein großes Problem sein kann.
Stellen Sie sich einen Video-Editor im Browser vor. Einen ganzen Videorahmen (der mehrere Megabytes groß sein kann) 60 Mal pro Sekunde zur Verarbeitung an einen Worker hin und her zu senden, wäre unerschwinglich teuer. Genau dieses Problem wurde mit SharedArrayBuffer
gelöst.
Der Game-Changer: Die Einführung von SharedArrayBuffer
Ein SharedArrayBuffer
ist ein Rohdatenpuffer mit fester Länge, ähnlich einem ArrayBuffer
. Der entscheidende Unterschied ist, dass ein SharedArrayBuffer
über mehrere Threads (z. B. den Main-Thread und einen oder mehrere Web Workers) geteilt werden kann. Wenn Sie einen SharedArrayBuffer
mit postMessage()
„senden“, senden Sie keine Kopie, sondern eine Referenz auf den selben Speicherblock.
Das bedeutet, dass alle Änderungen an den Pufferdaten, die von einem Thread vorgenommen werden, für alle anderen Threads, die eine Referenz darauf haben, sofort sichtbar sind. Dies eliminiert den kostspieligen Kopier- und Serialisierungsschritt und ermöglicht einen nahezu sofortigen Datenaustausch.
Stellen Sie es sich so vor:
- Web Workers mit
postMessage()
: Das ist, als ob zwei Kollegen an einem Dokument arbeiten, indem sie sich Kopien per E-Mail hin und her schicken. Jede Änderung erfordert das Senden einer komplett neuen Kopie. - Web Workers mit
SharedArrayBuffer
: Das ist, als ob zwei Kollegen am selben Dokument in einem geteilten Online-Editor (wie Google Docs) arbeiten. Änderungen sind für beide in Echtzeit sichtbar.
Die Gefahr von geteiltem Speicher: Race Conditions
Die sofortige Speicherfreigabe ist leistungsstark, führt aber auch ein klassisches Problem aus der Welt der nebenläufigen Programmierung ein: Race Conditions.
Eine Race Condition tritt auf, wenn mehrere Threads versuchen, gleichzeitig auf dieselben geteilten Daten zuzugreifen und sie zu ändern, und das Endergebnis von der unvorhersehbaren Reihenfolge ihrer Ausführung abhängt. Betrachten Sie einen einfachen Zähler, der in einem SharedArrayBuffer
gespeichert ist. Sowohl der Main-Thread als auch ein Worker möchten ihn inkrementieren.
- Thread A liest den aktuellen Wert, der 5 ist.
- Bevor Thread A den neuen Wert schreiben kann, pausiert das Betriebssystem ihn und wechselt zu Thread B.
- Thread B liest den aktuellen Wert, der immer noch 5 ist.
- Thread B berechnet den neuen Wert (6) und schreibt ihn zurück in den Speicher.
- Das System wechselt zurück zu Thread A. Er weiß nicht, dass Thread B etwas getan hat. Er setzt dort fort, wo er aufgehört hat, berechnet seinen neuen Wert (5 + 1 = 6) und schreibt 6 zurück in den Speicher.
Obwohl der Zähler zweimal inkrementiert wurde, ist der Endwert 6, nicht 7. Die Operationen waren nicht atomar – sie waren unterbrechbar, was zu Datenverlust führte. Genau aus diesem Grund können Sie einen SharedArrayBuffer
nicht ohne seinen entscheidenden Partner verwenden: das Atomics
-Objekt.
Der Wächter des geteilten Speichers: Das Atomics
-Objekt
Das Atomics
-Objekt stellt eine Reihe von statischen Methoden zur Verfügung, um atomare Operationen auf SharedArrayBuffer
-Objekten durchzuführen. Eine atomare Operation wird garantiert in ihrer Gesamtheit ausgeführt, ohne von einer anderen Operation unterbrochen zu werden. Sie geschieht entweder vollständig oder gar nicht.
Die Verwendung von Atomics
verhindert Race Conditions, indem sichergestellt wird, dass Lese-Änderungs-Schreib-Operationen auf geteiltem Speicher sicher ausgeführt werden.
Wichtige Atomics
-Methoden
Schauen wir uns einige der wichtigsten Methoden an, die Atomics
bereitstellt.
Atomics.load(typedArray, index)
: Liest atomar den Wert an einem gegebenen Index und gibt ihn zurück. Dies stellt sicher, dass Sie einen vollständigen, nicht korrumpierten Wert lesen.Atomics.store(typedArray, index, value)
: Speichert atomar einen Wert an einem gegebenen Index und gibt diesen Wert zurück. Dies stellt sicher, dass die Schreiboperation nicht unterbrochen wird.Atomics.add(typedArray, index, value)
: Addiert atomar einen Wert zu dem Wert am gegebenen Index. Es gibt den ursprünglichen Wert an dieser Position zurück. Dies ist das atomare Äquivalent vonx += value
.Atomics.sub(typedArray, index, value)
: Subtrahiert atomar einen Wert von dem Wert am gegebenen Index.Atomics.compareExchange(typedArray, index, expectedValue, replacementValue)
: Dies ist ein leistungsstarker bedingter Schreibvorgang. Er prüft, ob der Wert beiindex
gleichexpectedValue
ist. Wenn ja, ersetzt er ihn durchreplacementValue
und gibt den ursprünglichenexpectedValue
zurück. Wenn nicht, tut er nichts und gibt den aktuellen Wert zurück. Dies ist ein fundamentaler Baustein zur Implementierung komplexerer Synchronisationsprimitive wie Locks.
Synchronisation: Jenseits einfacher Operationen
Manchmal braucht man mehr als nur sicheres Lesen und Schreiben. Man braucht Threads, die sich koordinieren und aufeinander warten. Ein häufiges Anti-Pattern ist das „Busy-Waiting“, bei dem ein Thread in einer engen Schleife sitzt und ständig einen Speicherort auf eine Änderung überprüft. Dies verschwendet CPU-Zyklen und entleert den Akku.
Atomics
bietet eine viel effizientere Lösung mit wait()
und notify()
.
Atomics.wait(typedArray, index, value, timeout)
: Dies weist einen Thread an, in den Ruhezustand zu gehen. Es prüft, ob der Wert beiindex
immer nochvalue
ist. Wenn ja, schläft der Thread, bis er vonAtomics.notify()
aufgeweckt wird oder bis der optionaletimeout
(in Millisekunden) erreicht ist. Wenn sich der Wert beiindex
bereits geändert hat, kehrt es sofort zurück. Dies ist unglaublich effizient, da ein schlafender Thread fast keine CPU-Ressourcen verbraucht.Atomics.notify(typedArray, index, count)
: Dies wird verwendet, um Threads aufzuwecken, die überAtomics.wait()
an einem bestimmten Speicherort schlafen. Es werden höchstenscount
wartende Threads aufgeweckt (oder alle, wenncount
nicht angegeben oderInfinity
ist).
Alles zusammenfügen: Ein praktischer Leitfaden
Nachdem wir nun die Theorie verstehen, gehen wir die Schritte zur Implementierung einer Lösung mit SharedArrayBuffer
durch.
Schritt 1: Die Sicherheitsvoraussetzung – Cross-Origin-Isolation
Dies ist der häufigste Stolperstein für Entwickler. Aus Sicherheitsgründen ist SharedArrayBuffer
nur auf Seiten verfügbar, die sich in einem cross-origin isolated Zustand befinden. Dies ist eine Sicherheitsmaßnahme zur Minderung von spekulativen Ausführungs-Schwachstellen wie Spectre, die potenziell hochauflösende Zeitgeber (ermöglicht durch geteilten Speicher) nutzen könnten, um Daten über verschiedene Ursprünge hinweg zu leaken.
Um die Cross-Origin-Isolation zu aktivieren, müssen Sie Ihren Webserver so konfigurieren, dass er zwei spezifische HTTP-Header für Ihr Hauptdokument sendet:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
(COOP): Isoliert den Browserkontext Ihres Dokuments von anderen Dokumenten und verhindert so, dass diese direkt mit Ihrem window-Objekt interagieren.Cross-Origin-Embedder-Policy: require-corp
(COEP): Erfordert, dass alle von Ihrer Seite geladenen Unterressourcen (wie Bilder, Skripte und Iframes) entweder vom selben Ursprung stammen oder explizit mit demCross-Origin-Resource-Policy
-Header oder CORS als cross-origin ladbar markiert sind.
Dies kann eine Herausforderung bei der Einrichtung sein, insbesondere wenn Sie auf Skripte oder Ressourcen von Drittanbietern angewiesen sind, die nicht die erforderlichen Header bereitstellen. Nachdem Sie Ihren Server konfiguriert haben, können Sie überprüfen, ob Ihre Seite isoliert ist, indem Sie die Eigenschaft self.crossOriginIsolated
in der Browser-Konsole prüfen. Sie muss true
sein.
Schritt 2: Erstellen und Teilen des Puffers
In Ihrem Hauptskript erstellen Sie den SharedArrayBuffer
und eine „Sicht“ darauf mithilfe eines TypedArray
wie Int32Array
.
main.js:
// Zuerst auf Cross-Origin-Isolation prüfen!
if (!self.crossOriginIsolated) {
console.error("Diese Seite ist nicht Cross-Origin-isoliert. SharedArrayBuffer wird nicht verfügbar sein.");
} else {
// Einen geteilten Puffer für eine 32-Bit-Ganzzahl erstellen.
const buffer = new SharedArrayBuffer(4);
// Eine Sicht auf den Puffer erstellen. Alle atomaren Operationen finden auf der Sicht statt.
const int32Array = new Int32Array(buffer);
// Den Wert bei Index 0 initialisieren.
int32Array[0] = 0;
// Einen neuen Worker erstellen.
const worker = new Worker('worker.js');
// Den GETEILTEN Puffer an den Worker senden. Dies ist eine Referenzübergabe, keine Kopie.
worker.postMessage({ buffer });
// Auf Nachrichten vom Worker lauschen.
worker.onmessage = (event) => {
console.log(`Worker meldet Abschluss. Endgültiger Wert: ${Atomics.load(int32Array, 0)}`);
};
}
Schritt 3: Atomare Operationen im Worker durchführen
Der Worker empfängt den Puffer und kann nun atomare Operationen darauf ausführen.
worker.js:
self.onmessage = (event) => {
const { buffer } = event.data;
const int32Array = new Int32Array(buffer);
console.log("Worker hat den geteilten Puffer erhalten.");
// Führen wir einige atomare Operationen durch.
for (let i = 0; i < 1000000; i++) {
// Den geteilten Wert sicher inkrementieren.
Atomics.add(int32Array, 0, 1);
}
console.log("Worker hat das Inkrementieren beendet.");
// Dem Main-Thread signalisieren, dass wir fertig sind.
self.postMessage({ done: true });
};
Schritt 4: Ein fortgeschritteneres Beispiel – Parallele Summierung mit Synchronisation
Nehmen wir uns ein realistischeres Problem vor: die Summierung eines sehr großen Zahlenarrays mithilfe mehrerer Worker. Wir werden Atomics.wait()
und Atomics.notify()
für eine effiziente Synchronisation verwenden.
Unser geteilter Puffer wird drei Teile haben:
- Index 0: Ein Status-Flag (0 = in Bearbeitung, 1 = abgeschlossen).
- Index 1: Ein Zähler, wie viele Worker fertig sind.
- Index 2: Die endgültige Summe.
main.js:
if (self.crossOriginIsolated) {
const NUM_WORKERS = 4;
const DATA_SIZE = 10_000_000;
// [status, workers_finished, result_low, result_high]
// Wir verwenden zwei 32-Bit-Ganzzahlen für das Ergebnis, um einen Überlauf bei großen Summen zu vermeiden.
const sharedBuffer = new SharedArrayBuffer(4 * 4); // 4 Ganzzahlen
const sharedArray = new Int32Array(sharedBuffer);
// Einige Zufallsdaten zur Verarbeitung generieren
const data = new Uint8Array(DATA_SIZE);
for (let i = 0; i < DATA_SIZE; i++) {
data[i] = Math.floor(Math.random() * 10);
}
const chunkSize = Math.ceil(DATA_SIZE / NUM_WORKERS);
for (let i = 0; i < NUM_WORKERS; i++) {
const worker = new Worker('sum_worker.js');
const start = i * chunkSize;
const end = Math.min(start + chunkSize, DATA_SIZE);
// Eine nicht geteilte Sicht für den Daten-Chunk des Workers erstellen
const dataChunk = data.subarray(start, end);
worker.postMessage({
sharedBuffer,
dataChunk // Dies wird kopiert
});
}
console.log('Main-Thread wartet nun auf den Abschluss der Worker...');
// Warten, bis das Status-Flag bei Index 0 zu 1 wird
// Das ist viel besser als eine while-Schleife!
Atomics.wait(sharedArray, 0, 0); // Warten, wenn sharedArray[0] 0 ist
console.log('Main-Thread aufgeweckt!');
const finalSum = Atomics.load(sharedArray, 2);
console.log(`Die endgültige parallele Summe ist: ${finalSum}`);
} else {
console.error('Seite ist nicht Cross-Origin-isoliert.');
}
sum_worker.js:
self.onmessage = ({ data }) => {
const { sharedBuffer, dataChunk } = data;
const sharedArray = new Int32Array(sharedBuffer);
// Die Summe für den Chunk dieses Workers berechnen
let localSum = 0;
for (let i = 0; i < dataChunk.length; i++) {
localSum += dataChunk[i];
}
// Die lokale Summe atomar zur geteilten Gesamtsumme addieren
Atomics.add(sharedArray, 2, localSum);
// Den 'fertige Worker'-Zähler atomar inkrementieren
const finishedCount = Atomics.add(sharedArray, 1, 1) + 1;
// Wenn dies der letzte Worker ist, der fertig wird...
const NUM_WORKERS = 4; // Sollte in einer echten App übergeben werden
if (finishedCount === NUM_WORKERS) {
console.log('Letzter Worker fertig. Benachrichtige Main-Thread.');
// 1. Das Status-Flag auf 1 setzen (abgeschlossen)
Atomics.store(sharedArray, 0, 1);
// 2. Den Main-Thread benachrichtigen, der auf Index 0 wartet
Atomics.notify(sharedArray, 0, 1);
}
};
Anwendungsfälle und Applikationen in der Praxis
Wo macht diese leistungsstarke, aber komplexe Technologie tatsächlich einen Unterschied? Sie glänzt in Anwendungen, die aufwendige, parallelisierbare Berechnungen auf großen Datensätzen erfordern.
- WebAssembly (Wasm): Dies ist der Killer-Anwendungsfall. Sprachen wie C++, Rust und Go haben ausgereifte Unterstützung für Multithreading. Wasm ermöglicht es Entwicklern, diese bestehenden hochleistungsfähigen, multi-threaded Anwendungen (wie Spiel-Engines, CAD-Software und wissenschaftliche Modelle) für den Browser zu kompilieren, wobei
SharedArrayBuffer
als zugrunde liegender Mechanismus für die Thread-Kommunikation dient. - Datenverarbeitung im Browser: Groß angelegte Datenvisualisierungen, clientseitige Inferenz von Machine-Learning-Modellen und wissenschaftliche Simulationen, die riesige Datenmengen verarbeiten, können erheblich beschleunigt werden.
- Medienbearbeitung: Das Anwenden von Filtern auf hochauflösende Bilder oder die Audioverarbeitung einer Sounddatei kann in Blöcke aufgeteilt und parallel von mehreren Workern verarbeitet werden, was dem Benutzer Echtzeit-Feedback gibt.
- Hochleistungs-Gaming: Moderne Spiel-Engines setzen stark auf Multithreading für Physik, KI und das Laden von Assets.
SharedArrayBuffer
ermöglicht es, Spiele in Konsolenqualität zu entwickeln, die vollständig im Browser laufen.
Herausforderungen und abschließende Überlegungen
Obwohl SharedArrayBuffer
transformativ ist, ist es kein Allheilmittel. Es ist ein Low-Level-Werkzeug, das sorgfältige Handhabung erfordert.
- Komplexität: Nebenläufige Programmierung ist notorisch schwierig. Das Debuggen von Race Conditions und Deadlocks kann unglaublich herausfordernd sein. Sie müssen anders darüber nachdenken, wie der Zustand Ihrer Anwendung verwaltet wird.
- Deadlocks: Ein Deadlock tritt auf, wenn zwei oder mehr Threads für immer blockiert sind, weil jeder darauf wartet, dass der andere eine Ressource freigibt. Dies kann passieren, wenn Sie komplexe Sperrmechanismen falsch implementieren.
- Sicherheits-Overhead: Die Anforderung der Cross-Origin-Isolation ist eine erhebliche Hürde. Sie kann Integrationen mit Drittanbieterdiensten, Werbung und Zahlungs-Gateways unterbrechen, wenn diese nicht die notwendigen CORS/CORP-Header unterstützen.
- Nicht für jedes Problem geeignet: Für einfache Hintergrundaufgaben oder I/O-Operationen ist das traditionelle Web-Worker-Modell mit
postMessage()
oft einfacher und ausreichend. Greifen Sie nur zuSharedArrayBuffer
, wenn Sie einen klaren, CPU-gebundenen Engpass haben, der große Datenmengen betrifft.
Fazit
SharedArrayBuffer
stellt in Verbindung mit Atomics
und Web Workers einen Paradigmenwechsel für die Webentwicklung dar. Es sprengt die Grenzen des Single-Thread-Modells und lädt eine neue Klasse leistungsstarker, performanter und komplexer Anwendungen in den Browser ein. Es stellt die Web-Plattform bei rechenintensiven Aufgaben auf eine gleichere Stufe mit der nativen Anwendungsentwicklung.
Der Weg in die nebenläufige JavaScript-Programmierung ist anspruchsvoll und erfordert einen rigorosen Ansatz für Zustandsverwaltung, Synchronisation und Sicherheit. Aber für Entwickler, die die Grenzen des Möglichen im Web ausloten möchten – von Echtzeit-Audiosynthese über komplexe 3D-Renderings bis hin zu wissenschaftlichem Rechnen – ist die Beherrschung von SharedArrayBuffer
nicht mehr nur eine Option; sie ist eine wesentliche Fähigkeit für die Entwicklung der nächsten Generation von Webanwendungen.