Description
NodeJS 的事件循环(EventLoop)和浏览器是不一样的,NodeJS 使用 libuv 实现事件循环和所有异步行为。
NodeJS EventLoop 的流程
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
从官方流程图可以发现,NodeJS 的事件循环(EventLoop)是多任务队列,事件循环(EventLoop)将按照上图流程进入逐个阶段。
1 - Timer 阶段
setTimeout
和 setInterval
的回调会进入本阶段队列。
事件循环进入本阶段,会检查并执行到时的计时器回调,如果没有,结束此阶段。
2 - Pending callbacks 阶段
该阶段执行上一轮循环被延迟的某些系统操作回调(比如 TCP 错误)。
3 - Idle, prepare 阶段
提供给 NodeJS 内部使用。
4 - Poll 阶段
除 close
外的所有 I/O 回调会被推进该阶段的队列。
本阶段会计算需要阻塞和等待 I/O 的时间,并按下面步骤处理:
- 检查 I/O 回调队列,如果有,同步执行直到清空队列或者到达系统上限。
- 检查
setImmediate
回调队列,如果有,进入 Check 阶段。 - 检查是否有到时的计时器,如果有,回到 Timer 阶段。
- 如果都没有,则会阻塞在此阶段,等待新的异步任务,执行上述步骤处理,直到等待时间结束。
该阶段被称为轮询(Poll)的原因大概是,几乎所有的异步回调都在本阶段处理,并且会阻塞等待新的异步任务,根据新的任务类型发生阶段流转。
5 - Check 阶段
执行 setImmediate
注册的回调。
6 - Close callbacks 阶段
执行 I/O 的 close
回调,如果没有,则结束本阶段。
容易混淆的概念
setImmediate vs setTimeout
案例:
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
// 输出 ?
setImmediate
回调在 Check 阶段执行,setTimeout
回调在 Timer 阶段执行,理论上,setTimeout
回调应该更早执行,实际上上述代码输出是随机的,这与系统和进程性能有关。
因为 setTimeout
指定的时间是有下限的,虽然指定了 0,但是最小只能是 1ms
When delay is larger than 2147483647 or less than 1, the delay will be set to 1. Non-integer delays are truncated to an integer.
from NodeJS Timer 文档,
- 如果性能好执行快,进入 Timer 阶段时还没到 1ms,
setTimeout
的回调未到时,最后到 Check 阶段执行setImmediate
回调,然后再第二次循环才能执行; - 如果性能慢,进入 Timer 阶段时到了 1ms,
setTimeout
的回调会先执行。
如果把它们放入一个 I/O 回调的话,则 setImmediate
一定会先执行。
fs.readFile('/path/to/file', () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
// 输出 immediate -> timeout
上述代码会进入 Poll 阶段,等待执行 readFile
回调,添加 setImmediate
和 setTimeout
回调,查看 Poll 阶段处理步骤,此时 I/O 队列为空,setImmediate
队列存在,因此进入 Check 阶段执行回调,然后在下一次循环执行 setTimeout
的回调。
process.nextTick
虽然 process.nextTick
是异步 API 之一,但从技术上来说它不属于事件循环(EventLoop)的一部分。process.nextTick
会中断事件循环(EventLoop),不管在事件循环(EventLoop)的任何时刻,当前操作完成后(即执行栈为空时),会执行 nextTickQueue
的所有回调,然后再继续进行事件循环(EventLoop)。
NodeJS 对操作的定义:
an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed.
案例:
const fs = require('fs');
process.nextTick(() => {
console.log('nextTick1');
});
setTimeout(() => {
console.log('setTimeout1');
process.nextTick(() => {
console.log('nextTick2');
});
});
setTimeout(() => {
console.log('setTimeout2');
});
fs.readFile('./index.js', () => {
console.log('readFile');
});
执行过程:
- 在初始化代码执行完成后,执行栈为空,
nextTickQueue
有回调,执行输出'nextTick1'
- 进入 Timer 阶段
- 执行输出
setTimeout1
- 执行栈为空,
nextTickQueue
有回调,执行输出'nextTick2'
- 执行输出
setTimeout2
- 执行输出
- 进入 Poll 阶段,执行输出
readFile
解释:
nextTick2
在setTimeout2
之前说明了不管什么时候,执行栈为空,先执行nextTickQueue
。nextTick2
在readFile
之前说明了仍然在本次循环中。
process.nextTick
vs setImmediate
setImmediate
的含义是“立即”,容易让人以为比 process.nextTick
先执行,这个命名是一个历史问题。
实际上,上面已经提到过,setImmediate
是事件循环的一部分,它在循环即将结束的 Check 阶段执行,而 process.nextTick
无论何时,只要当前操作结束就会被执行,它们之间并没有太多联系。
关于微任务(microtask)
NodeJS 的官方文档并没有把 微任务(microtask) 写入事件循环,查阅了一些资料,猜测可能是以下原因:
微任务(microtask)是 WHATWG 中的概念,浏览器只有一个任务队列(task queue),任务(task)并无优先级,而微任务队列(microtask queue)提供了优先级。最初微任务(microtask)也是提供给 Promise/A+ 的 then
函数实现使用,后续更多的浏览器 API 被实现以微任务(microtask)执行。
NodeJS 实现事件循环时是多队列的,所有异步回调有着优先级调度。而对于 Promise.then
函数的实现,在上述事件循环的某个阶段后执行,也是符合 ES6 规范和Promise/A+ 规范的,除此之外并没有其他需要微任务(microtask)。
所以 NodeJS 使用了微任务(microtask)的语义,但并没有按 WHATWG 规范实现微任务队列。
但是在 NodeJS 11 后,修改了 process.nextTick
和 Promise.then
的执行时机,并且增加 queueMicrotask
API:
微任务(microtask)会在 process.nextTick
执行完后执行。