Eine Ereignisschleife ist der Mechanismus von Node.js zur Verarbeitung nicht blockierender E/A-Vorgänge – auch wenn JavaScript Single-Threaded ist – indem Vorgänge nach Möglichkeit auf den Systemkernel verlagert werden.
Da die meisten Kerne heutzutage Multi-Threaded sind, können sie eine Vielzahl von Vorgängen im Hintergrund ausführen. Wenn einer der Vorgänge abgeschlossen ist, benachrichtigt der Kernel Node.js, die entsprechende Rückruffunktion zur Abfragewarteschlange hinzuzufügen und auf die Gelegenheit zur Ausführung zu warten. Wir werden es später in diesem Artikel ausführlich vorstellen.
Wenn Node.js gestartet wird, initialisiert es die Ereignisschleife und verarbeitet das bereitgestellte Eingabeskript (oder wirft es in die REPL, was in diesem Artikel nicht behandelt wird). Es kann einige asynchrone APIs aufrufen, Timer planen usw. oder rufen Sie process.nextTick()
auf und beginnen Sie dann mit der Verarbeitung der Ereignisschleife.
Das folgende Diagramm zeigt einen vereinfachten Überblick über die Abfolge der Vorgänge der Ereignisschleife.
┌───────────────────────────┐ ┌─>│ Timer │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ ausstehende Rückrufe │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ Leerlauf, vorbereiten │ │ └─────────────┬────────────┘ ┌───────── ───────┐ │ ┌─────────────┴────────────┐ │ eingehend: │ │ │ Umfrage │<─────┤ Verbindungen, │ │ └─────────────┬─────────────┘ │ Daten usw. │ │ ┌─────────────┴────────────┐ └───────── ───────┘ │ │ überprüfen │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ └──┤ Rückrufe schließen │ └─────────────────────────────┘
Hinweis: Jede Box wird als Stufe des Ereignisschleifenmechanismus bezeichnet.
Jede Stufe verfügt über eine FIFO-Warteschlange zum Ausführen von Rückrufen. Obwohl jede Stufe etwas Besonderes ist, führt die Ereignisschleife im Allgemeinen beim Eintritt in eine bestimmte Stufe alle für diese Stufe spezifischen Vorgänge aus und führt dann die Rückrufe in der Warteschlange dieser Stufe aus, bis die Warteschlange erschöpft ist oder die maximale Anzahl von Rückrufen ausgeführt wurde. Wenn die Warteschlange erschöpft ist oder das Rückruflimit erreicht ist, geht die Ereignisschleife zur nächsten Phase über und so weiter.
Da jeder dieser Vorgänge möglicherweise weitere Vorgänge und neue Ereignisse plant, die vom Kernel in die Warteschlange gestellt werden, um sie während der Abfragephase zu verarbeiten, können Abfrageereignisse in die Warteschlange gestellt werden, während Ereignisse in der Abfragephase verarbeitet werden. Daher kann ein Rückruf mit langer Laufzeit dazu führen, dass die Abfragephase länger als die Schwellenwertzeit des Timers läuft. Weitere Informationen finden Sie im Abschnitt „Timer und Abfragen“ .
Hinweis: Es gibt geringfügige Unterschiede zwischen den Windows- und Unix/Linux-Implementierungen, die jedoch für den Zweck der Demonstration nicht wichtig sind. Der wichtigste Teil ist hier. Tatsächlich gibt es sieben oder acht Schritte, aber was uns wichtig ist, ist, dass Node.js tatsächlich einige der oben genannten Schritte verwendet.
setTimeout()
und setInterval()
festgelegt wurde.setImmediate()
geplant werden), in anderen Fällen wird der Knoten hier gegebenenfalls blockieren.setImmediate()
wird hier ausgeführt.socket.on('close', ...)
.Zwischen jedem Durchlauf der Ereignisschleife prüft Node.js, ob es auf asynchrone E/A oder Timer wartet, und schaltet sich andernfalls vollständig ab.
Timer geben den Schwellenwert an, bei dem der bereitgestellte Rückruf ausgeführt werden kann, und nicht den genauen Zeitpunkt, zu dem der Benutzer die Ausführung wünscht. Nach dem angegebenen Intervall wird der Timer-Rückruf so früh wie möglich ausgeführt. Sie können jedoch durch die Planung des Betriebssystems oder andere laufende Rückrufe verzögert werden.
Hinweis : Die Abfragephase steuert, wann der Timer ausgeführt wird.
Angenommen, Sie planen einen Timer, der nach 100 Millisekunden abläuft, und Ihr Skript beginnt dann mit dem asynchronen Lesen einer Datei, die 95 Millisekunden dauert:
const fs = require('fs'); Funktion someAsyncOperation(callback) { // Angenommen, dies dauert 95 ms fs.readFile('/path/to/file', callback); } const timeoutScheduled = Date.now(); setTimeout(() => { const delay = Date.now() - timeoutScheduled; console.log(`${delay}ms sind seit meinem Termin vergangen`); }, 100); // someAsyncOperation ausführen, deren Abschluss 95 ms dauert someAsyncOperation(() => { const startCallback = Date.now(); // etwas tun, das 10 ms dauert ... while (Date.now() - startCallback < 10) { // nichts tun } });
Wenn die Ereignisschleife in die Abfragephase eintritt, hat sie eine leere Warteschlange ( fs.readFile()
ist noch nicht abgeschlossen), sodass sie die verbleibende Anzahl von Millisekunden wartet, bis der schnellste Timer-Schwellenwert erreicht ist. Wenn 95 Millisekunden darauf gewartet wird, dass fs.readFile()
das Lesen der Datei beendet, wird der Rückruf, dessen Abschluss 10 Millisekunden dauert, zur Abfragewarteschlange hinzugefügt und ausgeführt. Wenn der Rückruf abgeschlossen ist, befinden sich keine Rückrufe mehr in der Warteschlange. Daher prüft der Ereignisschleifenmechanismus den Timer, der den Schwellenwert am schnellsten erreicht hat, und kehrt dann zur Timer- Phase zurück, um den Rückruf des Timers auszuführen. In diesem Beispiel sehen Sie, dass die Gesamtverzögerung zwischen der Planung des Timers und der Ausführung seines Rückrufs 105 Millisekunden beträgt.
HINWEIS: Um zu verhindern, dass die Abfragephase die Ereignisschleife blockiert, verfügt libuv (die C-Bibliothek, die die Node.js-Ereignisschleife und das gesamte asynchrone Verhalten der Plattform implementiert) auch über ein hartes Maximum (systemabhängig).
In dieser Phase werden Rückrufe für bestimmte Systemvorgänge (z. B. TCP-Fehlertypen) ausgeführt. Einige *nix-Systeme möchten beispielsweise warten, bis ein Fehler gemeldet wird, wenn ein TCP-Socket beim Versuch, eine Verbindung herzustellen, ECONNREFUSED
empfängt. Dies wird während der ausstehenden Rückrufphase zur Ausführung in die Warteschlange gestellt.
Die Polling- Phase hat zwei wichtige Funktionen:
Berechnen, wie lange E/A blockiert und abgefragt werden sollen.
Behandeln Sie dann die Ereignisse in der Abfragewarteschlange .
Wenn die Ereignisschleife in die Abfragephase eintritt und keine Timer geplant sind, geschieht eines von zwei Dingen:
Wenn die Abfragewarteschlange nicht leer ist
, durchläuft die Ereignisschleife die Rückrufwarteschlange und führt sie synchron aus, bis die Warteschlange leer ist , oder ein systembedingtes Hardlimit erreicht.
Wenn die Umfragewarteschlange leer ist , passieren zwei weitere Dinge:
Wenn das Skript durch setImmediate()
geplant wird, beendet die Ereignisschleife die Umfragephase und setzt die Prüfphase fort, um diese geplanten Skripte auszuführen.
Wenn das Skript nicht durch setImmediate()
geplant ist, wartet die Ereignisschleife darauf, dass der Rückruf zur Warteschlange hinzugefügt wird, und führt ihn dann sofort aus.
Sobald die Abfragewarteschlange leer ist, prüft die Ereignisschleife, ob ein Timer seinen Zeitschwellenwert erreicht hat. Wenn ein oder mehrere Timer bereit sind, kehrt die Ereignisschleife zur Timer-Phase zurück, um die Rückrufe für diese Timer auszuführen.
In dieser Phase kann unmittelbar nach Abschluss der Abfragephase ein Rückruf ausgeführt werden. Wenn die Abfragephase inaktiv wird und das Skript nach der Verwendung setImmediate()
in die Warteschlange gestellt wird, fährt die Ereignisschleife möglicherweise mit der Überprüfungsphase fort, anstatt zu warten.
setImmediate()
ist eigentlich ein spezieller Timer, der in einer separaten Phase der Ereignisschleife läuft. Es verwendet eine libuv-API, um Rückrufe zu planen, die nach Abschluss der Abfragephase ausgeführt werden.
Normalerweise erreicht die Ereignisschleife beim Ausführen von Code schließlich die Abfragephase, in der sie auf eingehende Verbindungen, Anforderungen usw. wartet. Wenn der Rückruf jedoch mit setImmediate()
geplant wurde und die Abfragephase in den Leerlauf geht, wird diese Phase beendet und mit der Prüfphase fortgefahren, anstatt weiter auf das Abfrageereignis zu warten.
Wenn der Socket oder Handler plötzlich geschlossen wird (z. B. socket.destroy()
), wird in dieser Phase das Ereignis 'close'
ausgegeben. Andernfalls wird es über process.nextTick()
ausgegeben.
setImmediate()
und setTimeout()
sind sehr ähnlich, verhalten sich jedoch unterschiedlich, je nachdem, wann sie aufgerufen werden.
setImmediate()
dient dazu, das Skript auszuführen, sobald die aktuelle Abfragephase abgeschlossen ist.setTimeout()
führt das Skript aus, nachdem ein Mindestschwellenwert (in ms) überschritten wurde.Die Reihenfolge, in der Timer ausgeführt werden, hängt vom Kontext ab, in dem sie aufgerufen werden. Wenn beide vom Hauptmodul aus aufgerufen werden, ist der Timer an die Leistung des Prozesses gebunden (die möglicherweise durch andere laufende Anwendungen auf dem Computer beeinträchtigt wird).
Wenn Sie beispielsweise das folgende Skript ausführen, das sich nicht innerhalb eines E/A-Zyklus (d. h. des Hauptmoduls) befindet, 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'); }); $ Knoten timeout_vs_immediate.js Time-out sofort $ Knoten timeout_vs_immediate.js sofort timeout
Wenn Sie diese beiden Funktionen jedoch in eine E/A-Schleife einfügen und aufrufen, wird setImmediate immer zuerst aufgerufen:
// timeout_vs_immediate.js const fs = require('fs'); fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); }); }); $ Knoten timeout_vs_immediate.js sofort Time-out $ Knoten timeout_vs_immediate.js sofortDer Hauptvorteil der Verwendung von setImmediate() für
die Zeitüberschreitung
setImmediate()
setTimeout()
besteht darin, dass setImmediate()
, wenn es während des E/A-Zyklus geplant wird, vor jedem darin enthaltenen Timer ausgeführt wird, je nachdem, wie viele Timer es gibt, die keinen Bezug zum
Möglicherweise ist Ihnen aufgefallen, process.nextTick()
im Diagramm nicht angezeigt wird, obwohl es Teil der asynchronen API ist. Dies liegt daran, dass process.nextTick()
technisch gesehen nicht Teil der Ereignisschleife ist. Stattdessen wird nextTickQueue
verarbeitet, nachdem der aktuelle Vorgang abgeschlossen ist, unabhängig von der aktuellen Phase der Ereignisschleife. Eine Operation wird hier als Übergang vom zugrunde liegenden C/C++-Prozessor betrachtet und verarbeitet den JavaScript-Code, der ausgeführt werden muss.
Wenn wir unser Diagramm noch einmal betrachten, werden bei jedem Aufruf von process.nextTick()
in einer bestimmten Phase alle an process.nextTick()
übergebenen Rückrufe aufgelöst, bevor die Ereignisschleife fortgesetzt wird. Dies kann zu einigen schlimmen Situationen führen, da Sie Ihre E/A über rekursive Aufrufe von process.nextTick()
“ „aushungern“ können und so verhindern, dass die Ereignisschleife die Abfragephase erreicht.
Warum ist so etwas in Node.js enthalten? Ein Teil davon ist eine Designphilosophie, nach der eine API immer asynchron sein sollte, auch wenn dies nicht muss. Nehmen Sie diesen Codeausschnitt als Beispiel:
function apiCall(arg, callback) { if (typeof arg !== 'string') return process.nextTick( Rückruf, neuer TypeError('Argument sollte ein String sein') ); }
Codeausschnitt zur Parameterprüfung. Wenn der Fehler falsch ist, wird der Fehler an die Rückruffunktion übergeben. Die API wurde kürzlich aktualisiert, um die Übergabe von Argumenten an process.nextTick()
zu ermöglichen. Dadurch kann sie jedes Argument nach der Position der Callback-Funktion akzeptieren und die Argumente als Argumente an die Callback-Funktion an die Callback-Funktion übergeben, sodass Sie dies nicht tun müssen Funktion zu verschachteln.
Wir geben den Fehler an den Benutzer zurück, jedoch erst, nachdem der restliche Code des Benutzers ausgeführt wurde. Durch die Verwendung von process.nextTick()
garantieren wir, dass apiCall()
seine Rückruffunktion immer nach dem Rest des Benutzercodes ausführt und bevor die Ereignisschleife fortgesetzt wird. Um dies zu erreichen, darf der JS-Aufrufstapel abgewickelt werden und dann sofort den bereitgestellten Rückruf ausführen, sodass rekursive Aufrufe von process.nextTick()
durchgeführt werden können, ohne dass RangeError: 超过V8 的最大调用堆栈大小
.
Dieses Konstruktionsprinzip kann zu einigen potenziellen Problemen führen. Nehmen Sie diesen Codeausschnitt als Beispiel:
let bar; // Dies hat eine asynchrone Signatur, ruft den Rückruf jedoch synchron auf Funktion someAsyncApiCall(callback) { Rückruf(); } // Der Rückruf wird aufgerufen, bevor „someAsyncApiCall“ abgeschlossen ist. someAsyncApiCall(() => { // Da someAsyncApiCall abgeschlossen wurde, wurde bar kein Wert zugewiesen console.log('bar', bar); // undefiniert }); bar = 1;
Der Benutzer definiert someAsyncApiCall()
mit einer asynchronen Signatur, aber tatsächlich läuft es synchron. Beim Aufruf wird der für someAsyncApiCall()
bereitgestellte Rückruf in derselben Phase der Ereignisschleife aufgerufen, da someAsyncApiCall()
eigentlich nichts asynchron ausführt. Infolgedessen versucht die Rückruffunktion, auf bar
zu verweisen, die Variable befindet sich jedoch möglicherweise noch nicht im Gültigkeitsbereich, da die Ausführung des Skripts noch nicht abgeschlossen ist.
Durch Platzieren des Rückrufs in process.nextTick()
kann das Skript weiterhin vollständig ausgeführt werden, sodass alle Variablen, Funktionen usw. initialisiert werden können, bevor der Rückruf aufgerufen wird. Es hat außerdem den Vorteil, dass die Ereignisschleife nicht fortgesetzt wird, und eignet sich dazu, den Benutzer zu warnen, wenn ein Fehler auftritt, bevor die Ereignisschleife fortgesetzt wird. Hier ist das vorherige Beispiel mit process.nextTick()
:
let bar; Funktion someAsyncApiCall(callback) { process.nextTick(callback); } someAsyncApiCall(() => { console.log('bar', bar); // 1 }); bar = 1;
Dies ist ein weiteres reales Beispiel:
const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});
Nur wenn der Port übergeben wird, wird der Port sofort gebunden. Daher kann der Callback 'listening'
sofort aufgerufen werden. Das Problem besteht darin, dass der Callback von .on('listening')
zu diesem Zeitpunkt noch nicht festgelegt wurde.
Um dieses Problem zu umgehen, wird das 'listening'
-Ereignis in nextTick()
in die Warteschlange gestellt, damit das Skript vollständig ausgeführt werden kann. Dadurch kann der Benutzer beliebige Event-Handler festlegen.
Für den Benutzer haben wir zwei ähnliche Aufrufe, deren Namen jedoch verwirrend sind.
process.nextTick()
wird sofort in derselben Phase ausgeführt.setImmediate()
wird bei der nächsten Iteration oder dem nächsten „Tick“ der Ereignisschleife ausgelöst.Im Wesentlichen sollten die beiden Namen vertauscht werden, da process.nextTick()
schneller ausgelöst wird als setImmediate()
. Dies ist jedoch ein Erbe aus der Vergangenheit und wird sich daher wahrscheinlich nicht ändern. Wenn Sie einen Namensaustausch vorschnell durchführen, werden die meisten Pakete auf npm beschädigt. Jeden Tag kommen mehr neue Module hinzu, was bedeutet, dass mit jedem Tag, an dem wir warten müssen, desto mehr potenzielle Schäden entstehen können. Obwohl diese Namen verwirrend sind, werden sich die Namen selbst nicht ändern.
Wir empfehlen Entwicklern, setImmediate()
in allen Situationen zu verwenden, da dies einfacher zu verstehen ist.
Es gibt zwei Hauptgründe:
um dem Benutzer die Möglichkeit zu geben, Fehler zu behandeln, alle nicht benötigten Ressourcen zu bereinigen oder die Anforderung erneut zu versuchen, bevor die Ereignisschleife fortgesetzt wird.
Manchmal ist es notwendig, den Rückruf auszuführen, nachdem der Stapel abgewickelt wurde, aber bevor die Ereignisschleife fortgesetzt wird.
Hier ist ein einfaches Beispiel, das den Erwartungen der Benutzer entspricht:
const server = net.createServer(); server.on('connection', (conn) => {}); server.listen(8080); server.on('listening', () => {});
Angenommen, listen()
wird am Anfang der Ereignisschleife ausgeführt, der Listening-Callback wird jedoch in setImmediate()
platziert. Sofern kein Hostname übergeben wird, wird der Port sofort gebunden. Damit die Ereignisschleife fortgesetzt werden kann, muss sie die Abfragephase erreichen. Dies bedeutet, dass möglicherweise eine Verbindung empfangen wurde und das Verbindungsereignis vor dem Überwachungsereignis ausgelöst wurde.
Ein weiteres Beispiel führt einen Funktionskonstruktor aus, der von EventEmitter
erbt und den Konstruktor aufrufen möchte:
const EventEmitter = require('events'); const util = require('util'); Funktion MyEmitter() { EventEmitter.call(this); this.emit('event'); } util.inherits(MyEmitter, EventEmitter); const myEmitter = new MyEmitter(); myEmitter.on('event', () => { console.log('ein Ereignis ist aufgetreten!'); });
Sie können das Ereignis nicht sofort vom Konstruktor aus auslösen, da das Skript noch nicht so weit verarbeitet wurde, dass der Benutzer dem Ereignis eine Rückruffunktion zuweist. Im Konstruktor selbst können Sie also mit process.nextTick()
einen Rückruf einrichten, sodass das Ereignis ausgegeben wird, nachdem der Konstruktor abgeschlossen ist, was erwartet wird:
const EventEmitter = require('events'); const util = require('util'); Funktion MyEmitter() { EventEmitter.call(this); // nextTick verwenden, um das Ereignis auszugeben, sobald ein Handler zugewiesen wurde process.nextTick(() => { this.emit('event'); }); } util.inherits(MyEmitter, EventEmitter); const myEmitter = new MyEmitter(); myEmitter.on('event', () => { console.log('ein Ereignis ist aufgetreten!'); });
Quelle: https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/