Node.js 开发手册NODE.JS

CHAPTER 02异步核心 · EVENT LOOP

事件循环与异步

理解事件循环的阶段、宏任务与微任务的执行顺序,是写出正确、可预测 Node 代码的关键。

开始阅读难度进阶先修第一章约2.5小时
02.1

事件循环总览

事件循环是一个无限循环,每一轮(tick)按固定顺序经过若干阶段,每个阶段有一个 FIFO 回调队列。某个阶段队列清空后进入下一阶段,所有阶段走完再回到开头。

阶段处理的回调
timerssetTimeout / setInterval 到期的回调
pending上一轮延迟的 I/O 回调(如 TCP 错误)
poll取回新的 I/O 事件、执行 I/O 回调
checksetImmediate 回调
closeclose 事件(如 socket.destroy)
02.2

宏任务与微任务

微任务(Promise.then/catch/finally、queueMicrotask)不属于上述任何阶段,而是在每个阶段之间、阶段队列清空后立即清空。所以微任务总优先于下一个宏任务。

console.log('1 同步');

setTimeout(() => console.log('4 timers 宏任务'), 0);

Promise.resolve().then(() => console.log('3 微任务'));

console.log('2 同步');
// 输出顺序:1 同步 → 2 同步 → 3 微任务 → 4 timers 宏任务
微任务会"插队"

一旦进入微任务队列,会一直清空到空才继续下一阶段。微任务里再产生微任务会持续执行,可能饿死 I/O,需谨慎。

02.3

timers 阶段

setTimeout(fn, 0) 不保证 0 毫秒后执行,而是"至少 delay 毫秒后、在 timers 阶段"执行。受事件循环繁忙程度影响。

const start = Date.now();
setTimeout(() => {
  console.log('过了', Date.now() - start, 'ms');
}, 0);
// 若主线程被长循环占用,实际延时会远大于 0
02.4

poll 与 check 阶段

poll 阶段取回新的 I/O 回调并执行;若队列空且存在 setImmediate,则进入 check 阶段。poll 会计算"应阻塞多久"以尽快触发最近的定时器。

const fs = require('node:fs');
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});
// 在 I/O 回调中:setImmediate 先执行(check 阶段就在 poll 之后)
02.5

setTimeout vs setImmediate

两者顺序在不同场景下不同:在 I/O 回调内 setImmediate 先跑;在模块顶层则 setTimeout 通常先跑(因为 timers 阶段早于 check)。

如何选择

需要"在当前操作之后、I/O 之前"执行,用 setImmediate;需要"延迟至少 N 毫秒"用 setTimeout。顺序不确定时不要用两者互相依赖。

02.6

process.nextTick

process.nextTick 不属于事件循环任何阶段,而是在当前操作完成后、事件循环继续前立即执行。比微任务还早。

Promise.resolve().then(() => console.log('微任务'));
process.nextTick(() => console.log('nextTick'));
// 输出:nextTick → 微任务
递归 nextTick 危险

递归调用 nextTick 会永远不让事件循环前进,导致 I/O 饿死。Node 有默认的 maxWarnings 警告但仍会阻塞。

02.7

阻塞主线程的危害

任何同步耗时操作(大循环、JSON.parse 大对象、同步文件读、crypto 计算)都会阻塞整个事件循环,使所有请求卡住。

// 反例:同步压缩大文件会阻塞所有请求
const zlib = require('node:zlib');
const out = zlib.gzipSync(hugeBuffer); // 阻塞!

// 正解:用异步 + 流
const out = hugeStream.pipe(createGzip());
CPU 密集走 worker

重计算请用 worker_threads(见性能章),把工作移出主线程,保持事件循环可响应。

02.8

常见陷阱与 FAQ

为什么 setTimeout(0) 比 setImmediate 慢?

在 I/O 回调里,先经历 poll 阶段(执行 I/O 回调),紧接着就是 check 阶段(setImmediate),而 timers 要等下一轮才到,所以 setImmediate 先执行。

Promise 回调算宏任务还是微任务?

微任务。它在阶段之间清空,优先于任何宏任务(包括 setTimeout)。

async/await 的 await 后面是微任务吗?

是的。await 之后的代码等价于 Promise.then 的微任务,因此在当前阶段结束后、下一宏任务前执行。

02.9

实战练习与资源

延伸资源