首页 › programming › 事件循环:JS单线程如何跑并发

事件循环: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 用最朴素的例子拆解:

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 引擎只是"借用"。

当你写下:

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"的面试题就迎刃而解:

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 的回调都进这里。事件循环在每次宏任务执行完后、下一次渲染前,会把微任务队列彻底清空才算完。所以:

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 尚未标准化、所以没覆盖,却是今天面试必考的点。

知识结构图

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) — 本文分析的核心视频
  2. loupe — JavaScript 可视化调试器 — 演讲中使用的配套工具,开源
  3. MDN Web Docs — Event Loop 与并发模型 — 官方权威的运行时模型文档
  4. Node.js 官方文档 — The Node.js Event Loop Timers and process.nextTick — Node 与浏览器循环差异的标准说明
  5. JavaScript Visualizer 9000 — Lydia Hallie — 逐行可视化执行过程的进阶学习工具

本文由「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学通信知识星球海报-扫码加入