Skip to main content

Multithreading in Node.js mit Worker Threads: Vor- und Nachteile

Artikel von
Headshot of James Walker

James Walker

27. Februar 2023

0 Min. Lesezeit

Node.js stellt Ihrer Anwendung eine Single-Threaded-Event-Loop zur Verfügung. CPU-intensive Vorgänge können dadurch den Hauptthread blockieren und Verzögerungen verursachen. Das Modul worker_threads begegnet diesem Problem, indem es einen Mechanismus zur parallelen Ausführung von Code mithilfe einer Form von Threading bereitstellt.

In einem früheren Beitrag haben Sie erfahren, was Worker Threads sind, wofür sie typischerweise eingesetzt werden und wie Sie sie in Ihr Projekt integrieren. In diesem Artikel betrachten wir die Fallstricke von Worker Threads und ihre Unterschiede zu Multithreading-Implementierungen in anderen Programmiersprachen. Außerdem stellen wir fünf bekannte Bibliotheken vor, die die Verwendung des Moduls worker_threads erleichtern.

Fallstricke und Tücken von Worker Threads

Die Vorteile von Worker Threads lassen sich einfach zusammenfassen: Sie sind die einzige Möglichkeit, beim Programmieren mit Node.js etwas Ähnliches wie Multithreading zu erreichen. CPU-intensive Vorgänge, Hintergrundverarbeitung und jede parallele Codeausführung außer asynchroner I/O lassen sich mit Worker Threads umsetzen.

Das Modul und das damit umgesetzte Konzept haben jedoch einige Einschränkungen. Bevor Sie mit der Implementierung Ihrer Worker beginnen, sollten Sie diese kennen, denn manche Situationen eignen sich nicht für die Parallelisierung mit diesem Mechanismus.

Worker Threads sind keine echten Threads

Die erste und wichtigste Einschränkung von Worker Threads ist, dass es sich nicht um Threads im herkömmlichen Sinn handelt. Tatsächlich multithreaded Anwendungen können mehrere Threads gleichzeitig ausführen, die standardmäßig denselben Zustand teilen. In Node.js sind Änderungen am Speicher eines Threads für die anderen nicht sichtbar. Bei der Implementierung von multithreaded Code ist ein sorgfältiges Speichermanagement nötig, um Race Conditions zu vermeiden.

Node.js-Worker Threads arbeiten unabhängig vom JavaScript-Code im Hauptprozess. Dazu starten sie eine isolierte Instanz der JavaScript-Runtime V8 von Node.js. Diese neue Runtime kann dann eine JavaScript-Datei außerhalb der Event-Loop des Hauptprozesses ausführen.

Da die Datei auf diese Weise ausgeführt wird, gibt es keine implizite Speicherfreigabe zwischen dem Hauptprogramm und dem Worker-„Thread“. Stattdessen steht ein ereignisbasiertes Nachrichtensystem zur Verfügung, über das Werte zwischen den Prozessen ausgetauscht werden können.

javascript
const {
    Worker,
    isMainThread,
    parentPort,
    workerData
} = require("worker_threads");

if (isMainThread) {
    const worker = new Worker(__filename, {workerData: "hello"});
    worker.on("message", msg => console.log(`Worker message received: ${msg}`));
    worker.on("error", err => console.error(error));
    worker.on("exit", code => console.log(`Worker exited with code ${code}.`));
}
else {
    const data = workerData;
    parentPort.postMessage(`You said \"${data}\".`);
}

Dieser Code, den Sie im ersten Teil genauer betrachtet haben, erstellt einen Worker-Prozess. Dieser empfängt einen Wert (hello) vom Hauptthread und sendet ihn in einer anderen Form zurück (You said "hello".). Mit der Funktion postMessage() werden Daten über die Grenze zwischen Hauptthread und Worker-Thread hinweg gesendet. Auf einer Seite gesetzte Variablen sind auf der anderen nicht sichtbar.

Eine Ausnahme gibt es: Mit einem SharedArrayBuffer können Sie Speicher direkt zwischen Threads teilen, indem Sie ihn ausdrücklich als gemeinsam genutzten Speicher reservieren:

javascript
const {Worker, isMainThread, parentPort} = require("worker_threads");

// Allocate memory for 4 integers
const sab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 4);
const arr = new Int32Array(sab);

if (isMainThread) {
    const worker = new Worker(__filename, {workerData: arr});
    worker.on("message", msg => {
   	 if (msg.type === "update") {
   		 console.log(arr);
   	 }
    });
    worker.postMessage({type: "init", arr});
}
else {
    parentPort.on("message", msg => {
   	 if (msg.type === "init") {
   		 msg.arr[0] = 1001;
   		 parentPort.postMessage({type: "update"});
   	 }
    });
}

