post

child_process in Node.js: Skalierung ohne Illusionen

Node.js läuft auf einem einzigen Event Loop – CPU-Arbeit blockiert alles. Dieser Beitrag zeigt, wann fork, cluster und worker_threads wirklich helfen und wann sie nur Overhead sind.

Wenn ich in einer Node.js-Schulung an den Punkt komme, an dem es um Skalierung geht, stelle ich gern eine harmlos klingende Frage in die Runde: „Node.js ist doch nicht-blockierend – heißt das, es nutzt automatisch alle Kerne meiner CPU?" Fast immer nicken ein paar Teilnehmer zustimmend. Und fast immer ist das der Moment, an dem ich das Whiteboard aufklappe und wir eine liebgewonnene Illusion gemeinsam beerdigen.

Denn die Antwort ist ein klares Nein. Node.js skaliert I/O hervorragend – Tausende gleichzeitiger Netzwerkverbindungen sind kein Problem. Aber CPU-Arbeit skaliert es von allein überhaupt nicht. Wer das nicht verinnerlicht hat, baut irgendwann einen Endpoint, der unter Last die gesamte Anwendung einfriert, und versteht nicht, warum. In diesem Beitrag räume ich mit dem Missverständnis auf und zeige, welche Werkzeuge Node.js für echte Parallelität mitbringt – und welches davon wann das richtige ist.

Ein Event Loop, ein Thread, ein Nadelöhr

Fangen wir bei der Wurzel an. Ein Node.js-Prozess hat einen Event Loop, und JavaScript läuft in diesem Prozess auf genau einem Thread. Das ist kein Bug, das ist das Designprinzip. Solange die Arbeit aus Warten besteht – auf eine Datenbankantwort, auf eine Datei, auf einen HTTP-Response – kann der Event Loop in der Zwischenzeit andere Dinge erledigen. Die eigentliche Ein- und Ausgabe passiert im Hintergrund über libuv, und dein Code bekommt am Ende nur den fertigen Callback. Wer verstehen will, wie diese Maschinerie im Detail tickt, findet in meinem Node.js-Training zur Event-Loop-Architektur die ausführliche Fassung.

Das Problem beginnt, sobald die Arbeit nicht aus Warten besteht, sondern aus Rechnen. Ein Bild skalieren, ein großes JSON parsen und transformieren, ein Passwort mit einer teuren Hash-Funktion prüfen, eine kryptografische Operation, eine synchrone Schleife über Millionen Einträge. Diese Arbeit findet auf demselben einen Thread statt. Und solange sie läuft, kann der Event Loop nichts anderes tun – keine neuen Requests annehmen, keine Timer feuern, keine Callbacks abarbeiten. Alles steht.

Ein Beispiel, das ich in Schulungen gern zeige, weil es so unschuldig aussieht:

const http = require('node:http');

