理解Node.js中Event Loop(五)

理解Node.js中Event Loop(五)

本文翻译至:Visualizing the I/O Queue in the Node.js Event Loop

欢迎阅读我们关于可视化 Node.js 事件循环系列的第五篇文章。在上一篇文章中,我们探讨了执行异步代码时的 I/O 队列及其优先级顺序。

在本文中,我们将继续关注 I/O 队列,同时逐步介绍Check(检查)队列。有一点需要注意,我将在下一个实验中解释。

目录

添加回调函数到队列

在我们继续实验之前,我想提一下,为了将回调函数插入Check队列(以下称-检查队列),我们​​使用内置的 setImmediate() 函数。语法很简单: setImmediate(callbackFn) 。当该函数在调用堆栈上执行时,回调函数将被插入到检查队列中。

实验9

代码:

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);
setImmediate(() => console.log("this is setImmediate 1"));

for (let i = 0; i < 2000000000; i++) {}

该代码片段延续了之前的实验。它包括了:

  • readFile() 的调用(将回调函数放入 I/O 队列中

  • process.nextTick() 的调用(将回调函数放入 nextTick 的调用),对 Promise.resolve().then() 的调用(将回调函数放入 Promise 队列中)

  • setTimeout() 的调用,将回调函数放入Timer队列中。

本实验中引入的 setImmediate() 调用将回调函数放入检查队列中。为了避免实验7 中的Timer问题,长时间运行的 for 循环可确保当控制进入计时器队列时setTimeout() 计时器已超时并且回调已准备好执行。

如果运行代码片段,您可能会注意到输出不是您所期望的。来自 setImmediate() 的回调消息在来自 readFile() 的回调消息之前打印。这是供参考的输出:

这可能看起来很奇怪,因为 I/O队列出现在检查队列之前,但是一旦我们理解了两个队列之间发生的 I/O 轮询的概念,这就不难理解了。

为了帮助说明这个概念,让我提供一个可视化模型。

首先,所有函数都在调用堆栈上执行,使回调函数在对应的队列中排队。但是,readFile() 回调不会同时和其他类型的回调开始排队。让我解释一下原因。

  1. 当进入事件循环时,首先都是查看微任务队列是否有回调。在这种情况下,nextTick 队列和 Promise 队列各有一个回调。 nextTick 队列具有优先级,因此我们看到首先记录的是“nextTick 1”,然后是“Promise 1”。

  2. 微任务队列内回调执行完毕,两个队列都是空的,控制权转移到Timer队列。有一个回调将“setTimeout 1”打印到控制台。


现在有趣的部分来了。当控制到 I/O 队列时,我们期望 readFile() 回调先执行,对吧?毕竟,我们有一个长时间运行的 for 循环校正了Timer队列,而且 readFile() 应该也执行完成了​​。

然而,实际上,事件循环必须轮询来检查 I/O 操作是否完成,并且它只对已完成的I/O操作添加回调函数到I/O队列。这说明着当事件循环第一次进入I/O队列时,队列仍然是空的。


  1. 控制流到了事件循环的I/O轮询部分,在那里它检查 readFile() 是否已经完成。readFile() 确认已完成,并且事件循环现在将相关的回调函数添加到 I/O 队列。但! 执行已经越过了 I/O 队列,回调必须等待再次执行的机会。

    翻译不太精确,可以看模型图,可以看到I/O轮训是发生在事件循环进入I/O队列之后的,也就是说,每一次I/O轮询到的新的I/O操作对应的回调被添加到I/O队列后,都只能再下一次事件循环中执行了。
  2. 然后,控制流进入到检查队列,在那里找到一个setImmediate的回调。它将“setImmediate 1”打印到控制台。然后再开始新的周期,因为在事件循环的当前周期中没有其他需要处理的内容。

  3. 下一周期中,微任务队列Timer队列都是空的,但是I/O队列中有回调。回调被执行,“readFile 1”最终被打印到控制台。

这就是为什么我们看到“setImmediate 1”记录在“readFile 1”之前。这种行为实际上也发生在我们之前的实验中,但我们没有任何进一步的代码可以运行,所以我们没有观察到它。

由实验9推断

仅当 I/O 操作完成后,才会轮询 I/O 事件并将回调函数添加到 I/O 队列中


结论

当 I/O 操作完成,回调函数不会立即添加到 I/O 队列中。

相反,I/O 轮询阶段(即可视化模型中的"I/O Polling")会检查 I/O 操作是否完成,并对已完成I/O操作的回调进行排队。这有时会导致 检查队列回调 在 I/O队列回调 之前执行

但是,当两个队列都包含回调函数时,I/O 队列中的回调始终优先并首先运行。在设计依赖 I/O 回调的系统时,理解这种行为至关重要,以确保回调的正确排序和执行。

猜测: 是否完成I/O操作是C++提供给Node的接口,从实现层面上看,一个个I/O操作与回调函数添加到I/O队列的设计,比起使用一个轮询的效率。无疑是后者高效得多。


此系列文章:

理解Node.js中Event Loop(一)

理解Node.js中Event Loop(二)

理解Node.js中Event Loop(三)

理解Node.js中Event Loop(四)

理解Node.js中Event Loop(五)

理解Node.js中Event Loop(六)

理解Node.js中Event Loop(七)