Node wurde ursprünglich zum Aufbau leistungsstarker Webserver entwickelt und verfügt über Funktionen wie ereignisgesteuerte, asynchrone E/A und Single-Threading. Das auf der Ereignisschleife basierende asynchrone Programmiermodell ermöglicht Node die Bewältigung hoher Parallelität und verbessert die Serverleistung erheblich. Da es gleichzeitig die Single-Threaded-Eigenschaften von JavaScript beibehält, muss sich Node nicht mit Problemen wie der Statussynchronisierung befassen Deadlock unter Multithreads Es gibt keinen Leistungsaufwand, der durch Thread-Kontextwechsel verursacht wird. Basierend auf diesen Eigenschaften verfügt Node über die inhärenten Vorteile einer hohen Leistung und einer hohen Parallelität, und darauf können verschiedene Hochgeschwindigkeits- und skalierbare Netzwerkanwendungsplattformen erstellt werden.
Dieser Artikel wird sich eingehend mit dem zugrunde liegenden Implementierungs- und Ausführungsmechanismus der asynchronen und Ereignisschleife von Node befassen. Ich hoffe, dass er für Sie hilfreich sein wird.
Warum verwendet Node Asynchron als Kernprogrammiermodell?
Wie bereits erwähnt, wurde Node ursprünglich zum Aufbau leistungsstarker Webserver entwickelt. Unter der Annahme, dass im Geschäftsszenario mehrere unabhängige Aufgaben zu erledigen sind, gibt es zwei moderne Mainstream-Lösungen:
die serielle Ausführung mit einem Thread.
Parallel mit mehreren Threads abgeschlossen.
Die serielle Single-Thread-Ausführung ist ein synchrones Programmiermodell. Obwohl sie eher der Denkweise des Programmierers entspricht und das Schreiben bequemerer Codes erleichtert, kann sie nur E/A verarbeiten, da sie E/A synchron ausführt Gleichzeitig führt eine einzelne Anfrage dazu, dass der Server langsam reagiert und in Anwendungsszenarien mit hoher Parallelität nicht angewendet werden kann. Da dies außerdem der Fall ist, wartet die CPU immer auf den Abschluss der E/A Andere Dinge, die die Verarbeitungsleistung der CPU einschränken, führen schließlich zu einer geringen Effizienz,
und das Multithread-Programmiermodell wird Entwicklern aufgrund von Problemen wie Zustandssynchronisation und Deadlocks bei der Programmierung auch Kopfschmerzen bereiten. Obwohl Multithreading die CPU-Auslastung auf Multi-Core-CPUs effektiv verbessern kann.
Obwohl das Programmiermodell der seriellen Single-Thread-Ausführung und der parallelen Multi-Thread-Ausführung seine eigenen Vorteile hat, weist es auch Mängel in Bezug auf Leistung und Entwicklungsschwierigkeiten auf.
Wenn der Client außerdem zwei Ressourcen gleichzeitig erhält, ist die Antwortgeschwindigkeit der synchronen Methode ausgehend von der Reaktionsgeschwindigkeit auf Clientanforderungen die Summe der Antwortgeschwindigkeiten der beiden Ressourcen und der Antwortgeschwindigkeit der beiden Ressourcen Die asynchrone Methode ist die mittlere von beiden. Der Leistungsvorteil ist im Vergleich zur Synchronisation sehr offensichtlich. Mit zunehmender Anwendungskomplexität wird sich dieses Szenario dahingehend entwickeln, auf n Anfragen gleichzeitig zu reagieren, und die Vorteile der asynchronen gegenüber der synchronisierten werden hervorgehoben.
Zusammenfassend gibt Node seine Antwort: Verwenden Sie einen einzelnen Thread, um Multithread-Deadlocks, Zustandssynchronisierung und andere Probleme zu vermeiden. Verwenden Sie asynchrone E/A, um zu verhindern, dass ein einzelner Thread blockiert, um die CPU besser zu nutzen. Aus diesem Grund verwendet Node asynchron als Kernprogrammiermodell.
Um den Mangel eines einzelnen Threads auszugleichen, der keine Multi-Core-CPUs nutzen kann, stellt Node außerdem einen Unterprozess bereit, der den Web Workern im Browser ähnelt und die CPU durch Worker-Prozesse effizient nutzen kann.
Nachdem wir darüber gesprochen haben, warum wir asynchron verwenden sollten, wie implementiert man asynchron?
Es gibt zwei Arten von asynchronen Operationen, die wir normalerweise nennen: Eine davon sind E/A-bezogene Operationen wie Datei-E/A und Netzwerk-E/A; die andere sind Operationen, die nichts mit E/A zu tun haben, wie z. B. setTimeOut
und setInterval
. Offensichtlich bezieht sich der von uns diskutierte asynchrone Vorgang auf Vorgänge im Zusammenhang mit E/A, also asynchrone E/A.
Asynchrone E/A wird in der Hoffnung vorgeschlagen, dass E/A-Aufrufe die Ausführung nachfolgender Programme nicht blockieren und die ursprüngliche Zeit, die auf den Abschluss von E/A gewartet wird, anderen erforderlichen Unternehmen zur Ausführung zugewiesen wird. Um dieses Ziel zu erreichen, müssen Sie nicht blockierende E/A verwenden.
Das Blockieren von E/A bedeutet, dass die CPU, nachdem sie einen E/A-Aufruf initiiert hat, blockiert, bis die E/A abgeschlossen ist. Wenn Sie wissen, dass blockierende E/A-Vorgänge nicht blockiert sind, ist die CPU sofort nach dem Einleiten des E/A-Aufrufs zurückgekehrt, anstatt zu blockieren und zu warten. Die CPU kann andere Transaktionen verarbeiten, bevor der E/A-Aufruf abgeschlossen ist. Offensichtlich bietet die nicht blockierende E/A im Vergleich zur blockierenden E/A mehr Leistungsverbesserungen.
Da also nicht blockierende E/A verwendet wird und die CPU sofort nach dem Einleiten des E/A-Aufrufs zurückkehren kann, woher weiß sie dann, dass die E/A abgeschlossen ist? Die Antwort ist Umfrage.
Um den Status von E/A-Aufrufen rechtzeitig zu erhalten, ruft die CPU kontinuierlich wiederholt E/A-Vorgänge auf, um zu bestätigen, ob die E/A abgeschlossen ist. Diese Technologie wiederholter Aufrufe zur Bestimmung, ob der Vorgang abgeschlossen ist, wird als Abfrage bezeichnet .
Offensichtlich führt die Abfrage dazu, dass die CPU wiederholt Statusbeurteilungen durchführt, was eine Verschwendung von CPU-Ressourcen darstellt. Darüber hinaus ist das Abfrageintervall schwer zu kontrollieren, da der Abschluss des E/A-Vorgangs nicht rechtzeitig erfolgt, was indirekt die Reaktionsgeschwindigkeit der Anwendung verringert Die CPU wird zwangsläufig für die Abfrage aufgewendet. Dies dauert länger und verringert die Auslastung der CPU-Ressourcen.
Obwohl die Abfrage die Anforderung erfüllt, dass nicht blockierende E/A die Ausführung nachfolgender Programme nicht blockiert, kann sie für die Anwendung dennoch nur als eine Art Synchronisation betrachtet werden, da die Anwendung immer noch auf die E/A warten muss O, um vollständig zurückzukehren. Ich habe immer noch viel Zeit damit verbracht, zu warten.
Die perfekte asynchrone E/A sollte darin bestehen, dass die Anwendung einen nicht blockierenden Aufruf initiiert. Es besteht keine Notwendigkeit, den Status des E/A-Aufrufs kontinuierlich abzufragen. Stattdessen kann die nächste Aufgabe direkt verarbeitet werden Die E/A ist abgeschlossen. Übergeben Sie die Daten einfach über ein Semaphor oder einen Rückruf an die Anwendung.
Wie implementiert man diese asynchrone E/A? Die Antwort ist Thread-Pool.
Obwohl in diesem Artikel immer erwähnt wurde, dass der Knoten in einem einzelnen Thread ausgeführt wird, bedeutet der einzelne Thread hier, dass der JavaScript-Code in einem einzelnen Thread ausgeführt wird. Für Teile wie E/A-Vorgänge, die nichts mit der Hauptgeschäftslogik zu tun haben. Durch die Ausführung in anderen Threads wird die Ausführung des Hauptthreads nicht beeinträchtigt oder blockiert. Im Gegenteil, es kann die Ausführungseffizienz des Hauptthreads verbessern und asynchrone E/A realisieren.
Lassen Sie den Hauptthread über den Thread-Pool nur E/A-Aufrufe durchführen, lassen Sie andere Threads blockierende E/A oder nicht blockierende E/A plus Abfragetechnologie ausführen, um die Datenerfassung abzuschließen, und verwenden Sie dann die Kommunikation zwischen Threads, um die I zu vervollständigen /O Die erhaltenen Daten werden übergeben, wodurch asynchrone E/A problemlos implementiert werden kann:
Der Hauptthread führt E/A-Aufrufe aus, während der Thread-Pool E/A-Vorgänge ausführt, die Datenerfassung abschließt und die Daten dann über die Kommunikation zwischen Threads an den Hauptthread weiterleitet, um einen E/A-Aufruf und den Hauptthread abzuschließen Wiederverwendung Die Rückruffunktion stellt die Daten dem Benutzer zur Verfügung, der sie dann verwendet, um Vorgänge auf der Ebene der Geschäftslogik abzuschließen. Dies ist ein vollständiger asynchroner E/A-Prozess in Node. Benutzer müssen sich keine Gedanken über die umständlichen Implementierungsdetails der zugrunde liegenden Ebene machen. Sie müssen lediglich die von Node gekapselte asynchrone API aufrufen und die Rückruffunktion übergeben, die die Geschäftslogik verwaltet, wie unten gezeigt:
const fs = require ("fs" ); fs.readFile('example.js', (data) => { // Geschäftslogik verarbeiten})
; mit einer Ereignisschleife zum Abschließen des asynchronen E/A-Prozesses; dieser Prozess wird über epoll unter Linux über kqueue unter FreeBSD und über Event-Ports unter Solaris implementiert. Der Thread-Pool wird unter Windows direkt vom Kernel (IOCP) bereitgestellt, während die *nix
-Serie von libuv selbst implementiert wird.
Aufgrund des Unterschieds zwischen der Windows-Plattform und *nix
-Plattform stellt Node libuv als abstrakte Kapselungsschicht bereit, sodass alle Beurteilungen der Plattformkompatibilität von dieser Schicht abgeschlossen werden und sichergestellt wird, dass der Knoten der oberen Schicht und der benutzerdefinierte Thread-Pool und IOCP der unteren Schicht sind unabhängig voneinander. Der Knoten bestimmt die Plattformbedingungen während der Kompilierung und kompiliert selektiv Quelldateien im Unix-Verzeichnis oder im Win-Verzeichnis in das Zielprogramm:
Das Obige ist die asynchrone Implementierung von Node.
(Die Größe des Thread-Pools kann über die Umgebungsvariable UV_THREADPOOL_SIZE
festgelegt werden. Der Standardwert ist 4. Der Benutzer kann die Größe dieses Werts basierend auf der tatsächlichen Situation anpassen.)
Dann stellt sich die Frage, nachdem die von der übergebenen Daten abgerufen wurden Thread-Pool, wie funktioniert der Hauptthread? Wann wird die Callback-Funktion aufgerufen? Die Antwort ist die Ereignisschleife.
DaCallback-Funktionen zur Verarbeitung von E/A-Daten verwendet, stellt sich zwangsläufig die Frage, wann und wie die Callback-Funktion aufgerufen werden soll. In der tatsächlichen Entwicklung sind häufig Szenarien mit mehreren und mehreren asynchronen E/A-Aufrufen beteiligt. Darüber hinaus ist es ein schwieriges Problem, die Aufrufe dieser asynchronen E/A-Rückrufe angemessen anzuordnen und den ordnungsgemäßen Ablauf sicherzustellen asynchrone I/O Zusätzlich zu /O gibt es auch nicht-I/O-asynchrone Aufrufe wie Timer. Solche APIs sind hochgradig in Echtzeit und haben entsprechend höhere Prioritäten. Wie werden Rückrufe mit unterschiedlichen Prioritäten geplant?
Daher muss ein Planungsmechanismus vorhanden sein, um asynchrone Aufgaben unterschiedlicher Priorität und Art zu koordinieren, um sicherzustellen, dass diese Aufgaben im Hauptthread ordnungsgemäß ausgeführt werden. Wie Browser hat sich Node für diese schwere Arbeit für die Ereignisschleife entschieden.
Node unterteilt Aufgaben je nach Typ und Priorität in sieben Kategorien: Timer, Ausstehend, Leerlauf, Vorbereiten, Abfragen, Prüfen und Schließen. Für jeden Aufgabentyp gibt es eine First-in-First-out-Aufgabenwarteschlange zum Speichern von Aufgaben und ihren Rückrufen (Timer werden in einem kleinen oberen Heap gespeichert). Basierend auf diesen sieben Typen unterteilt Node die Ausführung der Ereignisschleife in die folgenden sieben Stufen:
Die Ausführungspriorität dieser Stufe der
In dieser Phase überprüft die Ereignisschleife die Datenstruktur (minimaler Heap), in der der Timer gespeichert ist, durchläuft die darin enthaltenen Timer, vergleicht nacheinander die aktuelle Zeit und die Ablaufzeit und stellt fest, ob der Timer abgelaufen ist , wird der Timer sein Die Rückruffunktion wird herausgenommen und ausgeführt.
In derPhase werden Rückrufe ausgeführt, wenn Netzwerk-, E/A- und andere Ausnahmen auftreten. Einige von *nix
gemeldete Fehler werden in dieser Phase behandelt. Darüber hinaus werden einige E/A-Rückrufe, die in der Abfragephase des vorherigen Zyklus ausgeführt werden sollten, in diese Phase verschoben.
werden nur innerhalb der Ereignisschleife verwendet.
ruft neue I/O-Ereignisse ab; führt I/O-bezogene Callbacks aus (fast alle Callbacks außer Shutdown-Callbacks, Timer-geplanten Callbacks und setImmediate()
);
Die Abfragephase ist die wichtigste Phase der Ereignisschleife. In dieser Phase werden hauptsächlich Rückrufe für Netzwerk-E/A und Datei-E/A verarbeitet. Diese Stufe hat zwei Hauptfunktionen:
Berechnen, wie lange diese Stufe E/A blockieren und abfragen soll.
Verarbeiten Sie Rückrufe in der E/A-Warteschlange.
Wenn die Ereignisschleife in die Abfragephase eintritt und kein Timer eingestellt ist:
Wenn die Abfragewarteschlange nicht leer ist, durchläuft die Ereignisschleife die Warteschlange und führt sie synchron aus, bis die Warteschlange leer ist oder die maximale Anzahl, die ausgeführt werden kann, erreicht ist.
Wenn die Abfragewarteschlange leer ist, geschieht eines von zwei weiteren Dingen:
Wenn setImmediate()
Rückruf ausgeführt werden muss, endet die Abfragephase sofort und es wird in die Prüfphase eingetreten, um den Rückruf auszuführen.
Wenn keine setImmediate()
Rückrufe ausgeführt werden müssen, bleibt die Ereignisschleife in dieser Phase und wartet darauf, dass Rückrufe zur Warteschlange hinzugefügt werden, und führt sie dann sofort aus. Die Ereignisschleife wartet, bis das Timeout abgelaufen ist. Der Grund, warum ich hier aufhöre, liegt darin, dass Node hauptsächlich E/A verarbeitet, sodass er schneller auf E/A reagieren kann.
Sobald die Abfragewarteschlange leer ist, prüft die Ereignisschleife, ob Timer ihren Zeitschwellenwert erreicht haben. Wenn einer oder mehrere Timer den Zeitschwellenwert erreichen, kehrt die Ereignisschleife zur Timer-Phase zurück, um die Rückrufe für diese Timer auszuführen.
Phase werden die Rückrufe von setImmediate()
nacheinander ausgeführt.
In dieser Phase werden einige Rückrufe zum Schließen von Ressourcen ausgeführt, z. B. socket.on('close', ...)
. Eine verzögerte Ausführung dieser Phase hat nur geringe Auswirkungen und hat die niedrigste Priorität.
Wenn der Node-Prozess startet, initialisiert er die Ereignisschleife, führt den Eingabecode des Benutzers aus, führt entsprechende asynchrone API-Aufrufe, Timerplanung usw. durch und beginnt dann mit dem Eintritt in die Ereignisschleife:
┌───────── ── ────────────────┐ ┌─>│ Timer │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ ausstehende Rückrufe │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ Leerlauf, vorbereiten │ │ └─────────────┬────────────┘ ┌───────── ───────┐ │ ┌─────────────┴────────────┐ │ eingehend: │ │ │ Umfrage │<─────┤ Verbindungen, │ │ └─────────────┬─────────────┘ │ Daten usw. │ │ ┌─────────────┴────────────┐ └───────── ───────┘ │ │ überprüfen │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ └──┤ Rückrufe schließen │ └─────────────────────────────┘Jede
Iteration der Ereignisschleife (oft als Tick bezeichnet) hat die oben angegebene Priorität Die Reihenfolge tritt in die sieben Ausführungsstufen ein. Jede Stufe führt eine bestimmte Anzahl von Rückrufen in der Warteschlange aus. Der Grund, warum nur eine bestimmte Anzahl, aber nicht alle ausgeführt werden, besteht darin, zu verhindern, dass die Ausführungszeit der aktuellen Stufe zu lang wird Vermeiden Sie das Scheitern der nächsten Stufe.
OK, das Obige ist der grundlegende Ausführungsablauf der Ereignisschleife. Schauen wir uns nun eine andere Frage an.
Für das folgende Szenario:
const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});
Wenn der Dienst erfolgreich an Port 8000 gebunden ist, d. h. wenn listen()
erfolgreich aufgerufen wird, wurde der Rückruf des listening
Ereignisses noch nicht gebunden Nachdem der Port erfolgreich gebunden wurde, wird der Rückruf des von uns übergebenen listening
nicht ausgeführt.
Wenn wir über eine andere Frage nachdenken, haben wir möglicherweise einige Anforderungen während der Entwicklung, z. B. die Behandlung von Fehlern, das Bereinigen unnötiger Ressourcen und andere Aufgaben mit niedriger Priorität. Wenn diese Logiken synchron ausgeführt werden, wirkt sich dies auf die Effizienz der aktuellen Aufgabe aus. Wenn setImmediate()
asynchron übergeben wird, beispielsweise in Form von Rückrufen, kann der Ausführungszeitpunkt nicht garantiert werden und die Echtzeitleistung ist nicht hoch. Wie geht man also mit dieser Logik um?
Basierend auf diesen Problemen hat Node Referenzen vom Browser übernommen und eine Reihe von Mikrotask-Mechanismen implementiert. In Node wird zusätzlich zum Aufruf von new Promise().then()
die übergebene Rückruffunktion in eine Mikrotask gekapselt. Der Rückruf von process.nextTick()
wird auch in eine Mikrotask gekapselt und die Ausführungspriorität der Letzteres wird höher sein als Ersteres.
Wie läuft bei Mikrotasks der Ausführungsprozess der Ereignisschleife ab? Mit anderen Worten: Wann werden Mikrotasks ausgeführt?
In Knoten 11 und späteren Versionen wird die Mikrotask-Warteschlange sofort ausgeführt und die Warteschlange gelöscht, sobald eine Aufgabe in einer Phase ausgeführt wird.
Die Ausführung der Mikrotask beginnt, nachdem eine Stufe vor Knoten11 ausgeführt wurde.
Daher führt bei Mikrotasks jeder Zyklus der Ereignisschleife zunächst eine Aufgabe in der Timer-Phase aus und löscht dann die Mikrotask-Warteschlangen der Reihe nach von process.nextTick()
und new Promise().then()
und fährt dann mit der Ausführung fort die nächste Aufgabe in der Timer-Stufe oder die nächste Stufe, also eine Aufgabe in der ausstehenden Stufe usw. in dieser Reihenfolge.
Mithilfe von process.nextTick()
kann Node das obige Portbindungsproblem lösen: Innerhalb der Methode listen()
wird die Ausgabe des listening
Ereignisses in einen Rückruf gekapselt und an process.nextTick()
übergeben, wie im folgenden Pseudonym gezeigt Code:
Funktion listen() { // Listening-Port-Operationen ausführen... // Kapseln Sie die Ausgabe des „Listening“-Ereignisses in einen Rückruf und übergeben Sie ihn an „process.nextTick()“ in process.nextTick(() => { emit('listening'); }); };
Nachdem der aktuelle Code ausgeführt wurde, beginnt die Ausführung der Mikrotask, wodurch listening
-Ereignis ausgegeben und der Aufruf des Ereignisrückrufs ausgelöst wird.
Aufgrund der Unvorhersehbarkeit und Komplexität von Asynchronität selbst kann es bei der Verwendung der von Node bereitgestellten asynchronen API, obwohl wir das Ausführungsprinzip der Ereignisschleife beherrschen, dennoch zu einigen Phänomenen kommen, die nicht intuitiv oder erwartet sind. .
Beispielsweise unterscheidet sich die Ausführungsreihenfolge von Timern ( setTimeout
, setImmediate
) je nach Kontext, in dem sie aufgerufen werden. Wenn beide aus dem Kontext der obersten Ebene aufgerufen werden, hängt ihre Ausführungszeit von der Leistung des Prozesses oder der Maschine ab.
Schauen wir uns das folgende Beispiel an:
setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); });
Was ist das Ausführungsergebnis des obigen Codes? Gemäß unserer Beschreibung der Ereignisschleife haben Sie möglicherweise diese Antwort: Da die Timer-Phase vor der Prüfphase ausgeführt wird, wird zuerst der Rückruf von setTimeout()
und dann der Rückruf von setImmediate()
ausgeführt hingerichtet.
Tatsächlich ist das Ausgabeergebnis dieses Codes ungewiss. Timeout kann zuerst ausgegeben werden, oder es kann zuerst sofort ausgegeben werden. Dies liegt daran, dass beide Timer im globalen Kontext aufgerufen werden. Wenn die Ereignisschleife beginnt und bis zur Timer-Stufe ausgeführt wird, kann die aktuelle Zeit je nach Ausführungsleistung der Maschine größer als 1 ms sein , ist es tatsächlich ungewiss setTimeout()
in der ersten Timer-Stufe ausgeführt wird, sodass unterschiedliche Ausgabeergebnisse angezeigt werden.
(Wenn der Wert der delay
(der zweite Parameter von setTimeout
) größer als 2147483647
oder kleiner als 1
ist, wird delay
auf 1
gesetzt.)
Schauen wir uns den folgenden Code an:
const fs = require('fs'); fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); }); });
Es ist ersichtlich, dass in diesem Code beide Timer in Rückruffunktionen gekapselt und an readFile
übergeben werden. Es ist offensichtlich, dass beim Aufruf des Rückrufs die aktuelle Zeit größer als 1 ms sein muss, sodass der Rückruf von setTimeout
erfolgt länger sein als der Rückruf von setImmediate
Der Rückruf wird zuerst aufgerufen, daher lautet das gedruckte Ergebnis: timeout immediate
.
Die oben genannten Dinge im Zusammenhang mit Timern müssen Sie bei der Verwendung von Node beachten. Darüber hinaus müssen Sie auch auf die Ausführungsreihenfolge von process.nextTick()
, new Promise().then()
und setImmediate()
achten. Da dieser Teil relativ einfach ist, wurde er bereits erwähnt und wird nicht wiederholt .
: Der Artikel beginnt mit einer detaillierteren Erläuterung der Implementierungsprinzipien der Node-Ereignisschleife aus den beiden Perspektiven, warum Asynchronität erforderlich ist und wie Asynchronität implementiert wird, und erwähnt einige verwandte Themen, die meiner Meinung nach hilfreich sein werden Du.