post

Node.js: Async I/O erklärt Architektur

Die Rechnung „ein Thread mal 200 Verbindungen“ zeigt, welche Systeme zu Node.js passen und welche Arbeit besser in eigene Prozesse gehört.

≈ 8 Min. Lesezeit

Diesen Beitrag anhören (12 Min.)

MP3 herunterladen

Fünfzig Millisekunden sind keine auffällige Zahl. Ein JSON.parse über ein paar Megabyte, eine synchrone Template-Renderung, etwas Hashing – 50 ms CPU-Zeit fallen in keinem Profiler unangenehm auf. Jetzt die zweite Zahl: 200 gleichzeitige Verbindungen. In Node.js läuft sämtlicher JavaScript-Code auf einem einzigen Thread. Enthält jede dieser 200 Anfragen einen synchronen 50-ms-Block, wartet die letzte Antwort nicht 50 Millisekunden, sondern 200 × 50 ms = 10 Sekunden. Nicht weil die Maschine überlastet wäre – CPU und Netz langweilen sich –, sondern weil sich alle Anfragen denselben Thread teilen und der Reihe nach durch denselben Engpass müssen.

Diese kleine Rechnung ist der komplette Architektur-Vortrag über Node.js in einem Absatz. Wer sie einmal wirklich verstanden hat, kann die meisten Systementscheidungen rund um Node ableiten: welche Dienste dorthin gehören, welche nicht, und warum die Skalierungsantwort „mehr Prozesse“ heißt. Der Rest dieses Artikels buchstabiert das aus.

Ein Thread, viele Verbindungen

Node beantwortet die Frage „Wie bediene ich viele gleichzeitige Verbindungen?“ anders als die klassischen Server. Apache mit PHP oder ein Java-Servlet-Container geben jeder Anfrage einen eigenen Thread oder Prozess; blockiert einer, stört das die anderen kaum. Node dreht das Modell um: Es gibt genau einen Thread für JavaScript, und der darf nie warten. Alles, was dauert – ein Netzwerkaufruf, ein Dateizugriff, ein DNS-Lookup –, wird an das Betriebssystem oder an einen kleinen internen Threadpool abgegeben. Der JavaScript-Thread registriert lediglich einen Callback und wendet sich der nächsten Aufgabe zu.

Organisiert wird das von libuv, der C-Bibliothek, auf der Node aufsetzt. Sie fragt den Kernel über epoll (Linux), kqueue (macOS) oder IOCP (Windows), welche der offenen Operationen fertig sind, und arbeitet die zugehörigen Callbacks in einer festen Reihenfolge ab – das ist der Event-Loop. Der Loop durchläuft Phasen: Timer, anstehende Callbacks, Polling auf I/O, setImmediate, Close-Callbacks. Dazwischen schiebt sich die Microtask-Verarbeitung mit process.nextTick und Promise-Auflösungen. Dateisystem, DNS und Teile von Krypto laufen nicht über den Kernel-Mechanismus, sondern über den erwähnten Threadpool – standardmäßig vier Threads, einstellbar über UV_THREADPOOL_SIZE.

flowchart LR
  subgraph proc[Node-Prozess]
    JS[JavaScript<br/>ein einziger Thread] --> Loop[Event-Loop<br/>libuv]
  end
  Loop --> Kernel[Kernel<br/>epoll / kqueue / IOCP]
  Loop --> Pool[Threadpool<br/>fs, dns, crypto]
  Kernel -. Callback bereit .-> JS
  Pool -. Callback bereit .-> JS

Für den Alltag reichen zwei Merksätze. Erstens: I/O ist in Node praktisch gratis, weil das Warten woanders stattfindet – im Kernel oder im Threadpool. Zweitens: CPU-Zeit im JavaScript-Thread ist die teuerste Ressource des ganzen Systems, weil sie exklusiv ist. Jede Millisekunde, die eine Anfrage rechnet, ist eine Millisekunde, in der alle anderen Anfragen stillstehen.

Ein blockierender Aufruf blockiert alle

Mir ist das zum ersten Mal in einem Produktionsvorfall wirklich klar geworden, nicht beim Lesen der Dokumentation. Ein Reporting-Endpunkt nahm JSON-Dokumente entgegen, im Normalfall wenige Kilobyte. Eines Tages begann ein Client, Exporte mit rund 30 MB zu schicken. Der Code sah harmlos aus:

const http = require('http');

const server = http.createServer((req, res) => {
  let body = '';
  req.on('data', chunk => { body += chunk; });
  req.on('end', () => {
    // synchronous: the event loop stops while parsing
    const report = JSON.parse(body);
    res.end(JSON.stringify({ items: report.items.length }));
  });
});

server.listen(3000);

JSON.parse über 30 MB braucht mehrere hundert Millisekunden. In dieser Zeit passiert im Prozess sonst nichts: keine neuen Verbindungen, keine laufenden Antworten, keine Timer. Auch der Health-Check-Endpunkt antwortete nicht mehr, der Load-Balancer nahm die Instanz aus der Rotation, das Monitoring meldete den Dienst als tot – dabei lief der Prozess einwandfrei und hat schlicht gerechnet. Der Vorfall war nach einer Stunde verstanden, aber die Lektion war größer als der Vorfall: In Node gibt es keine „eine langsame Anfrage“. Es gibt nur langsame Anfragen, die alle anderen mitnehmen.

