最近在看《Node.js 调试指南》的时候遇到有意思的几道题,是关于 setTimeout, promise.then, process.nextTick, setImmediate 的执行顺序。今天抽空记录下这道题的分析过程及背后的原理与知识点。
题目如下:
// 题目一:
setTimeout(() => {
console.log("setTimeout");
}, 0);
setImmediate(() => {
console.log("setImmediate");
});
// 题目二:
const promise = Promise.resolve();
promise.then(() => {
console.log("promise");
});
process.nextTick(() => {
console.log("nextTick");
});
// 题目三:
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);
// 题目四
setInterval(() => {
console.log("setInterval");
}, 100);
process.nextTick(function tick() {
process.nextTick(tick);
});
在分析这几道题之前先有必要了解下 node.js 的事件循环
事件循环 Event Loop
我们可以简单理解 Event Loop 如下:
- 所有任务都在主线程上执行,形成一个执行栈(Execution Context Stack)
- 在主线程之外还存在一个任务队列(Task Queen),系统把异步任务放到任务队列中,然后主线程继续执行后续的任务
- 一旦执行栈中所有的任务执行完毕,系统就会读取任务队列。如果这时异步任务已结束等待状态,就会从任务队列进入执行栈,恢复执行
- 主线程不断重复上面的第三步
上面第三步中的读取任务队列包括以下 6 个阶段
- timers:执行 setTimeout()和 setInterval()中到期的 callback
- I/O callbacks:上一轮循环中有少数的 I/O callback 会被延迟到这一轮的这一阶段
- idle,prepare:仅内部调用
- poll:最重要的阶段,执行 I/O callback,在某些条件下 node 会阻塞在这个阶段
- check:执行 setImmediate()的 callback
- close callbacks:执行 close 事件的 callback,例如 socket.on(‘close’,func)
每个阶段都有一个 FIFO 的回调队列,当 Event Loop 执行到这个阶段时,就会从当前阶段的队列里拿出一个任务放到执行栈中执行,在队列任务清空或者执行的回调数量达到上限后,Event Loop 就会进入下一个阶段
poll 阶段
poll 阶段主要有两个功能,如下所述:
- 当 timers 的定时器到期后,执行定时器(setTimeout 和 setInterval)的 callback
- 执行 poll 队列里面的 I/O callback
如果 Event Loop 进入了 poll 阶段,且代码未设定 timer,则可能发生以下的情况:
- 如果 poll queue 不为空,则 Event Loop 将同步执行 queue 里的 callback,直至 queue 为空,或者执行的 callback 达到系统上限
- 如果 poll queue 为空,则可能发生以下情况:
- 如果代码中使用了 setImmediate(),则 Event Loop 将结束 poll 阶段并进入 check 阶段,执行 check 阶段的代码
- 如果代码中没有使用 setImmediate(),则 Event Loop 将阻塞在该阶段,等待 callback 加入 poll queue,如果有 callback 进来则立即执行
一旦 poll queue 为空,则 Event Loop 将检查 timers,如果有 timer 的时间到期,则 Event Loop 将回到 timers 阶段,然后执行 timer queue
事件循环原理
-
node 的初始化
- 初始化 node 环境。
- 执行输入代码。
- 执行 process.nextTick 回调。
- 执行 microtasks。
-
进入 event-loop
-
进入 timers 阶段
- 检查 timer 队列是否有到期的 timer 回调,如果有,将到期的 timer 回调按照 timerId 升序执行。
- 检查是否有 process.nextTick 任务,如果有,全部执行。
- 检查是否有 microtask,如果有,全部执行。
- 退出该阶段。
-
进入 IO callbacks 阶段。
- 检查是否有 pending 的 I/O 回调。如果有,执行回调。如果没有,退出该阶段。
- 检查是否有 process.nextTick 任务,如果有,全部执行。
- 检查是否有 microtask,如果有,全部执行。
- 退出该阶段。
-
进入 idle,prepare 阶段:
- 这两个阶段与我们编程关系不大,暂且按下不表。
-
进入 poll 阶段
- 首先检查是否存在尚未完成的回调,如果存在,那么分两种情况。
- 第一种情况:
- 如果有可用回调(可用回调包含到期的定时器还有一些 IO 事件等),执行所有可用回调。
- 检查是否有 process.nextTick 回调,如果有,全部执行。
- 检查是否有 microtaks,如果有,全部执行。
- 退出该阶段。
- 第二种情况:
- 如果没有可用回调。
- 检查是否有 immediate 回调,如果有,退出 poll 阶段。如果没有,阻塞在此阶段,等待新的事件通知。
- 第一种情况:
- 如果不存在尚未完成的回调,退出 poll 阶段。
- 首先检查是否存在尚未完成的回调,如果存在,那么分两种情况。
-
进入 check 阶段。
- 如果有 immediate 回调,则执行所有 immediate 回调。
- 检查是否有 process.nextTick 回调,如果有,全部执行。
- 检查是否有 microtaks,如果有,全部执行。
- 退出 check 阶段
-
进入 closing 阶段。
- 如果有 immediate 回调,则执行所有 immediate 回调。
- 检查是否有 process.nextTick 回调,如果有,全部执行。
- 检查是否有 microtaks,如果有,全部执行。
- 退出 closing 阶段
-
检查是否有活跃的 handles(定时器、IO 等事件句柄)。
- 如果有,继续下一轮循环。
- 如果没有,结束事件循环,退出程序。
-
通过上面的事件循环的介绍我们已经知道 setTimeout setImmediate 的执行机制,但是并没有介绍 process.nextTick()和 promise.then()。这里我们还需要知道宏任务与微任务的概念
宏任务 Macrotask
宏任务是指 Event Loop 在每个阶段执行的任务
宏任务包括 script (整体代码),setTimeout, setInterval, setImmediate, I/O, UI renderin
微任务 Microtask
微任务是指 Event Loop 在每个阶段之间执行的任务
微任务包括 process.nextTick, Promise.then,Object.observe,MutationObserver
宏任务与微任务执行顺序图
图中绿色小块表示 Event Loop 的各个阶段,执行的是宏任务,粉色箭头表示执行的是微任务
了解到这里我们再来分析上面的几道题
题目一的执行结果是:
setTimeout
setImmediate
//或者
setImmediate
setTimeout
为什么结果不确定呢?我们知道 setTimeout 的回调函数在 timer 阶段执行,setImmediate 的回调函数在 check 阶段执行。但是从事件循环开始到 timer 阶段会消耗一定的时间,所以会出现两种情况:
- 若 timer 前的准备时间超过 1ms,则执行 timer 阶段(setTimeout)的回调函数
- 若 timer 前的准备时间少于 1ms,则执行 check 阶段(setImmediate)的回调函数,下次 event loop 循环在执行 timer 阶段的函数
题目二的执行结果是
nextTick
promise
这里虽然和 process.nextTick 一样,promise.then 也将回调函数注册到 microtask,但 process.nextTick 的 microtask queue 总是优先于 promise 的 microtask queue 执行的
题目三的执行结果是
2
3
5
4
1
Promise 构造函数是同步执行的,所以先打印 2,3,在打印 5,接下来事件循环执行微任务执行 promise.then 的回调,打印 4,然后进入下一个事件循环执行 timer 阶段的回调打印 1
题目四的执行结果是
永远不会打印 setInterval
process.nextTick 会无限循环,将 event loop 阻塞在 microtask 阶段,导致 event loop 上其他 macrotask 阶段的回调函数没有机会执行
解决方法通常是用 setImmediate 代替 process.nextTick.
在 setImmediate 内执行 setImmedaite 时会将 immediate 注册到下一次 event loop 的 check 阶段,这样其他 macrotask 就有机会执行
至此终于将 node.js 事件循环宏任务与微任务分析清楚了
原文链接: https://jesse121.github.io/blog/articles/2019/10/20.html
版权声明: 转载请注明出处.