原文链接:Visualizing the Check Queue in the Node.js Event Loop,2023年4月14日,by Vishwas Gopinath
欢迎来到我们关于“可视化 Node.js 事件循环”的系列文章的第六篇。在 上一篇文章 中,我们探讨了 I/O 轮询阶段,并简要了解了检查队列以及如何使用内置的 setImmediate()
函数将函数入队。
在本文中,我们将运行更多实验来进一步理解检查队列。
注意:前九个实验涵盖了微任务、计时器、I/O 和检查队列,并在先前的文章中进行了讨论。所有实验都使用 CommonJS 模块格式运行。
实验十
代码
const fs = require("fs");
fs.readFile(__filename, () => {
console.log("this is readFile 1");
setImmediate(() => console.log("this is setImmediate 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()
的调用,会将回调函数排队到计时器队列中。
与前一个实验不同之处在于执行 setImmediate()
语句。现在它是在 readFile()
回调内部执行而不是最后执行。这是为了确保只有在 I/O 轮询完成后才会将 setImmediate()
回调排入队列。
可视化
执行调用栈上的所有语句后,nextTick 队列中有一个回调,Promise 队列中有另一个回调,计时器队列中有一个回调。由于 I/O 轮询还没完成,因此 I/O 队列中还没有回调。
当代码执行完成后,控制权进入事件循环。从 nextTick 队列开始的第一个回调出列并执行,向控制台记录一条消息。随着 nextTick 队列为空,事件循环转移到 Promise 队列,取出回调并在调用栈上执行,在控制台记录一条消息。
此时,Promise 队列为空,事件循环继续进行计时器队列。计时器队列中的回调函数出列并执行,控制台输出第三个日志信息。
现在,事件循环进入 I/O 队列阶段,但是这个队列目前没有任何回调。然后进入了 I/O 轮询阶段,在这个阶段,readFile()
完成操作将回调函数推入到 I/O 队列。
然后,事件循环继续进行检查队列和关闭队列,这两者都是空的。循环进行第二次迭代,它检查 nextTick 队列、 Promise 队列、 计时器队列,最后到达 I/O 队列。
在这里,它遇到了一个新的回调函数,该函数被执行,导致控制台输出第四个消息。此回调还包括对 setImmediate()
的调用,将另一个回调函数排队放入检查队列中。
最后, 事件循环进入检查队列 , 出列回调并执行它,最后一条消息被打印在控制台上。希望这个可视化能够让你理解。
this is process.nextTick 1
this is Promise.resolve 1
this is setTimeout 1
this is readFile 1
this is setImmediate 1
推导
在执行检查队列回调之前,会先执行微任务队列回调、计时器队列回调和 I/O 队列回调。
让我们继续进行下一个实验,以更好地了解微任务队列和检查队列的优先级顺序。
实验十一
代码
// index.js
const fs = require("fs");
fs.readFile(__filename, () => {
console.log("this is readFile 1");
setImmediate(() => console.log("this is setImmediate 1"));
process.nextTick(() =>
console.log("this is inner process.nextTick inside readFile")
);
Promise.resolve().then(() =>
console.log("this is inner Promise.resolve inside readFile")
);
});
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++) {}
可视化
在调用栈中的所有语句执行完毕后,nextTick、Promise 和计时器队列中会有一个回调。由于 I/O 轮询还没完成,因此 I/O 队列还是空的。当没有更多代码可以执行时,事件循环开始。
事件循环按照以下顺序出队并执行回调:nextTick 队列、Promise 队列、计时器队列、I/O 队列、检查队列和关闭队列。因此,要执行的第一个回调在 nextTick 队列中。
一旦它被执行,事件循环就会继续到下一个队列——即 Promise 队列。接着会执行 Promise 队列的回调函数。
在其完成之后,事件循环进入计时器队列,在那里 setTimeout()
的回调函数出队并执行。
然后事件循环进入仍为空的 I/O 队列,并进入 I/O 轮询阶段。已经完成的 readFile()
操作的回调函数被推送到 I/O 队列中。
接着事件循环进入检查队列(check)和关闭(close)队列,并且这两个队列都是空的。然后事件循环进行了第二次迭代。在检查 I/O 队列之前先检查 nextTick、Promise 和计时器队列,它们都是空的。
然后事件循环再次进入 I/O 队列,在那里遇到新的回调函数。第四个消息被记录在控制台中。
回调函数包含对 process.nextTick()
、Promise.resolve().then()
和 setImmediate()
的调用,导致新的回调函数排队在 nextTick、Promise 和 检查队列中。
事实证明,在进入检查队列之前,事件循环会检查微任务队列。它发现了一个 nextTick 队列中的回调,并执行它,并将相应的消息记录到控制台。然后它检查 Promise 队列,执行回调并将相应的消息记录到控制台。
最后,事件循环进入检查队列,出队回调函数并执行它,最后一条消息被打印到控制台上。
this is process.nextTick 1
this is Promise.resolve 1
this is setTimeout 1
this is readFile 1
this is inner process.nextTick inside readFile
this is inner Promise.resolve inside readFile
this is setImmediate 1
推论
微任务队列的回调会在 I/O 队列的回调之后、检查队列的回调之前执行。
让我们继续使用微任务队列和检查队列作为下一个实验的主题。
实验十二
代码
//index.js
setImmediate(() => console.log("this is setImmediate 1"));
setImmediate(() => {
console.log("this is setImmediate 2");
process.nextTick(() => console.log("this is process.nextTick 1"));
Promise.resolve().then(() => console.log("this is Promise.resolve 1"));
});
setImmediate(() => console.log("this is setImmediate 3"));
该代码包含三个对 setImmediate()
函数的调用,每个调用都有相应的日志语句。然而,第二个setImmediate()
函数还包括对 process.nextTick()
和 Promise.resolve().then()
的调用。
可视化
在调用栈执行所有语句后,检查队列中有三个回调函数。
当没有更多的代码需要执行时,控制进入事件循环。由于没有回调函数,因此跳过初始队列,并将焦点放在检查队列上。
第一个回调被出队并执行,导致第一个日志语句。接下来,第二个回调也被出队并执行,导致第二个日志语句。但是,第二个回调还在 nextTick 和 Promise 队列中分别推入了一个回调。这些队列具有高优先级,并且会在检查队列回调之间被检查。
因此,在检查队列中的第二个回调被执行后,nextTick 队列回调出队并执行。然后是 Promise 队列回调出对并执行。
现在,当微任务为空时,控制返回到检查队列,并且取出和运行了第三个回调。打印出最终的消息到控制台中。
this is setImmediate 1
this is setImmediate 2
this is process.nextTick 1
this is Promise.resolve 1
this is setImmediate 3
推导
微任务队列回调在检查队列回调之间执行。
在本文的最后一个实验中,我们将重新审视计时器队列异常情况,并考虑检查队列。
实验十三
代码
// index.js
setTimeout(() => console.log("this is setTimeout 1"), 0);
setImmediate(() => console.log("this is setImmediate 1"));
可视化
由于 CPU 使用率的不确定性,我们无法保证 0ms 定时器和检查队列回调之间的执行顺序。有关更详细的说明,请参阅实验七。
译注:在我的机器上测试了一下确实是这样。不过 0ms 定时器的问题,不同于之前文章中和 I/O 队列进行的比较,我机器上的顺序始终是固定的(先 setTimeout 再 I/O 队列,原因不知为何)。
推导
当使用延迟为 0ms 的
setTimeout()
和setImmediate()
方法时,执行顺序无法保证。
总结
实验表明,在检查队列中的回调在微任务队列、计时器队列和 I/O 队列中的回调执行后被执行。在检查队列回调之间,会先执行微任务队列回调。当使用延迟为 0ms 的 setTimeout() 和 setImmediate() 方法时,执行顺序无法保证,要取决于当前 CPU 的工作负载。