Features von übermorgen: Worker Threads in Node.js

Tales from the Web side  –  1 Kommentare

Seit Version 10.5 stellt Node.js sogenannte Worker Threads als experimentelles Feature zur Verfügung. Diese Blogartikel stellt das Feature kurz vor.

JavaScript-Code wird unter Node.js bekanntermaßen innerhalb eines einzigen Threads ausgeführt. In diesem Thread läuft die sogenannte Event-Loop, eine Schleife, die kontinuierlich Anfragen aus der Event-Queue prüft und Ereignisse von Ein- und Ausgabeoperationen verarbeitet.

Stellt ein Nutzer beispielsweise eine Anfrage an einen Node.js-basierten Webserver, wird innerhalb der Event-Loop zunächst geprüft, ob die Anfrage eine blockierende Ein- oder Ausgabeoperation benötigt. Ist das der Fall, wird einer von mehreren Node.js-internen Workern angestoßen (vom Prinzip her auch Threads, aber eben Node.js-intern), der die Operation ausführt. Sobald die Ein- oder Ausgabeoperation dann abgeschlossen wurde, wird man über entsprechende Callback-Funktion darüber informiert.

Das Entscheidende ist dabei, dass während der blockierenden Ein- und Ausgabeoperationen der Haupt-Thread nicht blockiert wird: Die Event-Loop läuft ununterbrochen weiter und ist somit in der Lage, eingehende Anfragen zeitnah zu bearbeiten. So weit, so gut.

Allerdings gibt es auch Fälle, die dazu führen, dass der Haupt-Thread blockiert wird, etwa durch CPU-intensive Berechnungen wie Verschlüsselung und Komprimierung von Daten.

Um dem wiederum entgegenzuwirken, gibt es bislang verschiedene Ansätze:

  • Computation offloading: hierbei werden komplexe Berechnungen an andere Services delegiert, beispielsweise indem entsprechende Nachrichten an einen Messaging-Broker geschickt und von anderen am Broker registrierten Services verarbeitet werden.
  • Partitioning: hierbei werden aufwändige Berechnungen in mehrere Abschnitte unterteilt, die dann nacheinander in verschiedenen Zyklen der Event-Loop abgearbeitet werden. Üblicherweise lagert man die entsprechende Berechnung in eine Funktion aus, die man dann über die Funktion setImmediate() in die "Check Phase" der Event-Loop einreiht.
  • Clustering über Hintergrund-Prozesse: hierbei wird mithilfe der Funktion fork() aus dem child_process-Package die jeweilige Berechnung an einen Kindprozess delegiert, wobei für die Kommunikation zwischen Elternprozess und Kindprozess IPC (Inter-Process Communication) zum Einsatz kommt. Packages wie worker-farm vereinfachen das Erstellen und Verwalten von Unterprozessen, sodass man sich nicht selbst um Aspekte wie Process Pooling und Wiederverwendung von Unterprozessen kümmern muss. Trotzdem bleiben die grundsätzlichen Nachteile von Unterprozessen bestehen: im Vergleich zu Threads sind sie speicherintensiv und langsam.

Worker Threads

Ein weiterer Lösungsansatz steht seit Node.js 10.5 in Form der sogenannten Worker Threads zur Verfügung. Durch die Worker Threads API ist es möglich, JavaScript-Code im gleichen Prozess parallel zum Haupt-Thread auszuführen.

Folgende Klassen/Objekte werden durch die API bereitgestellt:

  • Worker: repräsentiert einen Worker Thread.
  • MessageChannel: repräsentiert einen Kommunikationskanal, über den Worker Threads miteinander und mit dem Eltern-Thread kommunizieren.
  • MessagePort: repräsentiert eines der Enden eines Kommunikationskanal, ergo hat jeder Kommunikationskanal zwei dieser Message Ports.
  • workerData: Datenobjekt, das an einen Worker Thread übergeben wird und dann innerhalb dessen zur Verfügung steht.
  • parentPort: innerhalb eines Worker Threads derjenige Kommunikationskanal, über den mit dem Eltern-Thread kommuniziert werden kann.

Betrachten wir zur Veranschaulichung zunächst ein Beispiel, welches noch ohne Worker Threads auskommt. Folgendes Listing zeigt eine einfache Implementierung der Gaußschen Summenformel, die für eine gegebene Zahl n die Summe der Zahlen 1 bis n berechnet.

