Node wurde ursprünglich zum Aufbau eines Hochleistungs-Webservers entwickelt. Als serverseitige Laufzeitumgebung für JavaScript verfügt es ü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 durch Thread-Kontextwechsel. 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 befasst sich mit dem zugrunde liegenden Implementierungs- und Ausführungsmechanismus von Node Asynchronous und Event Loop. Ich hoffe, er wird Ihnen hilfreich sein.
Warum verwendet Node Asynchron als Kernprogrammiermodell?
Wie bereits erwähnt, wurde Node ursprünglich zum Erstellen leistungsstarker Webserver entwickelt. Unter der Annahme, dass im Geschäftsszenario mehrere unabhängige Aufgaben erledigt werden müssen, gibt es zwei moderne Mainstream-Lösungen:
Einzelner Thread, der seriell ausgeführt wird .
Mehrere Threads, die parallel bearbeitet werden müssen.
Die serielle Ausführung mit einem Thread ist ein synchrones Programmiermodell, obwohl es der Denkweise des Programmierers besser entspricht und es einfacher ist, bequemeren Code zu schreiben, da es E/A synchron ausführt Es kann jeweils eine einzelne Anforderung verarbeitet werden, was dazu führt, dass der Server langsam reagiert und nicht in Anwendungsszenarien mit hoher Parallelität verwendet werden kann. Da E/A blockiert wird, wartet die CPU außerdem immer auf den Abschluss der E/A Sie können keine anderen Dinge tun, was die Rechenleistung der CPU verringert, was letztendlich zu einer geringen Effizienz führt. 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.
Ausgehend von der Reaktionsgeschwindigkeit auf Clientanfragen beträgt die Antwortgeschwindigkeit der synchronen Methode außerdem die Summe der Antwortgeschwindigkeiten der beiden Ressourcen, wenn der Client gleichzeitig zwei Ressourcen erhält Die asynchrone Methode wird zwei sein. Die größte davon ist der Leistungsvorteil im Vergleich zur Synchronisation. Mit zunehmender Anwendungskomplexität wird sich dieses Szenario dahingehend entwickeln, auf n Anfragen gleichzeitig zu reagieren, und die Vorteile der asynchronen gegenüber der Synchronisierung werden hervorgehoben.
Zusammenfassend gibt Node seine Antwort: Verwenden Sie einen einzelnen Thread, um Multi-Thread-Deadlock, 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 Async als Kernprogrammiermodell.
Um die Mängel eines einzelnen Threads auszugleichen, der keine Multi-Core-CPUs nutzen kann, stellt Node außerdem einen Web Worker-ähnlichen Unterprozess im Browser bereit, der die CPU durch Worker-Prozesse effizient nutzen kann.
Wie implementiert man asynchron?
Es gibt zwei Arten von asynchronen Vorgängen, die wir normalerweise nennen: Die eine sind E/A-bezogene Vorgänge wie Datei-E/A und Netzwerk-E/A; die andere sind Vorgänge, die nichts mit E/A zu tun haben, wie
. Offensichtlich bezieht sich die Asynchronität, über die wir sprechen, auf Vorgänge im Zusammenhang mit E/A, also asynchrone E/A.setTimeOut
、setInterval
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, sind nicht blockierende E/A erforderlich.
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 Ausgeben eines 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-Aufrufe abgeschlossen sind, um festzustellen, ob der Vorgang abgeschlossen ist abgeschlossen ist, nennt man Polling.
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 immer noch nur als eine Art Synchronisierung angesehen werden, da die Anwendung noch auf die Anwendung warten muss I/O bis zum Abschluss Bei der Rückkehr dauerte das Warten immer noch sehr lange.
Der perfekte asynchrone I/O-Vorgang sollte darin bestehen, dass die Anwendung nicht blockierende Aufrufe initiiert. Es besteht keine Notwendigkeit, den Status der I/O-Aufrufe kontinuierlich abzufragen, sondern die nächste Aufgabe kann direkt verarbeitet werden /O ist abgeschlossen. Übergeben Sie dann die Daten ü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 dem Hauptgeschäft zu tun haben Logik, run Die Implementierung in anderen Threads hat keinen Einfluss auf die Ausführung des Hauptthreads oder blockiert diese. 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 nutzen Sie dann die Kommunikation zwischen Threads zur Übertragung Der E/A/O überträgt die erhaltenen Daten, was die Implementierung asynchroner E/A vereinfacht:
Der Hauptthread führt E/A-Aufrufe durch, während der Thread-Pool E/A-Vorgänge ausführt und die Erfassung abschließt Daten und leitet die Daten dann zwischen Threads weiter. Die Kommunikation überträgt die Daten an den Hauptthread, um einen E/A-Aufruf abzuschließen. Der Hauptthread verwendet dann die Rückruffunktion, um die Daten dem Benutzer bereitzustellen Operationen auf der Ebene der Geschäftslogik. Dies ist ein E/A-Aufruf im asynchronen Node. Benutzer müssen sich nicht um die umständlichen Implementierungsdetails der zugrunde liegenden Schicht kümmern. Sie müssen lediglich die von Node gekapselte asynchrone API aufrufen und die Rückruffunktion übergeben, die die Geschäftslogik verwaltet, wie unten gezeigt: Die asynchrone zugrunde liegende Schicht von
const fs = require("fs"); fs.readFile('example.js', (data) => { // 进行业务逻辑的处理 });
Nodejs Der Implementierungsmechanismus unterscheidet sich auf verschiedenen Plattformen: Unter Windows wird IOCP hauptsächlich verwendet, um E/A-Aufrufe an den Systemkernel zu senden und abgeschlossene E/A-Vorgänge vom Kernel zu erhalten, verbunden mit einer Ereignisschleife zum Abschließen Der asynchrone I/O-Prozess wird unter Linux über kqueue und unter Solaris über Event-Ports implementiert. Der Thread-Pool wird direkt vom Kernel (IOCP) unter Windows bereitgestellt und die *nix
-Reihe wird von libuv selbst implementiert. *nix
系列则由 libuv 自行实现。
由于 Windows 平台和 *nix
平台的差异,Node 提供了 libuv 作为抽象封装层,使得所有平台兼容性的判断都由这一层来完成,保证上层的 Node 与下层的自定义线程池及 IOCP 之间各自独立。Node 在编译期间会判断平台条件,选择性编译 unix 目录或是 win 目录下的源文件到目标程序中:
以上就是 Node 对异步的实现。
(线程池的大小可以通过环境变量 UV_THREADPOOL_SIZE
设置,默认值为 4,用户可结合实际情况来调整这个值的大小。)
那么问题来了,在得到线程池传递过来的数据后,主线程是如何、何时调用回调函数的呢?答案是事件循环。
既然使用回调函数来进行对 I/O 数据的处理,就必然涉及到何时、如何调用回调函数的问题。在实际开发中,往往会涉及到多个、多类异步 I/O 调用的场景,如何合理安排这些异步 I/O 回调的调用,确保异步回调的有序进行是一个难题,而且,除了异步 I/O 之外,还存在定时器这类非 I/O 的异步调用,这类 API 实时性强,优先级相应地更高,如何实现不同优先级回调地调度呢?
因此,必须存在一个调度机制,对不同优先级、不同类型的异步任务进行协调,确保这些任务在主线程上有条不紊地运行。与浏览器一样,Node 选择了事件循环来承担这项重任。
Node 根据任务的种类和优先级将它们分为七类:Timers、Pending、Idle、Prepare、Poll、Check、Close。对于每类任务,都存在一个先进先出的任务队列来存放任务及其回调(Timers 是用小顶堆存放)。基于这七个类型,Node 将事件循环的执行分为如下七个阶段:
这个阶段的执行优先级是最高的。
事件循环在这个阶段会检查存放定时器的数据结构(最小堆),对其中的定时器进行遍历,逐个比较当前时间和过期时间,判断该定时器是否过期,如果过期的话,就将该定时器的回调函数取出并执行。
该阶段会执行网络、IO 等异常时的回调。一些 *nix
*nix
-Plattform stellt Node libuv als abstrakte Kapselungsschicht bereit, sodass alle Plattformkompatibilitätsbeurteilungen durch diese Schicht vervollständigt werden und sichergestellt wird, dass die obere Schicht Node und Die Knoten der unteren Ebene sind benutzerdefinierte Thread-Pools und IOCP sind unabhängig voneinander. Der Knoten bestimmt die Plattformbedingungen während der Kompilierung und kompiliert selektiv Quelldateien im Unix-Verzeichnis oder 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. Benutzer können die Größe dieses Werts basierend auf der tatsächlichen Situation anpassen.) 🎜🎜Dann wird der Die Frage ist: Wie und wann ruft der Hauptthread nach dem Abrufen des Thread-Pools nach der Übergabe der Daten die Rückruffunktion auf? Die Antwort ist die Ereignisschleife. 🎜*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, auf diese Phase verschoben. 🎜🎜Leerlauf und Vorbereitung🎜🎜Diese beiden Phasen werden nur innerhalb der Ereignisschleife verwendet. 🎜检索新的 I/O 事件;执行与 I/O 相关的回调(除了关闭回调、定时器调度的回调和 之外几乎所有回调setImmediate()
);节点会在适当的时候阻塞在这里。
poll,即轮询阶段是事件循环最重要的阶段,网络 I/O、文件 I/O 的回调都主要在这个阶段被处理。该阶段有两个主要功能:
计算该阶段应该阻塞和轮询 I/O 的时间。
处理 I/O 队列中的回调。
当事件循环进入 poll 阶段并且没有设置定时器时:
如果轮询队列不为空,则事件循环将遍历该队列,同步地执行它们,直到队列为空或达到可执行的最大数量。
如果轮询队列为空,则会发生另外两种情况之一:
如果有 setImmediate()
回调需要执行,则立即结束 poll 阶段,并进入 check 阶段以执行回调。
如果没有 setImmediate()
回调需要执行,事件循环将停留在该阶段以等待回调被添加到队列中,然后立即执行它们。在超时时间到达前,事件循环会一直停留等待。之所以选择停留在这里是因为 Node 主要是处理 IO 的,这样可以更及时地响应 IO。
一旦轮询队列为空,事件循环将检查已达到时间阈值的定时器。如果有一个或多个定时器达到时间阈值,事件循环将回到 timers 阶段以执行这些定时器的回调。
该阶段会依次执行 setImmediate()
的回调。
该阶段会执行一些关闭资源的回调,如 socket.on('close', ...)
。该阶段晚点执行也影响不大,优先级最低。
当 Node 进程启动时,它会初始化事件循环,执行用户的输入代码,进行相应异步 API 的调用、计时器的调度等等,然后开始进入事件循环:
┌───────────────────────────┐ ┌─>│ timers │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ pending callbacks │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ idle, prepare │ │ └─────────────┬─────────────┘ ┌───────────────┐ │ ┌─────────────┴─────────────┐ │ incoming: │ │ │ poll │<p>事件循环的每一轮循环(通常被称为 tick),会按照如上给定的优先级顺序进入七个阶段的执行,每个阶段会执行一定数量的队列中的回调,之所以只执行一定数量而不全部执行完,是为了防止当前阶段执行时间过长,避免下一个阶段得不到执行。</p><p>OK,以上就是事件循环的基本执行流程。现在让我们来看另外一个问题。</p><p>对于以下这个场景:</p><pre class="brush:php;toolbar:false">const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});
当服务成功绑定到 8000 端口,即 listen()
成功调用时,此时 listening
事件的回调还没有绑定,因此端口成功绑定后,我们所传入的 listening
事件的回调并不会执行。
再思考另外一个问题,我们在开发中可能会有一些需求,如处理错误、清理不需要的资源等等优先级不是那么高的任务,如果以同步的方式执行这些逻辑,就会影响当前任务的执行效率;如果以异步的方式,比如以回调的形式传入 setImmediate()
又无法保证它们的执行时机,实时性不高。那么要如何处理这些逻辑呢?
基于这几个问题,Node 参考了浏览器,也实现了一套微任务的机制。在 Node 中,除了调用 new Promise().then()
所传入的回调函数会被封装成微任务外,process.nextTick()
的回调也会被封装成微任务,并且后者的执行优先级比前者高。
有了微任务后,事件循环的执行流程又是怎么样的呢?换句话说,微任务的执行时机在什么时候?
在 node 11 及 11 之后的版本,一旦执行完一个阶段里的一个任务就立刻执行微任务队列,清空该队列。
在 node11 之前执行完一个阶段后才开始执行微任务。
因此,有了微任务后,事件循环的每一轮循环,会先执行 timers 阶段的一个任务,然后按照先后顺序清空 process.nextTick()
和 new Promise().then()
的微任务队列,接着继续执行 timers 阶段的下一个任务或者下一个阶段,即 pending 阶段的一个任务,按照这样的顺序以此类推。
利用 process.nextTick()
,Node 就可以解决上面的端口绑定问题:在 listen()
方法内部,listening
事件的发出会被封装成回调传入 process.nextTick()
中,如下伪代码所示:
function listen() { // 进行监听端口的操作 ... // 将 `listening` 事件的发出封装成回调传入 `process.nextTick()` 中 process.nextTick(() => { emit('listening'); }); };
在当前代码执行完毕后便会开始执行微任务,从而发出 listening
事件,触发该事件回调的调用。
由于异步本身的不可预知性和复杂性,在使用 Node 提供的异步 API 的过程中,尽管我们已经掌握了事件循环的执行原理,但是仍可能会有一些不符合直觉或预期的现象产生。
比如定时器(setTimeout
、setImmediate
)的执行顺序会因为调用它们的上下文而有所不同。如果两者都是从顶层上下文中调用的,那么它们的执行时间取决于进程或机器的性能。
我们来看以下这个例子:
setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); });
以上代码的执行结果是什么呢?按照我们刚才对事件循环的描述,你可能会有这样的答案:由于 timers 阶段会比 check 阶段先执行,因此 setTimeout()
的回调会先执行,然后再执行 setImmediate()
的回调。
实际上,这段代码的输出结果是不确定的,可能先输出 timeout,也可能先输出 immediate。这是因为这两个定时器都是在全局上下文中调用的,当事件循环开始运行并执行到 timers 阶段时,当前时间可能大于 1 ms,也可能不足 1 ms,具体取决于机器的执行性能,因此 setTimeout()
在第一个 timers 阶段是否会被执行实际上是不确定的,因此才会出现不同的输出结果。
(当 delay
(setTimeout
的第二个参数)的值大于 2147483647
或小于 1
时, delay
会被设置为 1
。)
我们接着看下面这段代码:
const fs = require('fs'); fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); }); });
可以看到,在这段代码中两个定时器都被封装成回调函数传入 readFile
中,很明显当该回调被调用时当前时间肯定大于 1 ms 了,所以 setTimeout
的回调会比 setImmediate
的回调先得到调用,因此打印结果为:timeout immediate
。
以上是在使用 Node 时需要注意的与定时器相关的事项。除此之外,还需注意 process.nextTick()
与 new Promise().then()
还有 setImmediate()
的执行顺序,由于这部分比较简单,前面已经提到过,就不再赘述了。
文章开篇从为什么要异步、如何实现异步两个角度出发,较详细地阐述了 Node 事件循环的实现原理,并提到一些需要注意的相关事项,希望对你有所帮助。
更多node相关知识,请访问:nodejs 教程!
Das obige ist der detaillierte Inhalt vonLassen Sie uns ausführlich über den zugrunde liegenden Implementierungs- und Ausführungsmechanismus der asynchronen Schleifen und Ereignisschleifen von Node sprechen. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!