JS 事件循环 Node 篇
之前介绍过浏览器中的事件循环,本文将详细介绍 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
执行完之后执行
- 递归的调用
process.nextTick()
会导致I/O starving,官方推荐使用setImmediate()