Skalierung von Node.js-Anwendungen parallel mit Cluster und Worker Threads
Lukas Schneider
DevOps Engineer · Leapcell

Einleitung
Node.js zeichnet sich mit seiner Single-Threaded, ereignisgesteuerten Architektur durch hohe Konkurrenzfähigkeit und I/O-gebundene Operationen aus. Diese Natur kann jedoch zu einem Engpass werden, wenn CPU-gebundene Aufgaben anfallen oder wenn die schiere Menge der Anfragen einen einzelnen Prozess überlastet. In solchen Szenarien wird die Fähigkeit, Ihre Node.js-Anwendung effektiv zu skalieren, unerlässlich. Dieser Artikel befasst sich mit zwei primären Mustern für die vertikale Skalierung von Node.js-Anwendungen: Multi-Prozess mittels des cluster-Moduls und Multi-Thread mittels worker_threads. Das Verständnis dieser Muster ist entscheidend für den Aufbau robuster, hochleistungsfähiger Node.js-Dienste, die moderne Multi-Core-Prozessoren voll ausnutzen können. Wir werden untersuchen, wie jeder Ansatz die Einschränkungen eines einzelnen Node.js-Prozesses adressiert und Entwickler in die Lage versetzt, die am besten geeignete Strategie für ihre spezifischen Anforderungen zu wählen.
Verständnis von Konkurrenzfähigkeit in Node.js
Bevor wir uns mit den Skalierungsmustern befassen, wollen wir einige grundlegende Konzepte klären:
- Prozess: Eine unabhängige Ausführungsumgebung mit eigenem Speicherbereich und eigenen Ressourcen. In Node.js läuft eine typische Anwendung als einzelner Prozess.
- Thread: Eine Ausführungssequenz innerhalb eines Prozesses. Mehrere Threads können innerhalb eines einzelnen Prozesses existieren und sich dessen Speicherbereich teilen (obwohl der Haupt-Thread von Node.js für die JavaScript-Ausführung Single-Threaded ist).
- CPU-gebundene Aufgaben: Operationen, die eine erhebliche Menge an CPU-Zeit verbrauchen, wie z. B. komplexe Berechnungen, Datenkomprimierung oder Bildverarbeitung. Diese können die Event Loop in einer Single-Threaded-Umgebung blockieren.
- I/O-gebundene Aufgaben: Operationen, die den Großteil ihrer Zeit mit dem Warten auf externe Ressourcen verbringen, wie z. B. Netzwerkanfragen, Datenbankabfragen oder Dateisystemzugriffe. Die asynchrone Natur von Node.js bewältigt diese gut in einem einzelnen Thread.
- Event Loop: Der Kern der asynchronen, nicht blockierenden I/O von Node.js. Sie prüft kontinuierlich auf auszuführende Aufgaben und aufzurufende Callbacks. Eine langlaufende CPU-gebundene Aufgabe kann die Event Loop "blockieren" und die Anwendung unempfänglich machen.
Skalierung mit dem Multi-Prozess Cluster-Modul
Das cluster-Modul ermöglicht es Ihnen, Child-Prozesse (Worker) zu erstellen, die sich denselben Server-Port teilen. Dies verteilt eingehende Anfragen effektiv auf mehrere Node.js-Prozesse und ermöglicht es Ihrer Anwendung, alle verfügbaren CPU-Kerne zu nutzen.
Funktionsweise
Das cluster-Modul arbeitet nach einem Master-Worker-Modell:
- Master-Prozess: Dieser Prozess ist für das Spawnen und Verwalten von Worker-Prozessen zuständig. Er lauscht typischerweise auf einem einzelnen Port und leitet dann eingehende Verbindungen an seine Worker weiter.
- Worker-Prozesse: Dies sind unabhängige Node.js-Prozesse, die sich denselben Port wie der Master teilen und die eigentlichen Client-Anfragen bearbeiten. Jeder Worker führt eine separate Instanz Ihres Anwendungs-Codes aus, einschließlich seiner eigenen Event Loop und seines eigenen Speicherbereichs.
Implementierungsbeispiel
Lassen Sie uns dies mit einem einfachen HTTP-Server veranschaulichen, der eine CPU-intensive Aufgabe ausführt.
server.js (Master/Worker-Code):
const cluster = require('cluster'); const http = require('http'); const numCPUs = require('os').cpus().length; if (cluster.isMaster) { console.log(`Master ${process.pid} is running`); // Fork workers. for (let i = 0; i < numCPUs; i++) { cluster.fork(); } cluster.on('exit', (worker, code, signal) => { console.log(`Worker ${worker.process.pid} died`); // Optionally, respawn the worker here to ensure high availability // cluster.fork(); }); } else { // Worker processes can share any TCP connection. // In this case, it is an HTTP server. http.createServer((req, res) => { // Simulate a CPU-bound task if (req.url === '/cpu') { let sum = 0; for (let i = 0; i < 1e9; i++) { sum += i; } res.writeHead(200); res.end(`Hello from Worker ${process.pid}! Sum: ${sum}\n`); } else { res.writeHead(200); res.end(`Hello from Worker ${process.pid}!\n`); } }).listen(8000); console.log(`Worker ${process.pid} started`); }
Um dies auszuführen, speichern Sie es als server.js und führen Sie node server.js aus. Wenn Sie http://localhost:8000 aufrufen, sehen Sie, wie Anfragen von verschiedenen Worker-PIDs bearbeitet werden. Wenn Sie http://localhost:8000/cpu aufrufen, wird ein Worker beschäftigt sein, aber andere können weiterhin Anfragen bedienen.
Anwendungsfälle
- Ausgleich von HTTP-Anfragen: Der primäre Anwendungsfall, insbesondere für REST-APIs oder Webserver.
- Maximierung der CPU-Auslastung: Verteilt die Arbeitslast auf mehrere Kerne für Allzweckanwendungen.
- Verbesserung der Fehlertoleranz: Wenn ein Worker abstürzt, können andere weiterhin Anfragen bedienen.
Vor- und Nachteile
Vorteile:
- Vollständige CPU-Auslastung: Nutzt alle verfügbaren Kerne.
- Isolation: Jeder Worker hat seinen eigenen Speicherbereich, wodurch verhindert wird, dass Probleme eines Workers andere direkt beeinträchtigen.
- Erhöhte Durchsatzrate: Kann mehr gleichzeitige Anfragen bearbeiten.
Nachteile:
- Overhead für die Prozesskommunikation (IPC): Das Teilen von Daten zwischen Workern erfordert explizite IPC, die langsamer sein kann als Shared Memory.
- Komplexität der Zustandsverwaltung: Der globale Anwendungszustand erfordert sorgfältige Synchronisation oder externe Speicherung (z. B. Redis), da jeder Worker isoliert ist.
- Erhöhter Speicherverbrauch: Jeder Worker ist ein vollständiger Node.js-Prozess, was zu einem höheren RAM-Verbrauch führt.
Skalierung mit dem Multi-Thread Worker Threads Modul
Das worker_threads-Modul, das in Node.js v10.5.0 eingeführt und seit v12.x stabil ist, ermöglicht Ihnen die Ausführung mehrerer JavaScript-Threads innerhalb eines einzigen Node.js-Prozesses. Im Gegensatz zum cluster-Modul teilen sich Worker-Threads denselben Prozessspeicher (wenn auch mit isolierten V8-Instanzen für die JavaScript-Ausführung) und kommunizieren über Message Passing.
Funktionsweise
- Main Thread: Dies ist Ihr primärer Node.js-Ausführungsthread. Er kann neue Worker-Threads spawnen.
- Worker-Threads: Dies sind separate Threads, die eine isolierte Instanz der V8-Engine und ihre eigene Event Loop ausführen. Sie können JavaScript-Code parallel zum Main Thread ausführen. Die Kommunikation zwischen dem Main Thread und den Worker-Threads erfolgt über einen Message-Passing-Mechanismus oder durch die gemeinsame Nutzung von
SharedArrayBuffer-Objekten.
Implementierungsbeispiel
Lassen Sie uns die CPU-gebundene Aufgabe mit worker_threads refaktorieren.
main.js (Main Thread):
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads'); const http = require('http'); if (isMainThread) { console.log(`Main thread ${process.pid} is running`); http.createServer((req, res) => { if (req.url === '/cpu') { const worker = new Worker('./worker-task.js', { workerData: { num: 1e9 } // Data to pass to the worker }); worker.on('message', (result) => { res.writeHead(200); res.end(`Hello from Main Thread! Sum: ${result}\n`); }); worker.on('error', (err) => { console.error(err); res.writeHead(500); res.end('Error processing request.\n'); }); worker.on('exit', (code) => { if (code !== 0) console.error(`Worker stopped with exit code ${code}`); }); } else { res.writeHead(200); res.end('Hello from Main Thread (non-CPU path)!\n'); } }).listen(8001); console.log('Server listening on port 8001'); }
worker-task.js (Worker Thread):
const { parentPort, workerData } = require('worker_threads'); if (parentPort) { // Ensure it's a worker thread context let sum = 0; for (let i = 0; i < workerData.num; i++) { sum += i; } parentPort.postMessage(sum); }
Um dies auszuführen, speichern Sie main.js und worker-task.js im selben Verzeichnis und führen Sie node main.js aus. Greifen Sie auf http://localhost:8001 zu. Wenn Sie http://localhost:8001/cpu aufrufen, wird die Berechnung an einen Worker-Thread delegiert, wodurch der Main Thread frei bleibt, um andere Anfragen zu bearbeiten.
Anwendungsfälle
- CPU-gebundene Aufgaben in einem einzelnen Prozess: Ideal für Berechnungen wie Bildgrößenänderung, Videokodierung, Datenverarbeitung, kryptografische Operationen oder schwere Datenmanipulation, die ansonsten die Haupt-Event-Loop blockieren würden.
- Haupt-Thread reaktionsfähig halten: Stellt die UI-Reaktionsfähigkeit in Desktop-Anwendungen (z. B. Electron) sicher oder verhindert Latenzzeiten bei kritischen API-Pfaden.
- Parallele Ausführung von nicht blockierenden Aufgaben: Während Node.js mit nicht blockierendem I/O hervorragend zurechtkommt, können
worker_threadszur Parallelisierung mehrerer unabhängiger I/O-Operationen verwendet werden, wenn die Reihenfolge ihrer Ausführung keine Rolle spielt und Sie die Gesamtausführungszeit verkürzen möchten.
Vor- und Nachteile
Vorteile:
- Vermeidet Blockieren der Event Loop: Offloads extrem rechenintensive Arbeit vom Haupt-Thread.
- Geringerer Overhread (im Vergleich zu
cluster): Worker teilen sich einige Prozessressourcen, was zu geringerem Speicherverbrauch als separate Prozesse führt. - Effizientes Message Passing: Die Kommunikation über
postMessageist im Allgemeinen effizienter als die IPC zwischen separaten Prozessen. - Shared Memory (über
SharedArrayBuffer): Ermöglicht erweiterte Anwendungsfälle, bei denen Threads direkten Zugriff auf Shared Memory benötigen, dies erfordert jedoch eine sorgfältige Synchronisation.
Nachteile:
- Nicht direkt für HTTP-Anfrageverteilung gedacht:
worker_threadswurde nicht dafür entwickelt, Ports direkt zu überwachen (obwohl Worker dies können), und es handelt nicht automatisch wiecluster. - Mögliche Parallelitätsfehler: Shared Memory (über
SharedArrayBuffer) birgt Komplexitäten wie Race Conditions und Deadlocks, wenn es nicht sorgfältig mit atomaren Operationen und Sperren gehandhabt wird. - Immer noch Single-Threaded JavaScript-Ausführung pro Worker: Jeder Worker führt JavaScript sequenziell aus; Parallelität entsteht durch mehrere gleichzeitig ausgeführte Worker, nicht durch JavaScript selbst, das innerhalb eines einzelnen Workers parallel ausgeführt wird.
Schlussfolgerung
Sowohl cluster als auch worker_threads bieten leistungsstarke Mechanismen zur vertikalen Skalierung von Node.js-Anwendungen, dienen aber unterschiedlichen Hauptzwecken. Das cluster-Modul eignet sich ideal für die Verteilung eingehender Netzwerkanfragen auf mehrere Prozesse und nutzt effektiv alle CPU-Kerne für I/O-gebundene oder Allzweck-Webserver-Workloads. Umgekehrt glänzen worker_threads beim Offloading von CPU-gebundenen Berechnungen aus der Haupt-Event-Loop und gewährleisten Reaktionsfähigkeit und anhaltende Leistung für intensive Verarbeitungsaufgaben innerhalb eines einzigen Node.js-Prozesses. Eine robuste Node.js-Anwendung könnte sogar einen hybriden Ansatz verwenden, der cluster für die allgemeine Anfrageverteilung nutzt und dann worker_threads innerhalb jedes Worker-Prozesses für spezifische aufwendige Berechnungen einsetzt. Die Wahl zwischen oder Kombination dieser Muster hängt letztendlich von den spezifischen Leistungsengpässen und den architektonischen Anforderungen Ihrer Anwendung ab.