Das Fehlermodell unterscheidet sich damit grundlegend von Thread-pro-Anfrage-Servern. Dort äußert sich ein CPU-Fresser als ein langsamer Request in der Statistik. In Node äußert er sich als Latenz-Spitze über das gesamte System – und zwar auch für Anfragen, die mit dem Verursacher nichts zu tun haben.

Welche Systeme dazu passen – und welche nicht

Nimmt man das Modell ernst, sortieren sich Architekturentscheidungen fast von selbst. Node ist stark, wenn der Prozess überwiegend wartet und nur kurz rechnet:

  • API-Gateways und Proxies, die Anfragen prüfen, anreichern und weiterleiten
  • REST-APIs vor Datenbanken, bei denen die eigentliche Arbeit im Storage stattfindet
  • Aggregatoren, die mehrere Backends parallel abfragen und Ergebnisse zusammensetzen
  • Push- und Realtime-Dienste mit vielen offenen, meist stillen Verbindungen

All diese Systeme haben denselben Charakter: Tausende Verbindungen, von denen im Moment fast alle auf etwas warten. Genau dafür ist ein Event-Loop gebaut – die wartenden Verbindungen kosten fast nichts, ein Thread genügt für alle.

Umgekehrt gehört Arbeit mit ernsthafter CPU-Last nicht in den Request-Pfad eines Node-Prozesses: Bildverarbeitung, PDF-Erzeugung, das Verschlüsseln großer Dateien, aufwendige Berechnungen über Datensätzen. Nicht weil V8 langsam wäre – der JIT-Compiler ist erstaunlich schnell –, sondern weil jede dieser Millisekunden allen Anfragen gleichzeitig gehört.

In einem Projekt haben wir an dieser Linie ein Gateway geschnitten: Node übernahm Authentifizierung, Routing und das Zusammensetzen von Antworten aus drei Backends. Die Thumbnail-Erzeugung für hochgeladene Bilder, anfangs mitten im Upload-Handler, wanderte in einen separaten Prozess mit eigener Warteschlange – per child_process.fork gestartet und über Nachrichten angebunden. Die Upload-Latenz fiel, aber der eigentliche Gewinn war ein anderer: Die 99. Perzentile aller übrigen Endpunkte beruhigte sich, weil kein Bild mehr den gemeinsamen Thread besetzte. Der Event-Loop hatte uns die Grenze diktiert, an der das System zu trennen war – und es war eine gute Grenze.

Backpressure: auch Warten will organisiert sein

Async I/O heißt nicht, dass Daten von allein in der richtigen Geschwindigkeit fließen. Wer eine 2-GB-Exportdatei mit fs.readFile lädt, um sie an den Client zu schicken, hält 2 GB im Speicher – pro Anfrage. Streams lösen das, indem sie Daten in Stücken durchreichen:

const fs = require('fs');
const zlib = require('zlib');
const http = require('http');

http.createServer((req, res) => {
  res.setHeader('Content-Encoding', 'gzip');
  fs.createReadStream('./export.csv')
    .pipe(zlib.createGzip())
    .pipe(res);
}).listen(3000);

Das Interessante an pipe ist nicht die kompakte Schreibweise, sondern die eingebaute Flusskontrolle. Kommt das Ziel nicht hinterher – liefert write() also false –, pausiert der lesende Stream, bis das drain-Ereignis signalisiert, dass wieder Platz ist. Dieses Wechselspiel heißt Backpressure. Ohne diese Bremse liest die Platte mit ein paar hundert MB/s, während ein Mobilfunk-Client mit einem Bruchteil davon empfängt – die Differenz sammelt sich als Buffer im Heap, und der Prozess wächst, bis der Speicher endet.

Backpressure ist damit die zweite Stelle, an der das Laufzeitmodell zur Architekturfrage wird: Wer Uploads, Exporte oder Eventströme über Node führt, muss Datenfluss als etwas Begrenzbares entwerfen – mit Streams als Standardwerkzeug und pipe als Verbindungsstück, nicht mit „alles in eine Variable und dann senden“.

Skalieren heißt: mehr Prozesse

Ein Node-Prozess nutzt für JavaScript einen Kern. Auf einer Maschine mit acht Kernen liegen also sieben brach, solange nur ein Prozess läuft. Die Antwort der Plattform ist das cluster-Modul: Ein Master-Prozess startet pro Kern einen Worker, alle teilen sich denselben Port, der Master verteilt eingehende Verbindungen reihum.

const cluster = require('cluster');
const os = require('os');

if (cluster.isMaster) {
  os.cpus().forEach(() => cluster.fork());
  cluster.on('exit', worker => {
    console.log(`worker ${worker.process.pid} died, forking a new one`);
    cluster.fork();
  });
} else {
  require('./server');
}
flowchart TB
  In[eingehende Verbindungen<br/>Port 3000] --> M[Master-Prozess<br/>Round-Robin-Verteilung]
  M --> W1[Worker 1<br/>eigener Event-Loop]
  M --> W2[Worker 2<br/>eigener Event-Loop]
  M --> W3[Worker N<br/>eigener Event-Loop]

