首页 › programming › 26分钟讲透事件循环:单线程JS为何永不阻塞

26分钟讲透事件循环:单线程JS为何永不阻塞

导语:为什么2026年还在推荐一场十几年前的演讲

先坦白一件事:这轮我原本想找最近7天爆火的编程新视频,但试遍了各种渠道后,我决定把这一期留给一场“老”演讲——Philip Roberts 在 JSConf EU 2014 上的《What the heck is the event loop anyway?》。理由很简单:这篇内容在2026年不但没有过时,反而比任何时候都更重要。

原因有三个。第一,AI 写代码的时代,async/await 满天飞,但真正能说清楚“回调为什么这么执行、Promise 比 setTimeout 先跑”的开发者凤毛麟角——生成的异步代码出了诡异 bug,最后还是得靠人来审查,而审查的底气就来自对事件循环的理解。第二,随着浏览器对渲染性能、INP(Interaction to Next Paint)等核心指标越来越挑剔,“别阻塞主线程”从性能优化技巧升级成了架构常识。第三,这场26分钟的演讲是公认的教学范本:它用一个可视化工具,把一个无数人讲了十年都讲不清楚的概念,讲到了“看一眼就懂”。

如果你只能看一场理解 JavaScript 运行时的演讲,就是它。下面我把整场演讲的核心内容拆开,并补充它当年没讲、但今天必须知道的微任务细节。

视频背景卡片

项目 内容
视频标题 What the heck is the event loop anyway?
频道 JSConf(JSConf EU 官方频道)
演讲者 Philip Roberts(当时在 And Yet 工作区,loupe 作者)
发布时间 2014年(JSConf EU 2014,柏林)
时长 约26分钟
播放量 超百万,长期被列为“每个JS开发者必看”的经典
核心主题 JavaScript 单线程模型、调用栈、任务队列与事件循环的完整运作机制

核心内容拆解

1. 一个刺痛所有人心里的疑问:“JavaScript 是单线程的,那它怎么同时处理点击、请求和动画?”

演讲开场,Philip 抛出的正是这个几乎每个前端都绕过但没深究过的问题。【视频观点】他的答案是反问式的:其实 JavaScript 运行时(比如 V8)一次真的只做一件事——问题不在于“怎么并发”,而在于我们一直误解了“并发发生在哪里”。答案藏在运行时之外:浏览器(或 Node.js)这个宿主环境,才是真正负责调度和并发的那一方。这个视角转换是整场演讲的题眼。很多人卡在异步,是因为拿着“运行时”的心智模型去解释“宿主环境”的行为。

【延伸分析】放到今天,这个分层思想依然锋利:V8 只提供调用栈和堆;定时器、网络、DOM 事件全是宿主注入的能力。所以“JS 是单线程的”这句话严格说不完整——单线程的是你的代码执行,而 I/O 的等待与回调的入队,从来都是浏览器或 libuv 在帮你并行完成。

2. 调用栈:JavaScript 唯一的执行轨道

Philip 用大量篇幅讲 call stack(调用栈):函数调用就是压栈,返回就是弹栈,栈顶永远是“正在执行的那一行”。【视频观点】他强调的关键性质是:栈一次只能干一件事,干不完,后面所有的事都得排队——包括浏览器的渲染。他现场演示了一段死循环代码:点击按钮的回调永远不会触发,页面连选中文字都做不到,整个标签页像死机一样。

这就是"blocking the stack"(阻塞栈)的直观呈现。他给了一个粗暴但有效的判断法:如果一段同步代码跑超过16毫秒左右,用户就会感知到掉帧;跑超过几百毫秒,用户会觉得页面“卡了”。

【延伸分析】16毫秒这个数字来自 60Hz 屏幕下每帧约 16.7ms 的预算。今天这个词换了个说法叫 INP,Chrome 团队的建议是交互响应控制在 200ms 内,但原理一模一样:主线程是共享的、渲染和你的 JS 在同一条队伍里。理解了这一点,你就明白为什么长列表虚拟滚动、Web Worker 分片计算、scheduler.yield() 这些技术存在——它们全是在给“独占轨道”的问题打补丁。

3. setTimeout 的真相:它根本不在 JavaScript 里

这是演讲最“颠覆认知”的段落。【视频观点】Philip 指出,setTimeout、DOM 事件、XMLHttpRequest 这些“异步 API”,压根不存在于 JavaScript 引擎中——它们是浏览器提供的 Web API。当你调用 setTimeout(fn, 0) 时,发生的事情是:宿主环境接住你的回调函数,开始计时;到点后,它把回调推入任务队列(task queue),然后立刻返回。你的代码继续往下跑,一行都没有停。