Wenn Sie diesen Code in shared.js speichern und die Datei ausführen, wird folgende Ausgabe erzeugt:

$ node shared.js
Int32Array(4) [ 1001, 0, 0, 0 ]

Das funktioniert, weil der Worker-Thread auf den vom Hauptthread erstellten gemeinsamen Speicherbereich zugreifen kann. Wenn der Hauptthread die Daten später ausliest, sieht er die vom Worker vorgenommenen Änderungen. Eine echte Zustandsfreigabe ist das dennoch nicht, da Sie das Array mithilfe der Konstruktoroption workerData manuell für den Worker verfügbar machen müssen.

Zu viele Worker Threads zu starten, ist aufwendig

Einen Worker Thread zu erstellen, ist nicht dasselbe wie einen neuen Thread in einer multithreaded Sprache zu starten. Jeder Worker Thread führt eine eigene Instanz der JavaScript-Engine V8 aus. Zu viele Worker verbrauchen daher erhebliche Ressourcen auf Ihrem Host.

Worker starten zwar schnell, verursachen aber immer einen gewissen Mehraufwand. Das ist ein relativ kostspieliger Vorgang, weshalb Worker Threads für leichtgewichtige Aufgaben ungeeignet sind. Am besten eignen sie sich für die parallele Verarbeitung CPU-intensiver Aufgaben, bei denen die Performancegewinne die Kosten für den Prozessstart deutlich überwiegen.

Sie können die Ineffizienz verringern, indem Sie einen Pool von Worker Threads wiederverwenden. So vermeiden Sie die wiederholten Kosten für das Erstellen neuer Worker. Bibliotheken wie Piscina und Poolifier nehmen Ihnen die komplexe Verwaltung eines Worker-Pools ab.

Worker Threads für I/O zu verwenden, ist ineffizient

Aufgrund ihrer Funktionsweise eignen sich Worker Threads nicht für I/O-Aufgaben. Sie benötigen keine Worker Threads, um eine Datei zu lesen oder über das Netzwerk Daten abzurufen – Node.js bietet bereits bessere asynchrone Alternativen.

Die Dokumentation zu worker_threads rät ausdrücklich davon ab, das Modul in diesen Fällen zu verwenden. Das Erstellen und Verwalten des Worker-Prozesses mit einer eigenen V8-Engine ist deutlich ineffizienter als die asynchronen I/O-Implementierungen von Node.js. Wenn Sie solche Aufgaben als Worker Threads implementieren, beeinträchtigen Sie die Performance, verschwenden Ressourcen und schreiben redundanten Code.

Das Debuggen von Worker Threads kann schwierig sein

Worker Threads in einem Pool können schwer zu debuggen sein, da nicht immer klar erkennbar ist, welcher Worker ein Ereignis verarbeitet und welche Auswirkungen daraus entstehen. Mit console.log()-Anweisungen nach den Ursachen zu suchen, ist mühsam und fehleranfällig.

Wenn Sie Ihrem Pool eine AsyncResource zuweisen, erhalten Sie nützlichere Diagnoseinformationen. Sie liefert vollständige asynchrone Stack-Traces, die nachverfolgen, was im Pool geschieht. So können Sie die gesamte Abfolge der Aktivitäten nachvollziehen, die zu einer bestimmten Auswirkung führt.

Auch die gemeinsame Nutzung von Speicher mithilfe eines SharedArrayBuffer birgt Risiken. Um Race Conditions beim Zugriff auf und bei Änderungen am gemeinsamen Speicher zu vermeiden, müssen Sie Atomics verwenden oder ein eigenes System zur Verwaltung der Nebenläufigkeit implementieren. Treten Race Conditions auf, können sie zu ungewöhnlichem Verhalten in Ihrer Anwendung führen und sind oft schwer zu erkennen – insbesondere, wenn sie Speicher betreffen, der an vielen verschiedenen Stellen verwendet wird.

Die wichtigsten Threading-Bibliotheken für Node.js

Das integrierte Modul worker_threads von Node.js konzentriert sich auf die Grundlagen: Worker Threads erstellen und Daten mit ihnen austauschen. Hier sind fünf beliebte Bibliotheken, die das Modul um eine komfortablere Schnittstelle oder um Funktionen auf höherer Ebene wie Thread-Pooling ergänzen.

Piscina

Mit Piscina können Sie einfacher mit Worker-Pools arbeiten. Sie können eigene Aufgabenwarteschlangen erstellen, deren Abschluss nachverfolgen und eine Aufgabe abbrechen, die auf einem Worker ausgeführt wird, falls sie sich als überflüssig erweist.

Hier sehen Sie ein einfaches Beispiel für Piscina. Speichern Sie diesen Code in main.js:

javascript
const Piscina = require("piscina");
const piscina = new Piscina({filename: __dirname + "/worker.js"});

(async function() {
    const result = await piscina.run({msg: "hello"});
    console.log(result);    // "You said hello"
})();

Fügen Sie nun diesen Code zu worker.js hinzu:

javascript
module.exports = ({msg}) => `You said ${msg}`;

Installieren Sie das Piscina-Paket mit diesem Befehl:

$ npm install piscina

Wenn Sie node main.js ausführen, erscheint You said hello in Ihrem Terminal. Piscina bietet eine komfortablere Schnittstelle für die Worker-Threads-API.

Bree

Bree ist ein Job-Scheduler für Node.js. Damit können Sie asynchrone Aufgaben in einem festgelegten Intervall ausführen. Für jede Aufgabe lassen sich Parallelitätsgrenzen, Wiederholungen und Abbruch konfigurieren. Bree verwendet intern Worker Threads, um den Aufgabencode außerhalb der Haupt-Event-Loop auszuführen.

Installieren Sie Bree mit npm:

$ npm install bree

Erstellen Sie nun eine Datei namens bree-main.js mit folgendem Code:

javascript
const Bree = require("bree");

const bree = new Bree({
    jobs: [
   	 {
   		 name: "bree-job",
   		 interval: "5s",
   		 timeout: 0
   	 }
    ]
});

(async () => {
    await bree.start();
})();

Fügen Sie den folgenden Code zu jobs/bree-job.js hinzu:

javascript
const d = new Date();
console.log(`The time is ${d.getHours()}:${d.getMinutes()}:${d.getSeconds()}`);

Wenn Sie node bree-main.js ausführen, wird die Uhrzeit sofort und anschließend alle fünf Sekunden ausgegeben:

Worker for job "bree-job" online
The time is 11:45:30
Worker for job "bree-job" exited with code 0
Worker for job "bree-job" online
The time is 11:45:35
Worker for job "bree-job" exited with code 0

Poolifier

Poolifier ist eine weitere Implementierung für Worker-Pools. Damit können Sie mehrere Worker verwalten, ohne sich selbst um die Komplexität des Pools kümmern zu müssen. Pools können entweder fest sein und eine bestimmte Anzahl wiederverwendeter Worker enthalten oder dynamisch, sodass Worker bei Bedarf hinzugefügt werden, bis das von Ihnen konfigurierte Limit erreicht ist.

Mit folgendem Code in main.js können Sie einen einfachen Pool erstellen, der eine bestimmte Datei in einem Worker Thread ausführt:

javascript
const {FixedThreadPool, DynamicThreadPool} = require("poolifier");

// 4 fixed workers
const fixedPool = new FixedThreadPool(4, __dirname + "/fixed-worker.js");
fixedPool.execute({}).then(res => {
    console.log(res);
}).catch(e => {
    console.error(e);
});

// Between 2 and 12 workers, dynamically
const dynamicPool = new DynamicThreadPool(2, 12, __dirname + "/dynamic-worker.js");
dynamicPool.execute({}).then(res => {
    console.log(res);
}).catch(e => {
    console.error(e);
});

Legen Sie den Code für den festen Pool fest, indem Sie folgenden Inhalt zu fixed-worker.js hinzufügen:

javascript
const {ThreadWorker} = require("poolifier");

module.exports = new ThreadWorker(
    () => "Running in the fixed thread pool",
    {
   	 async: false
    }
);

Fügen Sie nun den Code für den dynamischen Pool zu dynamic-worker.js hinzu:

javascript
const {ThreadWorker} = require("poolifier");

module.exports = new ThreadWorker(
    () => "Running in the dynamic thread pool",
    {
   	 async: false
    }
);

Installieren Sie das Paket poolifier von npm und führen Sie anschließend main.js mit Node.js aus. Während beide Thread-Pools starten und ihre Aufgaben ausführen, sollte die folgende Ausgabe erscheinen:

Running in the fixed thread pool
Running in the dynamic thread pool

Der Prozess läuft weiter, bis Sie ihn mit Strg+C beenden. Poolifier hält die Thread-Pools für neue Aufgaben bereit und verhindert, dass der Prozess beendet wird, solange noch Pools vorhanden sind.

Worker Threads im Vergleich zu anderen Programmiersprachen

