之前介绍过浏览器中的事件循环,本文将详细介绍 Node 中的事件循环。
Node 中的事件循环比起浏览器中的 JavaScript 还是有一些区别的,各个浏览器在底层的实现上可能有些细微的出入;而 Node 只有一种实现,相对起来就少了一些理解上的麻烦。
首先要明确的是,事件循环同样运行在单线程环境下,JavaScript 的事件循环是依靠浏览器实现的,而Node 作为另一种运行时,事件循环由底层的 libuv 实现。
根据 Node.js 官方介绍,每次事件循环都包含了6个阶段,如下图所示
image-20220404145555012
「注意」:每个框被称为事件循环机制的一个阶段。
每个阶段都有一个 FIFO 队列来执行回调。虽然每个阶段都是特殊的,但通常情况下,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后执行该阶段队列中的回调,直到队列用尽或最大回调数已执行。当该队列已用尽或达到回调限制,事件循环将移动到下一阶段,等等。
setTimeout
、setInterval
)的回调setImmediate()
的回调socket.on('close', ...)
timers 是事件循环的第一个阶段,Node 会去检查有无已过期的 timer,如果有则把它的回调压入 timer的任务队列中等待执行,事实上,Node 并不能保证 timer 在预设时间到了就会立即执行,因为 Node 对timer的过期检查不一定靠谱,它会受机器上其它运行程序影响,或者那个时间点主线程不空闲。比如下面的代码,setTimeout()
和 setImmediate()
的执行顺序是不确定的。
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
但是把它们放到一个I/O回调里面,就一定是 setImmediate()
先执行,因为poll阶段后面就是check阶段。
官方文档对这个阶段的描述为除了timers、setImmediate,以及 close 操作之外的大多数的回调方法都位于这个阶段执行。事实上从源码来看,该阶段只是用来执行pending callback,例如一个TCP socket执行出现了错误,在一些*nix系统下可能希望稍后再处理这里的错误,那么这个回调就会放在「IO callback阶段」来执行。
一些常见的回调,例如 fs.readFile
的回调是放在 「poll 阶段」来执行的。
poll 阶段主要有2个功能:
even loop 将同步执行 poll 队列里的回调,直到队列为空或执行的回调达到系统上限(上限具体多少未详),接下来even loop会去检查有无预设的setImmediate()
,分两种情况:
setImmediate()
, event loop将结束poll阶段进入check阶段,并执行check阶段的任务队列setImmediate()
,event loop将阻塞在该阶段等待,等待新的事件出现,这也是该阶段为什么会被命名为 poll(轮询) 的原因。注意一个细节,没有setImmediate()
会导致event loop阻塞在「poll阶段」,这样之前设置的timer岂不是执行不了了?所以咧,在poll阶段event loop会有一个检查机制,检查timer队列是否为空,如果timer队列非空,event loop就开始下一轮事件循环,即重新进入到「timers阶段」。
setImmediate
是一个特殊的定时器方法,它占据了事件循环的一个阶段,整个「check 阶段」就是为setImmediate
方法而设置的。
setImmediate()
的回调会被加入check队列中, 从event loop的阶段图可以知道,check阶段的执行顺序在poll阶段之后。
如果一个 socket 或者一个句柄被关闭,那么就会产生一个close
事件,该事件会被加入到对应的队列中。clos阶段执行完毕后,本轮事件循环结束,循环进入到下一轮。
看完了上面的描述,我们明白了 Node 中的event loop 是分阶段处理的,对于每一阶段来说,处理事件队列中的事件就是执行对应的回调方法,每一阶段的event loop 都对应着不同的队列。当 event loop 到达某个阶段时,将执行该阶段的任务队列,直到队列清空或执行的回调达到系统上限后,才会转入下一个阶段。当所有阶段被顺序执行一次后,称 event loop 完成了一个 「tick」。
浏览器环境下,microtask
的任务队列是每个macrotask
执行完之后执行。
浏览器端
而在Node.js中,microtask
会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行microtask
队列的任务。
setImmediate()
和 setTimeout()
很类似,但是基于被调用的时机,他们也有不同表现。
setImmediate()
设计为一旦在当前 「poll 阶段」 阶段完成,就执行脚本。setTimeout()
在最小阈值(ms 单位)过后运行脚本。执行计时器的顺序将根据调用它们的上下文而异。如果二者都从主模块内调用,则计时器将受进程性能的约束(这可能会受到计算机 上其他正在运行应用程序的影响)。
例如,如果运行以下不在 I/O 周期(即主模块)内的脚本,则执行两个计时器的顺序是非确定性的,因为它受进程性能的约束:
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
// timeout
// immediate
// or
// immediate
// timeout
但是,如果你把这两个函数放入一个 「I/O 循环」内调用,setImmediate
总是被优先调用:
// timeout_vs_immediate.js
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
// immediate
// timeout
使用 setImmediate()
相对于setTimeout()
的主要优势是,如果setImmediate()
是在 I/O 周期内被调度的,那它将会在其中任何的定时器之前执行,跟这里存在多少个定时器无关。
process.nextTick
的意思就是定义出一个异步动作,并且让这个动作在事件循环当前阶段结束后执行。
例如下面的代码,将打印first
的操作放在nextTick
的回调中执行,最后先打印出next
,再打印first
。
process.nextTick(function() {
console.log('first');
});
console.log('next');
// next
// first
process.nextTick
其实并不是事件循环的一部分,但它的回调方法也是由事件循环调用的,该方法定义的回调方法会被加入到名为nextTickQueue
的队列中。在事件循环的任何阶段,如果nextTickQueue
不为空,都会在当前阶段操作结束后优先执行nextTickQueue
中的回调函数,当nextTickQueue
中的回调方法被执行完毕后,事件循环才会继续向下执行。
Node 限制了nextTickQueue
的大小,如果递归调用了process..nextTick
,那么当nextTickQueue
达到最大限制后会抛出一个错误,我们可以写一段代码来证实这一点。
function recurse(i) {
while(i < 9999) {
process.nextTick(recurse(i++));
}
}
recurse(0);
运行上面代码会报错:
RangeError: Maximum call stack size exceeded
既然nextTickQueue
也是一个队列,那么先被加入队列的回调会先执行,我们可以定义多个process.nextTick
,然后观察他们的执行顺序:
process.nextTick(function () {
console.log('first');
});
process.nextTick(function() {
console.log('second');
});
console.log('next');
// next
// first
// second
和其他回调函数一样,nextTick
定义的回调也是由事件循环执行的,如果nextTick
的回调方法中出现了阻塞操作,后面的要执行的回调同样会被阻塞。
process.nextTick(function () {
console.log('first');
// 由于死循环的存在,之后的事件被阻塞
while(true) { }
});
process.nextTick(function() {
console.log('second');
});
console.log('next');
// 依次打印 next first,不会打印 second
setImmediate
方法不属于ECMAScript
标准,而是Node
提出的新方法,它同样将一个回调函数加入到事件队列中,不同于setTimeout
和setInterval
,setlmmediate
并不接受一个时间作为参数,setlmmediate
的事件会在当前事件循环的结尾触发,对应的回调方法会在当前事件循环末尾「check 阶段」执行。
setImmediate
方法和process.nextTick
方法很相似,二者经常被拿来放在一起比较,从语义角度看,setImmediate()
应该比 process.nextTick()
先执行才对,而事实相反,由于process.nextTick
会在当前操作完成后立刻执行,因此总会在setImmediate
之前执行。
此外,当有递归的异步操作时只能使用setlmmediate
,不能使用process.nextTick
,前面已经展示过了递归调用nextTick
会出现的错误,下面使用setlmmediate
来试试看:
function recurse(i, end) {
if (i < end) {
console.log('Done');
} else {
console.log(i);
setImmediate(recurse, i + 1, end);
}
}
recurse(0, 9999999);
完全没问题!这是因为setImmediate
不会生成call stack
。
microtask
任务队列的执行时机不同microtask
在事件循环的各个阶段之间执行microtask
在事件循环的 macrotask
执行完之后执行process.nextTick()
会导致I/O starving,官方推荐使用setImmediate()