什么是事件循环
事件循环,即 Event Loop,其实就是 JS 管理事件执行的一个流程,具体的管理方法由 JS 运行的环境决定,目前 JS 的主要运行环境有浏览器和 Node。
浏览器和 Node 的事件循环,都是先初始化一个循环,执行同步代码,遇到异步操作时,会将其交给对应的线程处理,主线程则继续往下执行,异步操作执行完毕后,对应的 callback 回调会被推入事件队列,并在合适的时机执行。每执行一次循环体的过程,我们称之为一个 Tick。
与浏览器不同的是,Node 的循环分为几个阶段,每个阶段分别处理不同的事件,而浏览器的循环不存在这样的阶段划分。下面我们介绍一下 Node 事件循环的几个阶段。
Node 事件循环的流程
事件循环的六个阶段
Node 的事件循环分为几个阶段,如下图(除了 incoming,每一个方框代表一个阶段):
我们先简单看看每个阶段的作用,也就是在一个 Tick 中,Node 是按照怎样的顺序工作的:
- timers:执行
setTimeout、setInterval的回调; - pending callbacks:上个 Tick 中延迟到这个 Tick 的回调,就会在这个阶段执行;
- idle,prepare:Node 内部使用;
- poll:执行大多数异步操作的回调;
- check:执行
setImmediate的回调; - close callbacks:执行 close 相关的回调,如
socket.on('close', ...)每个阶段都对应一个 FIFO 的队列,循环进入某个阶段后,只有在两种情况下会跳出该阶段进入下一个阶段,一是将其队列中的回调全部执行完,二是执行数达到了该阶段的上限。
poll 阶段
接下来我们重点看一下 poll 阶段,poll 阶段主要做两件事情:
- 计算轮询时间
maxPollTime; - 执行 poll 队列中的回调
以下为 poll 阶段的流程:
- 事件循环进入 poll 阶段后,会先检查 poll 队列是否为空;
- 若 poll 队列不为空,则遍历并同步执行队列里的回调(直到全部执行完或达到执行数的上限),若 poll 队列为空,则检查是否有待执行的
setImmediate回调; - 如果有,则进入 check 阶段,如果没有,则原地等待新的回调被推入 poll 队列,并立即执行这些新推入的回调(等待时间的上限为前面计算出来的
maxPollTime)。
注意:poll 阶段空闲,即 poll 队列为空的时候,一旦有新的 setImmediate 回调,循环就会结束 poll 进入 check 阶段;或者一旦有新的 timer 计时结束,循环就会绕回 Timers 阶段;如果同时出现两者的回调,setImmediate 的优先级更高
setImmediate 和 setTimeout(fn, 0) 的执行顺序
首先明确 setImmediate 和 setTimeout 各自的执行时机:setImmediate 是一个特殊的定时器,它的回调会在 check 阶段执行,也就是紧跟在 poll 阶段后面执行;setTimeout 的回调会在达到 delay 时间后,尽快执行,前提是计时结束并把回调推入了 Timers 的队列。
另外还需明确一点,事件循环除了能够正常进入 Timers 阶段外,poll 阶段一旦空闲并且没有待执行的 setImmediate 回调,就会去检查是否有计时结束的 timer,如果有的话,就会绕回到 Timers 阶段,所以 setTimeout 回调的执行时机实际上有两个。
好了,我们现在来看 setImmediate 和 setTimeout(fn, 0) 回调的执行顺序,可以分为以下两种情况:
- 两者都在主模块(main module)调用,则两个回调的执行顺序不确定;
- 两者都不在主模块调用,而是在一个异步操作的回调里被调用,那么
setImmediate的回调会先于 setTimeout(fn, 0) 执行
以下示例中,我们都假定 setImmediate 的回调为 cb_immediate,setTimeout(fn, 0) 的回调为 cb_timeout。
两者都在 I/O 操作的回调内,cb_immediate 先执行
代码如下:
// test_timer.js
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
我们多次运行会发现,“immediate”始终会在“timeout”之前打印:
$ node test_timer.js
immediate
timeout
下面我们分析一下 cb_immediate 始终先执行的原因。
首先,I/O 操作的回调在 poll 队列里,是在 poll 阶段执行的。前面我们提到,poll 阶段一旦空闲,就会检查是否有待执行的 setImmediate 回调,如果有,就会结束等待进入 check 阶段,所以即使这时有新的 timer 计时结束,也要等到 check、close 都结束并进入新的 Tick 才能执行。poll 检查发现 cb_immediate 待执行,所以循环直接进入 check 阶段执行 cb_immediate,而 cb_timeout 最快也要在下一个 Tick 才会被执行。
两者都在 setTimeout 的回调内,cb_immediate 先执行
代码如下:
setTimeout(() => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {1
console.log('immediate');
});
}, 0);
多次运行后我们会发现,“immediate”始终比“timeout”先打印。
外层 setTimeout 的回调是在 Timers 阶段执行的,执行完回调后,事件循环继续往后面走,走到 poll 阶段后,一旦 poll 空闲,就会检查并发现有待执行的 setImmediate 回调,发现存在 cb_immediate,于是循环直接进入 check 阶段,率先执行 cb_immediate。
综合上面两个示例,我们可以看出,在同一个异步操作的回调中同时调用 setImmediate 和 setTimeout(fn, 0),setImmediate 之所以始终会先执行,正是由于计时器一旦错过了 Timers 阶段,下一个执行回调的时机在 poll 阶段,而 poll 阶段检查过程中,setImmediate 的优先级高于 setTimeout,一旦发现有待执行的 setImmediate 回调,循环就会继续往下走,所以 setImmediate 回调的执行永远先于 setTimeout 回调。
我们接着看一下在主模块同时调用 setImmediate 和 setTimeout(fn, 0) 的情况。
两者的调用都在主模块,执行顺序不确定
代码如下:
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {1
console.log('immediate');
});
运行以上代码,我们会发现,时而先打印出“timeout”,时而先打印出“immediate”,即两者的执行顺序是不确定的。
我们先理一下主要流程:
- 主线程执行同步代码;
- 将
setTimeout交给定时器线程处理; - 执行
setImmediate,即调用 libuv 提供的一个 API,该 API 会将cb_immediate放入 check 阶段的队列; - 同步代码执行完毕后,事件循环进入 Timers 阶段;
- 检查 Timers 队列,若不为空,执行回调(即
cb_timeout),若为空,继续往下执行; - 进入 pending callbacks 阶段;
- 进入 idle,prepare 阶段;
- 进入 poll 阶段;
- poll 空闲后,发现有待执行的
setImmediate回调(即cb_immediate),进入 check 阶段; - 执行
cb_Immediate
根据以上流程可以看出,cb_immediate 和 cb_timeout 的执行顺序,就取决于第 e 步中 Timers 的队列是否已经存在 cb_timeout,如果存在,则 cb_timeout 先执行,否则 cb_immediate 先执行。
这里补充一点,在 Node 中,setTimeout(fn, 0) 会被强制改为 setTimeout(fn, 1),这一点在官方文档中有相关说明:
When delay is larger than 2147483647 or less than 1, the delay will be set to 1.
如果循环进入 Timers 阶段的时候,距离 setTimeout 执行已经过去了 1ms,并且 cb_timeout 已经被推入 Timers 的队列,那么循环就会取出 cb_timeout 并执行;反之,如果循环进入 Timers 阶段的时候,cb_timeout 还没有在队列内,那么 cb_timeout 就不会在这个 Tick 被执行,cb_immediate 会先执行。
而循环进入 Timers 阶段的时候,是否已经经过了 1ms,会受到多方面的影响,包括同步代码执行所花费的时间,以及系统的性能,机器的状态差异也会导致每次运行的结果不同。所以同时在主模块调用 setImmediate 和 setTimeout(fn, 0),两个回调的执行顺序是不确定的。
process.nextTick()
process.nextTick() 回调的执行时机
大家可能会发现,我们在介绍事件循环的阶段时,process.nextTick() 没有出现在任何一个阶段。这是因为 process.nextTick() 不属于任何一个阶段,事实上,它是在 “阶段之间” 执行的。
在任何一个阶段调用了 process.nextTick(),nextTick 的回调都会在当前阶段结束后立即执行,所有回调执行完后,事件循环才进入下一个阶段,所以过多的或长时间执行的 nextTick 回调,其实是可以阻塞整个事件循环的,所以得小心使用 nextTick。
process.nextTick() VS setImmediate()
我们回顾一下两者的回调的执行时机,process.nextTick() 的回调是在当前阶段结束后就立即执行,setImmediate() 的回调是在每个循环的 check 阶段执行。细心的读者就会发现,process.nextTick() 似乎比 setImmediate() 更“immediate”,更即时。
事实上官方也提到了这一点,两者的名字应该倒过来才比较合理,但修改名字的话,影响范围太大了,npm 上所有用到这两个方法的包都会受到影响,所以即使两者的名字存在一定的迷惑性,但是目前看来,改名是不现实也是不可能的。
Node EventLoop VS 浏览器的 EventLoop
两者有以下两点区别:
- Node 的事件循环是分阶段的,每个阶段执行特定的事件回调,而浏览器不分阶段;
- Node 的微任务会在阶段之间执行,而浏览器的微任务是在每个宏任务结束后执行
和浏览器一样,Node 也会维护一个微任务列表。和浏览器不同的是,浏览器是在宏任务即将结束的时候,检查宏任务对应的微任务列表是否为空,若不为空,则执行所有微任务后,再进入下一个 Tick。而 Node 的微任务执行时机是在各个阶段之间,一个阶段结束后,事件循环会去检查微任务列表是否为空,若不为空,则执行完所有微任务后,才进入循环的下一个阶段。
注意,setImmediate 是 Node 特有的宏任务,process.nextTick 是 Node 特有的微任务。