【四】Node.js事件循环中的I/O队列


欢迎来到我们系列文章的第四篇,关于可视化Node.js事件循环。在上一篇文章中,我们探讨了定时器队列及其在执行异步代码时的优先级顺序。在本文中,我们将深入研究I/O队列,这是事件循环中起着关键作用的另一个队列。

在我们深入研究I/O出队列之前,让我们快速回顾一下微任务队列和定时器队列。要将回调函数添加到微任务队列中,我们使用诸如 process.nextTick()Promise.resolve() 这样的函数。

当涉及到在 Node.js 中执行异步代码时,微任务队列具有最高的优先级。要将回调函数添加到定时器队列中,我们使用诸如 setTimeout()setInterval() 这样的函数。

回调函数队列

要将回调函数添加到输入/输出队列中,我们可以使用内置的 Node.js 模块中的大多数异步方法。对于我们的实验,我们将使用 fs 模块中的 readFile() 方法。

前五个实验涉及微任务队列和定时器队列,并已在前两篇文章中介绍过。所有实验都使用 CommonJS 模块格式运行。

实验 6
1
2
3
4
5
6
7
8
9
// index.js
const fs = require("fs");

fs.readFile(__filename, () => {
console.log("这是 readFile 1");
});

process.nextTick(() => console.log("这是 process.nextTick 1"));
Promise.resolve().then(() => console.log("这是 Promise.resolve 1"));

首先,我们导入了fs模块并调用了其 readFile() 方法。这将向I/O队列中添加一个回调函数。在 readFile() 之后,我们向 nextTick 队列和 Promise 队列中分别添加了一个回调函数。

在调用堆栈中执行所有语句后,nextTick 队列、promise 队列和I/O队列中分别有一个回调函数。由于没有进一步的代码需要执行,控制权进入事件循环。

nextTick 队列具有最高的优先级,其次是 promise 队列,然后是I/O队列。nextTick 队列中的第一个回调函数被出列并在调用堆栈上执行,将一条消息记录到控制台中。

随着 nextTick 队列为空,事件循环继续到 promise 队列。回调函数被出列并在调用堆栈上执行,将一条消息打印到控制台中。

由于 promise 队列现在为空,事件循环转到定时器队列。由于定时器队列中没有回调函数,事件循环继续转到I/O队列,其中有一个回调函数。这个回调函数被出列并执行,导致控制台上的最终日志消息。

微任务队列中的回调函数在输入/输出队列中的回调函数之前执行。

对于我们的下一个实验,让我们将微任务队列与定时器队列进行交换。

实验 7
1
2
3
4
5
6
7
8
// index.js
const fs = require("fs");

setTimeout(() => console.log("这是 setTimeout 1"), 0);

fs.readFile(__filename, () => {
console.log("这是 readFile 1");
});

该代码涉及使用 setTimeout() 将定时器队列排队,延迟为 0 秒,而不是排队微任务队列。

乍一看,预期的输出似乎很简单:setTimeout() 回调函数在 readFile() 回调函数之前执行。然而,情况并非如此简单。以下是运行相同代码五次后的输出。

控制台日志显示每次代码运行时 setTimeoutreadFile的顺序不同。
这种输出的不一致性是由于使用延迟为 0 毫秒的setTimeout()I/O 异步方法时执行顺序的不可预测性。产生的明显问题是,“为什么无法保证执行顺序?”

这种异常情况是由定时器设置最小延迟导致的。在 DOMTimerC++ 代码中,我们遇到了非常有趣的一段代码。计算了毫秒间隔,但计算被限制在最大 1 毫秒或用户传递的间隔乘以 1 毫秒。

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

但是,1 毫秒延迟会如何影响两个日志语句的执行顺序呢?

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

另一方面,如果 CPU 忙于在 1.01 毫秒进入定时器队列,则定时器将已经过去,并且将执行回调函数。控制然后继续到 I/O 队列,并执行readFile()回调。

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

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

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

实验 8
1
2
3
4
5
6
7
8
9
10
11
12
// index.js
const fs = require("fs");

fs.readFile(__filename, () => {
console.log("这是 readFile 1");
});

process.nextTick(() => console.log("这是 process.nextTick 1"));
Promise.resolve().then(() => console.log("这是 Promise.resolve 1"));
setTimeout(() => console.log("这是 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 队列,在那里我们有一个回调函数被出列并执行,导致控制台中的最终日志消息。

I/O 队列中的回调函数在微任务队列中的回调函数和定时器队列中的回调函数之后执行。

结论

实验表明,在微任务队列和定时器队列中的回调函数之后,输入/输出队列中的回调函数被执行。当使用 0 毫秒延迟和 I/O 异步方法运行 setTimeout() 时,执行顺序取决于 CPU 的忙碌程度。