# Node.js Event Loop 到底是怎么工作的?从一段异步代码讲清事件循环

如果你接触过 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 等机制,就更容易把它们放进同一套运行模型里。

相关推荐
Scene2161 小时前
旧 REST 接口封装成 MCP 服务:完整实战指南
人工智能·后端
Zane19941 小时前
daemon 线程说没就没?一文讲透 threading 的适用场景与线程安全
后端·python
凤山老林2 小时前
从 RestTemplate 到 HttpClient 5:Spring Boot HTTP 客户端性能调优与连接池治理
spring boot·后端·http
Csvn2 小时前
📊 SQL 入门 Day 20:锁机制
后端·sql
Code额3 小时前
Python 连接 DeepSeek API,OpenAI 对话方式总结
后端·python·ai·ai编程
星火10243 小时前
【LangChain4j系列10】Guardrails 安全护栏
人工智能·后端
四千岁3 小时前
稀疏向量BM25Retriever不支持中文怎么办?jieba来帮忙
前端·javascript·后端
用户6919026813393 小时前
Docker基本概念
后端·docker·容器
颜进强3 小时前
14 - OpenSpec 老页面改造骨架:定位 + 增量 + 回归三件套
前端·后端·ai编程