「前端何去何从」事件循环是 Node.js 的心跳

6 阅读5分钟

引子

你大概已经用 AI 写过不少 Node.js 代码了。

让 Copilot 帮你生成一个 Express 路由,几秒钟的事。让它写一段文件读取逻辑,async/await 包得整整齐齐。大多数时候,这些代码能跑,甚至跑得不错。

但总有一些时刻,事情开始变得诡异:

  • setTimeout 和 Promise 的执行顺序,跟你在浏览器里的经验对不上
  • setImmediate 和 process.nextTick,两个你在前端从没见过的 API,文档说的和实际跑出来的不一样
  • 一个看起来没问题的异步操作,在高并发下突然表现异常

你去问 AI,它会给你一个看起来合理的解释。

但如果你追问细节

——"为什么在 I/O 回调里 setImmediate 一定比 setTimeout 先执行?"
——它大概率会开始含糊其辞,甚至给出错误的答案。

这不怪 AI。事件循环的行为高度依赖运行时上下文,同一段代码在不同位置执行,结果可能完全不同。这不是靠模式匹配能答对的问题。

作为前端开发者,你对"异步"并不陌生。  你知道 Promise,知道 async/await,知道"不要阻塞主线程"。但这些认知建立在浏览器的事件循环模型上——而那个模型,在 Node.js 里会产生误导。

这篇文章不会从零讲 JavaScript 异步。它假设你已经熟悉浏览器环境下的异步编程,然后带你看清:Node.js 的事件循环,到底和你以为的有什么不同。

你以为你懂的事件循环

