视觉指南:理解 Node.js 事件循环中的 I/O 队列
原文链接:Visualizing The Timer Queue in Node.js Event Loop,2023年4月6日,by Vishwas Gopinath
欢迎来到“可视化 Node.js 事件循环”的系列文章的第四篇。在 上一篇文章 中,我们探讨了计时器队列及其执行异步代码时的优先级顺序。在本文中,我们将深入研究 I/O 队列,这是另一个在事件循环中发挥重要作用的队列。
在深入研究 I/O 队列之前,让我们快速回顾一下微任务和计时器队列。为了将回调函数添加到微任务队列中,我们使用诸如 process.nextTick()
和 Promise.resolve()
等函数。
当涉及到执行 Node.js 异步代码时,微任务队列具有最高优先级。为了将回调函数添加到计时器队列中,我们使用 setTimeout()
和 setInterval()
等函数。
入队回调函数
要将回调函数添加到 I/O 队列中,我们可以使用内置的 Node.js 模块中的大多数异步方法。本次实验,我们将使用 fs
模块中的 readFile()
方法。
注意:前五个实验涉及微任务队列和计时器队列,并已在前两篇文章中介绍过。我们的所有实验都使用 CommonJS 模块格式运行。
实验六
代码
// index.js
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"));
首先,我们导入 fs
模块并调用其 readFile()
方法。这将向 I/O 队列添加一个回调函数。在 readFile()
之后,我们向 nextTick 队列 和 Promise 队列中添加了回调函数。
可视化
在执行调用堆栈中的所有语句后,nextTick 队列、Promise 队列和 I/O 队列中各有一个回调。由于没有代码要执行,控制权进入事件循环。
nextTick 队列具有最高优先级,其次是 Promise 队列和 I/O 队列。nextTick 队列中的第一个回调被出队并执行,在控制台上记录一条消息。
nextTick 队列为空时,事件循环转入到 Promise 队列,回调在调用栈上出现并被执行,在控制台上打印一条消息。
由于 Promise 队列现在为空,事件循环转入到计时器队列 。由于计时器队列中没有回调函数 ,因此事件循环转向 I/O 队 列 ,有 1 个回调函数 。这个回调函数被出队并执行 ,最后在控制台上输出日志消息。
this is process.nextTick 1
this is Promise.resolve 1
this is readFile 1
推论
微任务队列中的回调函数会在 I/O 队列中的回调函数之前执行。
我们的下一个实验,将微任务队列变为计时器队列。
实验七
代码
// index.js
const fs = require("fs");
setTimeout(() => console.log("this is setTimeout 1"), 0);
fs.readFile(__filename, () => {
console.log("this is readFile 1");
});
这段代码使用 setTimeout()
函数将计时器队列排队(延迟为 0s),而不是排入到微任务队列。
可视化
乍一看,预期的输出似乎很简单:setTimeout()
回调在 readFile()
回调之前执行。然而,事情并不那么简单。以下是运行五次相同代码后的输出结果。
由于使用 setTimeout()
和 I/O 异步方法时执行顺序的不可预测性,导致输出中出现不一致。显而易见的问题是,“为什么无法保证执行顺序?”
这种异常情况是由计时器设置最小延迟方式引起的。在 DOMTimer 的 C++ 代码中,我们遇到了非常有趣的代码片段。以毫秒为单位计算间隔,但计算被限制在 1ms 或用户传递间隔(user-passed interval)乘以 1ms 的最大值。
这意味着如果我们传入 0ms,则将设置间隔为 max(1,0),即 1。这将导致 setTimeout
延迟 1ms。似乎 Node.js 遵循类似实现方式。当你设置 0ms 延迟时,会被覆盖为 1ms 延迟。
但是,一个 1ms 的延迟如何影响两个日志语句的顺序?
在事件循环开始时,Node.js 需要确定 1 毫秒计时器是否已经执行(elapsed)。如果事件循环在 0.05 毫秒进入计时器队列并且尚未入队 1 毫秒回调,则控制权移动到 I/O 队列,执行 readFile()
回调。在下一次事件循环迭代中,将执行计时器队列回调。
另一方面,如果 CPU 正在忙碌并在 1.01 毫秒时进入计时器队列,则计时器已经执行并且回调函数将被执行。然后控制权将转移到 I/O 队列,并执行 readFile()
回调。
由于 CPU 的繁忙程度不确定以及 0ms 延迟被覆盖为 1ms 延迟,我们永远无法保证 0ms 定时器和 I/O 回调之间的执行顺序。
推论
当运行 0ms 延迟的
setTimeout()
方法和 I/O 异步方法时,执行顺序无法保证。
接下来,让我们回顾一下微任务队列、定时器队列和 I/O 队列中回调函数的执行顺序。
译注:我所在的 Windows 11 系统,node v18.12.1 环境,经过实验,输出始终是先
setTimeout
后readFile
,特此记录。不确实是系统兼容问题,还是 node 版本问题。
实验八
代码
// index.js
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()
调用会在计时器队列中排队。
为了避免前一个实验中的计时器问题,我们添加了一个什么也不做的 for
循环,确保当控制进入计时器队列时,setTimeout()
计时器已经执行,回调已经在队列之中。
可视化
为了可视化执行顺序,让我们分解一下代码中正在发生的事情。当调用堆栈执行所有语句时,我们最终会在 nextTick 队列、Promise 队列、计时器队列和 I/O 队列中各有一个回调。
没有代码要执行了,控制权进入事件循环。从 nextTick 队列中取出第一个回调并执行,在控制台上记录一条消息。现在 nextTick 队列为空,事件循环转移到 Promise 队列,回调被取出并在调用栈上执行,在控制台打印一条消息。
此时,Promise 队列已经为空,并且事件循环转移到计时器队列。回调函数被取出并执行。最后, 事件循环转移到 I/O 队列,我们有一个回调被取出并执行,在控制台中输出最终的日志消息。
this is process.nextTick 1
this is Promise.resolve 1
this is setTimeout 1
this is readFile 1
推论
I/O 队列回调在微任务队列回调和计时器队列回调之后执行。
总结
实验表明,I/O 队列中的回调在微任务队列和计时器队列中的回调之后执行。当运行 0ms 延迟的 setTimeout()
方法和 I/O 异步方法时,执行顺序取决于 CPU 繁忙程度。
继续阅读
- 第一部分:可视化 Node.js 事件循环
- 第二部分:可视化 Node.js 中的微任务队列
- 第三部分:可视化 Node.js 事件循环中的计时器队列
- 第四部分:可视化 Node.js 事件循环中的 I/O 队列(本篇)
- 第五部分:可视化 Node.js 事件循环中的 I/O 轮询
- 第六部分:可视化 Node.js 事件循环中的检查队列
- 第七部分:可视化 Node.js 事件循环中的关闭队列
转载自:https://juejin.cn/post/7224334902325854266