一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第4天,点击查看活动详情。
JS 事件循环 Node 篇
之前介绍过JS 事件循环 浏览器篇,本文将详细介绍 Node 中的事件循环。
Node 中的事件循环比起浏览器中的 JavaScript 还是有一些区别的,各个浏览器在底层的实现上可能有些细微的出入;而 Node 只有一种实现,相对起来就少了一些理解上的麻烦。
首先要明确的是,事件循环同样运行在单线程环境下,JavaScript 的事件循环是依靠浏览器实现的,而Node 作为另一种运行时,事件循环由底层的 libuv 实现。
根据 Node.js 官方介绍,每次事件循环都包含了6个阶段,如下图所示
注意:每个框被称为事件循环机制的一个阶段。
每个阶段都有一个 FIFO 队列来执行回调。虽然每个阶段都是特殊的,但通常情况下,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后执行该阶段队列中的回调,直到队列用尽或最大回调数已执行。当该队列已用尽或达到回调限制,事件循环将移动到下一阶段,等等。
阶段概述
- timers 阶段:这个阶段执行timer(
setTimeout、setInterval)的回调 - I/O callbacks 阶段:执行一些系统调用错误,比如网络通信的错误回调
- idle, prepare 阶段:仅node内部使用
- poll 阶段:获取新的I/O事件, 适当的条件下node将阻塞在这里
- check 阶段:执行
setImmediate()的回调 - close callbacks 阶段:执行一些关闭的回调函数,如:
socket.on('close', ...)
阶段的详细概述
timers 阶段
timers 是事件循环的第一个阶段,Node 会去检查有无已过期的 timer,如果有则把它的回调压入 timer的任务队列中等待执行,事实上,Node 并不能保证 timer 在预设时间到了就会立即执行,因为 Node 对timer的过期检查不一定靠谱,它会受机器上其它运行程序影响,或者那个时间点主线程不空闲。比如下面的代码,setTimeout() 和 setImmediate() 的执行顺序是不确定的。
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
但是把它们放到一个I/O回调里面,就一定是 setImmediate() 先执行,因为poll阶段后面就是check阶段。
I/O callbacks 阶段
官方文档对这个阶段的描述为除了timers、setImmediate,以及 close 操作之外的大多数的回调方法都位于这个阶段执行。事实上从源码来看,该阶段只是用来执行pending callback,例如一个TCP socket执行出现了错误,在一些*nix系统下可能希望稍后再处理这里的错误,那么这个回调就会放在IO callback阶段来执行。
一些常见的回调,例如 fs.readFile 的回调是放在 poll 阶段来执行的。
poll 阶段
poll 阶段主要有2个功能:
- 处理 poll 队列的事件
- 当有已超时的 timer,执行它的回调函数
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阶段。
check 阶段
setImmediate是一个特殊的定时器方法,它占据了事件循环的一个阶段,整个check 阶段就是为setImmediate方法而设置的。
setImmediate()的回调会被加入check队列中, 从event loop的阶段图可以知道,check阶段的执行顺序在poll阶段之后。
close callbacks 阶段
如果一个 socket 或者一个句柄被关闭,那么就会产生一个close事件,该事件会被加入到对应的队列中。clos阶段执行完毕后,本轮事件循环结束,循环进入到下一轮。
小结
看完了上面的描述,我们明白了 Node 中的event loop 是分阶段处理的,对于每一阶段来说,处理事件队列中的事件就是执行对应的回调方法,每一阶段的event loop 都对应着不同的队列。当 event loop 到达某个阶段时,将执行该阶段的任务队列,直到队列清空或执行的回调达到系统上限后,才会转入下一个阶段。当所有阶段被顺序执行一次后,称 event loop 完成了一个 tick。
Node.js 与浏览器的 Event Loop 差异
浏览器环境下,microtask的任务队列是每个macrotask执行完之后执行。
而在Node.js中,microtask会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行microtask队列的任务。
setImmediate 对比 setTimeout
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
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
nextTick VS setlmmediate
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。
总结
- Node.js 的事件循环分为6个阶段
- 浏览器和Node 环境下,
microtask任务队列的执行时机不同- Node.js中,
microtask在事件循环的各个阶段之间执行 - 浏览器端,
microtask在事件循环的macrotask执行完之后执行
- Node.js中,
- 递归的调用
process.nextTick()会导致I/O starving,官方推荐使用setImmediate()