了解 Node.js 事件循环阶段以及它如何执行 JavaScript 代码。
我相信,如果您正在阅读本文,您一定听说过 Node.js 著名的事件循环,它处理 Node.js 中的并发机制,以及它如何使 Node.js 成为一个独特的事件驱动 I/O 平台。作为事件驱动 I/O,所有执行的代码都以回调的形式执行。因此,了解事件循环如何以及按什么顺序执行这些回调非常重要。从现在开始,在这篇博文中,“事件循环”一词均指 Node.js 的事件循环。
事件循环本质上是一种包含特定迭代阶段的机制。你一定听说过“事件循环迭代”这个术语,它指的是事件循环在其所有阶段的迭代。
在这篇文章中,我将向您展示事件循环的底层架构、它的所有阶段、在哪个阶段执行哪些代码,以及一些细节,最后是一些我认为可以让您更好地理解事件循环概念的示例。
以下是事件循环按顺序迭代的所有阶段的图表:
事件循环是 Node.js 中的一种机制,它通过一系列循环进行迭代。以下是事件循环迭代的各个阶段:
每个阶段都有一个队列/堆,事件循环使用它来推送/存储要执行的回调(Node.js 中存在一个误解,认为只有一个全局队列,回调在其中排队等待执行,但事实并非如此。)。
-
计时器:
JavaScript 中计时器的回调(setTimeout、setInterval)会保存在堆内存中,直到它们过期。如果堆中有任何过期的计时器,事件循环就会取出与之关联的回调,并按照延迟时间的升序开始执行,直到计时器队列为空。但是,计时器回调的执行由事件循环的Poll阶段控制(我们将在本文后面讨论)。 -
待处理回调:
在此阶段,事件循环会执行与系统相关的回调(如果有)。例如,假设你正在编写一个 Node 服务器,而你想要运行该进程的端口正在被其他进程占用,Node 会抛出一个错误ECONNREFUSED
;某些 *nix 系统可能希望回调等待执行,因为操作系统正在处理其他任务。因此,这些回调会被推送到待处理回调队列中等待执行。 -
空闲/准备:在此阶段,事件循环不执行任何操作。它处于空闲状态并准备进入下一阶段。
-
轮询:
这个阶段是 Node.js 的独特之处。在这个阶段,事件循环会监视新的异步 I/O 回调。除了 setTimeout、setInterval、setImmediate 和 closing 回调之外,几乎所有回调都会被执行。
基本上,事件循环在这个阶段会做两件事:- 如果轮询阶段队列中已经有回调排队,它将执行这些回调,直到轮询阶段回调队列中的所有回调都排完为止。
- 如果队列中没有回调,事件循环将在轮询阶段停留一段时间。这个“一段时间”还取决于以下几点:
- 如果 setImmediate 队列中有任何待执行的回调,事件循环将不会在轮询阶段停留太久,而是进入下一个阶段,即 Check/setImmediate。再次,它将开始执行回调,直到 Check/setImmediate 阶段回调队列为空。
- 事件循环离开轮询阶段的第二种情况是,它发现有已过期的定时器,其回调函数正在等待执行。在这种情况下,事件循环将进入下一个阶段,即 Check/setImmediate 阶段,然后进入关闭回调阶段,最终从定时器阶段开始下一次迭代。
-
Check/setImmediate:在此阶段,事件循环从 Check 阶段的队列中取出回调,并开始逐个执行,直到队列为空。当 poll 阶段没有剩余的回调需要执行,并且 poll 阶段变为空闲状态时,事件循环将进入此阶段。通常,setImmediate 的回调在此阶段执行。
-
关闭回调:在此阶段,事件循环执行与关闭事件相关的回调,如
socket.on('close', fn)
或process.exit()
。
除此之外,还有一个microtask
包含相关回调的队列,process.nextTick
我们稍后会看到。
示例
让我们从一个简单的例子开始来了解以下代码是如何执行的:
function main() {
setTimeout(() => console.log('1'), 0);
setImmediate(() => console.log('2'));
}
main();
让我们回顾一下事件循环图,并将我们的阶段解释与之结合起来,尝试找出上述代码的输出:
当使用 node 作为解释器执行时,上述代码的输出为:
1
2
事件循环进入该Timers
阶段并执行与上述阶段相关的回调,setTimeout
之后进入后续阶段,在这些阶段中,它不会看到任何回调入队,直到到达Check (setImmediate)
执行与其关联的回调函数的阶段。因此得到了所需的输出。
注意:上面的输出也可以反转,即
2
1
因为事件循环不会在 0ms 内准确执行 setTimeout(fn, 0) 的回调。它会在 4-20 ms 之后稍作延迟后执行回调。(还记得吗?之前提到过,Poll阶段控制着定时器回调的执行,因为它会在 poll 阶段等待一些 I/O 操作。)
现在,当任何 JavaScript 代码由事件循环运行时,都会发生两件事。
- 当我们的 JavaScript 代码中的函数被调用时,事件循环首先实际上不执行,而是将初始回调注册到相应的队列。
- 一旦它们被注册,事件循环就会进入其阶段并开始迭代和执行回调,直到所有回调都被处理。
再举一个例子,或者说 Node.js 中存在一个误解,认为 setTimeout(fn, 0) 总是在 setImmediate 之前执行,但这完全是错误的!正如我们在上面的例子中看到的,事件循环最初处于 Timers 阶段,setTimeout 计时器可能已经过期,因此它先执行了 setTimeout,而这种行为是不可预测的。然而,这并非总是如此,这完全取决于回调的数量、事件循环所处的阶段等等。
但是,如果你这样做:
function main() {
fs.readFile('./xyz.txt', () => {
setTimeout(() => console.log('1'), 0);
setImmediate(() => console.log('2'));
});
}
main();
上述代码将始终输出:
2
1
我们来看看上面的代码是如何执行的:
-
当我们调用
main()
函数时,事件循环首先运行,但实际上并没有执行回调。我们遇到 fs.readFile 函数,该函数带有一个已注册的回调,并将该回调推送到 I/O 阶段队列。由于该函数的所有回调都已注册,事件循环现在可以自由地开始执行回调了。因此,它从计时器阶段开始遍历各个阶段。在计时器阶段和待处理回调阶段,它没有找到任何内容。 -
当事件循环不断遍历其各个阶段并看到文件读取操作完成时,它就开始执行回调。
请记住,当事件循环开始执行的回调时fs.readFile
,它处于 I/O 阶段,此后,它将进入 Check(setImmediate) 阶段。
- 因此,在当前运行中,检查阶段位于计时器阶段之前。因此,在 I/O 阶段, 的回调
setImmediate
始终会在 之前运行setTimeout(fn, 0)
。
让我们再考虑一个例子:
function main() {
setTimeout(() => console.log('1'), 50);
process.nextTick(() => console.log('2'));
setImmediate(() => console.log('3'));
process.nextTick(() => console.log('4'));
}
main();
在我们看到事件循环如何执行此代码之前,需要了解一件事:
process.nextTick 属于
microtasks
优先级高于所有其他阶段的阶段,因此与其关联的回调会在事件循环完成当前操作后立即执行。这意味着,无论我们将什么回调传递给 process.nextTick,事件循环都会完成其当前操作,然后执行microtasks
队列中的回调,直到队列耗尽。队列耗尽后,事件循环将返回到它离开工作的阶段。
- 它首先检查
microtask
队列并执行其中的回调(在本例中为 process.nextTick 回调)。 - 然后,它进入第一个阶段(计时器阶段),此时 50ms 计时器尚未到期。因此,它前进到其他阶段。
- 然后它进入“检查(setImmediate)”阶段,在此阶段它会看到计时器已过期并执行记录“3”的回调。
- 在事件循环的下一次迭代中,它看到 50 毫秒的计时器已过期,因此记录“1”。
以下是上述代码的输出:
2
4
3
1
再考虑一个例子,这次我们将异步回调传递给我们的一个process.nextTick
。
function main() {
setTimeout(() => console.log('1'), 50);
process.nextTick(() => console.log('2'));
setImmediate(() => console.log('3'));
process.nextTick(() => setTimeout(() => {
console.log('4');
}, 1000));
}
main();
上述代码片段的输出是:
2
3
1
4
现在,执行上述代码时会发生以下情况:
- 所有回调均已注册并推送至各自的队列。
- 由于
microtasks
队列回调会先执行,如上例所示,因此会输出“2”。同时,此时第二个 process.nextTick 回调,即 setTimeout(将输出“4”)已开始执行,并最终被推送到“Timers”阶段队列。 - 现在,事件循环进入其正常阶段并执行回调。它进入的第一个阶段是“计时器”。它发现 50 毫秒的计时器尚未到期,因此进入下一个阶段。
- 然后进入“检查(setImmediate)”阶段并执行 setImmediate 的回调,最终记录“3”。
- 现在,事件循环的下一轮迭代开始了。在这一轮迭代中,事件循环返回到“计时器”阶段,它根据注册信息,遇到两个已过期的计时器,即 50ms 和 1000ms,并执行与之关联的回调函数,该回调函数首先打印“1”,然后打印“4”。
因此,正如你所看到的,事件循环的各种状态、阶段,以及最重要的,process.nextTick
它是如何运作的。它基本上将提供给它的回调放入microtasks
队列中,并按优先级执行。
最后举个例子,也是一个比较详细的例子。你还记得这篇博文开头的事件循环示意图吗?好吧,请看一下下面的代码。我希望你能弄清楚下面代码的输出是什么。在代码之后,我绘制了一张事件循环如何执行以下代码的示意图。这将帮助你更好地理解:
1 const fs = require('fs');
2
3 function main() {
4 setTimeout(() => console.log('1'), 0);
5 setImmediate(() => console.log('2'));
6
7 fs.readFile('./xyz.txt', (err, buff) => {
8 setTimeout(() => {
9 console.log('3');
10 }, 1000);
11
12 process.nextTick(() => {
13 console.log('process.nextTick');
14 });
15
16 setImmediate(() => console.log('4'));
17 });
18
19 setImmediate(() => console.log('5'));
20
21 setTimeout(() => {
22 process.on('exit', (code) => {
23 console.log(`close callback`);
24 });
25 }, 1100);
26 }
27
28 main();
下面的 gif 展示了事件循环如何执行上述代码:
笔记:
- 下图中队列的数字就是上述代码中回调的行号。
- 由于我的重点是事件循环阶段如何执行代码,因此我没有在 gif 中插入空闲/准备阶段,因为它仅由事件循环内部使用。
上述代码将输出:
1
2
5
process.nextTick
4
3
close callback
或者,它也可以是(记住第一个例子):
2
5
1
process.nextTick
4
3
close callback
杂项
微任务和宏任务
- 微任务
所以,Node.js(或者更准确地说是 v8)中有一个叫做“微任务”的东西。需要明确的是,微任务不是事件循环的一部分,而是 v8 的一部分。在本文前面,您可能已经阅读过process.nextTick
。JavaScript 中有一些任务属于微任务process.nextTick
,例如Promise.resolve
,,等等。
这些任务的优先级高于其他任务/阶段,这意味着事件循环在其当前操作之后将执行队列的所有回调,microtasks
直到其耗尽,然后从其离开工作的阶段恢复其工作。
因此,每当 Node.js 遇到microtask
上述任何定义时,它都会将相关的回调推送到microtask
队列并立即开始执行(微任务优先),并执行所有回调,直到队列彻底排空。
话虽如此,如果你在队列中放入很多回调microtasks
,你最终可能会导致事件循环饥饿,因为它永远不会进入任何其他阶段。
- 宏任务
诸如setTimeout
、setInterval
、setImmediate
、requestAnimationFrame
、或其他I/O
任务都属于宏任务。它们不受事件循环的优先级排序。回调会根据事件循环的阶段执行。UI rendering
I/O callbacks
事件循环滴答
当事件循环一次(即事件循环的一次迭代)完成所有阶段的迭代时,我们称其为一次“tick”。
较高的事件循环tick频率和较低的tick持续时间(即一次迭代所花费的时间)表明事件循环运行状况良好。
希望你喜欢这篇文章。如果你对这个主题有任何疑问,请在评论区留言。我会尽力用我所知解答。我并非 Node.js 专家,但我查阅了多种资源,并将相关知识整合到这篇博客中。如果你觉得我哪里错了,请在评论区指正。
非常感谢你的阅读。
欢迎通过Twitter / GitHub与我联系。
祝你有美好的一天!👋
文章来源:https://dev.to/lunaticmonk/understanding-the-node-js-event-loop-phases-and-how-it-executes-the-javascript-code-1j9