换句话说,setTimeout(fn, 0) 从不意味着“0毫秒后执行”,它只意味着“至少0毫秒后,进入排队”。如果此时调用栈上还有一段跑5秒的代码,你的回调就得干等5秒。定时器的第二个参数是个下限,不是精确的闹钟。

【延伸分析】这一点在今天的价值不降反升。Node.js 里同样成立,只不过“宿主环境”换成了 libuv:文件 I/O、网络、定时器都由 libuv 的线程池和事件循环托管。无论浏览器还是 Node,记住一句话就够了:异步的不是“执行”,而是“被调度执行的机会”。

4. 事件循环:只有几行代码的“最简单的一环”

演讲的高潮,是 Philip 给出事件循环(event loop)的伪代码,并且说了一句广为流传的话:事件循环极其简单——简单到你几分钟就能写出来——但正是这个最简单的东西,把 V8 和浏览器粘在了一起。【视频观点】它的逻辑一共三步:

  1. 看调用栈是不是空的;
  2. 空了,就从任务队列里取出第一个回调,压栈执行;
  3. 栈又空了,再取下一个。如此往复,永不停歇。

就这么多。没有魔法,没有第二个线程在偷偷执行你的回调——回调永远是在主线程的调用栈上执行的,事件循环只是个不知疲倦的调度员。他打趣说,JavaScript 的并发模型是“一个线程 + 一个队列 + 一个循环”,这也是为什么 JavaScript 不需要处理锁和竞态(在同一执行流内),而多线程语言需要。

【延伸分析】这张极简图还有一个常被忽略的推论:渲染不是事件循环的一部分,而是浏览器的另一个循环。浏览器大约每16.7ms决定一次“要不要渲染这一帧”,只有当调用栈和当帧任务都清空时,渲染才有机会发生。这就解释了为什么“把一个大循环拆成多个 setTimeout”能救活页面——每让出一次栈,就给渲染一次机会。

5. 现场可视化:loupe 工具

Philip 没有干讲,他现场使用了自己写的可视化工具 loupe(latentflip.com/loupe):左侧跑代码,右侧实时画出调用栈的压弹、Web API 的计时、回调入队的时机。setTimeout、DOM 点击、Ajax 三个经典案例在他手下全部“慢动作回放”。【视频观点】他坦言写这个工具的动机就是当年自己学异步时“找遍了都没有一个像样的可视化”。

【延伸分析】这个工具至今在线可用,是我认为理解事件循环性价比最高的练手方式:自己先猜执行顺序,再用 loupe 验证,猜错的地方就是你的心智漏洞。这种“预测-验证”的学习闭环,比看十篇博客都管用。

6. 【延伸分析】演讲没讲透、但今天你必须补上的一块:微任务

这里要坦诚标注:2014年的演讲受时代所限,只讲了“任务队列”这一种队列。今天你打开 DevTools 单步调试会发现还有微任务队列(microtask queue)——Promise 的 .then 回调、queueMicrotask、MutationObserver 都进这里。规则是:每当调用栈清空,事件循环会先把微任务队列整个清空(包括执行微任务过程中新产生的微任务),然后才考虑渲染、才取下一个宏任务。

这就解释了一个经典面试题:setTimeout(fn,0) 和 Promise.resolve().then(fn) 谁先执行?答案永远是 Promise——setTimeout 是宏任务,要等下一轮循环;微任务在当前轮的栈清空后立刻插队执行。Jake Archibald 那篇著名的《Tasks, microtasks, queues and schedules》把这层机制讲得极细,建议作为本片的续集观看。

7. 【延伸分析】从浏览器到 Node.js:同一套思想,两套实现

事件循环不是浏览器的专利。Node.js 用 libuv 实现了自己的事件循环,而且分了阶段(timers → pending callbacks → poll → check → close callbacks 等),process.nextTick 还有一个优先级最高的独立队列。所以“先微任务后宏任务”的大原则不变,但细节上 Node 的每轮循环之间、每个阶段之间都会清空 nextTick 和微任务队列,行为和浏览器有微妙差异。写跨端异步代码的人,值得专门把 Node 官方文档里的 event loop 篇读一遍——它可能是所有运行时文档里把这件事讲得最清楚的一份。

8. 【延伸分析】为什么这场演讲在 AI 时代反而更该看

