Kodokon kodokon.com

Die Node-Event-Loop: Phasen und Fallstricke

Entdecke die libuv-Phasen, den echten Unterschied zwischen setImmediate und setTimeout und wie du eine blockierte Event-Loop erkennst.

10 Min. · 3 Fragen

Diese Lektion in Kodokon öffnen

Node.js überlässt seine Event-Loop libuv, das sie in klar getrennte Phasen gliedert: timers (setTimeout, setInterval), pending callbacks, idle/prepare (intern), poll (I/O: Sockets, Dateien), check (setImmediate) und close callbacks. Bei jedem Tick durchläuft die Loop diese Phasen in genau dieser Reihenfolge. Ein Detail, das wenige Entwicklerinnen und Entwickler kennen: Zwischen zwei Callbacks leert Node zuerst die Warteschlange von process.nextTick und danach die Promise-Microtasks - und das seit Node 11 nach jedem Callback, nicht mehr nur zwischen den Phasen.

JAVASCRIPT
import { readFile } from "node:fs";

setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));

readFile(new URL(import.meta.url), () => {
  setTimeout(() => console.log("io timeout"), 0);
  setImmediate(() => console.log("io immediate"));
});
Die Reihenfolge ist nur innerhalb eines I/O-Callbacks garantiert.

Beim Start des Skripts ist die Reihenfolge zwischen setTimeout(fn, 0) und setImmediate(fn) nicht deterministisch: setTimeout(fn, 0) wird in Wirklichkeit auf 1 ms angehoben, und wenn die Loop die Phase timers in weniger als einer Millisekunde erreicht, ist der Timer noch nicht fällig. Innerhalb eines I/O-Callbacks befindest du dich dagegen in der Phase poll: Die Phase check (setImmediate) kommt immer vor der Rückkehr zur Phase timers. Deshalb wird io immediate zuverlässig vor io timeout ausgegeben.

JAVASCRIPT
setTimeout(() => {
  process.nextTick(() => console.log("nextTick"));
  Promise.resolve().then(() => console.log("promise"));
  queueMicrotask(() => console.log("microtask"));
  console.log("sync");
}, 0);
// sync, nextTick, promise, microtask
Innerhalb eines Callbacks läuft nextTick vor den Microtasks.

Ein feiner Fallstrick: Auf der obersten Ebene eines ES-Moduls dreht sich diese Reihenfolge um - die Promise-Microtasks laufen vor process.nextTick. Der Grund: Die Auswertung eines ES-Moduls wird selbst als Promise-Job ausgeführt, die Microtask-Warteschlange wird also bereits geleert, wenn dein synchroner Code endet. In CommonJS behält nextTick seine Priorität auch auf der obersten Ebene. Prüfe eine solche Reihenfolge immer in dem Modulformat, das du tatsächlich ausrollst.

Die Event-Loop läuft auf einem einzigen Thread: Jede lange synchrone Berechnung - ein JSON.parse auf einer großen Payload, ein katastrophaler regulärer Ausdruck, eine synchrone Verschlüsselung - friert alle laufenden Anfragen ein. Miss in der Produktion die Latenz der Loop mit monitorEventLoopDelay: Ein p99 über einigen zehn Millisekunden deutet auf eine Blockade hin. Der folgende Code löst absichtlich eine Blockade aus und misst sie anschließend.

JAVASCRIPT
import { monitorEventLoopDelay }
  from "node:perf_hooks";

const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();

const end = Date.now() + 200;
while (Date.now() < end) {}

setTimeout(() => {
  h.disable();
  const ms = h.max / 1e6;
  console.log(`max delay: ${ms.toFixed(1)} ms`);
}, 300);
Das Histogramm deckt die Blockade von 200 ms auf.

Wissenscheck

Stelle sicher, dass du die wichtigsten Punkte dieser Lektion behalten hast.

  1. Innerhalb eines I/O-Callbacks (zum Beispiel fs.readFile) planst du setTimeout(fn, 0) und setImmediate(fn). Was läuft zuerst?
    • setTimeout, immer
    • setImmediate, immer
    • Die Reihenfolge ist zufällig, wie beim Start des Skripts
  2. In welcher Reihenfolge leert Node nach einem Callback die vorrangigen Warteschlangen?
    • Die Promise-Microtasks kommen zuerst
    • Beide Warteschlangen werden streng abwechselnd geleert
    • Zuerst wird die Warteschlange von process.nextTick geleert, dann die Microtasks
  3. Welches native Werkzeug misst Blockaden der Event-Loop in der Produktion präzise?
    • console.time rund um jede Anfrage
    • monitorEventLoopDelay aus node:perf_hooks
    • process.memoryUsage, aufgerufen in einem setInterval
    • das Modul node:trace_events