理解Node.js中Event Loop(四)
本文翻译至:Visualizing the I/O Queue in the Node.js Event Loop
欢迎阅读我们有关可视化 Node.js 事件循环系列的第四篇文章。在上一篇文章中,我们探讨了执行异步代码时的Timer队列及其优先级顺序。在本文中,我们将深入研究Input/Output出队列,这是另一个在事件循环中起着至关重要作用的队列。
在深入研究 I/O 队列之前,让我们快速回顾一下微任务和Timer队列。要将回调函数添加到微任务队列中,我们使用 process.nextTick()
和 Promise.resolve()
等函数。在 Node.js 中执行异步代码时,Microtask 队列具有最高优先级。
而要将回调函数添加到Timer队列中,我们使用 setTimeout()
和 setInterval()
等函数。
目录
添加回调函数到队列
要将回调函数添加到 I/O 队列,我们可以使用Node.js 内置模块中的大多数异步方法。对于我们的实验,我们将使用 fs 模块中的 readFile()
方法。
实验6
首先,我们导入 fs 模块并调用 readFile()
方法。这会将回调函数添加到I/O队列。在 readFile()
之后,我们向 nextTick 队列添加一个回调函数,向 Promise 队列添加一个回调函数。
const fs = require("fs");
fs.readFile(__filename, () => {
console.log("this is readFile 1");
});
process.nextTick(() => console.log("this is process.nextTick 1"));
Promise.resolve().then(() => console.log("this is Promise.resolve 1"));
可视化:
执行完主线程内执行栈中的代码后,nextTick 队列、promise 队列和 I/O 队列中各有一个回调,此时主线程空闲,控制进入事件循环。
nextTick 队列具有最高优先级,其次是 Promise 队列,然后是 I/O 队列。 nextTick 队列中的第一个回调函数被弹出队列并执行,将消息log到控制台。
当 nextTick 队列为空时,事件循环将进入 Promise 队列。回调弹出队列并在执行栈上执行,将消息打印到控制台。
现在 Promise 队列现在为空,事件循环将继续到Timer队列。如果Timer队列中没有回调,则事件循环将进入 I/O 队列,该队列有一个回调。此回调将弹出队列并执行,从而在控制台上生成最终日志消息。
由实验6推断
微任务队列中的回调先于 I/O 队列中的回调执行。
对于我们的下一个实验,让我们将 微队列 与 Timer 队列交换。
实验7
const fs = require("fs");
setTimeout(() => console.log("this is setTimeout 1"), 0);
fs.readFile(__filename, () => {
console.log("this is readFile 1");
});
由于 setTimeout()
属于 Timer队列管理,且setTimeout()
的延迟设置为0ms
,此次执行将涉及Timer队列而不是微任务队列。即对比的两个项目为Timer队列和I/O队列。
乍一看,预期的输出似乎很简单: setTimeout()
回调在 readFile()
回调之前执行。然而,事情并没有那么简单。这是运行同一段代码五次的输出。
这种输出不一致的原因是,在使用 setTimeout()
设置延迟为 0 毫秒和 I/O 异步方法时,执行顺序的不可预测性。显而易见的问题是,“为什么无法保证执行顺序?”
该异常是由于计时器的最小延迟设置方式造成的。在 DOMTimer 的 C++ 实现中,我们遇到了一段非常有趣的代码。计算间隔以毫秒为单位,但上限为 1 毫秒 或 用户传递的间隔乘以 1 毫秒。
std::max(oneMillisecond, interval * oneMillisecond)
这说明,如果我们传入 0
毫秒,则间隔为 max(1,0)
,即设置为 1
。这将导致 setTimeout
有 1
毫秒的延迟。 Node.js 似乎遵循类似的实现。当您设置 0
毫秒延迟时,它将被覆盖为 1
毫秒延迟。
但是 1ms 的延迟如何影响两个日志语句的顺序呢?
在事件循环开始时,Node.js 需要确定 1ms
的时间是否已达到。如果事件循环在 0.05ms
的时候进入Timer队列,而 1ms
的回调尚未添加到队列内,则控制权将移至 I/O 队列,执行 readFile()
回调。在事件循环的下一次迭代中,再执行Timer队列回调。
另一方面,如果 CPU 比较"繁忙"并在 1.01 ms
时进入Timer队列,则Timer队列将回调弹出并执行。然后控制将继续到 I/O 队列,并执行 readFile()
回调。
setTimeout()
回调还没有在"房间"内,所以认为Timer队列为空。就前往下一个房间(I/O队列)查看了。个人疑问: 按理说,一旦进入事件循环后,说明主线程已经为空闲状态,上一轮同步代码已经执行结束。那么为什么将一个setTimeout()
的回调添加到队列内需要这么"久"?
由于 CPU 繁忙程度的不确定性以及 0ms 延迟被覆盖为 1ms 延迟,我们永远无法保证 0ms 定时器和 I/O 回调之间的执行顺序。
P.S. 其实哪怕设置为1ms
,也无法保证setTimeout
和readFile()
的执行顺序,关键在于,在此轮事件循环中,Timer队列内的回调是否早一步被添加和执行。
由实验7推断
当以 0ms 延迟和 I/O 异步方法运行 setTimeout() 时,永远无法保证执行顺序。
实验8
const fs = require("fs");
fs.readFile(__filename, () => {
console.log("this is readFile 1");
});
process.nextTick(() => console.log("this is process.nextTick 1"));
Promise.resolve().then(() => console.log("this is Promise.resolve 1"));
setTimeout(() => console.log("this is setTimeout 1"), 0);
for (let i = 0; i < 2000000000; i++) {}
该代码包含多个调用,这些调用将回调函数放入不同的队列中。
readFile()
调用将回调函数放入 I/O 队列中process.nextTick()
调用将其放入 nextTick 队列中Promise.resolve().then()
调用将其放入队列中在 Promise队列中setTimeout()
调用将其放入 Timer队列中。
为了避免之前实验中出现的任何计时器问题,我们添加了一个不执行任何操作的 for 循环。这确保了当控制进入计时器队列时, setTimeout()
计时器已经过去,并且回调已准备好执行。
可视化:
为了直观地了解执行顺序,让我们分解一下代码中发生的情况。
当调用堆栈执行所有语句时,我们最终会在 nextTick 队列中得到一个回调,在 Promise 队列中得到一个回调,在Timer队列中得到一个回调,在 I/O 队列中得到一个回调。
主线程空闲,控制进入事件循环。
nextTick队列中的第一个回调将出队并执行,并将消息log到控制台。
现在 nextTick 队列为空,事件循环将进入 Promise 队列。回调弹出队列并在调用堆栈上执行,在控制台中打印一条消息。
此时 Promise队列 为空,事件循环进入 Timer队列。回调函数出队并执行。
最后,事件循环进入 I/O 队列,其中有一个回调被弹出队列并执行,从而在控制台中生成最终的日志消息。
由实验8推断
I/O 队列回调在 微任务队列回调 和 Timer 队列回调 之后执行。
结论
实验表明,Input/Output 队列 中的回调是在 微任务队列中的回调和 Timer队列中的回调之后执行的。但当setTimeout()
设置为0ms
的延迟来运行,且与 I/O 异步方法一同执行时,两者执行顺序取决于 CPU 的繁忙程度。
参考:
What is the reason JavaScript setTimeout is so inaccurate?
此系列文章: