Warum der Event Loop wichtig ist
Node.js führt dein JavaScript auf einem einzigen Main Thread aus, dennoch kann ein einziger Server tausende gleichzeitige Verbindungen verarbeiten. Das ist möglich, weil der Event Loop niemals wartet. Wenn eine Operation blockierend wäre, übergibt Node diese an das Betriebssystem oder an einen Thread Pool und macht mit anderen Aufgaben weiter. Sobald das Ergebnis bereitsteht, wird ein Callback in die Queue eingereiht und der Loop führt ihn aus.
Das Verständnis des Loops erklärt vieles am Verhalten von Node: warum ein synchrones Dateilesen den gesamten Server einfriert, warum ein Promise vor einem Timer mit einer Verzögerung von Null geloggt wird und warum CPU-intensive Arbeit einen Worker benötigt. Es ist das wichtigste mentale Modell, um performanten Node-Code zu schreiben.
Der Loop und seine Phasen
Der Event Loop ist ein Zyklus aus verschiedenen Phasen. Jede Phase besitzt eine Queue von Callbacks, die der Loop abarbeitet, bevor er zur nächsten Phase übergeht.
- Timers — fällige Callbacks, die über
setTimeoutundsetIntervalgeplant wurden. - Pending callbacks — aufgeschobene System-Callbacks, wie zum Beispiel bestimmte TCP-Fehler.
- Idle, prepare — interne Buchführung.
- Poll — ruft neue I/O-Events ab und führt deren Callbacks aus. Wenn nichts bereitsteht, kann der Loop hier kurzzeitig blockieren.
- Check — führt
setImmediate-Callbacks aus. - Close callbacks — verarbeitet Events wie das Schließen von Sockets.
Der Loop läuft so lange weiter, wie es Arbeit zu erledigen gibt. Wenn keine Timer mehr aktiv sind, kein ausstehender I/O vorliegt und keine Handles mehr existieren, beendet sich der Prozess.
Microtasks: nextTick und Promises
Zwischen den Phasen und nach jedem Callback leert Node zwei hochpriorisierte Queues:
process.nextTickCallbacks, die zuerst ausgeführt werden.- Promise Callbacks, die nach nextTick ausgeführt werden.
Beide werden vollständig geleert, bevor der Loop fortfährt, was zwei Konsequenzen hat. Erstens werden sie vor Timern und I/O-Callbacks ausgeführt, die bereits früher geplant wurden. Zweitens kann ein rekursives nextTick oder eine endlose Promise-Kette den Loop blockieren (starve the loop), sodass I/O-Operationen niemals ausgeführt werden.
// order.js
console.log("1: synchronous");
setTimeout(() => console.log("5: timers"), 0);
setImmediate(() => console.log("6: check"));
Promise.resolve().then(() => console.log("3: promise"));
process.nextTick(() => console.log("2: nextTick"));
Die Ausgabe ist 1, 2, 3, dann 5 und 6. Die Reihenfolge von 5 und 6 kann auf der obersten Ebene variieren, da sie davon abhängt, wie lange es dauert, die Timer-Phase zu erreichen; innerhalb eines I/O-Callbacks wird setImmediate immer vor setTimeout ausgeführt.
Der libuv Thread Pool
Einige Operationen können vom Betriebssystem nicht auf portable Weise asynchron ausgeführt werden: Dateisystemzugriffe, DNS-Lookups und bestimmte kryptografische Funktionen. libuv führt diese in einem Thread Pool aus, der standardmäßig vier Threads umfasst und über UV_THREADPOOL_SIZE konfiguriert werden kann.
Aus diesem Grund ist fs.readFile für deinen Code nicht-blockierend, obwohl es im Hintergrund nicht event-gesteuert arbeitet. Das bedeutet auch, dass eine Vielzahl von Dateioperationen in einer Warteschlange hinter dem Pool aus vier Threads landen kann. Die async-APIs halten den Main Thread frei; der Pool übernimmt das Warten.
Den Event Loop blockieren
Alles, was synchron und langsam auf dem Main Thread läuft, blockiert alles: andere Anfragen, Timer und I/O. Häufige Ursachen sind:
readFileSyncund andere synchrone Datei-Aufrufe in Request-Handlern.- Große
JSON.parseoderJSON.stringifybei umfangreichen Payloads. - Synchrone Kryptografie, wie zum Beispiel
pbkdf2Syncmit hohen Iterationszahlen. - Endlosschleifen (Tight Loops), Regex-Backtracking und rechenintensive Datentransformationen.
- Blockierende Bibliotheken, die CPU-intensive Arbeit auf dem Main Thread ausführen.
Messen statt raten: Event Loop Delay und Event Loop Utilization zeigen Ihnen, wie viel Zeit der Loop blockiert ist. Ein steigender Delay unter Last ist das entscheidende Signal.
Arbeit aus dem Event-Loop auslagern
Wenn Aufgaben tatsächlich CPU-gebunden sind, lagern Sie diese aus:
- worker_threads für Berechnungen innerhalb desselben Prozesses mittels Message Passing.
- child processes für schwerfälligere oder isolierte Jobs.
- Ein separater Service für die rechenintensivsten Workloads, der unabhängig skaliert werden kann.
- Chunking: Zerlegen Sie große Aufgaben in kleinere Stücke und nutzen Sie
yieldzwischen den einzelnen Schritten.
Nutzen Sie für I/O die async APIs und Streams, damit der Loop frei bleibt.
Best Practices
- Verwenden Sie async APIs in Request-Handlern; blockieren Sie niemals den Event Loop.
- Nutzen Sie
setImmediatefür I/O-Operationen anstelle von rekursivennextTick. - Lagern Sie CPU-intensive Aufgaben in Worker Threads oder Child Processes aus.
- Streamen Sie große Datenmengen, anstatt diese vollständig im Arbeitsspeicher zu puffern.
- Optimieren Sie
UV_THREADPOOL_SIZEerst nach entsprechenden Messungen. - Überwachen Sie Event Loop Delay und die Auslastung in der Production-Umgebung.
- Halten Sie Callback-Ketten kurz, damit Microtasks den I/O-Durchsatz nicht blockieren.
Häufige Fehler
- Verwendung von
readFileSyncinnerhalb eines Hot Paths. - Rekursive
process.nextTick, die den Loop blockieren. - Die Annahme, dass Node vollständig single-threaded ist, und das Ignorieren des Thread-Pools.
- Durchführung rechenintensiver Operationen inline, anstatt diese auszulagern.
- Die Erwartung, dass
setTimeout(fn, 0)vor I/O-Callbacks ausgeführt wird. - Ignorieren des Event-Loop-Delays, bis Latenzprobleme auftreten.
Wie geht es weiter?
Der Event Loop ist das Herzstück von Node.js. Machen Sie weiter mit Streams für chunked I/O und dem File System für den asynchronen Dateizugriff. Kehren Sie zu den Node.js Basics zurück, falls Ihnen die Runtime noch neu ist, und messen Sie anschließend den Event Loop Delay in einem Ihrer eigenen Services.