首页 / Node.js 教程 / 事件循环

Node.js 教程

事件循环

本教程共 76 篇 · 第 14 篇 · 更新于 2026-07-25 · 约 6 分钟阅读

Node.js事件循环宏任务微任务libuv

14. 事件循环

本节目标:用生活化比喻讲清事件循环的六个阶段和宏任务、微任务。

事件循环(Event Loop)是 Node.js 最难讲清楚、也最容易被面试问倒的知识点。很多人第一次看官方文档里的阶段图,感觉像在读电路图。我先用一个生活化的比喻帮你建立直觉,再拆开技术细节。

比喻:医院挂号窗口

想象一家医院只有一个挂号窗口(单线程),但大厅里有很多患者(I/O 请求)。

  • 有人只是来问路的(同步代码),窗口立刻回答,答完下一个。
  • 有人要抽血化验(异步 I/O),窗口给他一张回执单,说“结果出来叫号”。患者去旁边等,窗口继续服务下一个人。
  • 化验室(操作系统内核)做完检查,把结果塞到窗口旁的“待取件篮”里。
  • 窗口每忙完一阵,就扫一眼待取件篮,按优先级叫号。

这个“扫一眼篮子、按优先级叫号”的循环,就是事件循环。窗口永远不会傻等某个化验结果,所以始终能接待新患者。

Node.js 的事件循环比这复杂,但核心逻辑一模一样:JavaScript 线程不停运转,把耗时的 I/O 交给内核,等内核返工后再按一定顺序执行回调。

事件循环的六个阶段

Node.js 的事件循环分为几个阶段(phase),每个阶段维护一个先进先出的回调队列。循环按固定顺序进入每个阶段,把该阶段的队列清空(或达到上限)后,进入下一阶段。

简化版流程如下:

   ┌───────────────────────────┐
   │         timers            │  ← setTimeout、setInterval 回调
   └─────────────┬─────────────┘
                 v
   ┌───────────────────────────┐
┌─>│    pending callbacks      │  ← 系统级别的 I/O 回调(如 TCP 错误)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │      idle, prepare        │  ← 内部使用,不用管
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐      ┌───────────────┐
│  │          poll             │<─────┤  incoming I/O │
│  └─────────────┬─────────────┘      └───────────────┘
│  ┌─────────────┴─────────────┐
│  │          check            │  ← setImmediate 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │      close callbacks      │  ← socket.on('close', ...)
│  └─────────────┬─────────────┘
│                │
└────────────────┘

timers 阶段

执行 setTimeout()setInterval() 到期的回调。注意,这里说的“到期”是最小延迟,不是精确时间。如果当前事件循环很忙,回调会被推迟。

const start = Date.now();

setTimeout(() => {
  console.log('实际延迟:', Date.now() - start, 'ms');
}, 100);

// 如果中间有耗时操作,延迟可能远大于 100ms

pending callbacks 阶段

执行一些被推迟到下一次循环的系统 I/O 回调。比如 TCP 连接收到 ECONNREFUSED,某些 *nix 系统会选择在这一阶段报告错误。

poll 阶段

这是事件循环的“主战场”。poll 阶段做两件事:

  1. 计算它应该阻塞等待 I/O 多久。
  2. 执行 poll 队列里的 I/O 回调。

如果 poll 队列不为空,事件循环会同步执行队列里的回调,直到队列为空或达到系统上限。如果 poll 队列为空:

  • 如果有 setImmediate 在等待,结束 poll 阶段,进入 check 阶段。
  • 如果没有 setImmediate,事件循环会在这里阻塞等待新的 I/O 事件到来。

poll 阶段的阻塞是 Node.js 高效的原因:没有事做的时候,CPU 不空转,而是等内核通知。

check 阶段

执行 setImmediate() 的回调。它设计的初衷就是“在 poll 阶段结束后尽快执行”。

close callbacks 阶段

执行关闭事件的回调,比如 socket.on('close', ...)。如果是突然销毁(socket.destroy()),close 事件在这里触发;如果是正常关闭,通常走 process.nextTick()

微任务:插队的高手

上面六个阶段处理的是宏任务(macrotask)。但在每次阶段切换之间,事件循环会先清空两类微任务(microtask)

  1. process.nextTick() 的回调队列
  2. Promise.then() / catch() / finally() 的微任务队列

它们的优先级高于所有宏任务。尤其是 process.nextTick(),虽然名字带“next”,实际上它比 Promise 微任务还急,会在当前操作结束后立刻执行,甚至不让事件循环进入下一阶段。

看一段经典代码:

console.log('1. 同步');

setTimeout(() => console.log('2. timeout'), 0);

Promise.resolve().then(() => console.log('3. promise'));

process.nextTick(() => console.log('4. nextTick'));

console.log('5. 同步结束');

输出顺序:

1. 同步
5. 同步结束
4. nextTick
3. promise
2. timeout

同步代码先走完,然后 nextTick 插队,接着是 Promise 微任务,最后才轮到 timer 宏任务。

Note

自 libuv 1.45.0(Node.js 20+)起,timers 在每轮事件循环的 poll 阶段之后运行一次,而不是进入循环之前。这会轻微影响 setImmediatesetTimeout 的交互时序。v24 LTS 沿用了这一行为。

process.nextTick 的特殊地位

process.nextTick() 不在上面那张事件循环图里,因为它技术上不属于事件循环。它维护的是自己的 nextTickQueue,在当前操作完成后、事件循环继续之前被清空。

这个特性很强大,但也危险。如果你在 nextTick 回调里递归调用 process.nextTick(),事件循环永远进不了 poll 阶段,I/O 会被饿死。

Node.js 保留它的原因是历史包袱和某些语义需求。比如,在构造函数里触发事件,如果不推迟到 nextTick,监听者可能还没注册:

import EventEmitter from 'node:events';

class MyEmitter extends EventEmitter {
  constructor() {
    super();
    process.nextTick(() => this.emit('ready'));
  }
}

const emitter = new MyEmitter();
emitter.on('ready', () => console.log('收到了!'));

如果没有 nextTickemit('ready') 触发时,.on('ready') 还没执行。

setImmediate 与 setTimeout 的恩怨

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

在主模块里直接运行,两者顺序不确定,取决于进程性能。但如果把它们包在 I/O 回调里:

import { readFile } from 'node:fs';

readFile(import.meta.filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});

这时候 immediate 一定先输出。因为 I/O 回调在 poll 阶段执行,poll 阶段结束后紧接着就是 check 阶段,setImmediate 顺势跑掉。而 setTimeout 得等下一轮 timers 阶段。

这是 setImmediate 名字的由来:在当前 I/O 完成后“立即”执行。