function fibonacci(n) {
  if (n < 2) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

http
  .createServer((req, res) => {
    if (req.url === '/compute') {
      const result = fibonacci(45); // blocks the event loop for seconds
      res.end(`result: ${result}`);
    } else {
      res.end('ok');
    }
  })
  .listen(3000);

Solange fibonacci(45) rechnet, bekommt kein einziger anderer Client eine Antwort – auch nicht auf den harmlosen /-Pfad, der eigentlich in Mikrosekunden fertig wäre. Ein einziger langsamer Request legt den ganzen Server lahm. Genau das meint die offizielle Doku mit „Don't Block the Event Loop".

Vier Werkzeuge, vier Aufgaben

Node.js bietet für dieses Problem mehrere Werkzeuge an, und die werden regelmäßig verwechselt. Es lohnt sich, sie sauber auseinanderzuhalten:

  • child_process startet einen komplett eigenen Betriebssystemprozess mit eigenem V8, eigenem Heap, eigenem Event Loop. Kommunikation läuft über Pipes bzw. – bei fork – über einen IPC-Kanal per Message-Passing. Kein geteilter Speicher.
  • cluster startet mehrere Node-Instanzen hinter einem gemeinsamen Port und verteilt eingehende Verbindungen auf sie. Es baut intern auf child_process.fork() auf und dient dem Request-Durchsatz.
  • worker_threads startet einen weiteren Thread innerhalb desselben Prozesses. Jeder Worker hat sein eigenes V8-Isolate und seinen eigenen Event Loop, kann aber optional echten Speicher mit dem Hauptthread teilen. Gedacht für CPU-Arbeit.
  • Die normalen asynchronen APIs sind für alles, was I/O ist – und das braucht keine der drei oberen Optionen.

Diesen letzten Punkt betone ich bewusst, weil dort der häufigste Denkfehler lauert. Aber der Reihe nach.

child_process: spawn, exec, execFile, fork

Das Modul node:child_process bietet vier Wege, einen Kindprozess zu starten, und die Unterschiede sind praxisrelevant.

spawn(command, args, options) startet ein Programm und gibt dir dessen stdout und stderr als Streams. Es puffert nichts und startet standardmäßig keine Shell. Das ist die richtige Wahl, wenn die Ausgabe groß sein kann oder du sie streamend verarbeiten willst.

exec(command, options, callback) startet eine echte Shell und puffert die gesamte Ausgabe im Speicher, bevor der Callback aufgerufen wird. Das ist bequem für kurze Kommandos, hat aber zwei Tücken: Der Default für maxBuffer liegt bei 1 MB – wird die Ausgabe größer, killt Node den Prozess mit ERR_CHILD_PROCESS_STDIO_MAXBUFFER. Und weil eine Shell im Spiel ist, ist exec mit ungeprüftem Input eine Einladung zur Shell-Injection.

execFile(file, args, options, callback) verhält sich wie exec, startet aber keine Shell. Das ist sicherer und effizienter, wenn du ohnehin nur ein konkretes Programm mit Argumenten aufrufst.

const { execFile } = require('node:child_process');

execFile('git', ['rev-parse', 'HEAD'], (err, stdout) => {
  if (err) throw err;
  console.log('current commit:', stdout.trim());
});

Als Faustregel gebe ich mit: kurze Ausgabe eines konkreten Programms – execFile. Große oder streamende Ausgabe – spawn. exec mit Shell nur, wenn du die Shell-Features (Pipes, Globbing) wirklich brauchst und den Input kontrollierst.

Der interessanteste der vier ist fork. Es ist ein Spezialfall von spawn, der gezielt eine neue Node-Instanz startet – über process.execPath, denselben Node-Binary – und automatisch einen IPC-Kanal einrichtet. Damit kannst du zwischen Eltern- und Kindprozess Nachrichten austauschen, ohne dich um Pipes und Serialisierung von Hand zu kümmern.

Das Hauptbeispiel: schwere Berechnung in einem fork

Nehmen wir das blockierende Fibonacci-Beispiel von oben und lagern die Rechnung in einen Kindprozess aus. Der Elternprozess bleibt reaktionsfähig, weil die CPU-Last in einem völlig anderen OS-Prozess landet.

Zuerst der Worker in worker.js:

// worker.js
function fibonacci(n) {
  if (n < 2) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

process.on('message', ({ n }) => {
  const result = fibonacci(n);
  process.send({ n, result });
});

Und der Elternprozess, der ihn startet und beauftragt:

// main.js
const { fork } = require('node:child_process');
const http = require('node:http');

const child = fork('./worker.js');

const pending = new Map();
child.on('message', ({ n, result }) => {
  const res = pending.get(n);
  if (res) {
    res.end(`fibonacci(${n}) = ${result}`);
    pending.delete(n);
  }
});

http
  .createServer((req, res) => {
    if (req.url === '/compute') {
      pending.set(45, res);
      child.send({ n: 45 }); // heavy work happens elsewhere
    } else {
      res.end('ok'); // stays responsive during the computation
    }
  })
  .listen(3000);

Der Punkt, den ich in der Schulung an dieser Stelle mache: Der /-Pfad antwortet jetzt sofort, auch während /compute noch rechnet. Der Event Loop des Hauptprozesses ist frei, weil die teure Schleife in einem separaten Prozess mit eigenem V8-Heap läuft. Die Kommunikation zwischen beiden geht ausschließlich über Nachrichten – hier per child.send() und dem message-Event.

Genau hier entsteht ein weiterer wichtiger Merksatz: send ist kein Shared Memory. Die Nachricht wird serialisiert und im Kindprozess als Kopie neu aufgebaut. Der Default ist serialization: 'json', und JSON verliert Typen – ein Date wird zum String, undefined verschwindet, BigInt funktioniert gar nicht. Wer strukturierte Werte über IPC schicken will, kann serialization: 'advanced' setzen, was den Structured-Clone-Algorithmus nutzt und deutlich mehr Typen erhält.

Der folgende Ablauf zeigt das Prinzip als Sequenz:

sequenceDiagram
    participant Client
    participant Main as Main Prozess<br/>(Event Loop frei)
    participant Child as Forked Prozess<br/>(eigener V8-Heap)
    Client->>Main: GET /compute
    Main->>Child: send({ n: 45 })
    Note over Main: Event Loop bleibt frei<br/>für andere Requests
    Child->>Child: heavy computation
    Child-->>Main: message({ result })
    Main-->>Client: HTTP 200 result

worker_threads: dasselbe Ziel, geteilter Speicher

Ein fork ist robust, aber teuer. Jeder Kindprozess bringt ein vollständiges V8 und einen eigenen Heap mit – das kostet Speicher und Startzeit. Wenn du CPU-Arbeit parallelisieren willst und dabei nicht die volle Isolation eines eigenen Prozesses brauchst, sind worker_threads die leichtere Alternative. Sie sind seit Node 12 stabil und 2023 längst produktiver Standard.

Ein Worker läuft im selben Prozess, aber in einem eigenen Thread mit eigenem V8-Isolate und eigenem Event Loop. Dieselbe Rechnung sieht so aus:

// task.js
const { parentPort, workerData } = require('node:worker_threads');

function fibonacci(n) {
  if (n < 2) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

parentPort.postMessage(fibonacci(workerData.n));
// main-threads.js
const { Worker } = require('node:worker_threads');

function computeFib(n) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./task.js', { workerData: { n } });
    worker.on('message', resolve);
    worker.on('error', reject);
  });
}

