Node.js-Multithreading mit Worker-Threads: Tutorial zu worker_threads
James Walker
25. Oktober 2022
0 Min. LesezeitNode.js stellt eine Single-Thread-JavaScript-Laufzeitumgebung bereit, in der Code nicht mehrere Vorgänge parallel ausführen kann. Wenn Ihre Anwendung überwiegend synchron arbeitet, kann es bei lang laufenden Vorgängen zu Blockierungen kommen.
Node.js selbst ist jedoch eine multithreaded Anwendung. Das zeigt sich, wenn Sie eine der asynchronen Methoden der Standardbibliothek für I/O-Vorgänge verwenden, etwa eine Datei lesen oder eine Netzwerkanfrage senden. Diese Aufgaben werden an einen separaten Thread-Pool delegiert, den Node mithilfe der C-Bibliothek libuv erstellt und verwaltet. Auch wenn sich das wie Multithreading anfühlt, können asynchrone Funktionen weiterhin die Main Event Loop blockieren.
Was aber, wenn Sie eigene, unabhängige Threads für Ihre Node.js-Anwendung erstellen möchten? Multithreading kann die Leistung CPU-intensiver Abläufe deutlich verbessern, da beliebige Aufgaben parallel ausgeführt werden können. Node.js bietet zwar kein echtes Multithreading, aber mit dem Modul worker_threads lässt sich etwas Ähnliches umsetzen. In diesem Artikel erfahren Sie, was es leistet und wie Sie es in einigen praktischen Anwendungen einsetzen.
Was sind Worker-Threads?
Das Modul worker_threads implementiert eine Form von Threading, mit der Sie Parallelität in Ihre eigene Anwendung integrieren können. In einem Worker-Thread ausgeführter Code läuft in einem separaten Child-Prozess und kann dadurch Ihre Hauptanwendung nicht blockieren.
Worker-Threads sind keine echten Threads im herkömmlichen Sinn. Es handelt sich um eigenständige Prozesse, die deshalb nicht direkt auf den Ausführungskontext ihrer übergeordneten Prozesse zugreifen können. Die Kommunikation zwischen Worker-Threads und Ihrer Anwendung erfolgt über ein ereignisbasiertes Nachrichtensystem.
Worker-Threads machen Node.js zwar nicht zu einer echten Multithreading-Sprache, doch in vielen praktischen Fällen ist dieser Unterschied eher theoretischer Natur. Sie bieten einen praktischen Mechanismus, um mehrere Ausführungs-„Threads“ gleichzeitig laufen zu lassen und rechenintensive Aufgaben aus der Main Loop auszulagern.
Anwendungsfälle für Worker-Threads
Worker-Threads eignen sich für alle rechenintensiven CPU-Vorgänge. Für die Beschleunigung von I/O-Aufgaben sind sie aufgrund des mit jedem Thread verbundenen Overheads nicht geeignet. Die integrierten asynchronen I/O-Hilfsprogramme von Node.js sind bei Datei- und Netzwerkaufgaben schneller und effizienter.
Es gibt zahlreiche Situationen, in denen Worker-Threads hilfreich sind. Hier sind einige besonders häufige Anwendungsfälle, von denen Sie profitieren können:
Bilder skalieren: Das Skalieren großer Bilder kann mehrere Sekunden dauern. Wenn Sie mehrere Größen erzeugen müssen, summieren sich die Verzögerungen schnell. Das kommt häufig bei Anwendungen vor, die hochgeladene Fotos in Miniaturansichten sowie kleinere und größere Formate umwandeln. Mit drei Worker-Threads können Sie alle Größen gleichzeitig erzeugen und so die Gesamtdauer des Vorgangs verkürzen.
Videokomprimierung: Die Videokomprimierung zählt zu den rechenintensivsten Aufgaben. Worker-Threads können sie beschleunigen, indem sie mehrere Frames parallel verarbeiten und die Ergebnisse anschließend an den Main Thread zurücksenden.
Dateiverschlüsselung und weitere kryptografische Aufgaben: Kryptografische Vorgänge sind absichtlich komplex. Das Ver- und Entschlüsseln von Dateien, das Erzeugen geheimer Schlüssel und die Signaturprüfung können in einem Programm spürbare Verzögerungen verursachen, wenn diese Aufgaben im Main Thread ausgeführt werden.
Große Datenmengen sortieren und durchsuchen: Das Filtern und Sortieren von Daten erfordert umfangreiche Iterationen, um einzelne Werte miteinander zu vergleichen. Worker-Threads können den Vorgang beschleunigen, indem sie mehrere Datenbereiche parallel untersuchen.
Komplexe mathematische Berechnungen: Mathematische Berechnungen – etwa das Ermitteln von Primzahlen, das Faktorisieren großer Zahlen und komplexe Datenanalysen – sind grundsätzlich CPU-intensiv. Wenn Sie einen Teil der Arbeit in einem separaten Thread ausführen, kann die Main Loop andere Aufgaben übernehmen.
Bei all diesen Vorgängen entsteht die Verlangsamung dadurch, dass die CPU viel Zeit mit der Ausführung von Code verbringt, statt Daten von einem Datenträger oder aus dem Netzwerk einzulesen. Da es sich um iterative Aufgaben handelt, lassen sich die Leistung und Geschwindigkeit am besten verbessern, indem mehr Durchläufe parallel ausgeführt werden. Worker-Threads bieten dafür einen Mechanismus.
Das Modul für Worker-Threads verwenden
Worker-Threads erstellen Sie, indem Sie das Node-Modul worker_threads importieren und den Konstruktor new Worker() aufrufen. Ein neuer Prozess wird gestartet, um eine von Ihnen angegebene JavaScript-Datei auszuführen. Sie können Nachrichten mit dem Worker austauschen, um sich über Ereignisse, verarbeitungsbereite Daten oder Fehler benachrichtigen zu lassen. Wenn Sie bereits Web Workers im Browser verwendet haben, wird Ihnen das Konzept der Worker-Threads vertraut vorkommen.
Hier sehen Sie das einfachste Beispiel dafür, wie eine JavaScript-Datei in einem Worker-Thread ausgeführt wird:
Kopieren Sie dieses Code-Snippet und speichern Sie es in Ihrem Arbeitsverzeichnis unter dem Namen worker-demo.js. Wenn Sie den Code mit Node.js ausführen, sollte folgende Ausgabe erscheinen:
Dieser einfache Code veranschaulicht alle Grundlagen von Worker-Threads. Der Quellcode kann sowohl als Main Thread als auch als Worker-Thread fungieren. Das Modul worker_threads stellt den Export isMainThread bereit, mit dem Sie prüfen können, ob der Code als Main Thread ausgeführt wird.
Wenn die Datei im Main Thread läuft – das heißt, wenn Sie sie über Ihr Terminal gestartet haben –, wird der erste Zweig der if-Anweisung ausgeführt. Dadurch wird eine neue Worker-Instanz erstellt.
Der erste Parameter des Worker-Konstruktors ist der Pfad zur JavaScript-Datei, die im Worker ausgeführt werden soll. Für dieses Beispiel können Sie die globale Variable __filename verwenden, da dieselbe Datei sowohl den Code für den Main Thread als auch für den Worker-Thread enthält. Der zweite Parameter des Worker-Threads ist ein Optionsobjekt. Mit der Eigenschaft workerData können Sie Werte an den Worker-Thread übergeben.
Im restlichen Codeabschnitt des Main Threads werden Event-Listener für die Worker-Instanz eingerichtet. Damit können Sie auf Nachrichten, Fehler und das Beenden von Prozessen innerhalb des Workers reagieren.
Wenn der Beispielcode von einem Worker-Thread ausgeführt wird, hat die Variable isMainThread den Wert false und der else-Zweig wird ausgeführt. Dieser nutzt den Export workerData des Moduls worker_threads, um auf die vom Main Thread übergebenen Daten zuzugreifen. Der Export parentPort stellt eine Schnittstelle zum Main Thread bereit. Mit der Funktion postMessage() können Sie Daten an den Main Thread zurücksenden, wo sie als message-Ereignis gemeldet werden.
Wenn Sie also worker-demo.js ausführen, läuft die folgende Abfolge ab:
Ein neuer Worker wird erstellt und erhält
helloals Daten.Der im Worker ausgeführte Code greift auf seine Daten zu und sendet eine neue freundliche Nachricht an den Main Thread zurück.
Der Event-Listener des Main Threads empfängt die vom Worker-Thread gesendete Nachricht und gibt sie in der Konsole aus.
Der Worker-Thread hat keinen weiteren Code auszuführen und wird beendet. Der Main Thread wird darüber durch das
exit-Ereignis benachrichtigt.
Jetzt können Sie einige praktische Beispiele für Worker-Threads ausprobieren.
Bilder mit Worker-Threads skalieren
Dieser Code wandelt ein Bild parallel in drei verschiedene Größen um. Das geht deutlich schneller, als jede Größe nacheinander zu verarbeiten.
Erstellen Sie zunächst den Code zum Skalieren des Bildes. Speichern Sie ihn unter resize-worker.js:
Erstellen Sie den Code für den Main Thread in einer neuen Datei mit dem Namen resize-main.js:
Installieren Sie mit npm das sharp-Modul, das die Funktion zum Skalieren von Bildern bereitstellt:
Legen Sie als Nächstes ein großes Bild in Ihrem Arbeitsverzeichnis ab und nennen Sie es image.jpg. Sie können dieses farbenfrohe Foto von Threads von Unsplash verwenden. Führen Sie den Code mit folgendem Befehl aus:
Alle drei Meldungen „Resizing“ sollten sofort erscheinen. Der Main Thread durchläuft die angeforderten Größen, erstellt für jede einen neuen Thread und wartet, bis sie fertig sind. So werden alle Größen parallel erzeugt, ohne den Main Thread zu blockieren.
Videos mit Worker-Threads skalieren
Dieses Code-Snippet zeigt, wie Sie ein Video mit [FFmpeg in einem Worker-Thread skalieren können. So kann der Main Thread während des rechenintensiven Skalierens weiterhin andere Aufgaben ausführen.
Speichern Sie den Worker-Code in einer neuen Datei mit dem Namen video-worker.js:
Speichern Sie nun den Code für den Main Thread in einer neuen Datei mit dem Namen video-main.js:
Installieren Sie die für den Code erforderlichen npm-Abhängigkeiten:
Das Beispiel nutzt den beliebten Encoder ffmpeg. Das Paket fluent-ffmpeg ist ein Node.js-Wrapper für die bereits auf Ihrem System vorhandenen ffmpeg-Bibliotheken. Bevor Sie Ihren Code ausführen können, müssen Sie [ffmpeg installieren. In den meisten Linux-Umgebungen ist es bereits enthalten. Unter Windows und macOS müssen Sie es möglicherweise manuell installieren.
Mit folgendem Befehl können Sie prüfen, ob ffmpeg verfügbar ist:
Wenn eine Versionsnummer angezeigt wird, ist ffmpeg bereits installiert. Falls eine Meldung wie „command ‘ffmpeg’ not found“ erscheint, folgen Sie den Anweisungen auf der ffmpeg-Website, um die richtige Bibliothek für Ihr System herunterzuladen. Ffmpeg ist auch in den Paketquellen der meisten gängigen Linux-Distributionen sowie über Homebrew für macOS verfügbar:
Jetzt können Sie eine Videodatei im Hintergrund skalieren. Wenn Sie keine eigene Datei zur Hand haben, können Sie diesen Clip von Pexels herunterladen. Speichern Sie ihn als video.mp4 in Ihrem Arbeitsverzeichnis.
Führen Sie anschließend Ihr Skript aus, um den Clip auf 1280x720 zu verkleinern:
Dieses Beispiel funktioniert etwas anders als das vorherige. Es nutzt das Konzept eines „Worker-Pools“, um die Effizienz zu steigern. Anders als im vorherigen Beispiel wird der Prozess nach dem Skalieren nicht automatisch beendet. Der Worker-Thread bleibt aktiv und wartet auf Nachrichten. Beenden Sie den Prozess daher mit Strg+C in Ihrem Terminal.
Worker-Pools können den Ressourcenverbrauch senken. Da das Erstellen eines Workers jedes Mal einen Overhead verursacht, empfiehlt es sich, eine festgelegte Anzahl von Threads zu starten und sie nach Möglichkeit wiederzuverwenden. Poolifier, Piscina und ähnliche Bibliotheken vereinfachen die Verwendung von Worker-Threads auf diese Weise. Sie übernehmen die Erstellung von Threads bis zu einer von Ihnen festgelegten Obergrenze, um neue Aufgaben auszuführen. Weitere Aufgaben werden vorhandenen Threads zugewiesen, sobald diese wieder verfügbar sind.
Dateien mit Workern verschlüsseln
Dieses Beispiel zeigt, wie Sie worker_threads mit dem Node.js-Modul crypto kombinieren, um eine Datei in einem separaten Thread zu verschlüsseln.
Speichern Sie zunächst den Code für den Worker-Thread in einer neuen Datei mit dem Namen encrypt-worker.js:
Kopieren Sie diesen Code für den Main Thread in eine neue Datei mit dem Namen encrypt-main.js:
Erstellen Sie mit dem folgenden Befehl in Ihrem Terminal eine einfache Textdatei in Ihrem Arbeitsverzeichnis, die Sie anschließend verschlüsseln können:
Führen Sie nun den Code aus, um die Datei zu verschlüsseln:
In diesem Beispiel sendet der Worker-Thread eine Nachricht, um Ihnen den Pfad zur Ausgabedatei und den erzeugten geheimen Schlüssel mitzuteilen.
Crashkurs zu Worker-Threads
Node.js ist eine Single-Thread-Laufzeitumgebung. Das liegt daran, dass JavaScript eine synchrone, blockierende Sprache ist, in der alles in einem Thread abläuft. Die asynchronen Komponenten der Node.js-Standardbibliothek wirken nicht blockierend, weil sie einen separaten Thread-Pool nutzen.
Das Modul worker_threads bietet eine ähnliche Möglichkeit, die Sie in Ihren eigenen Code integrieren können. Es handelt sich nicht um echtes Multithreading, doch in vielen Fällen ist es ähnlich genug und ermöglicht Ihnen, Code parallel außerhalb des Main Threads auszuführen.
In diesem Artikel haben wir erklärt, was Worker-Threads sind, wann sie eingesetzt werden können und wie Sie sie in Ihrem Projekt nutzen können. Im nächsten Teil dieser Serie gehen wir auf die Vor- und Nachteile von Worker-Threads ein und vergleichen sie mit den Multi-Threading-Implementierungen anderer Sprachen.