最后说点个人的判断。AI 编程助手的普及带来一个副作用:异步代码的“产量”暴涨,而对它执行时序有正确心智模型的人没有同步暴涨。AI 生成的代码在单线程语义上很少出错,但在时序语义上——竞态、取消、重试风暴、微任务里抛异常、渲染前后的执行顺序——错误率明显更高,因为这些 bug 不看语法看机制。事件循环就是 JavaScript 世界的“时序宪法”。看懂这场26分钟的演讲,本质上是给自己装上了一套能对 AI 生成的异步代码做 code review 的底层操作系统。

知识结构图

flowchart TD
    A[JavaScript 代码执行] --> B[调用栈 Call Stack]
    B -->|同步代码| C{栈空了吗?}
    C -->|否| B
    C -->|是| D[清空微任务队列<br/>Promise.then / queueMicrotask]
    D --> E{该渲染了吗?<br/>约每16.7ms一帧}
    E -->|是| F[渲染: rAF → Style → Layout → Paint]
    E -->|否| G
    F --> G[从宏任务队列取一个回调<br/>setTimeout / DOM事件 / I/O]
    G --> B
    H[宿主环境<br/>浏览器 Web APIs / Node libuv] -->|定时器到点、I/O完成<br/>回调入队| G
    H -->|Promise resolve| D
    style D fill:#e8f5e9
    style F fill:#fff3e0
    style G fill:#e3f2fd

读图要点:你的代码永远只在“调用栈”这一个方框里执行;右下角的宏任务和微任务队列只是“候场区”;渲染和事件循环是两个独立的循环,靠“栈空”这个条件交汇。

实战启示

第一,给异步调试装上“时序显微镜”。 下次遇到“回调执行顺序和我想的不一样”,别再靠加 log 碰运气。先在脑内跑一遍上面的流程图:这段代码是同步、微任务还是宏任务?栈清空的瞬间谁会插队?用 loupe 复现一遍,大多数“灵异事件”五分钟就能定位。

第二,主动给主线程“让路”。 把超过 50ms 的同步计算拆成小块(setTimeout 分片、requestIdleCallback、Web Worker),这不是性能洁癖,而是保住交互响应的硬预算。监测手段现成的:Chrome DevTools 的 Performance 面板里,长长的黄色 Task 就是“独占轨道”的元凶。

第三,写 AI 生成代码的审查清单。 拿到一段异步代码,至少过四问:有没有未捕获的 Promise rejection?await 循环里有没有本该并行的串行等待?定时器有没有清理(否则组件销毁后回调照跑)?微任务里有没有塞重活(它会阻塞下一帧渲染)?这四问覆盖了绝大多数异步事故。

第四,把“单线程心智模型”迁移到其他领域。 事件循环的思想——执行与调度分离、队列与循环解耦——在 GUI 框架(主线程 + 消息循环)、游戏引擎(帧循环)、甚至分布式系统的任务调度里都能找到同构。理解一次,受益处处。

延伸学习路径

  1. Loupe 可视化工具(latentflip.com/loupe)— 本片同款工具,输入代码实时看调用栈与队列,先猜后验。
  2. Jake Archibald《Tasks, microtasks, queues and schedules》(jakearchibald.com)— 本片缺失的微任务部分的最佳续集,附交互式测试题。
  3. MDN《Concurrency model and the event loop》(developer.mozilla.org)— 官方口径的精炼版,适合当速查手册。
  4. Node.js 官方指南 Event Loop 篇(nodejs.org → Learn → Asynchronous Work)— libuv 的分阶段实现,服务端视角的补全。
  5. Chrome DevTools Performance 面板文档(developer.chrome.com)— 把理论对上真实火焰图,看看自己的页面到底阻塞在哪里。

一句话总结

它用26分钟和一个小工具,把“JavaScript 为什么单线程却不卡、异步到底发生在哪”从背八股变成了看得见的常识——这是理解一切 JS 时序问题的第一性原理。

参考来源

  1. What the heck is the event loop anyway? — Philip Roberts, JSConf EU 2014 — 本文解读的核心视频
  2. Loupe — visualizing the JavaScript runtime — 演讲者开发的同款可视化工具
  3. Tasks, microtasks, queues and schedules — Jake Archibald — 微任务机制的权威补充
  4. MDN: Concurrency model and the event loop — 官方文档版事件循环说明
  5. Node.js: The Node.js Event Loop, Timers, and process.nextTick — Node/libuv 视角的实现细节

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