mitmario.dev

Die Ereignisschleife

Node.js Sandbox 4 Min Lesezeit 4 BeispieleLektion 2 von 7

Node arbeitet in Runden. In jeder Runde schaut es nach, was inzwischen fertig geworden ist, und ruft die zugehörigen Rückrufe auf. Diese Runde heißt Ereignisschleife.

Wer ihre Reihenfolge vorhersagen kann, hat das Modell verstanden. Wer es nicht kann, sucht später Fehler, die keine sind. Das ist übrigens die klassische Frage im Vorstellungsgespräch, und sie ist es zu Recht.

Die Reihenfolge

Zuerst läuft der synchrone Code. Komplett, bis zum Ende. Erst wenn dort nichts mehr zu tun ist, kommt überhaupt etwas anderes dran.

Dann leert Node zwei Warteschlangen, und zwar in dieser Reihenfolge: erst process.nextTick, dann die Microtasks. Microtasks sind aufgelöste Promises und alles, was mit queueMicrotask angemeldet wurde.

Danach läuft die eigentliche Runde weiter. Sie hat mehrere Stationen, und für den Alltag reichen zwei: die Timer (setTimeout, setInterval) und die Station danach, in der setImmediate läuft. Dazwischen und drumherum werden die fertigen I/O-Rückmeldungen abgearbeitet, also gelesene Dateien und angekommene Netzwerkantworten.

Die Runde von innen gesehen
setTimeout(() => {
  setTimeout(() => console.log("setTimeout nach 50 ms"), 50);
  setImmediate(() => console.log("setImmediate"));
  Promise.resolve().then(() => console.log("Promise-Microtask"));
  process.nextTick(() => console.log("process.nextTick"));
  console.log("synchron");
}, 0);

Fünf Anmeldungen, fünf verschiedene Warteschlangen, und sie kommen genau in dieser Reihenfolge dran, obwohl sie fast umgekehrt im Code stehen. Der setTimeout steht oben und kommt zuletzt.

Der Sonderfall ganz oben in der Datei

Jetzt eine Merkwürdigkeit, die dich sonst irgendwann kalt erwischt. Schreib dieselben fünf Zeilen nicht in einen Rückruf, sondern direkt an den Anfang der Datei.

Dieselben fünf Zeilen ganz oben in der Datei
setTimeout(() => console.log("setTimeout nach 50 ms"), 50);
setImmediate(() => console.log("setImmediate"));
Promise.resolve().then(() => console.log("Promise-Microtask"));
process.nextTick(() => console.log("process.nextTick"));
console.log("synchron");

process.nextTick und der Microtask haben die Plätze getauscht. Alles andere steht gleich da.

Der Grund liegt in etwas, das du aus Abschnitt 2 kennst: Der Rumpf eines ES-Moduls wird selbst schon in einem Promise abgearbeitet. Node ist beim Ausführen deiner Datei also bereits mitten in der Microtask-Abarbeitung, und was dort dazukommt, wird noch in derselben Runde erledigt, bevor die nextTick-Warteschlange wieder an der Reihe ist.

Dass es hier ein Modul ist, sagt die package.json, die im Beispiel als eigener Reiter daneben liegt: "type": "module", der erste der drei Wege aus Lektion 2.2. Ohne sie wäre oben.js eine ganz normale CommonJS-Datei. An den fünf Zeilen ist nämlich nichts, woran Node ein Modul erkennen könnte, kein import, kein export, kein await, und damit greift auch der dritte Weg aus 2.2 nicht, bei dem Node in die Datei schaut.

Dass es wirklich am Modul liegt, zeigt dieselbe Datei mit der Endung .cjs, also als CommonJS.

Und dieselbe Datei als CommonJS
setTimeout(() => console.log("setTimeout nach 50 ms"), 50);
setImmediate(() => console.log("setImmediate"));
Promise.resolve().then(() => console.log("Promise-Microtask"));
process.nextTick(() => console.log("process.nextTick"));
console.log("synchron");

Da ist die gewohnte Reihenfolge wieder. Ein CommonJS-Modul wird synchron ausgeführt, es gibt kein Promise drumherum, und damit gilt die normale Regel.