computeFib(45).then((result) => {
  console.log('fibonacci(45) =', result);
});

Der große Unterschied zum fork: Worker-Threads können echten Speicher teilen. Ein SharedArrayBuffer ist für Haupt- und Worker-Thread derselbe Speicherbereich – keine Kopie, keine Serialisierung. Alternativ lässt sich ein ArrayBuffer per transferList übergeben, was ein Zero-Copy-Transfer ist: Der Buffer wandert zum Empfänger und ist beim Sender danach unbrauchbar. Für große Datenmengen zwischen Threads ist das ein echter Vorteil, den ein separater Prozess prinzipbedingt nicht bieten kann.

Der Fallstrick, der am meisten Schaden anrichtet

Jetzt kommt der Teil, bei dem ich in Schulungen am nachdrücklichsten werde, weil ich diesen Fehler in echten Codebasen immer wieder sehe: I/O-bound Arbeit mit worker_threads oder cluster „beschleunigen" zu wollen.

Die Motivation ist verständlich. Jemand hat gehört, dass Worker parallelisieren, sieht einen Endpoint, der viele Datenbankabfragen oder HTTP-Calls macht, und packt das Ganze in einen Worker-Pool. Das Ergebnis ist regelmäßig langsamer als vorher. Der Grund: Netzwerk- und Datei-I/O ist in Node.js bereits asynchron und blockiert den Event Loop überhaupt nicht. Da ist nichts zu entlasten. Die Doku sagt es unmissverständlich: Worker-Threads helfen bei I/O-intensiver Arbeit nicht nennenswert. Was du bekommst, ist der Overhead für Thread-Erstellung und Message-Passing – ohne jeden Gewinn.

Die Regel ist also einfach und trotzdem wird ständig gegen sie verstoßen:

  • Ist die Arbeit I/O-bound – Datenbank, Netzwerk, Dateisystem? Dann bleib bei den normalen asynchronen APIs. Worker und Prozesse bringen hier nichts außer Komplexität.
  • Ist die Arbeit CPU-bound – Hashing, Bildverarbeitung, Kompression, Krypto, große synchrone Transformationen? Erst dann sind worker_threads oder child_process das richtige Werkzeug.

Ein zweiter Fallstrick sitzt gleich daneben: pro Task einen Worker oder Prozess zu erzeugen. Die Erstellung ist teuer – V8 muss initialisiert werden, Speicher wird allokiert. Wer bei jedem Request einen frischen Worker startet, macht die Sache oft langsamer, nicht schneller. Die Antwort heißt Worker-Pool: eine feste Zahl wiederverwendbarer Worker, die Aufgaben aus einer Queue abarbeiten. Dieselbe Überlegung gilt für fork – halte die Kindprozesse am Leben und schick ihnen nacheinander Arbeit, statt sie ständig neu zu starten.

cluster: Durchsatz, nicht Entlastung

Bleibt cluster, und auch hier gibt es ein hartnäckiges Missverständnis. cluster löst nicht das Problem der einzelnen blockierenden Berechnung. Es löst das Problem des Durchsatzes: Ein einzelner Node-Prozess nutzt nur einen Kern, aber deine Maschine hat mehrere. cluster startet pro Kern eine Node-Instanz, alle lauschen hinter demselben Port, und der Primary verteilt eingehende Verbindungen im Round-Robin.

