Der Node.js Event Loop
Was ist der Event Loop?
Der Event Loop ermöglicht es Node.js, nicht-blockierende I/O-Operationen durchzuführen – trotz der Tatsache, dass standardmäßig ein einzelner JavaScript-Thread verwendet wird – indem er Operationen, wann immer möglich, an den Systemkern auslagert.
Da die meisten modernen Kernel multithreaded sind, können sie mehrere Operationen verarbeiten, die im Hintergrund ausgeführt werden. Wenn eine dieser Operationen abgeschlossen ist, teilt der Kernel Node.js dies mit, sodass der entsprechende Callback zur Poll-Warteschlange hinzugefügt werden kann, um schließlich ausgeführt zu werden. Wir werden dies später in diesem Thema genauer erläutern.
Event Loop Erläutert
Wenn Node.js startet, initialisiert es den Event Loop, verarbeitet das bereitgestellte Eingabe-Skript (oder wechselt in die REPL, was in diesem Dokument nicht behandelt wird), das möglicherweise asynchrone API-Aufrufe tätigt, Timer plant oder process.nextTick() aufruft, und beginnt dann mit der Verarbeitung des Event Loops.
Das folgende Diagramm zeigt eine vereinfachte Übersicht über die Reihenfolge der Operationen des Event Loops.
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
TIP
Jede Box wird als "Phase" des Event Loops bezeichnet.
Jede Phase hat eine FIFO-Warteschlange von Callbacks, die ausgeführt werden sollen. Während jede Phase auf ihre Weise besonders ist, führt der Event Loop, wenn er in eine bestimmte Phase eintritt, im Allgemeinen alle Operationen aus, die für diese Phase spezifisch sind, und führt dann Callbacks in der Warteschlange dieser Phase aus, bis die Warteschlange erschöpft ist oder die maximale Anzahl von Callbacks ausgeführt wurde. Wenn die Warteschlange erschöpft ist oder das Callback-Limit erreicht ist, wechselt der Event Loop zur nächsten Phase usw.
Da jede dieser Operationen weitere Operationen planen kann und neue Ereignisse, die in der Poll-Phase verarbeitet werden, vom Kernel in die Warteschlange gestellt werden, können Poll-Ereignisse in die Warteschlange gestellt werden, während Poll-Ereignisse verarbeitet werden. Infolgedessen können langlaufende Callbacks dazu führen, dass die Poll-Phase viel länger läuft als der Schwellenwert eines Timers. Weitere Details finden Sie in den Abschnitten Timer und Poll.
TIP
Es gibt eine leichte Diskrepanz zwischen der Windows- und der Unix/Linux-Implementierung, aber das ist für diese Demonstration nicht wichtig. Die wichtigsten Teile sind hier. Es gibt eigentlich sieben oder acht Schritte, aber die, die uns wichtig sind – die, die Node.js tatsächlich verwendet – sind die oben genannten.
Phasenübersicht
- timers: Diese Phase führt Rückrufe aus, die durch
setTimeout()
undsetInterval()
geplant wurden. - pending callbacks: Führt I/O-Rückrufe aus, die auf die nächste Schleifeniteration verschoben wurden.
- idle, prepare: Wird nur intern verwendet.
- poll: Ruft neue I/O-Ereignisse ab; führt I/O-bezogene Rückrufe aus (fast alle mit Ausnahme von Close-Rückrufen, den durch Timer geplanten und
setImmediate()
); Node wird hier gegebenenfalls blockieren. - check: Hier werden
setImmediate()
-Rückrufe aufgerufen. - close callbacks: Einige Close-Rückrufe, z. B.
socket.on('close', ...)
.
Zwischen jeder Ausführung der Ereignisschleife prüft Node.js, ob es auf asynchrone I/O oder Timer wartet und fährt sauber herunter, wenn keine vorhanden sind.
Phasen im Detail
timers
Ein Timer gibt den Schwellenwert an, nach dem ein bereitgestellter Rückruf ausgeführt werden kann, anstatt der genauen Zeit, zu der eine Person möchte, dass er ausgeführt wird. Timer-Rückrufe werden so früh wie möglich ausgeführt, nachdem die angegebene Zeit verstrichen ist. Die Zeitplanung durch das Betriebssystem oder die Ausführung anderer Rückrufe können sie jedoch verzögern.
TIP
Technisch gesehen steuert die Poll-Phase, wann Timer ausgeführt werden.
Nehmen wir zum Beispiel an, Sie planen einen Timeout, der nach einem Schwellenwert von 100 ms ausgeführt werden soll, dann beginnt Ihr Skript asynchron eine Datei zu lesen, was 95 ms dauert:
const fs = require('node:fs')
function someAsyncOperation(callback) {
// Angenommen, dies dauert 95 ms bis zur Fertigstellung
fs.readFile('/path/to/file', callback)
}
const timeoutScheduled = Date.now()
setTimeout(() => {
const delay = Date.now() - timeoutScheduled
console.log(`${delay}ms sind seit meiner Planung vergangen`)
}, 100)
// Führen Sie someAsyncOperation aus, was 95 ms dauert, bis es abgeschlossen ist
someAsyncOperation(() => {
const startCallback = Date.now()
// tun Sie etwas, was 10 ms dauern wird...
while (Date.now() - startCallback < 10) {
// nichts tun
}
})
Wenn die Ereignisschleife in die Poll-Phase eintritt, hat sie eine leere Warteschlange (fs.readFile()
ist noch nicht abgeschlossen), also wartet sie auf die Anzahl der ms, die verbleiben, bis der Schwellenwert des frühesten Timers erreicht ist. Während sie wartet, vergehen 95 ms, fs.readFile()
ist mit dem Lesen der Datei fertig und sein Rückruf, der 10 ms bis zur Fertigstellung benötigt, wird der Poll-Warteschlange hinzugefügt und ausgeführt. Wenn der Rückruf beendet ist, befinden sich keine weiteren Rückrufe in der Warteschlange. Die Ereignisschleife sieht, dass der Schwellenwert des frühesten Timers erreicht wurde und kehrt zur Timer-Phase zurück, um den Rückruf des Timers auszuführen. In diesem Beispiel werden Sie sehen, dass die Gesamtverzögerung zwischen der Planung des Timers und der Ausführung seines Rückrufs 105 ms beträgt.
TIP
Um zu verhindern, dass die Poll-Phase die Ereignisschleife aushungert, hat libuv (die C-Bibliothek, die die Node.js-Ereignisschleife und alle asynchronen Verhaltensweisen der Plattform implementiert) auch ein hartes Maximum (systemabhängig), bevor sie aufhört, nach weiteren Ereignissen zu suchen.
Ausstehende Rückrufe
In dieser Phase werden Rückrufe für einige Systemoperationen ausgeführt, z. B. für Arten von TCP-Fehlern. Wenn beispielsweise ein TCP-Socket beim Verbindungsversuch ECONNREFUSED
empfängt, möchten einige *nix-Systeme mit der Meldung des Fehlers warten. Dieser wird in der Phase der ausstehenden Rückrufe zur Ausführung in die Warteschlange gestellt.
Poll
Die Poll-Phase hat zwei Hauptfunktionen:
- Berechnen, wie lange sie blockieren und auf I/O warten soll, und dann
- Verarbeitung von Ereignissen in der Poll-Warteschlange.
Wenn die Ereignisschleife in die Poll-Phase eintritt und keine Timer geplant sind, geschieht eines von zwei Dingen:
Wenn die Poll-Warteschlange nicht leer ist, durchläuft die Ereignisschleife ihre Warteschlange mit Rückrufen und führt diese synchron aus, bis die Warteschlange erschöpft ist oder das systemabhängige Limit erreicht ist.
Wenn die Poll-Warteschlange leer ist, passieren zwei weitere Dinge:
Wenn Skripte mit
setImmediate()
geplant wurden, beendet die Ereignisschleife die Poll-Phase und fährt mit der Check-Phase fort, um diese geplanten Skripte auszuführen.Wenn Skripte nicht mit
setImmediate()
geplant wurden, wartet die Ereignisschleife darauf, dass Rückrufe zur Warteschlange hinzugefügt werden, und führt sie dann sofort aus.
Sobald die Poll-Warteschlange leer ist, prüft die Ereignisschleife, ob Timer erreicht sind, deren Zeitschwellenwerte erreicht wurden. Wenn ein oder mehrere Timer bereit sind, kehrt die Ereignisschleife zur Timer-Phase zurück, um die Rückrufe dieser Timer auszuführen.
Check
Diese Phase ermöglicht es einer Person, Rückrufe unmittelbar nach Abschluss der Poll-Phase auszuführen. Wenn die Poll-Phase in den Leerlauf geht und Skripte mit setImmediate()
in die Warteschlange gestellt wurden, kann die Ereignisschleife mit der Check-Phase fortfahren, anstatt zu warten.
setImmediate()
ist eigentlich ein spezieller Timer, der in einer separaten Phase der Ereignisschleife läuft. Er verwendet eine libuv-API, die Rückrufe zur Ausführung nach Abschluss der Poll-Phase plant.
Im Allgemeinen wird die Ereignisschleife beim Ausführen des Codes irgendwann die Poll-Phase erreichen, in der sie auf eine eingehende Verbindung, Anfrage usw. wartet. Wenn jedoch ein Rückruf mit setImmediate()
geplant wurde und die Poll-Phase in den Leerlauf geht, wird sie beendet und mit der Check-Phase fortgefahren, anstatt auf Poll-Ereignisse zu warten.
Close-Callbacks
Wenn ein Socket oder Handle abrupt geschlossen wird (z.B. socket.destroy()
), wird das 'close'
-Event in dieser Phase ausgelöst. Andernfalls wird es über process.nextTick()
ausgelöst.
setImmediate()
vs. setTimeout()
setImmediate()
und setTimeout()
sind ähnlich, verhalten sich aber unterschiedlich, je nachdem, wann sie aufgerufen werden.
setImmediate()
ist so konzipiert, dass ein Skript ausgeführt wird, sobald die aktuelle Poll-Phase abgeschlossen ist.setTimeout()
plant die Ausführung eines Skripts nach Ablauf eines minimalen Schwellenwerts in ms.
Die Reihenfolge, in der die Timer ausgeführt werden, variiert je nach Kontext, in dem sie aufgerufen werden. Wenn beide aus dem Hauptmodul aufgerufen werden, ist die Zeit durch die Leistung des Prozesses begrenzt (die durch andere Anwendungen, die auf dem Rechner laufen, beeinträchtigt werden kann).
Wenn wir beispielsweise das folgende Skript ausführen, das sich nicht in einem I/O-Zyklus befindet (d.h. das Hauptmodul), ist die Reihenfolge, in der die beiden Timer ausgeführt werden, nicht deterministisch, da sie durch die Leistung des Prozesses begrenzt ist:
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
Wenn Sie die beiden Aufrufe jedoch in einen I/O-Zyklus verschieben, wird der Immediate-Callback immer zuerst ausgeführt:
// timeout_vs_immediate.js
const fs = require('node:fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
$ node timeout_vs_immediate.js
immediate
timeout
$ node timeout_vs_immediate.js
immediate
timeout
Der Hauptvorteil der Verwendung von setImmediate()
gegenüber setTimeout()
ist, dass setImmediate()
immer vor allen Timern ausgeführt wird, wenn es innerhalb eines I/O-Zyklus geplant ist, unabhängig davon, wie viele Timer vorhanden sind.
process.nextTick()
process.nextTick()
verstehen
Vielleicht ist Ihnen aufgefallen, dass process.nextTick()
im Diagramm nicht angezeigt wurde, obwohl es Teil der asynchronen API ist. Das liegt daran, dass process.nextTick()
technisch gesehen nicht Teil der Ereignisschleife ist. Stattdessen wird die nextTickQueue
nach Abschluss der aktuellen Operation verarbeitet, unabhängig von der aktuellen Phase der Ereignisschleife. Hier wird eine Operation als Übergang vom zugrunde liegenden C/C++-Handler und der Verarbeitung des auszuführenden JavaScript definiert.
Wenn wir uns unser Diagramm noch einmal ansehen, werden alle an process.nextTick()
übergebenen Rückrufe aufgelöst, bevor die Ereignisschleife fortfährt, wenn Sie process.nextTick()
in einer bestimmten Phase aufrufen. Dies kann zu einigen schlechten Situationen führen, da es Ihnen ermöglicht, Ihre E/A durch rekursive process.nextTick()
-Aufrufe zu „belasten“, was verhindert, dass die Ereignisschleife die Poll-Phase erreicht.
Warum sollte das erlaubt sein?
Warum sollte so etwas in Node.js aufgenommen werden? Ein Teil davon ist eine Designphilosophie, bei der eine API immer asynchron sein sollte, auch wenn dies nicht erforderlich ist. Nehmen Sie zum Beispiel dieses Code-Snippet:
function apiCall(arg, callback) {
if (typeof arg !== 'string') return process.nextTick(callback, new TypeError('argument should be string'))
}
Das Snippet führt eine Argumentprüfung durch und übergibt den Fehler an den Rückruf, falls er nicht korrekt ist. Die API wurde vor kurzem aktualisiert, um die Übergabe von Argumenten an process.nextTick()
zu ermöglichen, sodass alle nach dem Rückruf übergebenen Argumente als Argumente an den Rückruf weitergegeben werden können, sodass Sie keine Funktionen verschachteln müssen.
Wir geben einen Fehler an den Benutzer zurück, jedoch erst, nachdem wir dem Rest des Codes des Benutzers die Ausführung erlaubt haben. Durch die Verwendung von process.nextTick()
garantieren wir, dass apiCall()
seinen Rückruf immer nach dem Rest des Codes des Benutzers und vor der Fortsetzung der Ereignisschleife ausführt. Um dies zu erreichen, kann der JS-Aufrufstapel abgewickelt werden und dann sofort den bereitgestellten Rückruf ausführen, wodurch eine Person rekursive Aufrufe von process.nextTick()
durchführen kann, ohne einen RangeError: Maximum call stack size exceeded from v8
zu erreichen.
Diese Philosophie kann zu einigen potenziell problematischen Situationen führen. Nehmen Sie zum Beispiel dieses Snippet:
let bar
// dies hat eine asynchrone Signatur, ruft aber den Rückruf synchron auf
function someAsyncApiCall(callback) {
callback()
}
// der Rückruf wird aufgerufen, bevor `someAsyncApiCall` abgeschlossen ist.
someAsyncApiCall(() => {
// da someAsyncApiCall noch nicht abgeschlossen ist, wurde bar noch kein Wert zugewiesen
console.log('bar', bar) // undefiniert
})
bar = 1
Der Benutzer definiert someAsyncApiCall()
so, dass es eine asynchrone Signatur hat, aber es arbeitet tatsächlich synchron. Wenn es aufgerufen wird, wird der an someAsyncApiCall()
übergebene Rückruf in derselben Phase der Ereignisschleife aufgerufen, da someAsyncApiCall()
nichts asynchron ausführt. Infolgedessen versucht der Rückruf, auf bar zu verweisen, obwohl diese Variable möglicherweise noch nicht im Gültigkeitsbereich liegt, da das Skript noch nicht vollständig ausgeführt werden konnte.
Indem der Rückruf in process.nextTick()
platziert wird, hat das Skript immer noch die Möglichkeit, vollständig ausgeführt zu werden, sodass alle Variablen, Funktionen usw. initialisiert werden, bevor der Rückruf aufgerufen wird. Es hat auch den Vorteil, dass die Ereignisschleife nicht fortgesetzt werden kann. Es kann für den Benutzer nützlich sein, vor der Fortsetzung der Ereignisschleife auf einen Fehler aufmerksam gemacht zu werden. Hier ist das vorherige Beispiel mit process.nextTick()
:
let bar
function someAsyncApiCall(callback) {
process.nextTick(callback)
}
someAsyncApiCall(() => {
console.log('bar', bar) // 1
})
bar = 1
Hier ist ein weiteres reales Beispiel:
const server = net.createServer(() => {}).listen(8080)
server.on('listening', () => {})
Wenn nur ein Port übergeben wird, wird der Port sofort gebunden. Der 'listening'
-Rückruf könnte also sofort aufgerufen werden. Das Problem ist, dass der .on('listening')
-Rückruf zu diesem Zeitpunkt noch nicht festgelegt wurde.
Um dies zu umgehen, wird das 'listening'
-Ereignis in einer nextTick()
in die Warteschlange gestellt, damit das Skript vollständig ausgeführt werden kann. Dies ermöglicht es dem Benutzer, alle gewünschten Ereignishandler festzulegen.
process.nextTick()
vs. setImmediate()
Wir haben zwei Aufrufe, die für Benutzer ähnlich sind, aber ihre Namen sind verwirrend.
process.nextTick()
wird sofort in derselben Phase ausgelöstsetImmediate()
wird in der folgenden Iteration oder dem folgenden 'Tick' der Ereignisschleife ausgelöst
Im Wesentlichen sollten die Namen vertauscht werden. process.nextTick()
wird sofortiger ausgelöst als setImmediate()
, aber dies ist ein Artefakt der Vergangenheit, das sich wahrscheinlich nicht ändern wird. Diese Umstellung würde einen großen Prozentsatz der Pakete auf npm beschädigen. Jeden Tag werden neue Module hinzugefügt, was bedeutet, dass jeden Tag, den wir warten, mehr potenzielle Schäden auftreten. Obwohl sie verwirrend sind, werden sich die Namen selbst nicht ändern.
TIP
Wir empfehlen Entwicklern, setImmediate()
in allen Fällen zu verwenden, da es einfacher zu verstehen ist.
Warum process.nextTick()
verwenden?
Es gibt zwei Hauptgründe:
Ermöglichen Sie Benutzern, Fehler zu behandeln, nicht mehr benötigte Ressourcen zu bereinigen oder die Anfrage erneut zu versuchen, bevor die Ereignisschleife fortgesetzt wird.
Manchmal ist es notwendig, dass ein Callback ausgeführt wird, nachdem der Call-Stack abgewickelt wurde, aber bevor die Ereignisschleife fortgesetzt wird.
Ein Beispiel ist die Erfüllung der Erwartungen des Benutzers. Einfaches Beispiel:
const server = net.createServer()
server.on('connection', conn => {})
server.listen(8080)
server.on('listening', () => {})
Nehmen wir an, listen()
wird zu Beginn der Ereignisschleife ausgeführt, aber der Listening-Callback wird in ein setImmediate()
platziert. Wenn kein Hostname übergeben wird, erfolgt die Bindung an den Port sofort. Damit die Ereignisschleife fortfahren kann, muss sie die Abfragephase erreichen, was bedeutet, dass eine nicht-Null-Chance besteht, dass eine Verbindung empfangen wurde, wodurch das Verbindungsereignis vor dem Listening-Ereignis ausgelöst werden kann.
Ein weiteres Beispiel ist die Erweiterung eines EventEmitter
und das Auslösen eines Ereignisses innerhalb des Konstruktors:
const EventEmitter = require('node:events')
class MyEmitter extends EventEmitter {
constructor() {
super()
this.emit('event')
}
}
const myEmitter = new MyEmitter()
myEmitter.on('event', () => {
console.log('an event occurred!')
})
Sie können ein Ereignis nicht sofort vom Konstruktor aus auslösen, da das Skript nicht bis zu dem Punkt verarbeitet wurde, an dem der Benutzer einen Callback für dieses Ereignis zuweist. Daher können Sie innerhalb des Konstruktors selbst process.nextTick()
verwenden, um einen Callback festzulegen, um das Ereignis nach Abschluss des Konstruktors auszulösen, was die erwarteten Ergebnisse liefert:
const EventEmitter = require('node:events')
class MyEmitter extends EventEmitter {
constructor() {
super()
// Verwenden Sie nextTick, um das Ereignis auszulösen, sobald ein Handler zugewiesen ist
process.nextTick(() => {
this.emit('event')
})
}
}
const myEmitter = new MyEmitter()
myEmitter.on('event', () => {
console.log('an event occurred!')
})