首页 › programming › 事件循环神课:单线程的障眼法

事件循环神课:单线程的障眼法——JSConf 那场百万播放演讲深度解读

概述

JavaScript 是单线程的,却同时替你发请求、监听点击、播放动画——它是怎么“同时”的?这个几乎所有前端工程师都被问过、却很少真正想透的问题,在一场不到半小时的演讲里被讲得干干净净。Philip Roberts 在 JSConf EU 上做的这场《What the heck is the event loop anyway?》,用一张图、一个自己写的可视化工具,把“单线程语言如何假装多线程”拆成了四个零件。十多年过去,它仍是无数异步教程绕不开的第一引用:每当有人在社交平台争论 setTimeout(fn, 0) 到底多久执行、微任务和宏任务谁先跑,这条视频就会被重新翻出来。本文带你完整重走这条视频的论证路线,并补上视频没讲、但今天你必须知道的那一层:规范里真实的队列结构、4 毫秒的隐藏规则,以及“JS 单线程”这句话的现代边界。

数据说明:本文写作时环境无法联网实时核验,以下热度数据为据频道页面的历史公开信息(量级表述),精确数字请以视频页实时显示为准。

一、视频基本信息

项目 内容
标题 What the heck is the event loop anyway?(事件循环到底是个啥?)
讲者 / 频道 Philip Roberts / JSConf(JSConf EU 2014 现场演讲)
发布时间 2014 年(据频道页面)
时长 约 26 分钟的现场讲座
热度 播放量百万级(据频道页面,未能实时核验);长期作为 JS 异步教学的头号引用视频
配套工具 Loupe(latentflip.com/loupe),可在浏览器里“看见”事件循环

说明一点:这不是一条“蹭热点”的新视频,选它恰恰因为它符合“神课”的标准——一个概念、一张图、十年不过时。凡是讲 JS 异步绕不开它的地方,它就一直在“近期爆火”的循环里。

二、核心主张拆解:演讲按什么顺序讲,为什么这个顺序妙

要点一:开场先扎你一刀——单线程意味着“一次只能干一件事”

演讲没有从概念开始,而是从一个所有前端都做过的噩梦开始:你的页面要发一个网络请求,请求还没回来,整个界面冻住了——按钮点不动、滚动卡死、白屏转圈。

Philip 指出,原因就藏在 JavaScript 的语言定义里:调用一个函数,就占用调用栈;栈一次只能跑一个函数。JavaScript 是单线程语言,主线程只有一个调用栈,一次只能做一件事。想并发?传统做法是上多线程——但 Philip 现场吐槽:多线程代码有锁、有竞态、有死锁,写起来痛苦、测起来更痛苦,浏览器环境尤其不适合。

于是问题被精确地立了起来:我们既想要“同时做多件事”的体验,又不想要多线程的心智负担,怎么办?这个提问方式是整场演讲的第一处高明:先让听众痛,再给解法。

要点二:一张图拆出四个零件——V8 只是“发动机”,不是“整车”

Philip 指出很多人混淆了一个事实:你在 Chrome DevTools 里看到的调用栈,来自 V8 引擎;但 V8 的源码里根本没有 setTimeout,也没有 DOM,没有 Ajax。这些全是浏览器宿主环境额外提供的能力——他把它们统一画成一个黑盒,叫 "WebAPIs"。

于是完整的运行时结构浮出水面,总共四个角色:

  1. 调用栈(Call Stack):干活的唯一工人。函数调用就入栈,返回就出栈。
  2. WebAPIs:浏览器提供的异步外挂。setTimeout、XHR、DOM 事件监听,都由它代为计时、代为发请求、代为等点击。它们“办完事”之后会把回调函数放进队列。
  3. 回调队列(Callback Queue):排队区。所有等执行的回调在这儿按先来后到排队。
  4. 事件循环(Event Loop):全场最简单也最关键的角色。Philip 给它的定义堪称整场演讲最经典的一句——事件循环的全部职责只有一句话:栈空了吗?空了,就把队列里的第一个回调搬进栈。没了。它没有更复杂的逻辑,就是这一个搬运工。

这张四部件图是整条视频的核心资产。它解释了一件让无数人困惑的事:JavaScript 引擎和 JavaScript 运行时是两回事。引擎(V8)只管按顺序执行代码,让单线程“同时”干活的魔法,全在宿主环境里。

要点三:现场戳破 setTimeout(fn, 0) 的神话

演讲的第二个高潮,是 Philip 用 Loupe 工具现场跑一段代码:

setTimeout(function() {
  console.log('inside timeout');
}, 0);
console.log('outside');

直觉上“0 毫秒后执行”,应该立刻打印 inside 吧?实际输出是先 outside,后 inside。Philip 的解释一针见血:setTimeout 的第二个参数不是“什么时候执行”,而是“至少过多久,才允许这个回调进入队列”。时间到了,回调只是拿到了排队的资格;真正什么时候执行,取决于两件事——调用栈什么时候清空、队列前面还压着多少人。

