Node.js 中的事件循环:工作机制和实际例子

1. 概览

事件循环是帮助 Node.js 用单一 JavaScript 线程处理数千个并发请求的核心机制。很多开发者每天写 async/await、setTimeout、Promise 代码,但并不清楚实际的执行顺序是如何进行的。

这篇文章通过具体的阶段解释事件循环的结构,区分 microtask 和 macrotask,然后用可运行的代码演示。读完后,你将能准确预测复杂异步代码的日志执行顺序,并避免像阻塞事件循环这样的常见错误。

2. Node.js 如何以单线程方式运行

Node.js 在一个主线程上运行 JavaScript,但 I/O 任务(读取文件、网络调用、数据库查询)被推送到 libuv——一个处理异步 I/O 的 C 库,使用线程池或内核机制(epoll、kqueue 等)。

┌───────────────────────────┐
│   JavaScript (single thread) │
└──────────────┬────────────┘
               │ 调用 fs.readFile、setTimeout、net...
               ▼
┌───────────────────────────┐
│     libuv (C/C++)          │
│  - 线程池                  │
│  - 事件循环                 │
│  - OS 异步 API              │
└───────────────────────────┘

当 I/O 任务完成后,libuv 将回调放入相应的队列中。事件循环不断检查这些队列,按轮次执行回调——这正是“循环”的含义。

注意: 你的 JavaScript 代码始终在单个线程上运行。如果你写了很重的同步代码(比如大量计算循环),它将阻塞整个事件循环,导致所有其他请求都必须等待。

3. 事件循环的各个阶段

事件循环不是简单地运行一个循环,而是在每次迭代(tick)中按固定顺序经过 6 个阶段:

   ┌───────────────────────┐
┌─>│        timers          │  setTimeout, setInterval
│  └──────────┬─────────────┘
│  ┌──────────▼─────────────┐
│  │     pending callbacks   │  被延迟的 I/O 回调
│  └──────────┬─────────────┘
│  ┌──────────▼─────────────┐
│  │        idle, prepare    │
│  └──────────┬─────────────┘
│  ┌──────────▼─────────────┐
│  │          poll           │  I/O 事件(读文件、网络)
│  └──────────┬─────────────┘
│  ┌──────────▼─────────────┐
│  │          check          │  setImmediate
│  └──────────┬─────────────┘
│  ┌──────────▼─────────────┐
└──┤     close callbacks     │  socket.on('close')
   └─────────────────────────┘

3.1. 每个阶段的简短描述

  • timers:执行已到期的 setTimeout/setInterval 回调。
  • pending callbacks:执行上一个循环周期中被延迟的 I/O 回调。
  • poll:等待并处理新的 I/O 事件(文件读取完成、收到网络响应等)。这是耗时最多的阶段。
  • check:执行 setImmediate 的回调。
  • close callbacks:处理关闭事件,比如 socket.on('close', ...)。

每个阶段都有自己的队列。事件循环处理完(或达到限制)当前阶段队列中的所有回调后,才会移动到下一个阶段。

4. Microtask 队列:Promise 和 process.nextTick

除了上面的 6 个 macrotask 阶段,Node.js 还有微任务队列,它在每个阶段之间被处理——不需要等待整个循环完成后才运行。

有两种类型的微任务:

  • process.nextTick() ——独立的队列,始终在所有其他微任务之前被处理。
  • Promise.then/catch/finally ——标准的微任务队列(按 ECMAScript 规范)。
console.log('1 - sync start');

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

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

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

console.log('5 - sync end');

解释:

  • 同步 console.log 立即运行(1、5)。
  • setTimeout 将回调推送到 timers 队列——在下一个阶段运行。
  • process.nextTick 和 Promise.then 都是微任务,但 nextTick 有更高的优先级。
  • 同步代码运行完后,Node.js 先清空 nextTick 队列,再清空 Promise 微任务队列,然后才进入 timers 阶段。
1 - sync start
5 - sync end
4 - nextTick
3 - promise
2 - setTimeout

注意: 如果你递归地不断调用 process.nextTick()(例如在一个回调中再次调用 nextTick),事件循环将永远不会进入下一个阶段——这被称为“I/O 饥饿”。

5. setTimeout vs setImmediate:哪个顺序是对的?

这是最容易引起混淆的问题。在顶层调用时,setTimeout(fn, 0) 和 setImmediate(fn) 之间的顺序不固定,但在 I/O 回调中调用时是固定的。

const fs = require('fs');

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

因为这段代码在 fs.readFile 的回调中——即在 poll 阶段——所以当 poll 阶段结束时,事件循环总是先切换到 check 阶段(setImmediate),然后新循环才回到 timers 阶段。

immediate
timeout

相反,如果在顶层放置这两个命令(在任何 I/O 回调之外),顺序取决于当时 Node.js 启动进程的性能——不应该在生产代码中依赖这个顺序。

