<!-- title: 事件循环：JS单线程如何跑并发 -->
<!-- domain: programming -->
<!-- video: https://www.youtube.com/watch?v=8aGhZQkoFbQ -->

# 事件循环：JavaScript 单线程如何跑出并发的魔法

## 导语

如果你写 JavaScript 超过一个月，大概率遇到过这些"灵异现象"：`setTimeout(fn, 0)` 的回调并不是立刻执行；一个 `for` 循环里塞十个 `setTimeout`，最后打印出来的全是 `10`；页面一卡，鼠标都不动了，但代码里你明明没写死循环。

这些谜题，根源都指向同一个东西——**事件循环（Event Loop）**。它是 JavaScript 单线程模型下"假装能并发"的全部秘密。

2014 年 JSConf EU 上，开发者 Philip Roberts 上台做了 26 分钟的演讲《What the heck is the event loop anyway?》。没有 PPT 堆砌的术语，他当场打开自己写的小工具 **loupe**，把一段段代码的执行轨迹可视化成"调用栈—Web API—回调队列"三块跳动的方块。这场演讲至今累计播放量超过 **500 万次**，几乎是每一个前端工程师的"入门第一课"，也是无数次面试中"说说 JS 的运行机制"的标准答案模板。

它为什么值得反复看？因为它回答的不是某个 API 怎么用，而是**JavaScript 世界运行的最底层法则**——理解了它，异步、回调、Promise、async/await、渲染卡顿、Node 的事件循环全都能串成一条线。这篇解读，我会在原视频框架之上，补上它当年没讲透（或当时还不存在）的部分：**微任务队列、`requestAnimationFrame`、Node.js 与浏览器的循环差异、Web Workers 如何真正并行**。

## 视频背景卡片

| 项目 | 内容 |
|------|------|
| 视频标题 | What the heck is the event loop anyway? |
| 演讲者 | Philip Roberts（@philip_roberts） |
| 频道 / 大会 | JSConf EU |
| 时长 | 约 26 分钟 |
| 播放量 | 500 万+ |
| 核心主题 | JavaScript 单线程运行时模型：调用栈、Web API、回调队列、事件循环 |
| 配套工具 | loupe（latentflip.com/loupe，开源可视化调试器） |

## 核心内容拆解

### 1. 单线程：JavaScript 与生俱来的"枷锁"

Roberts 开场就抛出一个反直觉的事实：**JavaScript 运行时只有一根调用栈（call stack）**。这意味着同一时刻，JS 只能干一件事。你在浏览器里点按钮、拖动、发请求、跑动画，背后的脚本全都得排队，前一个没干完，后一个就得等。

为什么要设计成单线程？这是 1995 年 Brendan Eich 用十天为 Netscape 仓促发明的语言的历史包袱，但更深层的原因是**安全与简单**：浏览器里最核心的东西是 DOM，如果允许多个线程同时操作 DOM，就得引入锁、互斥量、死锁——前端开发的复杂度会爆炸。单线程意味着"不存在数据竞争"，UI 状态天然是线程安全的。

代价是致命的：**任何一段慢代码，都会冻住整个页面**。Roberts 当场演示了一个 `while` 死循环模拟的"假阻塞"，500ms 内标签页完全无响应，鼠标点击、滚动、动画全停。这就是"阻塞主线程"的可怕之处——它不是"变慢"，而是"冻结"。

> 【视频原话/演示】他指着屏幕说：如果栈里有个东西要跑很久，浏览器就什么都做不了，包括重绘页面。

### 2. 调用栈：JS 唯一的工作台

调用栈是一个后进先出（LIFO）的数据结构，记录"代码执行到哪一层函数了"。Roberts 用最朴素的例子拆解：

```js
function multiply(a, b) { return a * b; }
function square(n) { return multiply(n, n); }
function printSquare(n) { console.log(square(n)); }
printSquare(4);
```

执行时，栈从下往上依次压入 `printSquare` → `square` → `multiply`，`multiply` 返回后弹出，再弹出 `square`……直到栈清空。栈顶永远是"当前正在执行的那行代码"。

理解这一点后，报错信息里的 `stack trace`（调用栈回溯）就不再神秘——它就是程序崩溃那一瞬间调用栈的快照。`RangeError: Maximum call stack exceeded`（栈溢出）的本质，是递归没有正确终止，栈一路压到底层分配的内存上限。

> 【我的延伸】这层"栈"是 V8、SpiderMonkey、JavaScriptCore 等引擎提供的，但调用栈本身不是 JS 独有——C、Java 都有。JS 真正特别的地方在于：**它只有一个栈，而且不提供创建新栈的语言级原语**（想并行只能靠 Web Workers，那是另一回事）。

### 3. Web API：浏览器借给 JS 的"另一只手"

这是整场演讲最关键的认知转折。很多人以为 `setTimeout`、`fetch`、DOM 事件是 JavaScript 的功能——**不是**。它们是浏览器（宿主环境）以 C++ 实现的 **Web API**，JS 引擎只是"借用"。

当你写下：

