返回首页

深入理解 Node.js 事件循环:单线程为什么能扛高并发

从 libuv 讲到事件循环的六个阶段,再到 nextTick 与微任务的优先级,把 Node.js 的异步底座一次讲透。

17 约 4 分钟 · 4193 字 后端

很多人对 Node.js 的第一印象是"单线程",紧接着就会冒出一个疑问:单线程怎么扛得住成千上万的并发连接?

答案藏在**事件循环(Event Loop)**里。它不是 V8 提供的,而是来自底层的 C 库 libuv。理解了事件循环,你就理解了 Node.js 异步模型的全部底层逻辑。

单线程 ≠ 只有一个线程

先纠正一个常见误解。我们说的"单线程"指的是执行 JavaScript 代码的线程只有一个,而不是整个 Node.js 进程只有一个线程。

实际上 Node.js 进程里至少有这些角色:

  • 一个主线程,负责跑你写的 JS 代码和事件循环
  • libuv 维护的一个线程池(默认 4 个),处理文件 I/O、DNS 解析、CPU 密集的 crypto 等
  • V8 自己的 GC、编译线程

所以"单线程"真正的含义是:你的业务代码不会被并行执行,不用担心多线程的数据竞争。而真正耗时的 I/O 被甩给了操作系统和线程池,主线程只在结果就绪时回来执行回调。

阻塞与非阻塞的区别

理解事件循环之前,先看一段对比。同步读文件会阻塞主线程:

import { readFileSync, readFile } from 'node:fs'

// 阻塞:读完之前什么都干不了
const data = readFileSync('big.log', 'utf8')
console.log('读完了')
console.log('这行要等上面读完才执行')

非阻塞版本则把工作交出去,主线程立刻往下走:

readFile('big.log', 'utf8', (err, data) => {
  console.log('读完了') // 稍后由事件循环调度执行
})
console.log('这行先执行') // 不用等文件

Node.js 的高并发能力,正是建立在"不阻塞主线程"这个原则之上。

事件循环的六个阶段

libuv 的事件循环不是一个简单的 while(true),它被划分成几个有序的阶段(phase),每一轮(tick)按顺序走一遍:

   ┌───────────────────────────┐
┌─>│           timers           │  setTimeout / setInterval 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks      │  上一轮延迟的部分系统回调(如 TCP 错误)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare        │  仅内部使用
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll             │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check            │  setImmediate 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks       │  socket.on('close') 等
   └───────────────────────────┘

我们日常真正需要关心的是三个阶段:

  • timers:执行到期的 setTimeout / setInterval 回调
  • poll:事件循环的核心,执行绝大多数 I/O 回调(读写文件、网络),队列空时会在这里"等活儿"
  • check:专门执行 setImmediate 的回调

poll 阶段:事件循环的心脏

poll 阶段决定了事件循环何时往下走、何时停下来等待。它的逻辑大致是:

  1. 如果 poll 队列里有 I/O 回调,依次执行直到队列清空或达到系统上限
  2. 队列空了之后:
    • 如果有 setImmediate 排队,就结束 poll,进入 check 阶段
    • 否则检查 timers,如果有快到期的定时器,就绕回 timers 阶段
    • 如果两者都没有,主线程会阻塞在这里等待新的 I/O 事件(这正是 Node 空闲时不占 CPU 的原因)

这套机制保证了 I/O 优先、定时器及时、空闲不空转。

微任务:nextTick 与 Promise 的插队

上面六个阶段处理的都是宏任务。但 Node 还有两个优先级更高的队列,它们不属于任何阶段,而是在每个阶段之间、以及每个宏任务回调执行完之后被清空:

  1. process.nextTick 队列(优先级最高)
  2. 微任务队列(Promise 的 .then / await 之后的逻辑)

执行顺序的铁律是:每执行完一个宏任务,先清空 nextTick 队列,再清空微任务队列,然后才进入下一个宏任务

看这段经典代码:

console.log('1: 同步')

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

setImmediate(() => console.log('3: setImmediate'))

Promise.resolve().then(() => console.log('4: Promise'))

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

console.log('6: 同步')

输出顺序是:

1: 同步
6: 同步
5: nextTick      ← 同步代码跑完,先清 nextTick
4: Promise       ← 再清微任务
2: setTimeout    ← 进入 timers 阶段
3: setImmediate  ← 进入 check 阶段

注意:setTimeout(fn, 0)setImmediate主模块里的先后顺序其实是不确定的,取决于进入事件循环时定时器是否已到期。但只要把它们放进一个 I/O 回调里,setImmediate 永远先执行 —— 因为 I/O 回调在 poll 阶段跑完,紧接着就是 check 阶段。

nextTick 用太狠会"饿死"事件循环

正因为 nextTick 队列会在进入下一阶段前被完全清空,如果你递归地调用它,事件循环会被卡住,I/O 永远得不到执行机会:

// 危险:事件循环再也走不到 poll 阶段
function loop() {
  process.nextTick(loop)
}
loop()
// 此后任何 setTimeout、网络请求回调都不会触发

需要"下一轮再执行"且不想阻塞 I/O 时,优先用 setImmediate,它会乖乖排到 check 阶段,不会饿死事件循环。

不要在事件循环里跑 CPU 密集任务

事件循环最怕的不是 I/O,而是长时间占用主线程的同步计算。下面这种代码会让整个服务卡死,所有请求都得排队:

app.get('/report', (req, res) => {
  // 同步跑 3 秒的大循环,这 3 秒内整个进程无法响应任何其他请求
  const result = heavyComputeSync()
  res.json(result)
})

正确的做法是把 CPU 密集任务挪出主线程:

  • worker_threads 开工作线程做计算
  • 拆成小块,用 setImmediate 分批让出主线程
  • 重活交给独立的服务或队列

小结

把事件循环拆开看,其实就几句话:

  • Node 单线程指的是跑 JS 的线程只有一个,I/O 被甩给 libuv 线程池和操作系统
  • 事件循环分六个阶段,日常重点是 timers、poll、check
  • poll 是核心,空闲时阻塞等待 I/O,这是高并发又不空转 CPU 的关键
  • 每个宏任务之后都会先清 nextTick、再清微任务,优先级高于所有定时器和 I/O
  • 别用递归 nextTick 饿死循环,别在主线程跑 CPU 密集计算

理解了这套调度,你写异步代码时就能预判执行顺序,排查"回调为什么没触发""服务为什么卡住"这类问题也会有据可依。