Wenn du die beiden Ausgaben lieber untereinander hättest als in zwei Beispielen nebeneinander: Starte die Sandbox am oberen Beispiel und tipp im Terminal cp oben.js oben.cjs, danach node oben.js und node oben.cjs. Dieselben fünf Zeilen, zweimal gestartet, und in Zeile zwei und drei der Ausgabe stehen sie vertauscht. Die Endung ist der ganze Unterschied.

Merk dir daraus nicht die Ausnahme, sondern den Satz dahinter: Die Reihenfolge hängt davon ab, in welchem Zusammenhang du etwas anmeldest. Sobald dein Code in einem Rückruf läuft, und das ist in einem Server praktisch immer der Fall, gilt die Regel aus dem ersten Beispiel.

Microtasks werden komplett abgeräumt

Ein Punkt, der oft übersehen wird: Node leert die Microtask-Warteschlange vollständig, nicht einen Eintrag. Auch die, die beim Abarbeiten neu dazukommen.

Microtasks räumen komplett ab
setTimeout(() => console.log("Timer, angemeldet fuer 0 ms"), 0);

let kette = Promise.resolve();
for (let i = 1; i <= 4; i++) {
  kette = kette.then(() => console.log(`Microtask ${i}`));
}

console.log("synchron");

Der Timer war für null Millisekunden angemeldet, die Kette entsteht erst danach, und trotzdem kommt sie zuerst dran. Alle vier Glieder, hintereinander weg.

Daraus folgt etwas Praktisches: Eine Promise-Kette, die sich selbst immer wieder verlängert, kann die Ereignisschleife am Weiterlaufen hindern. Das ist selten, aber wenn es passiert, sucht man lange.

Was setTimeout(fn, 0) wirklich bedeutet

Nicht „sofort”. Sondern: bei nächster Gelegenheit, nach allem, was schon wartet.

Vorher läuft der restliche synchrone Code, vorher werden nextTick und alle Microtasks abgeräumt, und vorher wird alles erledigt, was in dieser Runde ohnehin dran ist.

setTimeout(fn, 0) zu benutzen, um etwas ans Ende zu schieben, ist in Ordnung. Es zu benutzen, weil man eine bestimmte Reihenfolge braucht, ist fast immer ein Zeichen dafür, dass an einer anderen Stelle etwas nicht stimmt.

Noch ein Detail dazu: Stehen setTimeout(fn, 0) und setImmediate beide auf oberster Ebene einer Datei, ist ihre Reihenfolge nicht garantiert. Mal gewinnt der eine, mal der andere. Es hängt daran, ob seit dem Anmelden schon eine Millisekunde vergangen ist, wenn Node die Runde betritt, und das hängt am Prozessstart. Deshalb steht in den Beispielen oben überall ein Timer mit 50 Millisekunden und nicht mit 0.

Innerhalb eines I/O-Rückrufs ist der Fall dagegen klar: Dort gewinnt setImmediate immer, weil seine Station in derselben Runde noch kommt und die Timer-Station schon vorbei ist.

process.nextTick, der Sonderling

process.nextTick drängelt sich vor die Microtasks, und in fast allen Fällen brauchst du es nicht.

Es hat einen Haken, den man kennen sollte: Node leert diese Warteschlange komplett, bevor es irgendetwas anderes tut. Eine Funktion, die sich am Ende selbst wieder per process.nextTick anmeldet, friert den Prozess deshalb wirklich ein. Kein Timer läuft mehr, keine Anfrage wird mehr angenommen, und von außen sieht es aus wie ein Absturz, obwohl der Prozess beschäftigt ist.

Die Faustregel: Brauchst du „später”, nimm queueMicrotask oder setImmediate. process.nextTick ist etwas für Bibliotheken, nicht für Anwendungscode.

Zum Mitnehmen

Erst der synchrone Code komplett, dann nextTick, dann die Microtasks, dann der Rest der Runde. setTimeout(fn, 0) heißt nicht sofort.

Jetzt du

Basis Konto, kostenlos

Zu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.

Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.

In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.

Was in dieser Lektion steckt

  • Artikel mit 4 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe, dein Code läuft auf einem Server

    Öffnet sich mit dem Basis Konto.