JavaScript 圈有一条流传十年不衰的演讲:全场没有一行炫技代码,没有 PPT 特效,一个英国程序员站在台上,用一块白板式的画布,把“为什么你的页面会卡死”“setTimeout 到底准不准时”“回调函数到底是谁在调用”这三件事讲成了所有人都能听懂的现场演示。它被无数开发者称为“异步编程第一课”,在 Stack Overflow 的回答里、在各大学校的课程大纲里、在无数面试攻略里被反复引用。
更难得的是,这条视频的价值不随时间衰减:它讲清的那个“循环”,今天仍然是你手机里每一个网页、每一台服务器上的 Node.js 进程日日夜夜在跑的东西。看完它,你对“异步”的理解会从“背八股文”升级到“能在大脑里画图推演”。
| 项目 | 内容 |
|---|---|
| 视频标题 | What the heck is the event loop anyway?(事件循环到底是个啥?) |
| 演讲者 | Philip Roberts(当时任职于调度工具公司 helpfleet,后创办 And Yet 它) |
| 所属频道 | JSConf(JSConf EU 官方频道) |
| 发布时间 | 2014 年(JSConf EU 2014 大会演讲录像,具体上传日期未实时核验) |
| 时长 | 约 26 分钟(未经实时核验) |
| 热度 | 数百万播放量级的老牌高热演讲,长期位列“event loop”相关搜索首位附近(本篇写作环境网络受限,未能实时核验精确数字,据频道页面历史数据) |
数据核验说明:本篇写作时网络抓取工具被环境权限全部拦截,无法实时打开频道页面核对精确播放量与上传日期。上表中发布年份、演讲者、频道信息属于该演讲的公开常识级信息;精确数字类数据一律不给,以免编造。该视频可在 YouTube 以标题检索到。
演讲从一个人尽皆知但很少人深想的事实开始:JavaScript 是单线程语言。同一个时刻,它只能干一件事。这个设计不是失误而是选择——JS 诞生的场景是浏览器页面,如果两个线程同时改同一个 DOM 节点,一个在删、一个在改样式,浏览器就得处理各种“锁”的噩梦。单线程意味着简单、可预测:你的代码不用和“另一个自己”打架。
但代价立刻就来了。Philip 在现场演示:执行一个 while(i < 1e9){ i++ } 的死循环,页面上连一个按钮都点不动。因为单线程意味着——只要调用栈里还有活没干完,浏览器就没有任何余力响应你的点击。他把 alert() 称为“最恶劣的阻塞”:它不但堵住代码,还堵住整个界面的绘制。这一段的现场演示让无数观众第一次直观理解了“卡死”二字的物理学含义。
接下来是全片最著名的桥段。很多人以为 setTimeout(fn, 1000) 意思是“1000 毫秒后执行 fn”。Philip 在白板上画出真相:它的准确含义是“1000 毫秒后,把 fn 放进队列”——至于 fn 什么时候真正被执行,取决于那一刻调用栈是不是空的。如果主线程正在跑一个大计算,你的回调哪怕早就“到点”了,也只能在队列里干等着。所以 setTimeout 的第二个参数其实是“最少等多久”,而不是“精确等多久”。这一句话,解释了世界上千千万万个“定时器为什么不准”的 bug。
那异步操作到底是谁在干活?演讲给出关键澄清:不是 V8,也不是你的 JS 代码,而是浏览器本身。当你调用 setTimeout、发起一个 Ajax 请求、绑定一个点击事件,这些 API 是浏览器提供的(在 Chrome 里是 C++ 写的底层设施),它们在主线程之外的“车间”里计时、等网络、听事件。干完活之后,它们不会直接把回调塞回主线程——而是把回调放进一个任务队列(task queue)里排队。
这个澄清价值巨大,因为它纠正了一个流行误解:很多人以为“事件循环”是 JavaScript 引擎的一部分。真相是:V8 只负责解析和执行同步代码,事件循环由浏览器的底层实现(Node.js 则由 libuv 库实现)。JS 引擎本身连“定时器”这个概念都没有。
把前面所有零件拼起来,就是标题里的主角。事件循环(event loop)的职责简单到可以写成一两行伪代码:
Philip 用一个动效演示了整个过程:同步代码在栈上进进出出,异步回调在队列里排排坐,事件循环像个看门人,一次只放一个人进场。一次一个,这就是为什么 JavaScript 永远不会出现两个回调并发执行的情况,也是为什么一个回调写得烂就能堵死所有人。
这条视频录制于 2014 年,当时 ES6 的 Promise 还未普及。Philip 的模型里只有“一个队列、一次取一个”。此后的标准演化在他的模型上补了关键一笔:队列其实有两层——宏任务队列(setTimeout、I/O、事件回调)和微任务队列(Promise 回调、queueMicrotask、MutationObserver)。事件循环的完整节奏是:每执行完一个宏任务、并且清空整个微任务队列之后,浏览器才有可能进行一次渲染。也就是说,微任务的优先级高于渲染、也高于任何排队的定时器。这块内容后来由 Google Chrome 团队的工程师 Jake Archibald 在 2015 年的经典长文《Tasks, microtasks, queues and schedules》里给出了教科书级的补全,并被吸收进 WHATWG HTML 规范的事件循环处理模型。
规范层面。 事件循环不是民间经验,而是有正式文本的规范:WHATWG 维护的 HTML 规范中有一节 "Event Loops",逐条定义了“从队列取任务、执行、更新渲染”的完整处理模型,包括著名的细节:嵌套调用 setTimeout 超过五层后,浏览器会把最小超时钳制到 4 毫秒。MDN(Mozilla 开发者网络)的《Concurrency model and the Event Loop》页面则用更平易的语言描述了“运行至完成”(run-to-completion)语义。
事件循环不是 JS 的发明。 这是最值得追溯的一层。事件循环的历史比 JavaScript 老得多:它就是所有 GUI 程序的“消息循环”——Windows 时代 C 程序员人人写过的 GetMessage / TranslateMessage / DispatchMessage 大循环,就是同一个思想:一个循环、一个队列、一次处理一条消息。在服务器领域,这个模式有个正式名字:Reactor 模式,出自 Douglas Schmidt 等人 1990 年代的《Pattern-Oriented Software Architecture》第二卷。1999 年 Dan Kegel 提出 C10K 问题(单机一万并发连接怎么扛)后,nginx、Redis 等软件用单线程事件循环 + epoll/kqueue 完成了对多线程 Apache 的逆袭。
Node.js 的接力。 2009 年 Ryan Dahl 在第一场 Node.js 演讲里明确说过,Node 的灵感正来自 nginx 的事件驱动模型和浏览器里“异步无阻塞”的编程体验。Node 把浏览器的事件循环抽出来做成一个独立库——libuv——它有自己的线程池和分阶段的循环(定时器、pending 回调、轮询 I/O、check、close 等阶段),所以 Node 的事件循环和浏览器的并不完全一样,process.nextTick 的优先级甚至高于 Promise 微任务。这些在 Node.js 官方文档《Event loop, timers, and process.nextTick》里都有正式说明。
工程实践的沉淀。 今天 Google 的 Core Web Vitals 把“长任务”(主线程上超过 50 毫秒的任务)列为页面卡顿的直接元凶,前端性能优化的很大一部分工作——代码分包、任务切片、scheduler.yield() 这类新 API——本质上都是在给这条循环“让路”。一条 2014 年的演讲讲的东西,仍然定义着 2026 年性能优化的主战场。
证据充分的: 单线程的运行至完成模型、回调排队、任务队列一次取一个、setTimeout 只保证“不早于”——这些全部是规范明文规定的,MDN 和 WHATWG 文档可查,没有任何争议。
需要打折扣或补充的:
第一,把异步当“排队”而不是“并行”。 遇到任何时序玄学问题,别再靠 console.log 祈祷,直接在大脑里画这张图:调用栈清空 → 清空全部微任务 → 浏览器渲染一帧 → 取下一个宏任务。顺着这个顺序推演,Promise.then 和 setTimeout 谁先执行这类问题会从“背答案”变成“推导题”。
第二,主动给主线程让路。 如果你要在浏览器里做超过 50 毫秒的计算(大列表处理、数据解析),把它切成小块,块与块之间用 await new Promise(r => setTimeout(r)) 或新的 scheduler.yield() 让出主线程,界面就能保持响应。这是今天前端性能优化里性价比最高的一招,而它的原理,全部来自这条视频讲的那张图。
第三,用工具把看不见的循环看一遍。 Philip 在演讲中现场演示了他做的可视化工具 Loupe(latentflip.com/loupe):把任意一段代码粘进去,它会用动画展示调用栈和任务队列的每一步。花十分钟把自己最困惑的一段异步代码丢进去跑一遍,胜过读十篇博客——亲手看见循环转起来,是理解这件事最快的路径。
flowchart TD
A[JS主线程<br/>调用栈] --> B[执行当前函数]
B --> C{是异步调用吗}
C -->|否| B
C -->|是| D[交给宿主后台<br/>Web API 或 libuv]
D --> E[回调进入任务队列排队]
E --> F{调用栈空了吗}
F -->|否| B
F -->|是| G[清空微任务队列<br/>Promise 等]
G --> H[浏览器渲染一帧]
H --> I[取下一个宏任务<br/>如 setTimeout 回调]
I --> B
本文由「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 等场景全覆盖。