他把这个结论推广成一句总纲:JavaScript 里的“异步”,不是“同时做”,而是“把等待外包出去,把结果排成队”。计时外包给 WebAPIs,等待不占栈,所以界面不卡。

要点四:重新定义“阻塞”——慢不是罪,占着栈才是

演讲进行到中段,Philip 给“阻塞”下了一个非常实用的定义:阻塞不是指代码跑得慢,而是指代码占着调用栈不放,让其他所有事都进不来。

他用一组对比讲透:加载一张几 MB 的图片不阻塞主线程——因为网络等待发生在 WebAPIs 里;而一个 alert() 会阻塞——因为弹窗期间调用栈被占死,队列再多人也进不来。同步的死循环会阻塞,异步的慢请求不会。

这个定义在今天比 2014 年更重要——它就是现代 Web 性能指标的核心逻辑(后文细说)。

要点五:收尾给工具——让事件循环“看得见”

演讲结尾,Philip 放出了他写的可视化工具 Loupe:把任何一段 JS 代码贴进去,它能动画演示每一步入栈、出栈、谁去 WebAPIs 报到、回调何时落进队列、事件循环何时搬运。工具至今仍在线可用,是理解事件循环最直观的教具——看完视频最好的巩固方式,就是把自己最容易写错的代码贴进去跑一遍。

三、知识与理论溯源:这些概念背后是谁的功劳

单线程是历史决定的。1995 年,Brendan Eich 在 Netscape 用约十天时间造出 Mocha(JavaScript 前身),设计目标是网页脚本——操作 DOM、做表单校验。在这个场景下引入多线程意味着让脚本和浏览器内部状态在共享 DOM 上互相踩踏,风险远大于收益,于是语言层面干脆不提供线程原语。这不是疏忽,是场景选择。2019 年 Eich 本人在多个访谈(如 Hacker Noon 的回忆访谈)中复述过这段十天造语言的历史。

事件循环不在 ECMAScript 里。这是视频没点破、但溯源时必须知道的一层:翻开 ECMA-262(JavaScript 语言规范),你找不到“event loop”这个词条——规范里只有 Job Queue(任务队列)的概念。事件循环的定义写在 HTML Living Standard(WHATWG 维护的浏览器规范)里。也就是说,事件循环是宿主环境(浏览器、Node.js)的机制,而不是语言本身的机制。这正好呼应了视频里“V8 没有 setTimeout”的观察:引擎管执行,宿主管调度。

Reactor 模式:事件循环的学术祖先。这套“把等待外包、就绪后回调”的结构,在学术上有明确出处——Douglas C. Schmidt 等人在《Pattern-Oriented Software Architecture, Volume 2》(POSA2,1996/2000)中总结的 Reactor(反应器)模式:一个线程监听多个 I/O 事件源,就绪后分发给对应的处理器。Node.js 的架构正是这个模式的当代实现——Ryan Dahl 2009 年发布 Node.js 时,选择 libuv 库实现事件循环与异步 I/O,刻意复用“单线程 + 事件驱动”的浏览器模型,让前后端心智统一。Nginx 的 worker 进程内部也是同款结构。

Run-to-completion 语义。MDN 的《Concurrency model and the event loop》文档对这套模型有规范级描述,其中最关键的术语是 run-to-completion:一个函数一旦开始执行,就会执行到底,中途不会被其他 JS 代码打断。这就是为什么 JS 里没有传统意义的竞态条件——数据竞争的前提“执行中途被打断”在单线程事件循环下不成立。代价则是 Philip 说的:谁占栈,谁说了算。

微任务与 Jake Archibald 的补课。视频里“回调队列只有一个”是教学简化。真实世界里 Promise 的回调走的是另一条更优先的通道。这条通道的系统级梳理,公认最好的一篇是 Jake Archibald 2015 年的《Tasks, microtasks, queues and schedules》——他后来还在 JSConf Asia 2018 做了姊妹演讲《In The Loop》,可以视为对 Philip 这条视频的官方续集。

四、批判性审查:哪些可信,哪些是善意的谎言

可信的部分

视频的演示行为与规范完全一致:setTimeout(fn, 0) 后于同步代码执行,符合 HTML 规范的定时器语义;四部件模型与 MDN 官方文档的图示同构;"don't block the stack"的建议至今是性能优化的第一原则。作为入门教学,它没有一处硬伤——所有简化都标注了方向,没有误导。

简化一:队列不止一个,微任务插队

这是视频最大的时代局限。2014 年 Promise 尚未普及(ES6 才将其收编标准),视频只用回调演示,画出的队列只有一条。今天真实的心智模型必须升级为:

所以面试题里 Promise 打印先于 setTimeout,不是玄学,是队列等级制度。

简化二:setTimeout(fn, 0) 其实最少约 4 毫秒

HTML 规范明确规定了定时器钳制(timer clamping):嵌套调用超过 5 层后,setTimeout 的最小延迟被钳制为 4 毫秒;且页面处于后台标签时,定时器可能被节流到 1 秒以上(浏览器节能策略,Chrome 等主流浏览器均有激进的后台节流)。"0 毫秒"从来不是 0。

