先说明,本文针对的是node.js
运行时,由uv
实现的event loop
。
所有理论依据来源于 node.js
源码。(版本略)
0x00 总有面试官要刁难朕
我们不妨看一下这样的题目
console.log(1)
setTimeout(() => {
console.log(2)
}, 0)
Promise.resolve().then(() => {
console.log(3)
}).then(() => {
console.log(4)
})
console.log(5)
请问上面代码的打印结果?
▇▇▇▇▇▇▇▇▇▇ <--- 刮开查看答案
对吧,无数次被这种装X面试题恶心。
小声哔哔:谁项目里会这样写代码?
不过恶心归恶心,不管有没有实用性,透过这些题目来弄清楚技术的真相,是没有坏处的。
我们的目标是:以后还有类似的题目,不管千变万化,直接通关。

0x01 没有银弹,还是要拿源码说话
为了证明不是胡说八道,先贴出关键源码。
// 来自 deps/uv/src/unix/core.c
while (r != 0 && loop->stop_flag == 0) {
uv__update_time(loop);
uv__run_timers(loop); // ⭐️ timer
ran_pending = uv__run_pending(loop); // ⭐️ 上一个循环一些没来得及做完的事
uv__run_idle(loop); // ⭐️ 底层用,暂时不懂
uv__run_prepare(loop); // ⭐️ 底层用,暂时不懂
/*
* 忽略几行不重要的
*/
uv__io_poll(loop, timeout); // ⭐️io, network or file system 等等
uv__run_check(loop); // ⭐️ setImmediate
uv__run_closing_handles(loop); // ⭐️ event on('close')
if (mode == UV_RUN_ONCE) {
// 这里不重要
}
r = uv__loop_alive(loop);
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
// 这里不重要
}
然后我们开始逐个去了解。
timer
这部分主要是检查有没有可以执行的定时器,包括但不限于setTimeout``setInterval
。
这里的的具体实现在deps/uv/src/unix/timer.c
,简单说就是使用一个最小堆(小顶堆), 把时间最接近的一个取出来,判断当前时间是否可以执行。
pending
这个阶段是执行 上一个循环poll阶段
还没来得及处理的callback。
这句话,在下面介绍poll阶段
的时候才回过头来理解。
idle + prepare
按文档说是底层预留的,暂时我还没研究清楚。请忽略。
poll
关键!这个阶段处理的,就是我们比较熟悉的network , fs
之类的异步操作回调。就是说你去请求一个远程的接口,那么回调函数会在poll
阶段执行。
然后就是跟上面pending
的关联。
由于uv__io_poll
代码有点长就不贴了,有兴趣自己去看。
一般来说,我们的每一个阶段,都会处理完已经就绪的所有callback,如果poll
阶段触发大量的 callback,就会占用很多的时间。
我们的uv
当然是不会设计成这样的,所以,它会从timer
里拿到最小的(未来最快到达的)一个定时器的时间,作为poll
阶段的 timeout
。
如果timeout
到了,还有callback没开始执行的,对不起,请到pending
队列里。
可能是uv
认为,poll
阶段的callback,相对来说对“准时”不太敏感,所以通过这样尽量确保timer
的执行不会误差太多。
check
为什么叫做check
我也不清楚。
但是这个阶段将会运行我们 setImmediate
注册的回调。
很震惊吧,setImmediate
完全就不是timer
那一族的~~~~
closing_handles
执行close
事件注册的回调,放在循环的最后一个阶段,也是合情合理。
0x03 那么我们练习一下
关于process.nextTick
nextTick 是个复杂的实现,需要另外开一篇来讲解。
为了方便下面的练习,我暂时先把结论放出来。
nextTick会直接追加在每一个阶段末尾,就是说,如果timer
阶段的回调里有process.nextTick
,通过这个来注册的回调,会在紧接着的pending
之前就执行。
✏️ 题目一
setTimeout(() => {
console.log('A')
}, 0)
setImmediate(() => {
console.log('B')
})
答案:
AB 或 BA
解释:
首先这里的第一个知识点,是timer的第二个参数,取值范围是 [1, 2^31 - 1]。也就是说,这个 0 会被当成 1 处理。
然后根据运行环境的差异,如果进入到当前循环前,已经过去了 1ms ,那就打印 AB。
否则,如果在 1ms 内就开始了本次循环,那timer
还没准备后,就会在下一次循环触发,自然就打印 BA。
✏️ 题目二
const fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('A')
}, 0)
setImmediate(() => {
console.log('B')
})
})
答案:
BA
解释
知识点在于fs.readFile
,这个是 io操作,它的整个回调会在poll
阶段执行。
而poll
之后马上进入check
,所以正好先执行了刚注册的setImmediate
。
setTimeout
自然就要等到下一个循环的timer
阶段。
✏️ 题目三,这个划重点
setImmediate(() => {
console.log('1')
setImmediate(() => {
console.log('2')
})
process.nextTick(() => {
console.log('nextTick')
})
})
setImmediate(() => {
console.log('3')
})
答案:
1 3 nextTick 2
解释:
首先,最外层的两个setImmediate
会顺序注册到同一个check
阶段,而上面提到nextTick
会直接追加到当前阶段末尾,所以是1 3 nextTick
而不是1 nextTick 3
。
而内层的setImmediate
会注册到下一次循环的check
阶段,所以2
最后打印。
请细品。
0x04 继续练习之前,讲讲 promise
和 process.nextTick
类似,promise
的回调也是在当前阶段的末尾追加。
不过有意思的是,process.nextTick
拥有更高的优先级。
这个实现细节,也是需要另外一篇文章来讲解(挖坑+1)。。。。
0x05 继续练习吧
✏️ 题目四
const promise = Promise.resolve()
promise.then(() => {
console.log('A')
})
process.nextTick(() => {
console.log('B')
})
答案:
BA
解释
无需解释,先记住二者的优先级。
✏️ 题目五
setTimeout(() => {
console.log(1)
}, 0)
new Promise((resolve, reject) => {
console.log(2)
for (let i = 0; i < 10000; i++) {
i === 9999 && resolve()
}
console.log(3)
}).then(() => {
console.log(4)
})
console.log(5)
答案:
2 3 5 4 1
解释
这里有个知识点,new Promise
的参数是同步执行的。
所以 2 3 5
都是同步顺序输出的。
然后then
在一个同步的for循环后触发,会追加到本阶段末尾,所以4
紧接着输出。
最后是setTimeout
,会在下一个循环的timer
阶段执行,输出1
🐸 BOSS戦
setImmediate(() => {
console.log(1)
setTimeout(() => {
console.log(2)
}, 100)
setImmediate(() => {
console.log(3)
})
process.nextTick(() => {
console.log(4)
})
})
process.nextTick(() => {
console.log(5)
setTimeout(() => {
console.log(6)
}, 100)
setImmediate(() => {
console.log(7)
})
process.nextTick(() => {
console.log(8)
})
})
console.log(9)
答案:
9 5 8 1 7 4 3 6 2
解释:
你已经是一个成熟的程序员了,试着用上面的知识自己来解释吧。
Tips 可以尝试画出来,一共经过了多少个循环, 每个循环的每个阶段执行了什么。