Die verschiedenen Programmiersprachen setzen Multithreading unterschiedlich um. Bei Node.js handelt es sich um das Multiprozesssystem des Moduls worker_threads. Sehen wir uns an, was andere Sprachen bieten.

  • C/C++: Als Low-Level-Sprachen bieten C und C++ echtes Multithreading über die POSIX-Threading-Bibliothek pthreads. C++ verfügt außerdem über ein Thread-Objekt im Standard-Namespace, das Nebenläufigkeit noch einfacher macht. Sie sind dafür verantwortlich, Atomics und Mutexes zur korrekten Synchronisierung des Speichers zu verwenden und Race Conditions zu vermeiden.

  • Java: Auch Java bietet echtes Multithreading mit der Klasse Thread oder ihrer Schnittstelle Runnable. Unterstützt werden diese durch eine umfassende Sammlung von Nebenläufigkeitsfunktionen, mit denen Sie die erstellten Threads verwalten können.

  • Python: Python verfügt über eine Threading-Bibliothek, doch die beliebteste Python-Implementierung, CPython, kann immer nur einen Thread gleichzeitig ausführen. Threading scheint damit zwar verfügbar zu sein, beschleunigt CPU-intensive Abläufe aber nicht. Python bietet außerdem das Modul multiprocessing, das einen ähnlichen Ansatz wie die Worker Threads von Node.js verwendet.

  • Ruby: Bei Ruby ist die Situation ähnlich wie bei Python. Die Sprache bietet umfassende Thread-Unterstützung, doch MRI Ruby, die beliebteste Implementierung, unterstützt immer nur einen Thread gleichzeitig. Neuere alternative Interpreter wie JRuby und Rubinius unterstützen dagegen echtes Multithreading.

  • Rust: Rust bietet umfassende Multithreading-Unterstützung. Sie können damit auf einfache Weise Threads erstellen und Daten mit geringem Fehlerrisiko zwischen ihnen teilen. Durch das Sprachdesign werden viele häufige Fehler bei der Nebenläufigkeit unmöglich. Rust ist daher eine gute Wahl für Projekte, die stark auf Multithreading angewiesen sind.

Threading in C/C++, Rust und Java ist deutlich schneller als das Subprozessmodell von Node.js. Diese Sprachen bieten echte Threads mit gemeinsam genutztem Zustand und den damit verbundenen Herausforderungen des Speichermanagements. Höhere interpretierte Sprachen wie Python, Ruby und Node.js bieten keine nativen Thread-Implementierungen und setzen stattdessen auf ressourcenintensivere Worker-Subprozesse.

Fazit

Worker Threads ermöglichen Node.js-Entwicklern die parallele Ausführung von Code, indem sie neue Kindprozesse starten. Echtes Multithreading ist das jedoch nicht: Jeder „Thread“ ist ein unabhängiger Prozess ohne Zugriff auf den Kontext seines übergeordneten Prozesses. Die Kommunikation zwischen Threads ist nur über reservierten gemeinsamen Speicher und Nachrichten möglich, die über einen Event-Listener ausgetauscht werden.

Das Modul worker_threads ist dennoch ein unverzichtbarer Bestandteil des Node.js-Ökosystems. Innerhalb der Grenzen, die JavaScript als synchrone blockierende Sprache setzt, gibt es keine andere Möglichkeit, Multithreading und parallele Verarbeitung zu erreichen. Wichtig ist jedoch, die Einschränkungen von Worker Threads zu kennen, damit Sie fundiert entscheiden können, wann sie zum Einsatz kommen. Ein Worker in der falschen Situation kann die Performance Ihrer Anwendung senken und den Ressourcenverbrauch erhöhen.

Stark CPU-intensive Abläufe, bei denen Performance entscheidend ist, werden mit echten Threads in einer anderen Programmiersprache schneller ausgeführt. Für die meisten Node.js-Anwendungsfälle sind Worker Threads jedoch ausreichend, etwa für Aufgabenwarteschlangen in Web-Apps oder die Hintergrundverarbeitung von Videos auf Ihrem Desktop.

Weiterlesen

Blog

Frontier-Modelle fanden die Schwachstellen. Nur der Angreifer fand die Angriffsketten.

Die statische Analyse fand die Schwachstellen, doch erst Live-Angriffstests bewiesen, wie sie sich zu Angriffen verketten lassen. Ein Vergleich von Evo COS, Claude Security und Claude Code Security.

feature insights context
Blog

Autonome Angriffe sind bereits Realität. Die Verteidigung muss Schritt halten.

Autonome Angreifer verkürzen das Zeitfenster für die Verteidigung. Erfahren Sie, wie kontinuierliches Erkennen, Beheben, Validieren und Verhindern Sicherheitsteams helfen kann, Schritt zu halten.

Blog

Warum KI-Coding-Agenten immer wieder fehlerhafte Zugriffskontrollen schreiben

KI-Coding-Agenten können Autorisierungslogik erzeugen, die kompiliert und die Prüfung besteht, dabei aber die Daten eines Mandanten für einen anderen offenlegt. Erfahren Sie, warum fehlerhafte Zugriffskontrollen schwer zu erkennen sind und wie Sie sie verhindern.