需要修正的表述:“JavaScript 是单线程的”

更准确的说法是:JavaScript 的主线程是单线程的,语言不提供共享内存并发。但单线程并不等于一个线程:

Philip 在演讲里其实留了一句准确的话:JS 单线程的意义在于他自己不用管线程——并发复杂性被转移给了浏览器。这句话比"single-threaded"这个标签本身更经得起推敲。

视频没讲、但你今天必须知道的:渲染时机与性能指标

事件循环每转一圈,浏览器还有机会做一次渲染:执行样式计算、布局、绘制,并处理 requestAnimationFrame 回调。现代浏览器以屏幕刷新率(60Hz/120Hz)为节拍渲染——要让动画不卡,每一帧内 JS 执行加渲染的总预算只有 16.7 毫秒(120Hz 下减半到约 8.3 毫秒)。

这套机制直接催生了 Google Core Web Vitals 的现行核心指标 INP(Interaction to Next Paint,2024 年 3 月正式取代 FID):用户交互后到下一帧画出来的耗时。INP 差,十有八九就是 Philip 说的“占着栈不放”。为此 Web 平台后来补了专门的工具:performance 面板的 Long Tasks(长任务,超过 50 毫秒即标记),以及新的 scheduler API(scheduler.postTask、scheduler.yield,现代 Chromium 浏览器已支持),专门用来把大任务切片、让出主线程。

一句话总结批判部分:视频教的是模型,模型至今成立;但模型的 2014 版缺了两块拼图——微任务优先级和渲染节拍。把这两块补上,才是完整的事件循环心智模型。

五、认知提升与实用启示:三条可以立刻用上的建议

第一条:用“长任务”思维管理性能,而不是“优化函数”。打开 Chrome DevTools 的 Performance 面板,盯着那些超过 50 毫秒的黄色 Long Task 块——每一个都是“占着栈不放”的现行犯。修法不是把函数写得更快,而是切片:把一次跑 300 毫秒的循环,拆成每 40 毫秒让位一次(await scheduler.yield() 或退一步用 setTimeout 分批),让渲染有机会插进来。这是把 Philip 那句 "don't block the stack" 翻译成了可执行的工程动作。

第二条:写测试和排异步 Bug 时,先在纸上画两条队列。遇到“为什么日志顺序和我想的不一样”,先分类:这行代码是同步的?微任务(Promise/await)?还是宏任务(setTimeout/事件回调)?然后按“同步清栈 → 微任务清空 → 一个宏任务 → 再清微任务”的节拍推演。绝大多数 flaky 的异步单测,败因都是用 setTimeout(0) 去等一个 Promise——顺序模型错了,等待自然不稳。

第三条:把这套障眼法当万能钥匙,迁移到其他系统。“单线程 + 事件循环 + 队列”不是 JS 专属,它是高并发系统最主流的生存策略:Redis 命令执行是单线程事件循环,Nginx worker 是 Reactor 模式,GUI 框架的主循环(Windows 的消息循环、iOS 的 RunLoop)同构,甚至数据库的 WAL 提交流程里也能看到“外包等待、集中回调”的影子。理解了这条视频,你获得的不只是一个前端知识点,而是一类系统架构的第一性原理:并发不必来自多线程,也可以来自把等待组织得足够好。

六、视频论证结构图

flowchart TD
    A[痛点:单线程一次一件事] --> B[请求等待冻结页面]
    B --> C[拒绝多线程方案]
    C --> D[拆解运行时四部件]
    D --> E[调用栈:唯一工人]
    D --> F[WebAPIs:外包等待]
    D --> G[回调队列:排队区]
    D --> H[事件循环:栈空就搬运]
    F --> G
    G --> H
    H --> E
    E --> I[真相:定时0只是排队资格]
    I --> J[总纲:别堵住调用栈]
    J --> K[本文补课:微任务优先]
    J --> L[本文补课:渲染节拍与INP]

延伸阅读

  1. Jake Archibald, Tasks, microtasks, queues and schedules(jakearchibald.com,2015)——本文“微任务优先级”一节的最佳原文,配套演讲《In The Loop》(JSConf Asia 2018)可在 YouTube 观看。
  2. MDN, Concurrency model and the event loop——run-to-completion 语义的官方描述,与视频四部件图对照阅读效果最佳。

参考来源

  1. What the heck is the event loop anyway? — Philip Roberts, JSConf EU 2014 — 本文解读的视频本体(如链接有变,可在 YouTube 搜索标题)
  2. Loupe — 事件循环可视化工具 — 演讲者本人开发,视频结尾演示的教具
  3. MDN: Concurrency model and the Event Loop — 事件循环的官方规范级描述
  4. Jake Archibald: Tasks, microtasks, queues and schedules — 微任务与宏任务队列的经典补课文章
  5. HTML Living Standard: Event Loops — 事件循环的规范出处(含定时器钳制条款)

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