Multithreading in Node.js mit Worker Threads: Vor- und Nachteile
James Walker
27. Februar 2023
0 Min. LesezeitNode.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.
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:
Wenn Sie diesen Code in shared.js speichern und die Datei ausführen, wird folgende Ausgabe erzeugt:
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:
Fügen Sie nun diesen Code zu worker.js hinzu:
Installieren Sie das Piscina-Paket mit diesem Befehl:
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:
Erstellen Sie nun eine Datei namens bree-main.js mit folgendem Code:
Fügen Sie den folgenden Code zu jobs/bree-job.js hinzu:
Wenn Sie node bree-main.js ausführen, wird die Uhrzeit sofort und anschließend alle fünf Sekunden ausgegeben:
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:
Legen Sie den Code für den festen Pool fest, indem Sie folgenden Inhalt zu fixed-worker.js hinzufügen:
Fügen Sie nun den Code für den dynamischen Pool zu dynamic-worker.js hinzu:
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:
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
Threadoder ihrer SchnittstelleRunnable. 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.


