一、为什么你需要理解事件循环?
在 Node.js 中,事件循环是异步编程的核心。如果你曾为 setTimeout 和 Promise 的执行顺序感到困惑,或者遇到过 process.nextTick 导致的性能问题,那么这篇文章就是为你准备的。我们将从事件循环的六个阶段入手,配合代码实验,彻底搞懂异步任务的调度机制。
二、事件循环的六个阶段
Node.js 的事件循环由 libuv 库实现,主要包含以下阶段(按顺序执行):
- timers:执行
setTimeout和setInterval的回调。 - pending callbacks:执行延迟到下一轮的 I/O 回调。
- idle, prepare:内部使用。
- poll:获取新的 I/O 事件,执行 I/O 回调。
- check:执行
setImmediate的回调。 - close callbacks:执行关闭事件的回调(如
socket.on('close'))。
每个阶段都有一个先进先出的回调队列。当队列为空或达到最大限制时,事件循环会进入下一个阶段。
三、微任务与宏任务
在 Node.js 中,任务分为两类:
- 宏任务(MacroTask):由事件循环的各个阶段处理,如
setTimeout、setInterval、setImmediate、I/O 回调。 - 微任务(MicroTask):在每个宏任务完成后立即执行,包括
process.nextTick和Promise.then/catch/finally。
注意:process.nextTick 的优先级高于 Promise 微任务,它是“微任务中的微任务”。
四、实战:代码实验
实验 1:基本执行顺序
console.log('1: start');
setTimeout(() => {
console.log('2: setTimeout');
}, 0);
setImmediate(() => {
console.log('3: setImmediate');
});
process.nextTick(() => {
console.log('4: nextTick');
});
Promise.resolve().then(() => {
console.log('5: Promise');
});
console.log('6: end');
输出结果:
1: start
6: end
4: nextTick
5: Promise
2: setTimeout
3: setImmediate
解释:
- 同步代码先执行,打印 1 和 6。
- 微任务在宏任务之前执行:
process.nextTick优先于 Promise。 - 宏任务中,
setTimeout在 timers 阶段执行,setImmediate在 check 阶段执行。由于 timers 阶段先于 check,所以 2 先于 3。
实验 2:setTimeout vs setImmediate 的微妙差异
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('1: setTimeout');
}, 0);
setImmediate(() => {
console.log('2: setImmediate');
});
});
输出结果(几乎总是):
2: setImmediate
1: setTimeout
解释:当在 I/O 回调中(poll 阶段)注册 setTimeout 和 setImmediate 时,poll 阶段之后直接进入 check 阶段,因此 setImmediate 先执行。而 setTimeout 需要等到下一轮 timers 阶段。
💡 踩坑提示:如果不在 I/O 回调中,setTimeout 和 setImmediate 的执行顺序可能不确定,因为主模块的启动时间可能影响 timers 的触发。
实验 3:process.nextTick 的递归陷阱
function recursiveNextTick() {
process.nextTick(() => {
console.log('nextTick');
recursiveNextTick(); // 递归调用
});
}
recursiveNextTick();
setTimeout(() => {
console.log('setTimeout');
}, 100);
输出:
nextTick
nextTick
nextTick
...(无限循环,永远不会执行 setTimeout)
解释:process.nextTick 会在当前阶段结束前反复执行,导致事件循环永远无法进入下一阶段。因此,避免在 process.nextTick 中递归或使用长循环。
五、最佳实践
- 优先使用
setImmediate而非setTimeout(fn, 0):setImmediate在 check 阶段执行,性能更优,且不受定时器精度影响。 - 避免滥用
process.nextTick:它可能阻塞事件循环。建议使用setImmediate或Promise来推迟任务。 - 理解微任务的执行时机:微任务会在每个宏任务后立即执行,包括
process.nextTick和 Promise。因此,可以在微任务中处理高优先级的异步操作。 - 使用
async/await时注意:await之后的代码相当于Promise.then,属于微任务。
六、总结
Node.js 事件循环是异步编程的基石。通过理解六个阶段和微任务/宏任务的执行顺序,你可以编写出更高效、更可预测的代码。记住:
- 同步代码 > 微任务(nextTick > Promise) > 宏任务(timers > I/O > check > close)
- 在 I/O 回调中,
setImmediate优先于setTimeout - 谨慎使用
process.nextTick,避免递归
下一步,你可以阅读 Node.js 官方文档关于 Event Loop 的详细说明,或者深入 libuv 源码。