【五】Node.js事件循环中的I/O轮询


欢迎来到我们关于可视化Node.js事件循环的系列文章的第五篇。在上一篇文章中,我们探讨了I/O队列及其在执行异步代码时的优先级顺序。在本文中,我们将继续专注于I/O队列,并逐渐引入检查队列。有一个重要的观点需要注意,我将在我们的下一个实验中解释。

回调函数队列

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

前八个实验涉及微任务、计时器和I/O队列,并已在之前的文章中介绍过。所有实验都是使用CommonJS模块格式运行的。

实验 9
1
2
3
4
5
6
7
8
9
10
11
12
13
// 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);
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()的调用,将回调函数排入计时器队列。

在这个实验中引入的setImmediate()调用将回调函数排入检查队列。为了避免实验7中的计时器问题,一个长时间运行的for循环确保当控制进入计时器队列时,setTimeout()计时器已经过去,回调准备好被执行。

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

这可能看起来有些奇怪,因为I/O队列出现在检查队列之前,但一旦我们理解了在这两个队列之间发生的I/O轮询的概念,就会变得合理。为了帮助说明这个概念,让我提供一个可视化。
首先,所有函数都在调用栈上执行,导致回调函数排队到相应的队列中。然而,readFile()的回调函数并没有同时排队。让我解释一下为什么。

当控制进入事件循环时,首先检查微任务队列中的回调函数。在这种情况下,nextTick队列和promise队列中各有一个回调函数。nextTick队列具有优先级,因此我们首先看到"nextTick 1"被记录,然后是"Promise 1"

两个队列都是空的,控制权移动到计时器队列。有一个回调函数,将"setTimeout 1"记录到控制台。

现在来看有趣的部分。当控制进入I/O队列时,我们期望readFile()的回调函数会出现,对吗?毕竟,我们有一个长时间运行的for循环,而且readFile()应该已经完成了。

然而,在现实中,事件循环必须进行轮询以检查I/O操作是否已完成,它只会将已完成操作的回调排队。这意味着当控制第一次进入I/O队列时,队列仍然为空。

然后,控制权移动到事件循环的轮询部分,在那里它询问readFile()任务是否已完成。readFile()确认已完成,并且事件循环现在将关联的回调函数添加到I/O队列中。然而,执行已经超过了I/O队列,回调函数必须等待轮到它被执行。

然后,控制进入检查队列,在那里找到一个回调函数。它将"setImmediate 1"记录到控制台,然后开始一个新的迭代,因为在事件循环的当前迭代中没有其他要处理的内容了。

看起来微任务队列和计时器队列都是空的,但是I/O队列中有一个回调函数。执行了这个回调函数,最终将"readFile 1"记录到控制台。

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

只有在I/O完成后,I/O事件才会被轮询,并且回调函数会被添加到I/O队列中。

结论

一旦I/O操作完成,其回调函数不会立即排队到I/O队列中。相反,I/O轮询阶段检查I/O操作的完成情况,并将已完成操作的回调函数排队。这有时会导致在I/O队列回调之前执行检查队列回调。

然而,当两个队列都包含回调函数时,I/O队列中的回调函数始终具有优先级并且会先运行。当设计依赖于I/O回调的系统时,理解这种行为对于确保回调的正确顺序和执行至关重要。