以前我对于 Node.js 的事件循环只有一个模糊概念, 就是 Node.js 执行完毕同步任务后(我理解的同步任务是指,在当前调用栈中执行的代码,而异步任务是指在在当前调用栈中被放置在回调函数里面的代码), 如果同步任务有回调, 那么就丢到事件循环队列中去.同步任务执行完毕后再去执行队列中的回调. 但是最近总是在想一个问题, Node.js 的事件循环有一个问题, 既然是队列,那肯定是遵循先进先出的原则. 那么下面这段代码的输出就应该是 1, 2, 3. 可是最终的输出结果是 2, 3, 1. 今天摸鱼的时候突然想知道为什么,最终我在阮一峰老师的文章下对 Node.js 事件循环有了新的理解.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
main = () => {
setTimeout(() => {
console.log(1);
}, 5 * 1000);
setTimeout(() => {
console.log(2);
}, 1 * 1000);
setTimeout(() => {
console.log(3);
}, 3 * 1000);
}
main();
// output: 2, 3, 1
Node.js 的异步任务有两种, 一种是追加在本轮
事件循环中的异步任务, 一种是追加到次轮
事件循环中的异步任务. Node.js 规定,process.nextTick
和 Promise
的回调函数,追加在本轮循环,即同步任务一旦执行完成,就开始执行它们. 而 setTimeout()
, setInterval()
, setImmediate()
的回调函数,追加在次轮循环. 本轮循环的回调总是优先于次轮的回调, 而 process.nextTick()
的任务队列是则优先于 Promise
的任务队列执行.并且只有前一个队列全部清空以后,才会执行下一个队列. 所以在本轮循环中, 执行顺序是这样的: 1. 同步任务, 2. process.nextTick(), 3. Promise 的回调
接着次轮循环的执行顺序, 首先事件循环并不是永不停止的,事件循环虽然会无限次地执行, 但只要异步任务的回调函数队列清空了, 就会停止执行. 而当又有同步任务的回调被放进队列中, 就会继续触发事件循环.下图是次轮
事件循环中的执行顺序.
我们可以看到第一个执行的是 timer 阶段. Node.js 有 4 个 timer, 他们是
setTimeout()
,setInterval()
,setImmediate()
,process.nextTick()
.process.nextTick()
的回调在上文已经说明过了. 而在次轮循环中, timer 阶段执行的是setTimeout()
,setInterval()
的回调.然后是 I/O callbacks 阶段, 除了 Node.js
setTimeout()
,setInterval()
,setImmediate()
的回调, 以及用于关闭请求的回调函数,比如socket.on(‘close’, …)的回调, 其他的回调都在这个阶段执行.然后是 idle, prepare 阶段, 该阶段只供 libuv 内部调用,这里可以忽略.
然后是 Poll 阶段, 这个阶段是轮询时间,用于等待还未返回的 I/O 事件,比如服务器的回应、用户移动鼠标等等.这个阶段的时间会比较长.如果没有其他异步任务要处理(比如到期的定时器),会一直停留在这个阶段,等待 I/O 请求返回结果
check 阶段, 该阶段执行
setImmediate()
的回调函数.最后是 close callbacks 阶段, 该阶段执行关闭请求的回调函数,比如socket.on(‘close’, …)
我们可以看到在 Poll 阶段, 如果没有其他异步任务要处理(比如到期的定时器), 则会一直停留在这个阶段, 等待 I/O 请求返回结果. 所以这也就能解答为什么最上面的代码块的输出结果为什么是 2, 3, 1 了. 到此我的疑问算是解开了, 这也让我对 Node.js 事件循环的有了新的理解.
最后, 有一个有趣的问题, 就是 setTimeout(callback, 0)
和 setImmediate()
的执行顺序.根据上文讲的事件循环的顺序, 理所应当的会认为肯定是 setTimeout(callback, 0)
先执行, 但是实际测试发现并不是下图是两次执行相同代码的运行结果. 这个问题阮一峰老师的文章有详细的讲解, 有兴趣的可以去看看 Ref 中的文章.