const n = process.argv[2] || 500;

const sum = (n) => {
let sum = 0;
for (let i = 0; i < n; i++) {
sum += i;
}
return sum;
};

async function run() {
const result = sum(n);
console.log(result);
}

setInterval(() => {
console.log('Hello world');
}, 500);

run().catch((error) => console.error(error));

Während die Funktion für verhältnismäßig kleine n noch recht schnell das Ergebnis liefert, dauert die Berechnung für ein n von beispielsweise 50.000.000 schon einige Sekunden. Da die Berechnung innerhalb des Haupt-Threads ausgeführt wird, ist der für diese Zeit blockiert. Die Folge: Die Nachricht "Hello World", die über setInterval() alle 500 Millisekunden ausgegeben werden soll, wird erst ausgegeben, wenn das Ergebnis obiger Berechnung feststeht. Die Ausgabe lautet daher:

$ node start.js 50000000
1249999975000000
Hello world
Hello world
Hello world
Hello world
Hello world
Hello world

An diesem Problem ändert sich übrigens auch nichts, wenn man die Funktion sum() asynchron implementiert. Folgender Code führt zu gleichem Ergebnis. Auch hier beginnt die Ausgabe der "Hello World"-Nachrichten erst nachdem das Ergebnis der Berechnung feststeht.

const n = process.argv[2] || 500;

const sum = async (n) => {
return new Promise((resolve, reject) => {
let sum = 0;
for (let i = 0; i < n; i++) {
sum += i;
}
resolve(sum);
});
};

async function run() {
const result = await sum(n);
console.log(result);
}

setInterval(() => {
console.log('Hello world');
}, 500);

run().catch((error) => console.error(error));

Mit Worker Threads lassen sich komplexe Berechnungen wie die im Beispiel aus dem Haupt-Thread in einen Worker Thread auslagern. Folgendes Listing zeigt den Code, der dazu auf Seiten des Haupt-Threads für das Beispiel benötigt wird. Um einen Worker Thread zu initiieren, genügt ein Aufruf des Konstruktors Worker, dem man einen Pfad zu derjenigen Datei übergibt, die den im Worker Thread auszuführenden Code enthält (dazu gleich mehr). Als zweiten Parameter kann zudem ein Konfigurationsobjekt übergeben werden, mit Hilfe dessen sich unter anderem über die workerData-Eigenschaft Daten an den Worker Thread übergeben lassen (im Beispiel wird auf diese Weise das n übergeben).

const { Worker } = require('worker_threads');

const n = process.argv[2] || 500;

function runService(workerData) {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js', { workerData });
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0)
reject(new Error(`Worker stopped with exit code ${code}`));
});
});
}

async function run() {
const result = await runService({
n
});
console.log(result.result);
}

setInterval(() => {
console.log('Hello world');
}, 500);

run().catch((error) => console.error(error));

Den Code für den Worker Thread zeigt folgendes Listing. Über workerData steht der übergebene Parameter n zur Verfügung, das Ergebnis der (unveränderten) Funktion sum() wird nach der Berechnung über die Methode postMessage() an den Eltern-Thread gesendet.

const { workerData, parentPort } = require('worker_threads');

const sum = async (n) => {
return new Promise((resolve, reject) => {
let sum = 0;
for (let i = 0; i < n; i++) {
sum += i;
}
resolve(sum);
});
};

const { n } = workerData;
(async () => {
const result = await sum(n);
parentPort.postMessage({ result, status: 'Done' });
})();

Wie man anhand der folgenden Ausgabe des Programms sieht, geschieht die Ausgabe der "Hello World"-Nachrichten unmittelbar, während parallel vom Worker Thread das Ergebnis der sum()-Funktion berechnet wird (zu beachten: je nach Node.js-Version muss der folgende Code unter Angabe des --experimental-worker Flags gestartet werden – unter Node.js 12 funktioniert der Code allerdings auch ohne diese Angabe).

$ node start.js 50000000
Hello world
Hello world
Hello world
Hello world
Hello world
Hello world
Hello world
Hello world
1249999975000000
Hello world
Hello world
Hello world
Hello world
Hello world

Fazit

Worker Threads sind ein noch experimentelles Feature von Node.js, die es ermöglichen, JavaScript-Code unabhängig vom Haupt-Thread auszuführen und damit eine Blockierung des Haupt-Threads bei komplexen Berechnungen zu verhindern.