```js
console.log('hi');
setTimeout(function cb() { console.log('there'); }, 5000);
console.log('JSConf');
```

真正的执行过程是：`console.log('hi')` 入栈、执行、出栈；`setTimeout` 入栈，引擎把它**连同回调函数 `cb` 一起交给浏览器的定时器 Web API**，然后立刻出栈——它本身瞬间就执行完了；`console.log('JSConf')` 入栈、执行。5 秒后，定时器触发，`cb` 被推进**回调队列（Callback Queue）**，等待栈清空后被搬上栈执行。

> 【视频原话】Roberts 反复强调：`setTimeout` 的第二个参数不是"延迟执行回调"，而是"至少等这么久，把回调放进队列"。这是无数 bug 的源头。

### 4. 回调队列与事件循环：唯一的使命

回调队列是一个先进先出（FIFO）的结构，存放着"已经就绪、等待执行"的回调函数。而**事件循环本身，就是一个死循环**，它的工作用一句伪代码就能说完：

```
while (true) {
  if (调用栈为空 && 回调队列非空) {
    把队列里的第一个回调压入调用栈，执行它
  }
}
```

就这一条规则。事件循环永远在问两个问题：**栈空了吗？队列里有货吗？**两个条件同时满足，它就搬一个上去。

Roberts 用 loupe 把这个过程可视化为三块区域的联动——栈、Web API、队列之间，回调像传送带上的零件一样流动。观众第一次直观看到：`setTimeout(fn, 0)` 之所以"不立刻执行"，是因为 `fn` 要先进队列，而栈上还有同步代码没跑完，事件循环不会插队。

### 5. setTimeout 的真相：1000ms 不等于 1000ms

理解了上面的模型，那个经典的"循环里全是 10"的面试题就迎刃而解：

```js
for (var i = 1; i <= 3; i++) {
  setTimeout(function () { console.log(i); }, 0);
}
// 输出：4, 4, 4（不是 1, 2, 3）
```

因为 `setTimeout` 把三个回调都丢进了 Web API，而循环本身是同步的、瞬间跑完的。等回调真正上栈执行时，`i` 早已经是循环结束后的 `4`。`var` 没有块级作用域，三个闭包共享同一个 `i`。换成 `let`（ES6 块级作用域），或用 `IIFE` 立即捕获，问题就消失。

更重要的洞见：**`setTimeout(fn, 1000)` 的语义是"至少 1000ms 后"，不是"恰好 1000ms 后"**。如果 1000ms 到了，但栈上正跑着一个耗时的同步任务（比如一个 3000ms 的计算），回调只能继续在队列里干等。延迟会被无限拉长。

> 【我的延伸】这正是 Chrome 开发者工具里 `setTimeout` 那一栏标红的 "reason: ..." 的来历——它诚实地告诉你这个回调为什么晚了。

### 6. 阻塞渲染：60fps 与 16.6ms 的战争

Roberts 演讲后半段揭示了一个常被忽略的事实：**浏览器的"渲染"（重绘/回流）和 JS 回调一样，也要等栈清空**。浏览器大约每 16.6ms 想重绘一次（对应 60fps），但如果主线程被一段慢代码占着，渲染请求就只能排队，用户看到的就是"卡顿、掉帧、拖影"。

他演示了同步处理图片（一次性遍历上百万像素）会让页面冻死，而用 `setTimeout` 把任务切成小块、每块跑完把控制权还给事件循环，页面就能保持流畅响应——这是后来 `requestIdleCallback`、时间切片（Time Slicing）乃至 React 的 Concurrent 模式（Fiber 架构）的思想源头。

> 【我的延伸】Roberts 没讲的：现代浏览器有一个独立的 **微任务队列（Microtask Queue）**，优先级高于回调队列（宏任务）。`Promise.then`、`MutationObserver`、`queueMicrotask` 的回调都进这里。事件循环在每次宏任务执行完后、下一次渲染前，会**把微任务队列彻底清空**才算完。所以：

```js
console.log('1');
setTimeout(() => console.log('2'));
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出：1, 4, 3, 2
```

`3` 永远在 `2` 前面，因为微任务抢在宏任务之前执行。这是 Roberts 2014 年演讲时 Promise 尚未标准化、所以没覆盖，却是今天面试必考的点。

## 知识结构图

```mermaid
flowchart TD
    A[JavaScript 代码] --> B[调用栈 Call Stack<br/>单线程 · LIFO · 唯一工作台]
    B -->|遇到异步 API| C[Web API<br/>浏览器 C++ 实现<br/>setTimeout / fetch / DOM]
    C -->|定时器到 / 请求回 / 事件触发| D{就绪的回调}
    D -->|Promise.then / queueMicrotask| E[微任务队列<br/>高优先级 · 每轮清空]
    D -->|setTimeout / 事件回调| F[宏任务队列<br/>Callback Queue · FIFO]
    E --> G[事件循环 Event Loop]
    F --> G
    G -->|栈空时搬一个回调上栈| B
    G -->|栈空且队列空时| H[浏览器渲染 Render<br/>每 ~16.6ms · 60fps]
    H --> B
    style B fill:#ffe0b2
    style G fill:#c8e6c9
    style E fill:#bbdefb
```