const cluster = require('node:cluster');
const http = require('node:http');
const os = require('node:os');

if (cluster.isPrimary) {
  for (let i = 0; i < os.cpus().length; i++) {
    cluster.fork();
  }
  cluster.on('exit', (worker) => {
    console.log(`worker ${worker.process.pid} died, restarting`);
    cluster.fork();
  });
} else {
  http
    .createServer((req, res) => res.end(`handled by pid ${process.pid}`))
    .listen(3000);
}

Damit verdoppelt oder vervierfacht sich der Durchsatz an gleichzeitigen Requests. Aber wenn ein einzelner Request eine sekundenlange synchrone Berechnung auslöst, blockiert diese immer noch den Worker, der sie bearbeitet. cluster macht aus vielen langsamen Requests nur dann viele parallele, wenn jeder auf einem anderen Worker landet – und die begrenzte Zahl an Workern ist irgendwann erschöpft. Für das eigentliche CPU-Problem pro Request brauchst du weiterhin worker_threads oder ausgelagerte Prozesse. Übrigens ist cluster.isPrimary seit Node 16 der bevorzugte Name; isMaster existiert noch, gilt aber als veraltet.

Wann was: die Entscheidung auf einen Blick

Wenn Teilnehmer mich am Ende fragen, wie sie sich das merken sollen, zeichne ich diesen Entscheidungsbaum:

flowchart TD
    A[Arbeit einordnen] --> B{I/O-bound?}
    B -->|Ja| C[Normale async APIs<br/>keine Worker nötig]
    B -->|Nein, CPU-bound| D{Geteilter Speicher<br/>oder leichtgewichtig?}
    D -->|Ja| E[worker_threads<br/>SharedArrayBuffer möglich]
    D -->|Nein, Isolation<br/>oder externes Skript| F[child_process / fork<br/>eigener Prozess + IPC]
    A --> G{Nur mehr Durchsatz<br/>auf allen Kernen?}
    G -->|Ja| H[cluster<br/>mehrere Instanzen, ein Port]

Das ist der Kern, den ich mitgeben will. Die drei Werkzeuge sind keine Alternativen für dieselbe Frage, sondern Antworten auf verschiedene Fragen:

  • child_process / fork gibt dir einen eigenen Prozess mit eigenem Heap und Kommunikation über kopierte IPC-Nachrichten – maximale Isolation, ideal für externe Programme oder eigenständige Node-Skripte.
  • cluster gibt dir mehrere Node-Instanzen hinter einem Port für mehr Request-Durchsatz – es skaliert Instanzen, nicht einzelne Berechnungen.
  • worker_threads gibt dir Threads im selben Prozess mit optional geteiltem Speicher – die richtige Wahl, um CPU-Arbeit zu parallelisieren, ohne einen ganzen Prozess hochzufahren.

Fazit

Die Illusion, gegen die dieser Beitrag anschreibt, ist die bequemste: dass Node.js CPU-Arbeit schon irgendwie von allein auf alle Kerne verteilt. Tut es nicht. Ein Prozess, ein Event Loop, ein JS-Thread – und jede synchrone Rechenlast blockiert alles andere. Wer das akzeptiert, trifft danach die richtigen Entscheidungen fast von selbst: I/O braucht keine Worker, sondern nur die asynchronen APIs, die Node ohnehin mitbringt. Erst CPU-Arbeit rechtfertigt schwereres Geschütz – worker_threads für Parallelität im Prozess mit geteiltem Speicher, fork für isolierte Prozesse, cluster für reinen Durchsatz.

Der teuerste Fehler ist nicht, das falsche Werkzeug zu wählen. Er ist, ein Werkzeug zu greifen, wo gar kein Problem ist – I/O mit Workern zu „beschleunigen" und dafür Komplexität und Overhead einzukaufen, ohne einen Millisekunden Gewinn. Miss erst, ob du überhaupt CPU-gebunden bist. Wenn du es bist, weißt du jetzt, wohin die Arbeit gehört. Wer beim Thema Datenverarbeitung ohnehin an großen Datenmengen arbeitet, findet in meinem Beitrag zu Node.js-Streams und Backpressure die passende Ergänzung – denn oft ist die beste Skalierung die, bei der man Daten gar nicht erst komplett in den Speicher lädt.

Weiterführende Quellen

Kommentare

Kommentar schreiben