如果你接触过 Node.js,大概率听过这样一句话:
Node.js 通过 Event Loop(事件循环)实现非阻塞 I/O,所以即使 JavaScript 主要运行在单线程上,也能处理大量并发请求。
这句话很好记,但真正理解起来没那么简单。
比如继续往下问:
- Event Loop 到底在"循环"什么?
fs.readFile()读取文件时,JavaScript 主线程在做什么?Promise.then()为什么经常比setTimeout()更早执行?process.nextTick()为什么比较特殊?setTimeout(fn, 0)为什么不是立即执行?setImmediate()和setTimeout(0)到底谁先?- 为什么一个耗时任务能让其他请求一起变慢?
这些问题其实都指向同一个核心:
Node.js 是怎么在一个 JavaScript 主线程和大量异步任务之间完成调度的?
先从一段普通代码开始:
js
const fs = require('node:fs')
console.log('start')
setTimeout(() => {
console.log('timeout')
}, 0)
fs.readFile('./data.txt', () => {
console.log('file')
})
Promise.resolve().then(() => {
console.log('promise')
})
console.log('end')
这里同时出现了同步 JavaScript、Timer(定时器)、文件 I/O 和 Promise 微任务。
它们不会简单按照代码从上到下依次完成。
把这些任务为什么会在不同时间执行弄清楚,Event Loop 的整体逻辑也就基本清楚了。
1. Event Loop 到底解决了什么问题?
先看一个很常见的服务端场景:
js
const user = await queryDatabase()
假设一次数据库查询需要 300ms。
如果 JavaScript 主线程在这 300ms 里什么都不做,只停下来等数据库返回:
text
请求 A
↓
查询数据库
↓
等待 300ms
↓
数据库返回
↓
继续处理
请求 B
↓
现在才能开始
服务器大量时间都会浪费在等待上。
但数据库查询耗时 300ms,并不代表 CPU 真的连续计算了 300ms。这段时间可能主要花在数据库执行、网络传输和等待结果上。
所以 Node.js 更希望这样工作:
text
Request A → Database ──────────┐
│
Request B → Redis ─────────────┤
│
Request C → HTTP API ──────────┤
│
Request D → Database ──────────┤
↓
谁完成了,再处理谁
一个请求在等待 I/O 时,JavaScript 主线程可以继续处理其他工作。
等 I/O 完成以后,再回来执行对应的 JavaScript callback(回调函数)。
Event Loop 在这里承担的就是调度职责:
持续协调已经准备好的任务,并在合适的时机安排对应的 JavaScript 回调执行。
需要先区分一点:
Event Loop 本身并不负责完成所有异步工作。
数据库、网络、文件读取等实际操作,可能由操作系统、libuv 或底层工作线程完成。任务完成以后,对应的 JavaScript 回调才会重新进入调度流程。
2. 一段异步代码在 Node.js 中经历了什么?
先把代码简化一下:
js
const fs = require('node:fs')
console.log('A')
fs.readFile('./test.txt', () => {
console.log('B')
})
console.log('C')
运行以后通常会看到:
text
A
C
B
为什么 C 会出现在 B 前面?
可以一步步看。
第一步:执行同步代码
Node.js 先执行:
js
console.log('A')
所以首先输出:
text
A
第二步:遇到 fs.readFile()
接下来:
js
fs.readFile('./test.txt', () => {
console.log('B')
})
文件读取属于异步操作。
Node.js 发起读取以后,不需要让 JavaScript 主线程停在这里等待,真正的文件读取工作会交给底层处理。
于是 JavaScript 可以继续往下执行。
第三步:继续执行同步代码
js
console.log('C')
马上执行。
此时输出已经是:
text
A
C
第四步:文件读取完成
等文件读取完成后:
js
() => {
console.log('B')
}
这个 callback 才具备执行条件。
但它也不能直接打断正在运行的 JavaScript,而是要等主线程空下来,并在合适的 Event Loop 阶段得到调度。
最后输出:
text
B
整个过程可以简化成:
text
JavaScript 主线程
│
▼
执行 console.log('A')
│
▼
遇到 fs.readFile()
│
├────────→ 文件读取交给底层处理
│
▼
继续执行 console.log('C')
│
▼
当前同步代码执行完成
│
▼
文件读取完成
│
▼
callback 等待调度
│
▼
Event Loop 安排执行
│
▼
console.log('B')
这里可以先建立一个基本认知:
异步任务可以在主线程之外等待,但最终的 JavaScript 回调仍然需要回到 JavaScript 执行线程上运行。
3. Event Loop 一轮会处理哪些事情?
Event Loop 并不是把所有 callback 扔进同一个队列,再简单地从头执行到尾。
不同类型的任务有各自对应的处理阶段。
对于日常开发,可以先认识几个主要阶段:
| 阶段 | 主要职责 |
|---|---|
| Timers | 处理达到时间阈值的 setTimeout()、setInterval() |
| Pending Callbacks | 处理部分延迟执行的系统级 I/O callback |
| Idle / Prepare | Node.js / libuv 内部使用 |
| Poll | 获取 I/O 事件并执行大量 I/O callback |
| Check | 执行 setImmediate() |
| Close Callbacks | 处理部分资源关闭事件 |
这里不需要一开始就死记阶段顺序。
对理解日常异步代码来说,最值得关注的是:
- Poll
- Check
- Timers
以及后面会讲到的:
- Microtask Queue(微任务队列)
- next tick queue
因为真正分析代码执行顺序时,它们往往会一起出现。
4. 为什么 Poll 是 Event Loop 中很关键的一环?
Node.js 很擅长处理 I/O,而 Poll 正好和 I/O 密切相关。
可以把它简化理解成:
text
进入 Poll
│
├── 有没有已经准备好的 I/O callback?
│
│ ├── 有
│ │ ↓
│ │ 执行 callback
│ │
│ └── 没有
│
└── 接下来有没有其他需要处理的任务?
│
├── 有
│ ↓
│ 继续后续阶段
│
└── 没有
↓
等待新的 I/O
这也能解释一个问题:
Node.js 暂时没任务时,为什么不会不停循环,把 CPU 跑满?
因为它不是一直在执行:
text
文件好了吗?
文件好了吗?
文件好了吗?
文件好了吗?
这样的空转检查。
当没有可执行任务时,底层可以等待新的 I/O 事件;事件到来以后,再继续工作。
所以像下面这些场景:
text
等待数据库
等待 Redis
等待网络
等待文件
等待 HTTP API
并不需要 JavaScript 主线程持续占用 CPU。
5. setTimeout(fn, 0) 为什么不是立即执行?
来看一段简单代码:
js
console.log('start')
setTimeout(() => {
console.log('timeout')
}, 0)
console.log('end')
输出:
text
start
end
timeout
这里的:
js
setTimeout(fn, 0)
并不是:
0ms 后立即执行
fn。
更准确地说,它表示:
达到指定的时间阈值以后,这个 callback 才具备被 Timer 调度的条件。
真正什么时候执行,还得看 JavaScript 主线程什么时候空下来。
比如:
js
setTimeout(() => {
console.log('timeout')
}, 100)
const start = Date.now()
while (Date.now() - start < 1000) {
// 模拟耗时计算
}
Timer 设置的是 100ms,但主线程被循环占用了大约 1000ms。
所以实际过程更接近:
text
0ms
↓
注册 timer
100ms
↓
已经达到时间阈值
↓
主线程仍然在忙
↓
继续等待
约 1000ms
↓
主线程空下来
↓
timer callback 才有机会执行
所以:
Timer 表示的是时间阈值,而不是精准执行时间。
6. Promise 和 Microtask Queue 在哪里?
再来看 Promise:
js
console.log('A')
Promise.resolve().then(() => {
console.log('B')
})
console.log('C')
输出:
text
A
C
B
Promise.then() 里的 callback 不会立即执行,而是进入 Microtask Queue(微任务队列)。
可以简单理解成:
text
当前同步 JavaScript
│
▼
Call Stack 清空
│
▼
Microtask Queue
│
▼
继续后面的调度
常见的 Promise 微任务包括:
js
.then()
.catch()
.finally()
这里需要把两个层次区分开。
前面讲的:
text
Poll
Check
Timers
属于 Event Loop 的主要阶段。
而 Promise 的 Microtask Queue 属于 JavaScript / V8 的微任务调度机制。
比如:
js
console.log('1')
setTimeout(() => {
console.log('2')
}, 0)
Promise.resolve().then(() => {
console.log('3')
})
console.log('4')
先执行同步代码:
text
1
4
然后处理 Promise 微任务:
text
3
之后 Timer 才有机会执行:
text
2
所以最终输出:
text
1
4
3
2
到这里,可以先把任务粗略分成三个层次:
text
同步 JavaScript
↓
Promise Microtask
↓
Event Loop 中的 Timer / I/O 等任务
Node.js 里还有一个更特殊的机制:
js
process.nextTick()
7. process.nextTick() 为什么比较特殊?
先看一个最小例子:
js
console.log('start')
process.nextTick(() => {
console.log('nextTick')
})
Promise.resolve().then(() => {
console.log('promise')
})
console.log('end')
在常见的 CommonJS 脚本场景下,输出是:
text
start
end
nextTick
promise
先执行同步 JavaScript:
text
start
end
此时 process.nextTick() 和 Promise 都只是注册了后续任务。
同步代码结束后,Node.js 先处理自己的 next tick queue:
text
nextTick
然后再处理 Promise 所在的 Microtask Queue:
text
promise
整个过程就是:
text
同步 JavaScript
│
├── start
└── end
↓
next tick queue
│
nextTick
↓
Microtask Queue
│
promise
↓
继续 Event Loop
再加入一个 Timer
js
console.log('start')
setTimeout(() => {
console.log('timeout')
}, 0)
Promise.resolve().then(() => {
console.log('promise')
})
process.nextTick(() => {
console.log('nextTick')
})
console.log('end')
在常见 CommonJS 脚本场景下:
text
start
end
nextTick
promise
timeout
可以分成:
text
同步任务
start
end
↓
next tick queue
nextTick
↓
Microtask Queue
promise
↓
Event Loop
timeout
也就能把三种机制分开:
text
process.nextTick
→ Node.js 的 next tick queue
Promise / queueMicrotask
→ V8 的 Microtask Queue
setTimeout / setImmediate
→ 在 Event Loop 对应阶段得到处理
为什么叫 nextTick,却不是"下一轮 Event Loop"?
这个名字容易让人理解成:
等下一轮 Event Loop 再执行。
实际上更接近:
当前这一段 JavaScript 执行结束以后,在 Event Loop 继续推进之前执行。
所以 process.nextTick() 本身并不是 Poll、Check、Timers 这样的 Event Loop phase(阶段)。
process.nextTick() 使用过多会怎样?
比如:
js
function loop() {
process.nextTick(loop)
}
loop()
每执行一个 nextTick,又会创建下一个:
text
nextTick A
↓
创建 nextTick B
↓
执行 nextTick B
↓
创建 nextTick C
↓
继续......
如果一直这样下去,Event Loop 可能迟迟没有机会继续处理后面的 I/O。
文件、网络等 callback 也就只能继续等待。
这种情况通常叫:
I/O Starvation(I/O 饥饿)。
需要补充的是,上面的执行顺序以常见 CommonJS 脚本场景为例。不同执行上下文下,process.nextTick() 和 Promise 微任务的细节可能有所不同。
理解它们分别属于不同队列,比记一个覆盖所有场景的固定口诀更重要。
8. setImmediate() 和 setTimeout(0) 到底谁先?
先看:
js
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
setTimeout() 和 setImmediate() 属于不同的调度机制。
setTimeout() 对应 Timer,而:
js
setImmediate()
会在 Check 阶段得到处理。
如果两者直接出现在主模块顶层,具体先后不能脱离运行上下文简单判断。
但放到 I/O callback 中,关系会清楚很多:
js
const fs = require('node:fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
可以把这段流程简化成:
text
I/O callback
↓
Poll
↓
Check
↓
setImmediate()
↓
后续 Timer
因此在这个场景中,通常会先看到:
text
immediate
再看到:
text
timeout
重点不是记住"谁永远更快",而是知道:
setImmediate()和setTimeout()会在不同调度位置得到处理,执行顺序需要结合代码当前所处的上下文判断。
9. 把前面的执行流程串起来
到这里,Timer、I/O、Poll、setImmediate()、Promise 和 process.nextTick() 都已经分别讲过了。
把它们放进同一张流程图里,会更容易看到 Node.js 整体是怎么工作的。

这张图不需要沿着每一根箭头去背。
重点看三层关系:
text
第一层
同步 JavaScript 先执行
↓
第二层
当前 JS callback / operation 执行结束后
处理 next tick queue 和 Microtask Queue
↓
第三层
Event Loop 继续推进
Timer / I/O / setImmediate 等 callback
在对应位置获得执行机会
这样前面看起来比较零散的 API,就能放回同一套调度模型里。
10. Event Loop 为什么会被阻塞?
理解了前面的流程以后,再看 Event Loop Blocking(事件循环阻塞)就很直观了。
假设有这样一个接口:
js
app.get('/report', (req, res) => {
calculateHugeReport()
res.send('done')
})
而:
js
calculateHugeReport()
需要持续执行 3 秒 CPU 计算。
这 3 秒里,JavaScript 主线程一直在运行它:
text
JavaScript Main Thread
│
▼
calculateHugeReport()
│
▼
████████████████ 3 秒
与此同时,其他 callback 可能早就已经准备好了:
text
Timer callback
I/O callback
HTTP callback
Promise 后续任务
但这些 JavaScript 最终都需要主线程来执行。
于是:
text
Request A → CPU 计算
│
Request B ─────┤
Request C ─────┤
Timer ─────────┤
I/O callback ──┘
↓
等待
这就是为什么一个耗时任务可能让整个 Node.js 服务一起变慢。
Event Loop 很适合这样的情况:
text
很多任务都在等待 I/O
但它不能自动解决:
text
一个 JavaScript 任务持续占用 CPU
对于真正耗时的 CPU 密集型计算,通常需要进一步考虑 Worker Threads(工作线程)、Worker Pool(工作线程池)或者独立计算服务。
11. 再用一段代码完整走一遍
最后来看一个综合例子:
js
const fs = require('node:fs')
console.log('1')
setTimeout(() => {
console.log('2')
}, 0)
setImmediate(() => {
console.log('3')
})
Promise.resolve().then(() => {
console.log('4')
})
process.nextTick(() => {
console.log('5')
})
fs.readFile(__filename, () => {
console.log('6')
})
console.log('7')
遇到这样的代码,可以先分层,而不是马上猜完整输出。
第一层:同步 JavaScript
首先可以确定:
text
1
7
第二层:nextTick 和微任务
在常见 CommonJS 场景中:
text
next tick queue
↓
5
Microtask Queue
↓
4
所以前半部分可以确定:
text
1
7
5
4
第三层:Event Loop 中的任务
之后再考虑:
text
Timer
Poll
Check
I/O callback
其中:
js
setTimeout(..., 0)
和:
js
setImmediate(...)
如果出现在顶层,需要结合当时 Event Loop 的状态判断。
fs.readFile() 的 callback 什么时候能执行,也取决于真实的 I/O 完成时间。
所以分析这类代码时,可以按照下面的层次拆:
text
同步 JavaScript
↓
next tick queue
↓
Microtask Queue
↓
Event Loop
↓
已经准备好的 Timer / I/O / Check callback
只要先判断一个 callback 属于哪一种调度机制,复杂的执行顺序就会容易很多。
12. 面试中应该掌握哪些 Event Loop 问题?
如果是为了日常开发和面试,可以重点掌握下面几个问题。
1. Event Loop 是什么?
可以回答:
Event Loop 是 Node.js 用来协调异步事件和 JavaScript callback 执行的调度机制。JavaScript 主线程在等待 I/O 时可以继续处理其他工作,等异步任务完成以后,再在合适的时机执行对应回调。
2. Node.js 为什么能实现非阻塞 I/O?
可以理解成:
text
发起 I/O
↓
等待交给 OS / libuv 等
↓
JavaScript 主线程继续执行
↓
I/O 完成
↓
callback 等待调度
3. Event Loop 有哪些主要阶段?
重点知道:
- Timers
- Pending Callbacks
- Poll
- Check
- Close Callbacks
其中 Poll、Check、Timers 更值得理解。
4. Poll 阶段主要做什么?
主要和 I/O 事件有关,包括处理已经准备好的 I/O callback,以及在合适情况下等待新的 I/O 事件。
5. setTimeout(0) 为什么不是立即执行?
因为 0ms 表示的是时间阈值。
达到阈值以后,callback 仍然需要等待主线程和 Event Loop 调度。
6. Promise 为什么经常比 Timer 更早执行?
因为 Promise callback 属于 Microtask Queue。
当前 JavaScript 执行结束以后,会先处理微任务,再继续后面的 Event Loop 调度。
7. process.nextTick() 为什么特殊?
它使用 Node.js 自己维护的 next tick queue,并不属于 Event Loop 的某一个 phase。
在常见 CommonJS 场景中,可以先理解成:
text
同步 JavaScript
↓
process.nextTick
↓
Promise Microtask
↓
Event Loop
如果不断递归创建 nextTick,还可能造成 I/O Starvation。
8. setImmediate() 和 setTimeout(0) 谁先?
不能脱离执行上下文直接判断。
如果两者在 I/O callback 中创建,setImmediate() 会在后续 Check 阶段得到处理,因此通常会先执行。
9. 什么会阻塞 Event Loop?
常见情况包括:
- 长时间 CPU 计算
- 大循环
- 大 JSON 处理
- 同步 I/O
- 同步压缩或加密
- 复杂算法
它们的共同点都是:
JavaScript 主线程长时间没有把执行权交回来。
13. 最后再看 Event Loop
现在重新看文章开头的代码:
js
console.log('start')
setTimeout(() => {
console.log('timeout')
}, 0)
fs.readFile('./data.txt', () => {
console.log('file')
})
Promise.resolve().then(() => {
console.log('promise')
})
console.log('end')
至少可以先确定:
text
同步 JavaScript
↓
start
end
↓
Microtask
↓
promise
↓
Event Loop
↓
Timer / I/O callback
如果代码中再出现:
js
process.nextTick()
在常见 CommonJS 场景下,则可以再增加一层:
text
同步 JavaScript
↓
next tick queue
↓
Microtask Queue
↓
Event Loop
到这里,Event Loop 最核心的几件事其实已经串起来了:
- I/O 等待为什么不会一直占着 JavaScript 主线程
- 异步任务完成以后,callback 怎么重新获得执行机会
- Promise 微任务为什么会在 Timer 之前处理
process.nextTick()为什么有自己独立的调度位置setImmediate()为什么和 Poll / Check 有关- Timer 为什么到了时间仍然可能延迟
- CPU 耗时任务为什么会拖慢其他请求
最终可以把 Node.js 的运行方式简单概括成:
text
一个 JavaScript 主线程
│
├── 执行同步 JavaScript
├── 发起异步任务
├── 处理 nextTick / Microtask
└── 通过 Event Loop
↓
调度已经准备好的 callback
Event Loop 的价值,就是让一个 JavaScript 主线程能够和大量异步 I/O 协同工作。
它的边界也同样明确:
如果 JavaScript 主线程本身被长时间占用,其他已经准备好的任务仍然只能等待。
理解这一点以后,再去看 Node.js 的异步 I/O、Promise、Stream、Worker Threads 等机制,就更容易把它们放进同一套运行模型里。