在浏览器里,事件循环的模型相对简单。你可能已经内化了这个流程:

  1. 从宏任务队列取一个任务执行(比如一个 setTimeout 回调)
  2. 清空所有微任务(Promise.thenqueueMicrotask
  3. 如果需要,执行渲染(重绘、重排)
  4. 回到第 1 步

这个模型足够应付绝大多数前端场景。

但当你带着这个心智模型走进 Node.js,会发现好几个地方对不上:

Node.js 没有"渲染"这回事。

浏览器事件循环的节奏很大程度上被屏幕刷新率(通常 60fps)驱动,每一帧大约 16.6ms。Node.js 没有 UI 线程,事件循环的驱动力是 I/O 事件和定时器,不存在"一帧"的概念。

不是一个队列,而是六个阶段。
浏览器的事件循环可以简化为"一个宏任务队列 + 一个微任务队列"。
Node.js 的事件循环是一个由 6 个阶段(phase)组成的循环,每个阶段有自己的任务队列,事件循环按固定顺序依次经过这些阶段。

微任务的执行时机不同。
在浏览器中,每执行完一个宏任务就清空微任务队列。在 Node.js(v11+)中,微任务在每个阶段切换时清空——也就是说,一个阶段内可能连续执行多个回调,然后才轮到微任务。

多了两个你没见过的 API。
setImmediate 和 process.nextTick 是 Node.js 独有的。它们的执行时机和优先级,是理解 Node.js 事件循环的关键拼图。

来看一段代码,分别在浏览器和 Node.js 里跑一下:

// 在浏览器控制台和 Node.js 中分别运行,对比输出顺序
console.log('1: script start');

setTimeout(() => {
  console.log('2: setTimeout');
}, 0);

Promise.resolve().then(() => {
  console.log('3: Promise.then');
});

queueMicrotask(() => {
  console.log('4: queueMicrotask');
});

console.log('5: script end');

在浏览器和 Node.js 中,这段代码的输出顺序是一样的:

1: script start
5: script end
3: Promise.then
4: queueMicrotask
2: setTimeout

看起来没区别?那是因为这段代码太简单了。差异藏在更复杂的场景里——当 setImmediateprocess.nextTick、I/O 回调同时出现时,浏览器的心智模型就不够用了。

接下来,我们看看 Node.js 事件循环的完整面貌。

Node.js 事件循环的全貌

Node.js 的事件循环由 libuv 驱动,每一轮循环(通常称为一个 tick)依次经过 6 个阶段:

   ┌───────────────────────────┐
┌─>│         timers            │  执行 setTimeout / setInterval 回调
│  └─────────────┬─────────────┘
│                │
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  执行系统级回调(如 TCP 错误)
│  └─────────────┬─────────────┘
│                │
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  Node.js 内部使用
│  └─────────────┬─────────────┘
│                │
│  ┌─────────────┴─────────────┐
│  │           poll             │  获取新的 I/O 事件,执行 I/O 回调
│  └─────────────┬─────────────┘
│                │
│  ┌─────────────┴─────────────┐
│  │          check             │  执行 setImmediate 回调
│  └─────────────┬─────────────┘
│                │
│  ┌─────────────┴─────────────┐
│  │      close callbacks      │  执行关闭事件回调(如 socket.on('close'))
│  └─────────────┬─────────────┘
│                │
└────────────────┘

关键认知:事件循环不是"有事就做",而是"按阶段轮询"。

每个阶段都有一个 FIFO 队列,存放该阶段需要执行的回调。当事件循环进入某个阶段时,它会执行该阶段队列中的回调(直到队列清空或达到执行上限),然后进入下一个阶段。

在每两个阶段之间,Node.js 会检查并清空两个特殊队列:

  1. process.nextTick 队列(nextTickQueue)
  2. Promise 微任务队列(microtask queue)

这意味着微任务不属于任何阶段,而是在阶段切换的"间隙"执行。这一点非常重要,后面会详细展开。

现在,让我们逐个阶段看看它们各自在做什么。

六个阶段,逐个击破

1. timers 阶段

这是事件循环的第一个阶段,负责执行 setTimeout 和 setInterval 的回调。

但有一个关键细节:定时器的延迟时间是"最小延迟",不是"精确延迟"。

setTimeout(fn, 100) 的意思不是"100ms 后执行 fn",而是"至少 100ms 后,当事件循环走到 timers 阶段时,执行 fn"。如果事件循环正忙于其他阶段的回调,定时器的实际触发时间会晚于预期。

// timer-precision.js
const start = Date.now();

setTimeout(() => {
  const delay = Date.now() - start;
  console.log(`实际延迟: ${delay}ms(期望: 100ms)`);
}, 100);

// 模拟一个耗时操作,阻塞事件循环
const blockUntil = start + 200;
while (Date.now() < blockUntil) {
  // 忙等待 200ms
}

console.log('同步代码执行完毕');

运行 node timer-precision.js,你会看到实际延迟大约是 200ms 而不是 100ms——因为同步代码阻塞了事件循环,定时器只能等到同步代码执行完、事件循环重新转起来之后才能触发。

这在浏览器里也是一样的道理,但在 Node.js 服务端场景下,这个特性的影响更大:一个 CPU 密集的操作可能让所有定时器集体延迟。

另一个容易忽略的细节:setTimeout(fn, 0) 在 Node.js 中实际上等价于 setTimeout(fn, 1)。Node.js 内部会将延迟为 0 的定时器强制设为 1ms。这个看似无关紧要的细节,在后面讨论 setTimeout vs setImmediate 的执行顺序时会变得关键。

2. pending callbacks 阶段

这个阶段执行的是被推迟到下一轮循环的系统级回调,比如某些 TCP 错误(ECONNREFUSED)的回调。

3. idle, prepare 阶段

这是 Node.js 内部使用的阶段,不暴露给用户代码。跳过。

4. poll 阶段(重点)

poll 是事件循环中最重要的阶段,也是事件循环"停留时间最长"的阶段。它做两件事:

  1. 计算应该阻塞多久来等待 I/O 事件
  2. 处理 poll 队列中的回调

几乎所有的 I/O 回调都在这个阶段执行:文件读写完成的回调、网络请求返回的回调、数据库查询结果的回调……这些都是 poll 阶段的"客人"。

poll 阶段的行为取决于当前状态:

  • 如果 poll 队列不为空:  依次执行队列中的回调,直到队列清空或达到系统限制

  • 如果 poll 队列为空:

    • 如果有 setImmediate 回调等待执行 → 结束 poll 阶段,进入 check 阶段
    • 如果没有 setImmediate → 事件循环会在这里"等待",直到有新的 I/O 事件到来,或者有定时器到期

这就是为什么一个简单的 HTTP 服务器不会退出——它一直在 poll 阶段等待新的连接请求。

// poll-demo.js
const fs = require('fs');

console.log('1: script start');

// 这个回调会在 poll 阶段执行
fs.readFile(__filename, () => {
  console.log('2: file read complete (poll phase)');

  // 在 I/O 回调内部注册的 setTimeout
  setTimeout(() => {
    console.log('3: setTimeout inside I/O callback');
  }, 0);

  // 在 I/O 回调内部注册的 setImmediate
  setImmediate(() => {
    console.log('4: setImmediate inside I/O callback');
  });
});

console.log('5: script end');

运行 node poll-demo.js,输出:

1: script start
5: script end
2: file read complete (poll phase)
4: setImmediate inside I/O callback
3: setTimeout inside I/O callback

注意第 4 行和第 3 行的顺序:在 I/O 回调内部,setImmediate 总是比 setTimeout(fn, 0) 先执行。原因很简单——I/O 回调在 poll 阶段执行,poll 之后紧接着就是 check 阶段(setImmediate),而 setTimeout 要等到下一轮循环的 timers 阶段才能执行。

这是一个非常实用的规律,记住它。

5. check 阶段

check 阶段专门用来执行 setImmediate 的回调。

setImmediate 是 Node.js 独有的 API(浏览器没有)。它的语义是:"在当前 poll 阶段完成后立即执行"。这让它成为一个非常有用的工具——当你想在 I/O 操作完成后尽快执行某段代码,但又不想阻塞当前的 I/O 处理时,setImmediate 是最佳选择。

现在来看一个经典问题:setTimeout(fn, 0) 和 setImmediate,谁先执行?

// order-main.js — 在主模块中执行
setTimeout(() => {
  console.log('setTimeout');
}, 0);

setImmediate(() => {
  console.log('setImmediate');
});

多运行几次 node order-main.js,你会发现输出顺序不确定——有时 setTimeout 先,有时 setImmediate 先。

为什么?因为 setTimeout(fn, 0) 实际上是 setTimeout(fn, 1)。当主模块代码执行完毕,事件循环开始第一轮时,1ms 的定时器是否已经到期取决于系统当时的状态。如果到期了,timers 阶段会先执行它;如果还没到期,事件循环会跳过 timers,一路走到 check 阶段执行 setImmediate

但在 I/O 回调内部,顺序是确定的:

// order-io.js — 在 I/O 回调中执行
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('setTimeout');
  }, 0);

  setImmediate(() => {
    console.log('setImmediate');
  });
});

