Skip to main content

Node.js-Multithreading mit Worker-Threads: Tutorial zu worker_threads

Artikel von
Headshot of James Walker

James Walker

blog feature multithreading

25. Oktober 2022

0 Min. Lesezeit

Node.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:

const {
    Worker,
    isMainThread,
    parentPort
    workerData
} = require("worker_threads");

if (isMainThread) {
    const worker = new Worker(__filename, {workerData: "hello"});
    worker.on("message", msg => console.log(`Worker message received: ${msg}`));
    worker.on("error", err => console.error(error));
    worker.on("exit", code => console.log(`Worker exited with code ${code}.`));
}
else {
    const data = workerData;
    parentPort.postMessage(`You said \"${data}\".`);
}

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:

$ node worker-demo.js
Worker message received: You said "hello".
Worker exited with code 0.

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 hello als 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:

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

const {src, width, height} = workerData;
const [filename, ext] = src.split(".");

console.log(`Resizing ${src} to ${width}px wide`);

const resize = async () => {
await sharp(src)
.resize(width, height, {fit: "cover"})
.toFile(`${src}-${width}.${ext}`);
};

resize();

Erstellen Sie den Code für den Main Thread in einer neuen Datei mit dem Namen resize-main.js:

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

const src = process.argv[2];

const sizes = [
{width: 1920},
{width: 1280},
{width: 640}
];

for (const size of sizes) {
const worker = new Worker(
    __dirname + "/resize-worker.js",
    {
        workerData: {
            src,
            ...size
        }
    }
);
}

Installieren Sie mit npm das sharp-Modul, das die Funktion zum Skalieren von Bildern bereitstellt:

$ npm install sharp

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:

$ node resize-main.js image.jpg

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:

const {parentPort} = require("worker_threads");
const ffmpeg = require("fluent-ffmpeg");

const resizeVideo = (src, size) => {
    const [filename, ext] = src.split(".");
    const output = `${__dirname}/${filename}-${size}.${ext}`;
    ffmpeg(`${__dirname}/${src}`)
    .size(size)
    .on("end", () => parentPort.postMessage({output, input: src, type: "done"}))
    .save(output);
};

parentPort.on("message", msg => {
    const {file, size} = msg;
    const [filename, ext] = file.split(".")[0];
    resizeVideo(file, size);
});

Speichern Sie nun den Code für den Main Thread in einer neuen Datei mit dem Namen video-main.js:

const {StaticPool} = require("node-worker-threads-pool");

const pool = new StaticPool({
    size: 4,
    task: __dirname + "/video-worker.js"
});

const videoToResize = process.argv[2];
const videoTargetSize = process.argv[3];

const resize = async () => {
    await pool.exec({file: videoToResize, size: videoTargetSize}).then(() => {
    if (msg?.type === "done") {
    console.log(`Saved ${videoToResize} to ${msg.output}`);
    }
    });
};

resize();

Installieren Sie die für den Code erforderlichen npm-Abhängigkeiten:

$ npm install fluent-ffmpeg node-worker-threads-pool

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:

$ ffmpeg
ffmpeg version 4.2.7-0ubuntu0.1 Copyright (c) 2000-2022 the FFmpeg developers

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:

$ brew install ffmpeg

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:

$ node video-main.js video.mp4 1280x720
Saved video.mp4 to /path-to-video-1280x720.mp4

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:

const {parentPort, workerData} = require("worker_threads");
const crypto = require("crypto");
const fs = require("fs");

const {file} = workerData;
const output = `${file}.encrypted`;

const key = crypto.randomBytes(32);
const iv = crypto.randomBytes(16);

const cipher = crypto.createCipheriv("aes-256-ctr", Buffer.from(key), iv);
const readStream = fs.createReadStream(file);
const writeStream = fs.createWriteStream(output);
readStream.pipe(cipher).pipe(writeStream);
writeStream.on("close", () => parentPort.postMessage({key: key.toString("hex"), output, type: "done"}));

Kopieren Sie diesen Code für den Main Thread in eine neue Datei mit dem Namen encrypt-main.js:

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

// Perform some other tasks here

const fileToEncrypt = process.argv[2];

const worker = new Worker(
    __dirname + "/encrypt-worker.js",
    {
    workerData: {
    file: fileToEncrypt
    }
    }
);

worker.on("message", msg => {
    if (msg?.type === "done") {
    console.log(`File encrypted to ${msg.output}`);
    console.log(`The key is ${msg.key} - don't lose it!`);
    }
});

// Perform some other tasks that don't need to wait for the encryption here

Erstellen Sie mit dem folgenden Befehl in Ihrem Terminal eine einfache Textdatei in Ihrem Arbeitsverzeichnis, die Sie anschließend verschlüsseln können:

$ echo foobar > demo.txt

Führen Sie nun den Code aus, um die Datei zu verschlüsseln:

$ node encrypt-main.js demo.txt
File encrypted to demo.txt.encrypted
The key is 20eb1974c95553ff4f16638192da8b4cbfd780cec8544579f04cde181b78bd7c - don't lose it!

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.