您看到的行为是因为事件循环中有多种类型的队列,并且系统根据它们的类型按顺序运行事件。它不仅仅是一个巨大的事件队列,其中所有内容都根据添加到事件队列的时间以 FIFO 顺序运行。相反,它喜欢运行一种类型的所有事件(达到限制),前进到下一种类型,运行所有这些等等。
而且,I/O 事件仅在循环中的一个特定时间点添加到其队列中,因此它们被强制按特定顺序排列。这就是 setImmediate() 回调在 readFile() 回调之前执行的原因,即使在 while 循环完成时两者都准备就绪。
然后我认为事件循环将移动到计时器(无)、I/O 回调(fs.readFile 的回调)、空闲/准备(无)、轮询(无)、检查(setImmediate 的回调)最后关闭回调( none) 按顺序执行,但结果是 setImmediate() 仍然先运行。
问题在于事件循环的 I/O 回调阶段会运行已在事件队列中的 I/O 回调,但它们在完成后不会自动放入事件队列中。相反,它们仅在流程后期的I/O poll 步骤中被放入事件队列(见下图)。因此,第一次通过 I/O 回调阶段时,还没有要运行的 I/O 回调,因此您不会得到 readfile 的输出,而您认为应该会。
但是,setImmediate() 回调在第一次通过事件循环时已准备就绪,因此它可以在 readFile() 回调之前运行。
这种延迟添加 I/O 回调可能是您对 readFile() 回调最后发生而不是 setImmediate() 回调之前发生感到惊讶的解释。
while 循环结束时会发生以下情况:
- 当 while 循环完成时,它会从计时器回调开始,并看到计时器已准备好运行,因此它会运行它。
- 然后,它会运行任何已经存在但还没有的 I/O 回调。来自
readFile() 的 I/O 回调尚未收集。它将在此周期的后期收集。
- 然后,它会经过几个其他阶段并进行 I/O 轮询。有收集
readFile() 回调事件并将其放入 I/O 队列(但尚未运行)。
- 然后,它进入 checkHandlers 阶段,在该阶段运行
setImmediate() 回调。
- 然后,它再次启动事件循环。没有计时器,所以它进入 I/O 回调,最后找到并运行
readFile() 回调。
因此,让我们为那些不熟悉事件循环过程的人更详细地记录一下代码中实际发生的情况。当您运行此代码时(将时间添加到输出中):
const fs = require('fs')
let begin = 0;
function log(msg) {
if (!begin) {
begin = Date.now();
}
let t = ((Date.now() - begin) / 1000).toFixed(3);
console.log("" + t + ": " + msg);
}
log('start program');
setTimeout(() => log('timer'), 10);
setImmediate(() => log('immediate'));
fs.readFile(__filename, () => log('readfile'));
const now = Date.now();
log('start loop');
while(Date.now() - now < 1000) {}
log('done loop');
你得到这个输出:
0.000: start program
0.004: start loop
1.004: done loop
1.005: timer
1.006: immediate
1.008: readfile
我已经添加了相对于程序启动时间的秒数,这样您就可以看到执行的时间。
会发生什么:
- 定时器已启动并设置为从现在开始 10 毫秒,其他代码继续运行
-
fs.readFile()操作开始,其他代码继续运行
-
setImmediate()已注册到事件系统中,其事件在相应的事件队列中,其他代码继续运行
-
while 循环开始循环
- 在
while 循环期间,fs.readFile() 完成其工作(在后台运行)。它的事件已准备就绪,但尚未在相应的事件队列中(稍后会详细介绍)
-
while 循环在 1 秒后循环结束,这个初始的 Javascript 序列完成并返回到系统
- 解释器现在需要从事件循环中获取“下一个”事件。但是,所有类型的事件都没有得到平等对待。事件系统有一个特定的顺序,它处理队列中不同类型的事件。在我们的例子中,这里首先处理定时器事件(我将在下文中解释这一点)。系统检查是否有任何计时器“过期”并准备好调用它们的回调。在这种情况下,它会发现我们的计时器已“过期”并准备就绪。
- 定时器回调被调用,我们看到控制台消息
timer。
- 没有更多的计时器,因此事件循环进入下一个阶段。事件循环的下一阶段是运行任何挂起的 I/O 回调。但是,事件队列中还没有挂起的 I/O 回调。尽管
readFile() 现在已经完成,但它还没有在队列中(解释来了)。
- 然后,下一步是收集所有已完成的 I/O 事件并让它们准备好运行。在这里,
readFile() 事件将被收集(虽然尚未运行)并放入 I/O 事件队列。
- 然后下一步是运行任何待处理的
setImmediate() 处理程序。当它这样做时,我们会得到输出immediate。
- 然后,事件过程的下一步是运行任何关闭处理程序(这里没有要运行的)。
- 然后,事件循环通过检查计时器重新开始。没有待运行的计时器。
- 然后,事件循环运行所有挂起的 I/O 回调。这里
readFile() 回调运行,我们在控制台中看到readfile。
- 程序没有更多的事件等待,所以它执行。
事件循环本身是一系列用于不同类型事件的队列,并且(除了一些例外),每个队列在移动到下一个类型的队列之前都会被处理。这会导致事件分组(一组中的计时器,另一组中的待处理 I/O 回调,另一组中的 setImmediate() 等等)。它不是所有类型中的严格 FIFO 队列。事件在组内是先进先出的。但是,所有挂起的计时器回调(有一定的限制,以防止一种类型的事件无限期地占用事件循环)在其他类型的回调之前处理。
你可以在这张图中看到基本结构:
来自this very excellent article。如果您真的想了解所有这些内容,请多读几遍这篇参考文章。
最初让我感到惊讶的是为什么readFile 总是出现在最后。这是因为即使readFile() 操作完成,它也不会立即放入队列中。相反,在事件循环中有一个步骤,其中收集已完成的 I/O 事件(将在事件循环的下一个循环中处理)并在 I/O 事件之前的当前循环结束时处理 setImmediate() 事件刚刚收集的。这使得 readFile() 回调在 setImmediate() 回调之后进行,即使它们都准备好在 while 循环期间进行。
此外,执行readFile() 和setImmediate() 的顺序并不重要。因为它们都准备好在while 循环完成之前开始执行,所以它们的执行顺序由事件循环的排序决定,因为它运行不同类型的事件,而不是它们的确切时间结束。
在第二个代码块中,删除 readFile() 并将 setImmediate() 放在 setTimeout() 之前。使用我的定时版本,将是这样的:
const fs = require('fs')
let begin = 0;
function log(msg) {
if (!begin) {
begin = Date.now();
}
let t = ((Date.now() - begin) / 1000).toFixed(3);
console.log("" + t + ": " + msg);
}
log('start program');
setImmediate(() => log('immediate'));
setTimeout(() => log('timer'), 10);
const now = Date.now();
log('start loop');
while(Date.now() - now < 1000) {}
log('done loop');
而且,它会生成以下输出:
0.000: start program
0.003: start loop
1.003: done loop
1.005: timer
1.008: immediate
解释类似(这次稍微缩短了一点,因为前面已经解释了很多细节)。
-
setImmediate() 已注册到相应的队列中。
-
setTimeout() 已注册到定时器队列中。
- while 循环运行 1000 毫秒
- 代码完成执行并将控制权返回给系统
- 系统从以计时器事件开始的事件逻辑的顶部开始。我们之前启动的计时器现在已完成,因此它运行其回调并记录
timer。
- 由于没有更多的计时器,事件循环会运行其他几种类型的事件队列,直到到达运行
setImmediate() 处理程序的位置并记录immediate。
如果,您有多个项目计划在 I/O 回调中启动,例如:
// timeout_vs_immediate.js
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
然后,您的行为会略有不同,因为 setTimeout() 和 setImmediate() 将在事件循环处于其循环的不同部分时安排。在此特定示例中,setImmediate() 将始终在计时器之前执行,因此输出将是:
immediate
timeout
在上面的流程图中,您可以看到“运行已完成的 I/O 处理程序”步骤所在的位置。因为setTimeout() 和setImmediate() 调用将在I/O 处理程序中进行调度,所以它们将在事件循环的“运行已完成的I/O 处理程序”阶段进行调度。按照事件循环的流程,setImmediate() 将在事件循环返回到服务计时器之前在“检查处理程序”阶段得到服务。
如果setImmediate() 和setTimeout() 被安排在事件循环中的不同点,那么计时器可能会在setImmediate() 之前触发,这就是前面示例中发生的情况。因此,两者的相对时间取决于调用函数时事件循环所处的阶段。