运行多少次 node order-io.js,结果都是:

setImmediate
setTimeout

原因在上一节已经解释过:I/O 回调在 poll 阶段执行,poll 之后是 check(setImmediate),然后才是下一轮的 timers(setTimeout)。

6. close callbacks 阶段

这个阶段处理关闭事件的回调,比如 socket.on('close', ...)

如果一个 socket 或 handle 被突然关闭(比如 socket.destroy()),close 事件会在这个阶段触发。如果是通过正常流程关闭的,close 事件通常通过 process.nextTick 触发。

对于大多数应用开发场景,你不需要特别关注这个阶段。知道它是事件循环的最后一站就够了。

微任务:不属于任何阶段的"插队者"

到目前为止,我们讲的 6 个阶段都是事件循环的"正式成员"。但还有两类回调,它们不属于任何阶段,却拥有最高的执行优先级:

  1. process.nextTick 回调(nextTickQueue)
  2. Promise 微任务(microtask queue,包括 Promise.thenqueueMicrotask

在 Node.js v11+ 中,每当事件循环准备从一个阶段切换到下一个阶段时,它会先清空这两个队列。顺序是:先清空 nextTickQueue,再清空 microtask queue。

// microtask-order.js
console.log('1: script start');

process.nextTick(() => {
  console.log('2: process.nextTick');
});

Promise.resolve().then(() => {
  console.log('3: Promise.then');
});

setTimeout(() => {
  console.log('4: setTimeout');
}, 0);

setImmediate(() => {
  console.log('5: setImmediate');
});

console.log('6: script end');

运行 node microtask-order.js,输出:

1: script start
6: script end
2: process.nextTick
3: Promise.then
4: setTimeout
5: setImmediate

解析这个输出:

  1. 同步代码先执行:输出 1 和 6
  2. 同步代码执行完毕,事件循环开始。在进入第一个阶段(timers)之前,先清空微任务队列
  3. process.nextTick 优先级高于 Promise.then,所以 2 在 3 前面
  4. 微任务清空后,进入 timers 阶段,执行 setTimeout 回调:输出 4
  5. 继续走到 check 阶段,执行 setImmediate 回调:输出 5

现在来看一个更复杂的例子,理解微任务在阶段切换时的行为:

// microtask-between-phases.js
const fs = require('fs');

fs.readFile(__filename, () => {
  // 我们现在在 poll 阶段

  setTimeout(() => {
    console.log('1: setTimeout');
    process.nextTick(() => {
      console.log('2: nextTick inside setTimeout');
    });
  }, 0);

  setImmediate(() => {
    console.log('3: setImmediate');
    process.nextTick(() => {
      console.log('4: nextTick inside setImmediate');
    });
  });

  process.nextTick(() => {
    console.log('5: nextTick');
  });

  Promise.resolve().then(() => {
    console.log('6: Promise.then');
  });
});

运行 node microtask-between-phases.js,输出:

5: nextTick
6: Promise.then
3: setImmediate
4: nextTick inside setImmediate
1: setTimeout
2: nextTick inside setTimeout

逐步解析:

  1. I/O 回调在 poll 阶段执行完毕后,准备切换到 check 阶段
  2. 切换前先清空微任务:nextTick(5)→ Promise.then(6)
  3. 进入 check 阶段,执行 setImmediate(3)
  4. check 阶段结束,切换前清空微任务:nextTick inside setImmediate(4)
  5. 下一轮循环进入 timers 阶段,执行 setTimeout(1)
  6. timers 阶段结束,清空微任务:nextTick inside setTimeout(2)

process.nextTick 的危险面

process.nextTick 的高优先级是一把双刃剑。如果你在 nextTick 回调中递归调用 nextTick,微任务队列永远清不空,事件循环永远无法进入下一个阶段:

// starvation.js — 不要在生产环境运行!
function recursiveNextTick() {
  process.nextTick(() => {
    console.log('nextTick 执行');
    recursiveNextTick(); // 永远不会让出控制权
  });
}

recursiveNextTick();

setTimeout(() => {
  console.log('这行永远不会执行');
}, 0);

这就是所谓的"饿死"事件循环。setTimeout 的回调永远等不到 timers 阶段,因为事件循环被困在了微任务队列里。

实际建议:  除非你明确需要在当前操作完成后、事件循环继续之前执行某段代码,否则优先使用 setImmediate 而不是 process.nextTick。Node.js 官方文档也给出了同样的建议。

实战中的三个坑

理论讲完了,来看看这些知识在实际开发中会以什么形式"咬"你一口。

坑 1:setTimeout vs setImmediate 的顺序"薛定谔"

这个问题在前面已经提到过,但值得单独拎出来强调,因为它是 Node.js 事件循环中最常被问到的问题。

规律很简单:

  • 在主模块中:  顺序不确定(取决于 1ms 定时器是否已到期)
  • 在 I/O 回调中:  setImmediate 总是先于 setTimeout(fn, 0)
// pitfall-1.js
const fs = require('fs');

// 场景 1:主模块 — 顺序不确定
setTimeout(() => console.log('主模块: setTimeout'), 0);
setImmediate(() => console.log('主模块: setImmediate'));

// 场景 2:I/O 回调内 — 顺序确定
fs.readFile(__filename, () => {
  setTimeout(() => console.log('I/O 内: setTimeout'), 0);
  setImmediate(() => console.log('I/O 内: setImmediate'));
});

多运行几次,你会发现场景 1 的顺序会变,但场景 2 永远是 setImmediate 先。

坑 2:连续 await 的微任务陷阱

async/await 是 Promise 的语法糖,每个 await 后面的代码都会被包装成微任务。当多个 async 函数"交错"执行时,执行顺序可能出乎意料:

// pitfall-2.js
async function taskA() {
  console.log('A-1');
  await Promise.resolve();
  console.log('A-2');
  await Promise.resolve();
  console.log('A-3');
}

async function taskB() {
  console.log('B-1');
  await Promise.resolve();
  console.log('B-2');
  await Promise.resolve();
  console.log('B-3');
}

taskA();
taskB();

运行 node pitfall-2.js,输出:

A-1
B-1
A-2
B-2
A-3
B-3

两个函数在每个 await 处"交替"执行。这是因为每个 await 都会让出控制权,把后续代码放入微任务队列。当 A-1 执行完遇到 await,控制权交给 B,B-1 执行完也遇到 await,然后微任务队列按顺序执行 A-2、B-2、A-3、B-3。

在实际开发中,如果你有两个 async 函数操作同一份数据,这种交错执行可能导致竞态条件——即使 Node.js 是单线程的。

坑 3:CPU 密集任务"冻住"事件循环

Node.js 的单线程模型意味着:如果你的代码在做 CPU 密集的计算,整个事件循环都会被阻塞。所有的定时器、I/O 回调、网络请求处理都得等着。

// pitfall-3.js
const http = require('http');

const server = http.createServer((req, res) => {
  if (req.url === '/slow') {
    // 模拟 CPU 密集操作:计算斐波那契数列
    const fib = (n) => (n <= 1 ? n : fib(n - 1) + fib(n - 2));
    const result = fib(42);
    res.end(`Result: ${result}`);
  } else {
    res.end('Hello!');
  }
});

server.listen(3000);
console.log('Server running on http://localhost:3000');

当有人访问 /slow 时,fib(42) 大约需要 1-2 秒。在这段时间里,所有其他请求(包括访问 / 的请求)都会被阻塞——因为事件循环被困在了这个同步计算中,无法进入 poll 阶段处理新的网络事件。

这就是"Node.js 不适合 CPU 密集型任务"这句话的真正含义。不是说 Node.js 算不了,而是算的时候整个服务都停了。

解决方案:  对于 CPU 密集的操作,使用 worker_threads 把计算放到独立线程中,不阻塞主事件循环。这超出了本文的范围,但知道这个出口很重要。

写在最后

事件循环是 Node.js 的心跳。理解它,你就能听懂 Node.js 在告诉你什么。

从浏览器到 Node.js,最大的跨越不是学新的 API,而是更新你对"异步"的理解。  希望这篇文章帮你完成了这一步。