很多人对 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 阶段决定了事件循环何时往下走、何时停下来等待。它的逻辑大致是:
- 如果 poll 队列里有 I/O 回调,依次执行直到队列清空或达到系统上限
- 队列空了之后:
- 如果有
setImmediate排队,就结束 poll,进入 check 阶段 - 否则检查 timers,如果有快到期的定时器,就绕回 timers 阶段
- 如果两者都没有,主线程会阻塞在这里等待新的 I/O 事件(这正是 Node 空闲时不占 CPU 的原因)
- 如果有
这套机制保证了 I/O 优先、定时器及时、空闲不空转。
微任务:nextTick 与 Promise 的插队
上面六个阶段处理的都是宏任务。但 Node 还有两个优先级更高的队列,它们不属于任何阶段,而是在每个阶段之间、以及每个宏任务回调执行完之后被清空:
process.nextTick队列(优先级最高)- 微任务队列(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 密集计算
理解了这套调度,你写异步代码时就能预判执行顺序,排查"回调为什么没触发""服务为什么卡住"这类问题也会有据可依。