Node.js-Skalierbarkeit mit Worker-Threads erschließen
Ethan Miller
Product Engineer · Leapcell

Einführung
Seit Jahren nutzen Entwickler Node.js wegen seines nicht-blockierenden I/O-Modells und seiner ereignisgesteuerten Architektur, was es zu einer ausgezeichneten Wahl für die Erstellung hochskalierbarer Webserver und Echtzeitanwendungen macht. Eine anhaltende Herausforderung war jedoch seine Single-Thread-Natur. Während sie perfekt für I/O-gebundene Operationen (wie Datenbankinteraktionen oder Netzwerkanfragen) ist, können CPU-gebundene Aufgaben (wie komplexe Berechnungen, Datenkomprimierung oder Bildverarbeitung) die Ereignisschleife blockieren, was zu Leistungsengpässen und nicht reagierenden Anwendungen führt. Diese Einschränkung zwang Entwickler oft, intensive Aufgaben auf externe Dienste auszulagern oder andere Sprachen in Betracht zu ziehen. Heute, mit dem Aufkommen von worker_threads
in Node.js, können wir diesem Single-Thread-Engpass endlich Lebewohl sagen und echte Parallelität innerhalb eines einzigen Node.js-Prozesses erschließen. Dieser Artikel befasst sich damit, wie worker_threads
Node.js-Anwendungen befähigen, CPU-intensive Workloads effizienter zu bewältigen und einen reibungsloseren Betrieb und eine verbesserte Skalierbarkeit zu gewährleisten.
Den Single-Thread-Engpass mit Worker-Threads überwinden
Um die Bedeutung von worker_threads
zu verstehen, müssen wir zunächst die beteiligten Kernkonzepte erfassen:
Die Ereignisschleife: Im Herzen von Node.js befindet sich die Ereignisschleife, ein einzelner Thread, der für die Verarbeitung aller JavaScript-Ausführungen, Rückrufe und I/O-Operationen zuständig ist. Wenn eine CPU-intensive Aufgabe auf diesem Thread ausgeführt wird, monopolisiert sie die Ereignisschleife und verhindert, dass andere Operationen verarbeitet werden, bis sie abgeschlossen ist. Dies wird als "Blockieren der Ereignisschleife" bezeichnet.
Threads: Ein Thread ist die kleinste Sequenz von programmierten Anweisungen, die unabhängig von einem Scheduler verwaltet werden kann. Traditionell lief Node.js hauptsächlich auf einem einzigen Hauptthread. worker_threads
führen die Fähigkeit ein, zusätzliche Threads innerhalb desselben Node.js-Prozesses zu erstellen.
Worker-Threads: Im Gegensatz zum Hauptthread laufen Worker-Threads in isolierten Umgebungen mit eigenen V8-Instanzen und Ereignisschleifen. Diese Isolation ist entscheidend, da sie verhindert, dass eine in einem Worker ausgeführte CPU-gebundene Aufgabe die Ereignisschleife des Hauptthreads blockiert. Sie kommunizieren über einen Nachrichtenübertragungsmechanismus miteinander.
Funktionsprinzip
Das Kernprinzip hinter worker_threads
ist die Auslagerung CPU-intensiver Aufgaben vom Hauptthread auf separate Worker-Threads. Wenn der Hauptthread eine rechenintensive Operation feststellt, anstatt sie direkt auszuführen, startet er einen Worker-Thread. Der Worker-Thread führt dann die Berechnung durch und sendet das Ergebnis nach Abschluss über Nachrichten zurück an den Hauptthread. Dies ermöglicht es dem Hauptthread, andere Anfragen ohne Unterbrechung weiter zu bearbeiten und die Reaktionsfähigkeit der Anwendung aufrechtzuerhalten.
Implementierungsdetails und Beispiele
Lassen Sie uns dies anhand eines praktischen Beispiels veranschaulichen: die Durchführung einer komplexen, CPU-gebundenen Berechnung wie das Finden von Primzahlen in einem großen Bereich.
Zuerst sehen wir, wie eine blockierende Operation ohne worker_threads
aussehen würde:
// main.js - Ohne worker_threads (BLOCKIEREND!) function findPrimes(start, end) { const primes = []; for (let i = start; i <= end; i++) { let isPrime = true; if (i <= 1) { isPrime = false; } else { for (let j = 2; j <= Math.sqrt(i); j++) { if (i % j === 0) { isPrime = false; break; } } } if (isPrime) { primes.push(i); } } return primes; } const express = require('express'); const app = express(); const port = 3000; app.get('/blocking-prime', (req, res) => { console.log('Empfangene blockierende Prime-Anfrage'); const primes = findPrimes(2, 20_000_000); // Dies blockiert die Ereignisschleife res.json({ count: primes.length, firstPrime: primes[0], lastPrime: primes[primes.length - 1] }); console.log('Abgeschlossene blockierende Prime-Anfrage'); }); app.get('/non-blocking', (req, res) => { console.log('Empfangene nicht-blockierende Anfrage'); res.send('Diese Anfrage ist nicht-blockierend'); console.log('Abgeschlossene nicht-blockierende Anfrage'); }); app.listen(port, () => { console.log(`Server läuft auf http://localhost:${port}`); });
Wenn Sie /blocking-prime
und dann sofort /non-blocking
aufrufen, werden Sie eine deutliche Verzögerung feststellen, bevor /non-blocking
antwortet, da die findPrimes
-Funktion den Hauptthread monopolisiert.
Lassen Sie uns dies nun mit worker_threads
refaktorisieren:
-
main.js
(Die Hauptanwendungsdatei):// main.js - Mit worker_threads const express = require('express'); const { Worker } = require('worker_threads'); const app = express(); const port = 3000; app.get('/worker-prime', (req, res) => { console.log('Empfangene Worker-Prime-Anfrage auf dem Hauptthread'); // Erstellen eines neuen Worker-Threads const worker = new Worker('./prime_worker.js', { workerData: { start: 2, end: 20_000_000 } }); // Auf Nachrichten vom Worker warten worker.on('message', (result) => { const { primes, duration } = result; res.json({ count: primes.length, firstPrime: primes[0], lastPrime: primes[primes.length - 1], duration: `${duration}ms` }); console.log('Abgeschlossene Worker-Prime-Anfrage auf dem Hauptthread'); }); // Auf Fehler vom Worker warten worker.on('error', (err) => { console.error('Worker-Fehler:', err); res.status(500).send('Fehler im Worker-Thread'); }); // Warten, bis der Worker beendet wird worker.on('exit', (code) => { if (code !== 0) { console.error(`Worker wurde mit Exit-Code ${code} beendet`); } }); }); app.get('/non-blocking', (req, res) => { console.log('Empfangene nicht-blockierende Anfrage auf dem Hauptthread'); res.send('Diese Anfrage ist jetzt wirklich nicht-blockierend!'); console.log('Abgeschlossene nicht-blockierende Anfrage auf dem Hauptthread'); }); app.listen(port, () => { console.log(`Server läuft auf http://localhost:${port}`); });
-
prime_worker.js
(Das Worker-Skript):// prime_worker.js const { parentPort, workerData } = require('worker_threads'); function findPrimes(start, end) { const primes = []; for (let i = start; i <= end; i++) { let isPrime = true; if (i <= 1) { isPrime = false; } else { for (let j = 2; j <= Math.sqrt(i); j++) { if (i % j === 0) { isPrime = false; break; } } } if (isPrime) { primes.push(i); } } return primes; } const { start, end } = workerData; const startTime = process.hrtime.bigint(); const primes = findPrimes(start, end); const endTime = process.hrtime.bigint(); const duration = Number(endTime - startTime) / 1_000_000; // Nanosekunden in Millisekunden umwandeln // Das Ergebnis zurück an den Hauptthread senden parentPort.postMessage({ primes, duration });
Mit dieser Konfiguration sehen Sie, wenn Sie /worker-prime
und dann sofort /non-blocking
aufrufen, dass die Anfrage /non-blocking
fast sofort antwortet, was zeigt, dass die Primzahlberechnung die Haupt-Ereignisschleife nicht mehr blockiert.
Wichtige worker_threads
-Komponenten:
Worker
-Klasse: Wird im Hauptthread verwendet, um neue Worker-Threads zu erstellen. Sein Konstruktor nimmt den Pfad zum Worker-Skript und ein optionalesworkerData
-Objekt entgegen, das an den Worker übergeben wird.parentPort
(im Worker): Ein Objekt, das im Worker-Skript verfügbar ist und den Kommunikationskanal zurück zum Hauptthread darstellt. Sie verwendenparentPort.postMessage()
, um Daten zurückzusenden.workerData
(im Worker): Ein Objekt, das im Worker-Skript verfügbar ist und die Daten enthält, die aus der OptionworkerData
des Hauptthreads übergeben wurden.worker.on('message', ...)
(im Hauptthread): Der Ereignis-Listener im Hauptthread zum Empfangen von Nachrichten, die vom Worker gesendet wurden.worker.on('error', ...)
undworker.on('exit', ...)
: Wichtig für eine robuste Fehlerbehandlung und Überwachung des Worker-Lebenszyklus.
Anwendungsszenarien
worker_threads
sind ideal für jede Node.js-Anwendung, die CPU-gebundene Herausforderungen hat. Häufige Anwendungsfälle sind:
- Komplexe mathematische Berechnungen: Datenanalyse, wissenschaftliche Simulationen, Finanzberechnungen.
- Bild- und Videoverarbeitung: Größenänderung, Wasserzeichen, Filterung, Kodierung/Dekodierung.
- Datenkomprimierung/-dekomprimierung: Komprimieren/Dekompression großer Dateien.
- Hashing und Verschlüsselung: Kryptografische Operationen.
- Schwere Datenanalyse und -transformation: Parsen großer CSV-, JSON- oder XML-Dateien.
- Machine Learning-Inferenz: Ausführen vortrainierter Modelle.
Durch die Auslagerung dieser Aufgaben auf Worker-Threads bleibt die Haupt-Ereignisschleife frei, um eingehende Anfragen und andere I/O-Operationen zu bearbeiten, was die allgemeine Reaktionsfähigkeit und den Durchsatz Ihrer Node.js-Anwendung erheblichverbessert.
Fazit
Node.js worker_threads
sind ein Game-Changer, der die Art und Weise, wie wir CPU-gebundene Aufgaben in einer traditionell Single-Thread-Umgebung angehen, grundlegend verändert. Durch die Ermöglichung echter Parallelität befähigen sie Entwickler, robustere, performantere und skalierbarere Anwendungen zu erstellen, ohne auf Multiprocess-Architekturen oder externe Dienste für intensive Berechnungen zurückgreifen zu müssen. Die Einführung von worker_threads
ermöglicht es Node.js, sein Label "Single-Thread-Engpass" für CPU-intensive Workloads abzulegen, was es zu einer noch vielseitigeren und leistungsfähigeren Wahl für die moderne Anwendungsentwicklung macht.