Jeder Worker hat seinen eigenen Event-Loop und seinen eigenen Heap. Das hat zwei angenehme Nebenwirkungen und eine unbequeme Konsequenz. Angenehm: Ein abgestürzter Worker reißt nur sich selbst mit, der Master startet ihn neu; und ein blockierter Worker blockiert nur noch ein Achtel des Systems statt alles. Unbequem: Es gibt keinen gemeinsamen Speicher. Sessions, Caches, Zähler – alles, was bisher bequem in einer Modul-Variablen lag, muss raus aus dem Prozess, etwa in Redis oder die Datenbank. Aus meiner Sicht ist das kein Verlust, sondern ein früher Zwang zu einem Zustand, den man für horizontale Skalierung über Maschinen hinweg ohnehin braucht. Wer mit cluster auf acht Worker kommt, kommt später fast ohne Änderung auf acht Maschinen.

Wichtig ist die Einordnung: cluster mildert das Blockier-Problem, es löst es nicht. Die 50-ms-Rechnung vom Anfang gilt in jedem Worker unverändert. Für echte CPU-Arbeit bleibt die saubere Antwort ein eigener Prozess mit Warteschlange davor – cluster ist Lastverteilung, kein Freibrief für Rechenarbeit im Request-Pfad.

async/await: neue Syntax, dasselbe Modell

Seit wenigen Wochen – Node 7.6, erschienen im Februar 2017 – laufen async/await ohne Flag. Auf Node 6 LTS, das viele Produktionssysteme fahren, braucht es dafür weiterhin Babel. Nach Jahren von Callback-Pyramiden und Promise-Ketten liest sich derselbe Ablauf plötzlich wie synchroner Code:

async function loadDashboard(userId) {
  const user = await fetchUser(userId);
  const orders = await fetchOrders(user.id);
  return { user, orders };
}

// independent calls: run them concurrently instead
async function loadDashboardFast(userId) {
  const [user, orders] = await Promise.all([
    fetchUser(userId),
    fetchOrders(userId)
  ]);
  return { user, orders };
}

Synchron ist daran nichts. An jedem await gibt die Funktion den Thread frei; der Event-Loop bedient andere Callbacks und setzt die Funktion fort, sobald das Promise aufgelöst ist. Genau deshalb gehören zwei Dinge in denselben Gedanken. Erstens: Zwei nacheinander stehende await-Zeilen laufen sequenziell, auch wenn sie unabhängig sind – für Parallelität bleibt Promise.all zuständig, wie im zweiten Beispiel. Zweitens, und das halte ich für die eigentliche Falle der neuen Syntax: await macht Wartezeiten kooperativ, aber CPU-Arbeit zwischen zwei await-Punkten blockiert exakt wie vorher. Ein JSON.parse über 30 MB wird nicht höflicher, weil davor ein await steht. Wer vom Callback über das Promise zu async/await gewechselt ist, hat deutlich besseren Code – und ein unverändertes Laufzeitmodell. Die Syntax ist neu, die Rechnung bleibt.

Lauffähige Beispiele zu all dem – Event-Loop, Streams, Express, Async-Muster vom Callback bis zum Stream – sammle ich in einem öffentlichen Repository: MikeBild/introduction-nodejs. Die Beispiele sind bewusst klein gehalten; jedes zeigt genau eine der hier beschriebenen Grenzen.

Unterm Strich

Der Event-Loop ist ein brauchbarer Architektur-Test, und zwar ein billiger. Für jedes geplante Feature lässt sich fragen: Wartet dieser Code, oder rechnet er? Wartende Arbeit – Datenbank, HTTP, Dateien, offene Verbindungen – passt in den Node-Prozess, in fast beliebiger Menge. Rechnende Arbeit gehört in eigene Prozesse, hinter eine Warteschlange, mit cluster und child_process als Bordmitteln. Streams mit funktionierendem Backpressure halten den Speicher flach, wo große Datenmengen fließen.

Bemerkenswert finde ich, wie viel Architektur aus einer einzigen Laufzeit-Eigenschaft folgt. Die Trennung von Gateway und Bildverarbeitung, zustandslose Prozesse, Warteschlangen vor CPU-Arbeit – nichts davon steht in einem Framework-Handbuch, alles davon steckt in der Rechnung 200 × 50 ms. Plattformen erziehen ihre Systeme, ob man das will oder nicht. Bei Node ist die Erziehung streng, aber sie zeigt in eine Richtung, die sich auch jenseits von Node auszahlt: kleine Prozesse, die überwiegend warten, nichts Wichtiges im lokalen Speicher halten und ihre Datenflüsse begrenzen. Wer die 10 Sekunden vom Anfang einmal in einem echten Dashboard gesehen hat, vergisst diese Richtung nicht mehr.

Weiterführende Quellen

Kommentare