这张图把"一次完整的循环"画清了：同步代码上栈执行 → 遇到异步就外包给 Web API → 就绪的回调分别进入微任务/宏任务队列 → 事件循环在栈空时先掏空微任务，再取一个宏任务，间隙里插一次渲染。**理解这条流水线，你就理解了浏览器前端的全部时间观。**

## 实战启示

看完这个模型，怎么落到日常工程里？我总结了四条能立刻用的原则：

**第一，永远别在主线程跑重活。** 任何可能超过 50ms 的同步计算（解析大 JSON、复杂图表布局、加密哈希）都会触发"长任务（Long Task）"，造成掉帧。把大数据切片处理，用 `setTimeout` 或 `requestIdleCallback` 让出主线程；计算密集型任务直接丢给 **Web Worker**——它是 JS 里获得"真正的另一根栈、真正的并行"的唯一合法途径，通过 `postMessage` 通信，不会抢 DOM。

**第二，分清微任务和宏任务的优先级。** 写"任务调度器"或"批量更新"逻辑时，`queueMicrotask` 会饿死渲染（因为它在每次宏任务后清空，可能无限堆积），而 `setTimeout(0)` 更安全地让出渲染机会。React 的批量更新机制就深谙此道。

**第三，`setTimeout(fn, 0)` 是"让出控制权"的惯用法，不是"立刻执行"。** 别指望它精确计时；需要精确动画用 `requestAnimationFrame`（与渲染同步），需要去抖用 `setTimeout`/`requestIdleCallback`。

**第四，理解了单线程，才懂为什么 `async/await` 不是银弹。** 它让异步代码"读起来像同步"，但底下的 Promise 链依然跑在单线程上——一个 `await` 后面跟着 CPU 密集计算，照样卡死主线程。并发 ≠ 并行：`Promise.all` 是并发地"等待多个 I/O"，不是并行地"同时计算"。

认知升级的核心只有一句：**JavaScript 的"异步"，本质是"延迟 + 回调队列 + 一个不知疲倦的循环"，单线程从未改变。**

## 延伸学习路径

1. **Jake Archibald — In The Loop（JSConf.Asia）**：把微任务、宏任务、渲染顺序讲得最透彻的进阶演讲，正好补上 Roberts 2014 年的留白。
2. **loupe 在线工具**（latentflip.com/loupe）：Roberts 演讲同款可视化器，把自己的代码贴进去，亲眼看回调在栈和队列间流动。
3. **Node.js 官方文档 "The Node.js Event Loop"**：Node 的循环和浏览器**完全不同**——它基于 libuv，有 timers、poll、check（`setImmediate`）等多个 phase，是后端面试的高频考点。
4. **MDN — "Using Web Workers"**：唯一能让 JS 真正并行的方案，适合处理图像、加密、大数据。
5. **Lydia Hallie 的 "JavaScript Visualizer 9000"**（jsvisualizer.com）：把执行过程逐行动画化，配合本文食用效果最佳。

## 一句话总结

> 这场 26 分钟的演讲用一个"栈—Web API—队列—循环"的四方模型，讲透了 JavaScript 单线程却"看起来并发"的全部秘密——它是你理解一切前端异步、卡顿、调度的地基。

## 参考来源

1. [What the heck is the event loop anyway? — Philip Roberts（JSConf EU）](https://www.youtube.com/watch?v=8aGhZQkoFbQ) — 本文分析的核心视频
2. [loupe — JavaScript 可视化调试器](https://latentflip.com/loupe/) — 演讲中使用的配套工具，开源
3. [MDN Web Docs — Event Loop 与并发模型](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/EventLoop) — 官方权威的运行时模型文档
4. [Node.js 官方文档 — The Node.js Event Loop Timers and process.nextTick](https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/) — Node 与浏览器循环差异的标准说明
5. [JavaScript Visualizer 9000 — Lydia Hallie](https://www.jsv9000.app/) — 逐行可视化执行过程的进阶学习工具

---
> 本文由「51学通信」技术团队出品。51学通信由通信行业18年老兵**爱卫生**（微信ID: gprshome201101）维护，聚焦5G/6G核心网、3GPP标准解读、通信前沿技术深度分析。
> 出版作品有《5G核心网原理与实践》《IMS原理与实践》等4本通信专业书籍。知识星球「51学通信」提供深度技术答疑和行业洞察。

> [!tip] 51学通信知识星球
> **星主：爱卫生**（微信：gprshome201101）——公众号「51学通信」创办者，19 年通信行业运维与讲师经验，著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书（各大电商平台有售）。
> **星球定位**：深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域，主打运维岗位与信令分析。
> **当前规模**：5200 篇主题 / 990 个回答，198 元/年。
> **加入福利**：加入星球后请加爱卫生微信，拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题，另有群内分享。
> **AI 加持**：51学通信知识星球已作为 skill 接入 WorkBuddy，使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例，做总结、找文档和 PPT 等场景全覆盖。

![51学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
