likes
comments
collection
share

视觉指南:理解 Node.js 事件循环中的 I/O 队列

作者站长头像
站长
· 阅读数 38

原文链接:Visualizing The Timer Queue in Node.js Event Loop,2023年4月6日,by Vishwas Gopinath

视觉指南:理解 Node.js 事件循环中的 I/O 队列

欢迎来到“可视化 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 队列中添加了回调函数。

可视化

视觉指南:理解 Node.js 事件循环中的 I/O 队列

在执行调用堆栈中的所有语句后,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() 回调之前执行。然而,事情并不那么简单。以下是运行五次相同代码后的输出结果。

视觉指南:理解 Node.js 事件循环中的 I/O 队列

由于使用 setTimeout() 和 I/O 异步方法时执行顺序的不可预测性,导致输出中出现不一致。显而易见的问题是,“为什么无法保证执行顺序?”

这种异常情况是由计时器设置最小延迟方式引起的。在 DOMTimer 的 C++ 代码中,我们遇到了非常有趣的代码片段。以毫秒为单位计算间隔,但计算被限制在 1ms 或用户传递间隔(user-passed interval)乘以 1ms 的最大值

这意味着如果我们传入 0ms,则将设置间隔为 max(1,0),即 1。这将导致 setTimeout 延迟 1ms。似乎 Node.js 遵循类似实现方式。当你设置 0ms 延迟时,会被覆盖为 1ms 延迟。

但是,一个 1ms 的延迟如何影响两个日志语句的顺序?

视觉指南:理解 Node.js 事件循环中的 I/O 队列

在事件循环开始时,Node.js 需要确定 1 毫秒计时器是否已经执行(elapsed)。如果事件循环在 0.05 毫秒进入计时器队列并且尚未入队 1 毫秒回调,则控制权移动到 I/O 队列,执行 readFile() 回调。在下一次事件循环迭代中,将执行计时器队列回调。

视觉指南:理解 Node.js 事件循环中的 I/O 队列

另一方面,如果 CPU 正在忙碌并在 1.01 毫秒时进入计时器队列,则计时器已经执行并且回调函数将被执行。然后控制权将转移到 I/O 队列,并执行 readFile() 回调。

由于 CPU 的繁忙程度不确定以及 0ms 延迟被覆盖为 1ms 延迟,我们永远无法保证 0ms 定时器和 I/O 回调之间的执行顺序。

推论

当运行 0ms 延迟的 setTimeout() 方法和 I/O 异步方法时,执行顺序无法保证。

接下来,让我们回顾一下微任务队列、定时器队列和 I/O 队列中回调函数的执行顺序。

译注:我所在的 Windows 11 系统,node v18.12.1 环境,经过实验,输出始终是先 setTimeoutreadFile,特此记录。不确实是系统兼容问题,还是 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 繁忙程度。

继续阅读

转载自:https://juejin.cn/post/7224334902325854266
评论
请登录