Was ist eine Ereignisschleife?
Die Ereignisschleife ermöglicht es Node.js, nicht blockierende E/A-Vorgänge auszuführen – trotz der Tatsache, dass JavaScript Single-Threaded ist – indem Sie Vorgänge nach Möglichkeit auf den Betriebssystemkern verlagern.
Da die meisten modernen Systemkerne Multithread-fähig sind, kann der Kernel mehrere im Hintergrund ausgeführte Vorgänge verarbeiten. Wenn einer der Vorgänge abgeschlossen ist, teilt der Kernel Node.js mit, und der entsprechende Rückruf wird zur Abfragewarteschlange hinzugefügt und schließlich ausgeführt. In diesem Thema werden später weitere Details dazu erläutert.
Ereignisschleife
Node.js initialisiert die Ereignisschleife am Anfang und verarbeitet das Zielskript Das Skript kann einen asynchronen API-Aufruf, eine geplante Aufgabe oder „process.nextTick()“ ausführen und dann die Ereignisschleife starten.
Die folgende Tabelle beschreibt kurz die Abfolge der Vorgänge der Ereignisschleife.
Hinweis: Jedes Feld stellt eine Phase in der Ereignisschleife dar.
Jede Stufe verfügt über eine First-In-First-Out-Warteschlange (FIFO) mit Rückruffunktionen, die ausgeführt werden müssen. Gleichzeitig ist jede Stufe etwas Besonderes: Wenn die Ereignisschleife eine bestimmte Stufe erreicht, werden die für diese Stufe eindeutigen Vorgänge ausgeführt und dann werden die Rückrufe in der Warteschlange dieser Stufe ausgeführt, bis die Warteschlange leer ist die Ausführungsgrenze ist erreicht. Zu diesem Zeitpunkt tritt die Ereignisschleife in die nächste Phase ein und der Zyklus wird wiederholt.
Da diese Vorgänge möglicherweise mehr geplante Aufgabenvorgänge generieren und neue Ereignisse, die in der Abfragephase verarbeitet werden, zur Kernel-Warteschlange hinzugefügt werden, gibt es neue Ereignisse, wenn die Abfrageereignisse verarbeitet werden. Das Umfrageereignis wird hinzugefügt. Daher führen langfristige Rückrufaufgaben dazu, dass die Abfragephase den Timer-Schwellenwert überschreitet. Weitere Informationen finden Sie in den Abschnitten „Timer“ und „Umfrage“.
Hinweis: Es gibt leichte Widersprüche bei der Implementierung von Windows und Unix/Linux, die jedoch derzeit keinen Einfluss auf die Beschreibung haben. Die wichtigsten Teile sind da. Tatsächlich gibt es sieben oder acht Stufen, aber worauf wir uns konzentrieren – was Node.js tatsächlich verwendet – sind die oben genannten.
Phasenübersicht
Timer: In dieser Phase werden die geplanten Rückrufe setTimeout() und setInterval() ausgeführt
I/O-Callbacks: Fast alle Close-Callbacks ausführen, bei denen Ausnahmen auftreten, Callbacks geplant durch Timer und setImmediate(); idle, Prepare ): Nur für den internen Gebrauch Umfrage: Neue E/A-Ereignisse abrufen; nodejs werden zu diesem Zeitpunkt entsprechend blockiert; Rückrufe aufrufen: Zum Beispiel socket.on('close', .. . ); Zwischen den Ausführungen der Ereignisschleife prüft Node.js, ob die asynchrone E/A oder der Timer, auf den/die gewartet wird, gelöscht und beendet wird, wenn keine vorhanden ist. Phasendetails Timer (Timer) Timing The Der Zweck der Funktion besteht darin, die Ausführung der angegebenen Rückruffunktion nach einem bestimmten Schwellenwert zu ermöglichen. Die spezifische Ausführungszeit ist nicht unbedingt der genaue Schwellenwert. Der Timer-Rückruf wird ausgeführt, sobald die angegebene Zeit abgelaufen ist. Der Zeitplan des Betriebssystems oder die Ausführung anderer Rückrufe kann jedoch die Ausführung des Rückrufs verzögern. Hinweis: Technisch gesehen steuert die Abfragephase den Ausführungszeitpunkt des Timers. Sie legen beispielsweise fest, dass ein Vorgang nach 100 ms ausgeführt werden soll, und dann beginnt das Skript mit der Ausführung eines Dateilesevorgangs, der 95 ms dauert:Wenn der Ereignis: Wenn die Schleife in die Abfragephase eintritt, ist die Warteschlange leer (fs.readFile() ist noch nicht abgeschlossen), sodass weiterhin Zeit vergeht, bis der schnellste Timer ausgeführt werden muss. Nach 95 ms hat fs.readFile() das Lesen der Datei abgeschlossen und ihr Rückruf wird zur Abfragewarteschlange hinzugefügt. Dieser Rückruf muss 10 ms lang ausgeführt werden. Wenn dieser Rückruf ausgeführt wird und keine weiteren Rückrufe in der Warteschlange vorhanden sind, erkennt die Ereignisschleife den zuletzt abgelaufenen Timer und kehrt dann zur Timer-Phase zurück, um den vorherigen Timer-Rückruf auszuführen.
var fs = require('fs'); function someAsyncOperation (callback) { // Assume this takes 95ms to complete fs.readFile('/path/to/file', callback); } var timeoutScheduled = Date.now(); setTimeout(function () { var delay = Date.now() - timeoutScheduled; console.log(delay + "ms have passed since I was scheduled"); }, 100); // do someAsyncOperation which takes 95 ms to completesomeAsyncOperation(function () { var startCallback = Date.now(); // do something that will take 10ms... while (Date.now() - startCallback < 10) { ; // do nothing } });
如果轮询队列是空的:
如果有setImmediate()定义了回调,那么事件循环会终止轮询阶段并进入检查阶段去执行定时器回调;
如果没有setImmediate(),事件回调会等待回调被加入队列并立即执行。
一旦轮询队列空了,事件循环会查找已经到时的定时器。如果找到了,事件循环就回到定时器阶段去执行回调。
检查(check)
这个阶段允许回调函数在轮询阶段完成后立即执行。如果轮询阶段空闲了,并且有回调已经被 setImmediate() 加入队列,事件循环会进入检查阶段而不是在轮询阶段等待。
setImmediate() 是个特殊的定时器,在事件循环中一个单独的阶段运行。它使用libuv的API 来使得回调函数在轮询阶段完成后执行。
基本上,随着代码的执行,事件循环会最终进入到等待状态的轮询阶段,可能是等待一个连接、请求等。然而,如果有一个setImmediate() 设置了一个回调并且轮询阶段空闲了,那么事件循环会进入到检查阶段而不是等待轮询事件。 ---- 这车轱辘话说来说去的
关闭事件的回调(close callbacks)
如果一个 socket 或句柄(handle)被突然关闭(is closed abruptly),例如 socket.destroy(), 'close' 事件会被发出到这个阶段。否则这种事件会通过 process.nextTick() 被发出。
setImmediate() vs setTimeout()
这两个很相似,但调用时机会的不同会导致它们不同的表现。
setImmediate() 被设计成一旦轮询阶段完成就执行回调函数;
setTimeout() 规划了在某个时间值过后执行回调函数;
这两个执行的顺序会因为它们被调用时的上下文而有所不同。如果都是在主模块调用,那么它们会受到进程性能的影响(运行在本机的其他程序会影响它们)。
例如,如果我们在非 I/O 循环中运行下面的脚本(即在主模块中),他俩的顺序是不固定的,因为会受到进程性能的影响:
// timeout_vs_immediate.jssetTimeout(function timeout () { console.log('timeout'); },0); setImmediate(function immediate () { console.log('immediate'); });
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
但是如果把它们放进 I/O 循环中,setImmediate() 的回调总是先执行:
// timeout_vs_immediate.jsvar fs = require('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
setImmediate() 比 setTimeout() 优势的地方是 setImmediate() 在 I/O 循环中总是先于任何定时器,不管已经定义了多少定时器。
process.nextTick()
理解 process.nextTick()
你可能已经注意到了 process.nextTick() 没有在上面那个表格里出现,虽然它确实是一个异步API。这是因为它技术上不属于事件循环。然而,nextTickQueue 会在当前操作结束后被处理,不管是在事件循环的哪个阶段。
回头看看之前那个表格,你在某个阶段的任何时候调用它,它的所有回调函数都会在事件循环继续进行之前得到处理。有时候这会导致比较糟糕的情况,因为它允许你用递归调用的方式去“阻塞” I/O,这会让事件循环无法进入到轮询阶段。
为什么要允许这样
部分是因为 Node.js 的设计哲学:API 应该总是异步的,即使本不需要是异步。
blablabla,后面几段看的我有点尴尬+晕。既尴尬又晕是觉得这几段说的有点啰嗦,而且举的例子不合适。例子要么是同步的,不是异步的。要么是例子里的写法完全可以避免,比如应该先添加 'connect' 事件监听再进行 .connect() 操作;又或者变量声明最好放在变量使用之前,可以避免变量的提前声明和当时赋值的麻烦。
难道是我没理解里面的秘辛?
process.nextTick() vs setTimeout()
这两个函数有些相似但是名字让人困惑:
process.netxtTick() 在事件循环的当前阶段立即生效;
setImmediate() 生效是在接下来的迭代或者事件循环的下一次tick;
本质上,它们的名字应该互换一下。process.nextTick() 比 setImmediate() 更“立刻”执行,但这是个历史问题没法改变。如果改了,npm上大堆的包就要挂了。
我们推荐开发者在所有情况下都使用 setImmediate() 因为它更显而易见(reason about),另外兼容性也更广,例如浏览器端。
为什么使用 process.nextTick()
有两大原因:
允许用户处理错误,清理不需要的资源,或许在事件循环结束前再次尝试发送请求;
必须让回调函数在调用栈已经清除(unwound)后并且事件循环继续下去之前执行;
下面的两个例子都是类似的,即在 line1 派发事件,却在 line2 才添加监听,因此监听的回调是不可能被执行到的。
于是可以用 process.nextTick() 使得当前调用栈先执行完毕,也即先执行 line2 注册事件监听,然后在 nextTick 派发事件。
const EventEmitter = require('events'); const util = require('util'); function MyEmitter() { EventEmitter.call(this); // use nextTick to emit the event once a handler is assigned process.nextTick(function () { this.emit('event'); }.bind(this)); } util.inherits(MyEmitter, EventEmitter); const myEmitter = new MyEmitter(); myEmitter.on('event', function() { console.log('an event occurred!'); });
翻译总结:
这篇文章写的不太简练,也可能为了有更多的受众吧,我感觉车轱辘话比较多,一个意思要说好几遍。
从编程应用的角度简单来说:
Node.js 中的事件循环大概有七八个阶段,每个阶段都有自己的队列(queue),需要等本阶段的队列处理完成后才进入其他阶段。阶段之间会互相转换,循环顺序并不是完全固定的 ,因为很多阶段是由外部的事件触发的。
其中比较重要的是三个:
定时器阶段 timers:
定时器阶段执行定时器任务(setTimeOut(), setInterval())。
轮询阶段 poll:
轮询阶段由 I/O 事件触发,例如 'connect','data' 等。这是比较重/重要的阶段,因为大部分程序功能就是为了 I/O 数据。
本阶段会处理定时器任务和 poll 队列中的任务,具体逻辑:
如果有 setImmediate(),终止轮询阶段并进入检查阶段去执行;
如果没有 setImmediate(),那么就查看有没有到期的定时器,有的话就回到定时器阶段执行回调函数;
处理到期的定时器任务,然后
处理队列任务,直到队列空了或者达到上限
如果队列任务没了:
检查阶段 check:
当轮询阶段空闲并且已经有 setImmediate() 的时候,会进入检查阶段并执行。
比较次要但也列在表格中的两个:
I/O 阶段:
本阶段处理 I/O 异常错误;
'close'事件回调:
本阶段处理各种 'close' 事件回调;
关于 setTimeout(), setImmediate(), process.nextTick():
setTimeout() 在某个时间值过后尽快执行回调函数;
setImmediate() 一旦轮询阶段完成就执行回调函数;
process.nextTick() 在当前调用栈结束后就立即处理,这时也必然是“事件循环继续进行之前” ;
优先级顺序从高到低: process.nextTick() > setImmediate() > setTimeout()
注:这里只是多数情况下,即轮询阶段(I/O 回调中)。比如之前比较 setImmediate() 和 setTimeout() 的时候就区分了所处阶段/上下文。
另:
关于调用栈,事件循环还可以参考这篇文章:
https://blog.risingstack.com/node-js-at-scale-understanding-node-js-event-loop/
这篇文章里对事件任务区分了大任务(macro task) 、小任务(micro task),每个事件循环只处理一个大任务 ,但会处理完所有小任务。
这一点和前面的文章说的不同。
examples of microtasks:
process.nextTick
promises
Object.observe
examples of macrotasks:
setTimeout
setInterval
setImmediate
I/O