6. 阻塞事件循环:最常见的错误

因为只有一个线程处理 JavaScript,任何运行时间长的同步代码都会阻塞整个服务器。

const express = require('express');
const app = express();

app.get('/fibonacci', (req, res) => {
  function fib(n) {
    return n <= 1 ? n : fib(n - 1) + fib(n - 2);
  }
  res.send(`Result: ${fib(40)}`); // 阻塞!
});

app.listen(3000);

当一个请求调用 /fibonacci 时,fib(40) 函数同步运行,耗时几秒钟。在这期间,所有其他请求(即使只是一个简单的 ping)都必须等待,因为事件循环被卡在一个任务上。

6.1. 解决方案:将繁重工作推送到 Worker Thread

const { Worker } = require('worker_threads');

app.get('/fibonacci', (req, res) => {
  const worker = new Worker('./fib-worker.js', {
    workerData: { n: 40 }
  });

  worker.on('message', (result) => res.send(`Result: ${result}`));
  worker.on('error', (err) => res.status(500).send(err.message));
});
// fib-worker.js
const { workerData, parentPort } = require('worker_threads');

function fib(n) {
  return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}

parentPort.postMessage(fib(workerData.n));

Worker 在单独的线程上运行,不占用主线程。主请求的事件循环仍然空闲,可以在 worker 计算期间处理其他请求。

何时不需要 Worker Thread: 对于 I/O 任务(读取数据库、调用 API、读取文件)——这些已经是异步的,不需要 worker。Worker Thread 只对 CPU 密集型任务有用(重型计算、加密、图像处理等)。

7. 使用 perf_hooks 测量事件循环延迟

要在生产环境中检测事件循环是否被阻塞,可以使用内置的 perf_hooks 模块。

const { monitorEventLoopDelay } = require('perf_hooks');

const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();

setInterval(() => {
  console.log(`Event loop delay (mean): ${histogram.mean / 1e6} ms`);
  histogram.reset();
}, 5000);

monitorEventLoopDelay 创建一个直方图,测量事件循环相对于理想计划的“延迟”时间。如果 mean 值异常增加(例如超过 100ms),这表示某处有代码在阻塞。

8. 编写测试以验证执行顺序

为了确认对微任务/宏任务顺序的正确理解,可以编写测试,将日志记录到数组中,然后断言顺序。

// order.js
function recordOrder(log) {
  log.push('sync-start');

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

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

  process.nextTick(() => log.push('next-tick'));

  log.push('sync-end');
}

module.exports = { recordOrder };
// order.test.js
const { recordOrder } = require('./order');

test('执行顺序:sync -> nextTick -> promise -> timeout', (done) => {
  const log = [];
  recordOrder(log);

  // 同步代码立即运行完成
  expect(log).toEqual(['sync-start', 'sync-end']);

  setImmediate(() => {
    expect(log).toEqual([
      'sync-start',
      'sync-end',
      'next-tick',
      'promise',
      'timeout',
    ]);
    done();
  });
});

这个测试使用 Jest 和 setImmediate 来等待所有微任务和 timers 阶段完成,然后断言 log 的最终顺序。这是验证对事件循环理解的准确方式,而不仅仅是用眼睛看日志。

npx jest order.test.js
PASS  ./order.test.js
  ✓ 执行顺序:sync -> nextTick -> promise -> timeout (5 ms)

9. 常见错误

  • 滥用递归 process.nextTick:导致 I/O 饥饿,服务器无法处理新的请求。
  • 认为 setTimeout(fn, 0) 立即运行:实际上它总是至少等待事件循环的一个 tick,并且在所有微任务之后运行。
  • 在主线程上运行 CPU 密集型代码:例如解析巨大的 JSON、高成本的 bcrypt 加密、同步图像处理——所有这些都应该推送到 Worker Thread 或子进程。
  • 假设顶层的 setTimeout vs setImmediate 的顺序:不可靠,避免编写依赖于它的逻辑。

何时需要小心: 如果你的应用有处理大数据的端点(导出 Excel、计算统计、调整图像大小),应该定期测量 monitorEventLoopDelay,以便早期发现问题,防止影响整个服务器。

10. 总结

事件循环是 Node.js 能以低资源处理大量连接的原因,但它也带来了限制:所有同步 JavaScript 代码都在单个线程上运行。清楚地理解 6 个阶段(timers、pending callbacks、poll、check、close)和微任务队列的位置(process.nextTick、Promise)可以帮助你准确预测执行顺序,避免难以调试的错误。

遇到 CPU 密集型任务时,不要犹豫使用 worker_threads 或分离到单独的进程——事件循环只有在你不让它喘不过气来时才强大。

nodejsevent-loopjavascriptasynchronousbackend