Javascript Worker Threads

Singlethread- vs. Multithread

28.10.2023

javascripttutorialhowtodenodejs
Gregor Wedlich
Gregor Wedlich
Life, the Universe and Everything.
Donate with:
Lightning
Alby

Inhaltsverzeichnis

    Worker Threads stehen seit der Node.js-Version 10.5 als experimentelles Feature zur Verfügung und wurden in der Version 12.11.0 auch als stabil eingestuft. Zum Zeitpunkt dieses Artikels war die Node.js-Version 20.9.0 die aktuelle LTS-Version.

    JavaScript ist traditionell eine Singlethreaded-Sprache, was bedeutet, dass alle Operationen nacheinander in einem einzigen Thread ausgeführt werden. Dies hat zur Folge, dass unser Programm warten muss, bis die letzte Aufgabe im Main-Thread abgearbeitet ist, was dazu führen kann, dass unsere Anwendung einfriert. Um dieses Problem zu lösen, wird in JavaScript die Aufgabenbearbeitung über den sogenannten Event-Loop gesteuert. Der Event-Loop ermöglicht es, asynchron und nicht-blockierend zu agieren, selbst wenn JavaScript eine Single-Threaded-Sprache ist. I/O-Aufgaben (Netzwerkanfragen, Dateizugriffe und Benutzereingaben), die in der Regel nicht rechenintensiv sind, können somit durch den Event-Loop asynchron abgearbeitet werden.

    Wer mehr über den Event-Loop erfahren möchte dem lege ich zwei Videos ans Herz, das eine etwas kürzer das andere etwas länger.

    By loading the video, you agree to the privacy policy of YouTube. The video will be loaded from YouTube's servers.
    By loading the video, you agree to the privacy policy of YouTube. The video will be loaded from YouTube's servers.

    Die asynchrone Bearbeitung unserer Aufgaben ist in der Regel der synchronen Verarbeitung und den Worker Threads vorzuziehen. Leider trifft dies nicht auf rechenintensive Aufgaben wie beispielsweise Bildkomprimierung oder komplexe Berechnungen zu. Diese könnten den Main-Thread blockieren und somit unsere Anwendung zum Einfrieren bringen. Genau aus diesem Grund wurden Worker Threads eingeführt.

    Worker Threads ermöglichen es uns, Aufgaben auf verschiedene Threads zu verteilen. Dies hat den Vorteil, dass der Main-Thread frei bleibt und unsere Anwendung reibungslos weiterlaufen kann. Die Verteilung der Aufgaben auf mehrere Threads kann zudem zu einem Leistungsgewinn führen.

    Jetzt könnte man logischerweise annehmen, einfach alle Aufgaben in Worker Threads auszulagern. An dieser Stelle muss ich jedoch enttäuschen: In der Regel ist das asynchrone Abarbeiten von Aufgaben die effizientere Methode und meistens den Worker Threads vorzuziehen. Dies liegt häufig daran, dass Worker Threads einen hohen Overhead mit sich bringen, da jeder Worker erst gestartet werden muss. Das führt dazu, dass die asynchrone Bearbeitung der Aufgaben in den meisten Fällen auf aktuellen Computern schneller ist. Nur bei wirklich rechenintensiven Aufgaben könnte es sinnvoll sein, auf Worker Threads zurückzugreifen.

    Ich möchte anhand der Berechnung von Fibonacci-Zahlen erläutern, wann es sinnvoll ist, auf Worker Threads zu setzen und wann man darauf vielleicht verzichten sollte.

    Exkurs: Fibonacci-Folge

    Damit alle wissen was Fibonacci-Zahlen sind und auch um den Code weiter unten besser zuverstehen hier ein versuch das euch etwas näher zubringen. Ihr könnt es aber auch überspringen da ich einfach eine möglichkeit gebraucht habe Komplexe berechnungen durchzuführen. Aber auch ganz unabhängig vom Nutzen von Fibonacci-Zahlen.

    Was ist nun die Fibonacci-Folge?

    Die Fibonacci-Folge ist eine Folge von Zahlen, bei der jede Zahl die Summe der beiden vorherigen Zahlen ist. Die Sequenz beginnt typischerweise mit 0 und 1.

    1n-0 + n-1 + n-1 2n-2 + n-3 + n-5 3n-8 + n-13 + n-21 4n-34 + n-55 + n-89 5n-144 ...

    Eine kleine kleine Analogie für Dummies ;-)

    Stell dir die Fibonacci-Folge als eine Reihe von Boxen vor welche hintereinander aufgereit sind. Jede der Boxen enthält eine Zahl. Die ersten zwei Boxen haben also die Zahl 0 und 1.

    Wenn du die Zahl der nächsten Box wissen möchtest addiere die zahlen aus den letzten beiden Boxen:

    1Box1: 0 2Box2: 1 3Box3: 1 // 0 + 1 4Box4: 2 // 1 + 1 5Box5: 3 // 1 + 2 6Box6: 5 // 2 + 3

    Vergleich zwischen Main-Thread und Worker-Thread

    Ich habe hier eine iterative Fibonacci-Funktion in node.js erstellt, die alle Fibonacci-Zahlen nur einmal berechnet und sie in einem Array speichert. Diese Methode ist sehr effizient und hilft uns gut zu simulieren, wann der Einsatz von Worker Threads aus Performance-Gründen sinnvoll ist und wann eher nicht.

    Ohne Worker Threads

    1const rounds = 100; 2 3const fibonacci = (n) => { 4 const numbers = [0, 1]; 5 for (let i = 2; i <= n; i++) { 6 numbers[i] = numbers[i - 1] + numbers[i - 2]; 7 } 8 return numbers[n]; 9}; 10 11const startWithoutWorker = Date.now(); 12for (let i = 0; i < rounds; i++) { 13 fibonacci(100); 14 process.stdout.write(`\r${i + 1}/${rounds}`); 15} 16const endWithoutWorker = Date.now(); 17console.log( 18 ` Total duration without worker threads (ms): ${ 19 endWithoutWorker - startWithoutWorker 20 }` 21);

    Wir beginnen mit der Berechnung und legen fest, dass diese 100-mal durchgeführt werden soll. Dies wiederholen wir 100-mal damit wir die Auslastung besser simulieren. Das Ergebnis sieht wie folgt aus:

    1100/100 Total duration without worker threads (ms): 5

    100.000-mal:

    1100/100 Total duration without worker threads (ms): 127

    1.000.000-mal:

    1100/100 Total duration without worker threads (ms): 1378

    10.000.000-mal:

    1100/100 Total duration without worker threads (ms): 10790

    All unsere Berechnungen wurden im Event-Loop ausgeführt (Single-Threaded). Zum Vergleich schauen wir uns nun die gleichen Berechnungen mit Worker Threads an.

    Mit Worker Threads

    Javascript Worker Threads

    Dazu erstellen wir zwei Dateien: eine "worker.js", der wir die Aufgaben zur Berechnung übergeben, und eine Datei, die den Worker aufruft, "index.js". Beginnen wir mit der letzteren. Ich werde hier nicht auf die Funktionsweise und die Implementierung von Worker Threads eingehen; dafür empfehle ich, die Node.js-Dokumentation!

    1// index.js 2import { Worker } from "worker_threads"; 3 4const rounds = 100; 5const maxConcurrentWorkers = 16; 6 7let completedRounds = 0; 8 9const startWithWorker = Date.now(); 10 11function runWorker() { 12 if (completedRounds >= rounds) { 13 return; 14 } 15 16 const worker = new Worker("./worker.js"); 17 worker.on("message", (result) => { 18 completedRounds++; 19 process.stdout.write(`\r${completedRounds}/${rounds}`); 20 if (completedRounds === rounds) { 21 const endWithWorker = Date.now(); 22 console.log( 23 `\nTotal duration with worker threads (ms): ${ 24 endWithWorker - startWithWorker 25 }` 26 ); 27 process.exit(); 28 } else { 29 runWorker(); 30 } 31 }); 32 worker.postMessage(100); 33} 34 35for (let i = 0; i < maxConcurrentWorkers; i++) { 36 runWorker(); 37}

    Genau wie in unserem Beispiel ohne Worker Threads führen wir die Berechnung 100-mal durch (const rounds = 100;). Da wir nun aber mit Worker Threads arbeiten, können wir diese Berechnungen auf die Worker auslagern und sie parallel durchführen, was als "Parallelisierung" bezeichnet wird. Hierbei ist zu beachten, dass man bei CPU-intensiven Aufgaben in der Regel einen Worker pro CPU-Thread verwenden möchte. Bei stark I/O-lastigen Aufgaben in einem Worker Thread könnte es jedoch sinnvoll sein, mehr Worker pro CPU-Thread zu nutzen. Da wir eine stark CPU-gebundene Berechnung durchführen, macht es Sinn, nur so viele Worker zu erstellen, wie wir CPU-Threads zur Verfügung haben. In meinem Fall sind das bei meiner CPU (Ryzen 5800X3D) 16 Threads.

    Und hier noch der Code für den Worker:

    1// worker.js 2import { parentPort } from "worker_threads"; 3 4const fibonacci = (n) => { 5 const numbers = [0, 1]; 6 for (let i = 2; i <= n; i++) { 7 numbers[i] = numbers[i - 1] + numbers[i - 2]; 8 } 9 return numbers[n]; 10}; 11 12parentPort.on("message", (n) => { 13 const result = fibonacci(n); 14 parentPort.postMessage(result); 15});

    Beginnen wir also nun mit unseren Berechnungen, dieses Mal jedoch mit Worker Threads.

    100-mal:

    1Total duration with worker threads (ms): 605

    100.000-mal:

    1Total duration with worker threads (ms): 730

    1.000.000-mal:

    1Total duration with worker threads (ms): 1222

    10.000.000-mal:

    1Total duration with worker threads (ms): 5237

    10.000.000-mal (mit nur einem Worker):

    1Total duration with worker threads (ms): 14259

    Was wir jetzt sehen, ist folgendes: Bis zu 1.000.000 Durchläufen ist die Fibonacci-Berechnung im Main-Thread deutlich schneller als die Berechnung mit Worker Threads. Erst bei einer sehr hohen Anzahl an Fibonacci-Berechnungen machen sich die Worker Threads bemerkbar. Und auch das nur, wenn wir genügend Worker bereitstellen. Begrenzen wir dies auf nur einen Worker, dauert die Berechnung sogar deutlich länger. Die Gründe dafür sind vielfältig, der wichtigste ist jedoch der hohe Overhead den Worker Threads mit sich bringen.

    Jeder Worker muss zunächst eine V8-Instanz starten, was sowohl Zeit kostet als auch zusätzlichen Speicher belegt. Das bedeutet, das man zusätzlich auch den Speicherbedarf im Auge behalten muss.

    Fazit

    Unser kleines Experiment zeigt, dass in den meisten Fällen der Einsatz von Worker Threads nicht empfehlenswert ist, zumindest wenn es darum geht, die Performance der Anwendung zu steigern. Nur für wirklich rechenintensive Aufgaben könnten Worker Threads relevant sein.

    Ein weiterer Pluspunkt für Worker Threads könnte bei Aufgaben liegen, die parallel im Hintergrund ablaufen können und nicht auf Benutzereingaben angewiesen sind. Zum Beispiel könnte ein Programm einen Bilderupload anbieten und die Bilder im Hintergrund komprimieren. Dadurch bleibt der Main-Thread und somit der Event-Loop frei, und die Anwendung läuft nicht Gefahr, einzufrieren.

    Ich hoffe, ich konnte etwas Licht ins Dunkel von "Worker Threads" bringen. Falls ich Fehler gemacht habe oder etwas unklar ist, lasst es mich gerne in den Kommentaren wissen.

    Comments: