Gedächtnis teilen zwischen Node.js-Worker-Threads mit SharedArrayBuffer und Atomics
Warum gibt postMessage jedem Node.js Worker eine private Kopie, obwohl SharedArrayBuffer sinnvoller wäre, und wie Atomics verhindert, dass Updates verloren gehen, sowie das Warten zwischen Threads ermöglicht.
Quellen: The Day I Learned JavaScript Threads Don’t Actually Share Memory
Node.js-Worker-Threads sind echte Betriebssystem-Threads, wobei jeder seine eigene JavaScript-Engine-Instanz besitzt. Das verleitet dazu, anzunehmen, sie teilen sich das Speichergerüst wie Threads in C oder Java, doch diese Annahme ist falsch, sobald zwei Worker gemeinsam an denselben Daten arbeiten sollen. Dieser Artikel zeigt, was postMessage tatsächlich mit Ihren Werten macht, wann ein echtes gemeinsames Speichergerüst sinnvoll ist und wie Atomics dafür sorgt, dass gleichzeitige Aktualisierungen korrekt bleiben.
Falls Sie mit Worker-Threads im Allgemeinen noch nicht vertraut sind, beginnen Sie mit der Übertragung rechenintensiver Aufgaben in Node.js mithilfe von Worker-Threads und Pools; der Fokus hier ist enger: Was geschieht mit dem Speicher, sobald ein Worker vorhanden ist.
postMessage sendet eine Kopie, nicht einen Verweis
Das Experiment beginnt damit, dass der Hauptthread einen Worker aus einer separaten Datei erstellt.
// main.js
const { Worker } = require("worker_threads");
const worker = new Worker("./worker.js");
Anschließend sendet er ein gewöhnliches Array an den Worker und protokolliert kurz darauf seine eigene Kopie dieses Arrays.
const sharedLookingArray = [1, 2, 3];
worker.postMessage(sharedLookingArray);setTimeout(() => {
console.log("Main thread's array:", sharedLookingArray); // still [1, 2, 3]
}, 100);
Auf der anderen Seite erhält der Worker das Array, fügt ein Element hinzu und protokolliert das Ergebnis.
// worker.js
const { parentPort } = require("worker_threads");
parentPort.on("message", (arr) => {
arr.push(4);
console.log("Worker's array:", arr); // [1, 2, 3, 4]
});
Der Worker gibt [1, 2, 3, 4] aus, während der Hauptthread unabhängig davon, wie lange er wartet, weiterhin [1, 2, 3] ausgibt. Der Grund dafür ist, dass postMessage den Wert über den strukturierten Klonalgorithmus verarbeitet, derselbe Mechanismus, der global als structuredClone bereitgestellt wird. Die Daten werden auf der Sendeseite serialisiert und auf der Empfängenseite als eigenständiges Objekt wieder aufgebaut, sodass nach dem Aufruf zwei unverwandte Arrays vorhanden sind – eines pro Thread.
Das ist eine bewusste und sichere Standardlösung. Zwei Threads können nicht um denselben Speicher konkurrieren, da ihnen dieser niemals gleichzeitig zur Verfügung gestellt wird. Der Aufwand besteht in der Kopie selbst, die mit der Größe des Dateninhalts zunimmt und bei jeder Nachricht anfällt. (Für große binäre Daten kann Node auch einen ArrayBuffer als übertragbaren Datensatz an den anderen Thread senden, wodurch eine Kopie vermieden wird – der Sender kann ihn jedoch weiterhin nicht verwenden; das ist ein Übergeben, kein Teilen.)
Wann gemeinsamer Speicher das richtige Werkzeug ist
Kopieren wird zum falschen Kompromiss, wenn Threads häufig über dieselben Daten koordinieren müssen – so oft, dass das Hin- und Herübertragen mehr kostet als die zu parallelisierende Arbeit. Für solche Fälle bietet JavaScript SharedArrayBuffer: einen rohen Speicherblock, auf den mehrere Threads direkt zugreifen können, ohne dass Kopien angefertigt werden.
Der Hauptthread lädt erneut das Worker-Modul:
// main.js
const { Worker } = require("worker_threads");
Dann wird ein vier Byte großer gemeinsamer Puffer zugewiesen, der ausreicht für einen 32-Bit-Integer. Dieser Puffer wird in eine Int32Array-Ansicht eingebettet und über workerData an den Worker weitergegeben.
const sharedBuffer = new SharedArrayBuffer(4); // 4 bytes, one 32-bit integer
const sharedCounter = new Int32Array(sharedBuffer);const worker = new Worker("./worker.js", { workerData: { sharedBuffer } });
Der Worker erstellt seine eigene Int32Array-Ansicht über genau denselben Puffer:
// worker.js
const { workerData } = require("worker_threads");
const sharedCounter = new Int32Array(workerData.sharedBuffer);
Von diesem Zeitpunkt an ist jede Schreiboperation durch eine der Ansichten auch in der anderen sichtbar, da beide auf dieselben Bytes verweisen.
sharedCounter[0] += 1; // this thread and the main thread are now touching the same memory
Das stellt eine echte Abweichung von dem üblichen Verhalten der JavaScript-Konkurrenz dar. Code in einem einzigen Realm läuft auf einem Thread, wodurch die bekannten asynchronen Rennen, bei denen zwei async-Funktionen in einer überraschenden Reihenfolge ablaufen, weiterhin dadurch entstehen, dass ein einziger Thread an definierten Stellen abwechselnd arbeitet. Mit einem SharedArrayBuffer können hingegen zwei Threads buchstäblich zur gleichen Zeit denselben Speicher lesen und schreiben.
Die verlorene Aktualisierung, die nun möglich ist
Betrachten wir die offensichtliche Methode, um den gemeinsam genutzten Zähler zu erhöhen:
// naive, and genuinely unsafe with real shared memory
sharedCounter[0] = sharedCounter[0] + 1;
Es sieht aus wie eine einzige Operation, doch es sind drei: Der aktuelle Wert wird gelesen, eins addiert und das Ergebnis wieder geschrieben. Wenn ein anderer Thread den Zähler zwischen dem Lesen und Schreiben dieses Threads liest, werden beide denselben neuen Wert berechnen, wodurch eine Erhöhung verloren geht. Es wird nichts ausgeworfen und nichts protokolliert; die endgültige Zahl ist einfach zu niedrig. Da das Ergebnis von der genauen Abstimmung zweier Betriebssystem-Threads abhängt, kann sich der Fehler einmal in tausend Ausführungen zeigen und verschwinden, sobald Logging hinzugefügt wird.
Atome: unteilbare Operationen auf gemeinsamer Speicher
Das Atomics-Objekt bietet Operationen auf gemeinsam genutzten Ganzzahl-Arrays, die als einzige Einheit ausgeführt werden. Kein anderer Thread kann eine atomare Operation mitten im Lauf beobachten oder stören.
Atomics.add(sharedCounter, 0, 1); // read, increment, and write, as one indivisible step
Falls jeder Thread mit Atomics.add inkrementiert anstelle einer Lese-Änderung-Schreib-Vorgangsabfolge, kann keine Aktualisierung verloren gehen, denn der Engine wird garantiert, dass andere atomare Operationen an diesem Element nicht innerhalb dieser einen Operation eingeflochten werden können. Diese Garantie gilt nur, wenn alle Schreibenden atomare Operationen verwenden; ein einzelner Thread, der weiterhin eine naive Inkrementierung vornimmt, führt erneut zu Konkurrenzsituationen. Alles Komplexere, was man mit gemeinsamer Speicher nutzen könnte – sei es Zähler, Sperren oder Warteschlangen – setzt sich letztendlich aus Operationen wie dieser zusammen, die auf Hardwareebene atomar sind und nicht nur „im Allgemeinen in Ordnung“ sind.
Atomics.wait und Atomics.notify: Blockieren zwischen Threads
Atomics bietet außerdem etwas, was JavaScript ansonsten nicht hat: eine Möglichkeit dafür, dass ein Thread die Ausführung vollständig einstellt, bis ein anderer Thread ihn wieder aktiviert. Im Worker blockiert Atomics.wait, solange der Zähler noch den Wert 0 enthält:
// worker.js — waits for main thread to signal it's time to proceed
Atomics.wait(sharedCounter, 0, 0); // blocks this thread while sharedCounter[0] === 0
console.log("Signaled to continue, value is now:", sharedCounter[0]);
Der Hauptthread speichert einen neuen Wert und weckt anschließend einen wartenden Thread:
// main.js — does the signaling
Atomics.store(sharedCounter, 0, 1);
Atomics.notify(sharedCounter, 0, 1); // wakes up one waiting thread
Es ist wichtig, vor der Benachrichtigung zu speichern. Atomics.wait überprüft zunächst den Wert und gibt sofort zurück, wenn er nicht mehr dem erwarteten Wert entspricht – dadurch geht ein vor dem Start des Wartens gesendetes Signal nicht verloren.
Das ist ein echter blockierender Aufruf, was in JavaScript ungewöhnlich ist, da dort asynchrone Aufgaben normalerweise über Event-Loop-Callbacks abgewickelt werden. Das Blockieren des Threads, der den Event Loop ausführt, würde alles andere, was dieser Thread verarbeitet, einfrieren – deshalb erlauben Browser Atomics.wait nicht auf dem Hauptthread und gehört dieser Aufruf im Allgemeinen zu Worker-Threads. (Browser verlangen außerdem, dass die Seite vor der Verwendung von SharedArrayBuffer cross-origin isoliert ist; Node weist keine solche Einschränkung auf.)
Eine realistische Anwendung: Echtzeit-Statusanzeige über einen Worker-Pool
Hier ist ein Fall, in dem die zusätzliche Komplexität sich auszahlt. Mehrere CPU-intensive Worker verarbeiten jeweils einen Teil eines großen Datensatzes, und der Hauptthread benötigt eine Echtzeit-Progressanzeige, ohne dass jeder Worker für jedes Element eine Nachricht senden muss. Der Hauptthread erstellt einen gemeinsamen Zähler und gibt seinen Puffer zusammen mit dem jeweiligen Datenteil an jeden Worker weiter:
// main.js
const progress = new Int32Array(new SharedArrayBuffer(4));
const workers = chunks.map((chunk) =>
new Worker("./process-chunk.js", { workerData: { chunk, progressBuffer: progress.buffer } })
);
Anschließend wird der Zähler alle halbe Sekunde mit Atomics.load abgefragt, wodurch sichergestellt wird, dass stets ein vollständiger, aktueller Wert gelesen wird:
setInterval(() => {
console.log(`Processed ${Atomics.load(progress, 0)} items so far`);
}, 500);
Jeder Worker umhüllt den gemeinsamen Puffer mit seiner eigenen Sicht:
// process-chunk.js
const { workerData } = require("worker_threads");
const progress = new Int32Array(workerData.progressBuffer);
Während er die Elemente verarbeitet, erhöht er den gemeinsamen Zähler atomar:
for (const item of workerData.chunk) {
processItem(item);
Atomics.add(progress, 0, 1); // every worker safely increments the same shared counter
}
Sämtliche Worker schreiben direkt auf dieselben vier Bytes – es entsteht kein Overhead durch Nachrichten pro Element, und der Hauptthread kann jederzeit einen genauen Gesamtwert abrufen, ohne dass jemand Bericht erstatten muss. Genau dieses Problembild mit häufiger und enger Koordination zwischen wirklich parallelen CPU-Aufgaben rechtfertigt den Einsatz gemeinsamer Speicher statt Kopierens.
Der größte Teil des Codes sollte nicht darauf zurückgreifen
Typischer Backend-Node-Code benötigt selten etwas davon. Der größte Teil der Konkurrenzfähigkeit in einem API-Server ist I/O-basiert, was bereits der einkernige Event-Loop gut bewältigt. Für die CPU-intensiven Aufgaben, wie Bildverarbeitung, Datenumwandlung oder Kryptografie, reichen in der Regel Worker-Threads mit herkömmlichem postMessage-Kopieren aus.
SharedArrayBuffer und Atomics dienen einer eng definierten Anwendungssituation: der hochfrequenten Koordination zwischen parallelen Threads, bei der das Kopieren bei jeder Kommunikation selbst zum Engpass werden würde. Das Einsatz von gemeinsamer Speicher, bevor dieses Problem gemessen wurde, macht den Code nicht schneller; es entstehen lediglich auf Hardwareebene auftretende Konkurrenzsituationen, die durch das Kopiermodell eigentlich vermieden werden sollten.
Kernpunkte
postMessageerstellt strukturierte Kopien der Daten, sodass jeder Thread mit seiner eigenen Kopie arbeitet und Änderungen niemals auf die andere übergehen.SharedArrayBuffergewährt mehreren Threads direkten Zugriff auf dieselben Bytes über typisierte Array-Views.- Eine einfache Anweisung wie
x = x + 1auf gemeinsamer Speicher kann Updates unbemerkt verlieren; verwenden Sie stattdessen für jeden SchreibvorgangAtomics.addund ähnliche Funktionen.
Atomics.wait blockiert einen Thread tatsächlich und gehört zu den Worker-Threads; nutzen Sie es in Kombination mit Atomics.store und Atomics.notify.