首页 › programming › 事件循环:异步JS神课深读

一行代码就能卡死页面?把 JS 异步真相讲透的事件循环神课深读

这条视频为什么值得读

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 以标题检索到。

视频讲了什么:按叙事顺序拆成六个关键节点

1. 前提:JavaScript 只有一条主线程

演讲从一个人尽皆知但很少人深想的事实开始:JavaScript 是单线程语言。同一个时刻,它只能干一件事。这个设计不是失误而是选择——JS 诞生的场景是浏览器页面,如果两个线程同时改同一个 DOM 节点,一个在删、一个在改样式,浏览器就得处理各种“锁”的噩梦。单线程意味着简单、可预测:你的代码不用和“另一个自己”打架。

2. 阻塞:单线程的代价

但代价立刻就来了。Philip 在现场演示:执行一个 while(i < 1e9){ i++ } 的死循环,页面上连一个按钮都点不动。因为单线程意味着——只要调用栈里还有活没干完,浏览器就没有任何余力响应你的点击。他把 alert() 称为“最恶劣的阻塞”:它不但堵住代码,还堵住整个界面的绘制。这一段的现场演示让无数观众第一次直观理解了“卡死”二字的物理学含义。

3. setTimeout 的“谎言”

接下来是全片最著名的桥段。很多人以为 setTimeout(fn, 1000) 意思是“1000 毫秒后执行 fn”。Philip 在白板上画出真相:它的准确含义是“1000 毫秒后,把 fn 放进队列”——至于 fn 什么时候真正被执行,取决于那一刻调用栈是不是空的。如果主线程正在跑一个大计算,你的回调哪怕早就“到点”了,也只能在队列里干等着。所以 setTimeout 的第二个参数其实是“最少等多久”,而不是“精确等多久”。这一句话,解释了世界上千千万万个“定时器为什么不准”的 bug。

4. Web API:浏览器自带的后台车间

那异步操作到底是谁在干活?演讲给出关键澄清:不是 V8,也不是你的 JS 代码,而是浏览器本身。当你调用 setTimeout、发起一个 Ajax 请求、绑定一个点击事件,这些 API 是浏览器提供的(在 Chrome 里是 C++ 写的底层设施),它们在主线程之外的“车间”里计时、等网络、听事件。干完活之后,它们不会直接把回调塞回主线程——而是把回调放进一个任务队列(task queue)里排队。

这个澄清价值巨大,因为它纠正了一个流行误解:很多人以为“事件循环”是 JavaScript 引擎的一部分。真相是:V8 只负责解析和执行同步代码,事件循环由浏览器的底层实现(Node.js 则由 libuv 库实现)。JS 引擎本身连“定时器”这个概念都没有。

5. 事件循环:那个不停问“栈空了吗”的看门人

把前面所有零件拼起来,就是标题里的主角。事件循环(event loop)的职责简单到可以写成一两行伪代码:

Philip 用一个动效演示了整个过程:同步代码在栈上进进出出,异步回调在队列里排排坐,事件循环像个看门人,一次只放一个人进场。一次一个,这就是为什么 JavaScript 永远不会出现两个回调并发执行的情况,也是为什么一个回调写得烂就能堵死所有人。

6. 演讲没来得及讲透的:微任务

这条视频录制于 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 文档可查,没有任何争议。

需要打折扣或补充的:

  1. “一个队列”的简化模型已经过时。 如上所述,2014 年的演讲没有区分宏任务与微任务。如果你今天用 Philip 的原始模型去推演 Promise 相关的执行顺序,会得出错误结论。他的模型是理解事件循环的“骨架”,微任务是后来长出的“血肉”。
  2. “1000 毫秒后执行”的口误级简化。 演讲里正确指出了 setTimeout 的语义,但很多观众看完仍会忽略浏览器对嵌套定时器 4 毫秒的钳制,以及页面后台标签页里定时器可能被节流到每秒一次甚至更久——省电策略会进一步扭曲你设定的任何时间。
  3. “单线程”是 JS 语言层面的说法,不是宿主的极限。 浏览器有 Web Worker,Node 有 worker_threads 和线程池——真正单线程的是“执行你的 JS 的那一条主线程”,宿主环境本身并不缺线程。演讲受时长所限没有展开,但这点不搞清楚,容易把“JS 单线程”误解为“整个浏览器只有一个线程”。
  4. 演讲对“阻塞是坏事”的渲染略带夸张。 事实上 99% 的页面代码执行都在几毫秒内完成,阻塞平时感知不到;真正的问题是大计算、同步长循环。把阻塞讲成悬在头顶的剑,是演讲手法,不是工程常态。

三个可以立刻用上的启示

第一,把异步当“排队”而不是“并行”。 遇到任何时序玄学问题,别再靠 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

延伸阅读

  1. Jake Archibald《Tasks, microtasks, queues and schedules》(2015)——微任务机制最权威的一手科普,是这条演讲的最佳续集
  2. Loupe 可视化工具——Philip Roberts 写的事件循环在线演示器,把演讲里的动画搬回家自己跑

参考来源

  1. What the heck is the event loop anyway? | Philip Roberts | JSConf EU — 本文解读的原始视频
  2. MDN: Concurrency model and the event loop — 运行至完成语义与任务队列的官方描述
  3. Node.js 官方文档: Event loop, timers, and process.nextTick — Node 侧事件循环分阶段模型
  4. Jake Archibald: Tasks, microtasks, queues and schedules — 宏任务/微任务与渲染时机的经典实验文章
  5. WHATWG HTML 规范: Event Loops — 事件循环处理模型的正式定义,含 4ms 定时器钳制

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