zurück zum Artikel

Features von übermorgen: Worker Threads in Node.js

Philip Ackermann

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

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 [1] und Komprimierung [2] von Daten.

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

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

Folgende Klassen/Objekte werden durch die API bereitgestellt:

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 [6], 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 [7] ü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

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. ( [8])


URL dieses Artikels:
https://www.heise.de/-4354189

Links in diesem Artikel:
[1] https://nodejs.org/api/crypto.html
[2] https://nodejs.org/api/zlib.html
[3] https://nodejs.org/de/docs/guides/event-loop-timers-and-nexttick/
[4] https://www.npmjs.com/package/worker-farm
[5] https://nodejs.org/api/worker_threads.html
[6] https://nodejs.org/api/worker_threads.html#worker_threads_new_worker_filename_options
[7] https://nodejs.org/api/worker_threads.html#worker_threads_new_worker_filename_options
[8] mailto:philip.ackermann@fit.fraunhofer.de