事件循环 — 单线程凭什么同时推进很多事
事件循环 — 单线程凭什么同时推进很多事
这一章回答一个问题: JavaScript 明明"一次只能干一件事",为什么三个网络请求还能同时进行? 答案是:语言只负责"干",而"什么时候轮到谁干"由一个叫事件循环的外围机制调度。 读完本章,你能看着一段没见过的代码,准确说出它的输出顺序——这是全书第一个硬技能。
1. 这一章讲什么
第 00 章说了异步"是什么",这一章拆异步"靠什么转"。内容分三块:
- 同步与异步两种执行方式的对照(第 2 节);
- 事件循环的五个零件和一段代码的逐步推演(第 3 节——本章核心);
- 任务、微任务、回调三个概念的分工(第 4 节)。
它在全书链条里的位置:这是机制层的地基。第 02 章的回调、第 03 章的 Promise,全是往这个循环的队列里塞东西的不同姿势;不懂这章,后面所有"为什么 Promise 比 setTimeout 先执行"都是死记。
2. 顶层全景:电话亭与生产线
原书用电话亭打比方1:
- 同步像只有一间电话亭:前面的人不打完,后面的人只能排队干等。代码就是一行一行,上一行不返回,下一行不执行——
console.log("First"); console.log("Second"); console.log("Third");的输出永远是 First、Second、Third2。 - 异步像一排电话亭:各打各的,谁先打完谁先走。
setTimeout这种操作不占用"打电话"这件事本身,它登记一个"2 秒后叫我",然后立刻让出位置3:
console.log("Start");
setTimeout(() => { console.log("Timeout completed"); }, 2000);
console.log("End");输出是 Start → End →(2 秒后)Timeout completed。写在后面的 End 反而先打印——这一章就是要把这个"反直觉"变成可推导。
本章主线例子就是这段代码的加长版,下面第 3 节逐行推演它的内部状态。
3. 核心原理:事件循环的五个零件
3.1 先立一个事实:JavaScript 是单线程的
"单线程"指语言只有一条执行流水线(原书:只有一个调用栈,"can do only one thing at a time")4。如果只有同步代码,一个慢操作(比如等网络)就会占死这条线,界面冻结——这正是第 00 章的问题。出路是:语言把"等"外包出去,自己继续跑。外包给谁、跑完怎么交接,就是事件循环管的事。
3.2 五个零件
原书列了五个组成部分,各司其职5:
| 零件 | 干什么 | 原书名字 |
|---|---|---|
| 调用栈 | 现在正在执行的函数的栈;调用一个函数压进去,返回弹出来 | Call Stack |
| 宿主接口 | 浏览器(或 Node.js)提供的计时器、网络请求、DOM 事件等能力 | Web APIs |
| 任务队列 | 普通异步回调的候场区 | Callback Queue(又叫 Task Queue) |
| 微任务队列 | 另一类候场区,优先级更高 | Microtask Queue |
| 事件循环 | 裁判:调用栈一空,就按规则从队列里取下一个来执行 | Event Loop |
它们的关系:
你的代码 → 压入调用栈执行
│
├─ 碰到同步语句 → 就地执行
└─ 碰到异步操作(如 setTimeout、fetch)→ 交给宿主接口去等
│
等完了,回调被放进某个队列 ◄──┐
│
调用栈空了 ◄── 事件循环检查:微任务队列有货吗?有就全部取出执行 ──────────┤
│ │
└─ 微任务清空后,才取任务队列里的第一个 → 压回调用栈 → 又开始上面一圈 ──┘
图说:异步操作从不"插队"执行;它们只是先去排队,等当前的执行空了才轮到。3.3 主线例子逐步推演:五行代码,六步状态
把第 2 节那段代码加一行 Promise,就是原书给的完整考题6:
console.log('Start');
setTimeout(() => { console.log('Timeout'); }, 0);
Promise.resolve().then(() => { console.log('Promise'); });
console.log('End');setTimeout 写的等待时长是 0 毫秒——不是"立即",仍然是异步。逐个状态走一遍(步骤编号即时间顺序):
① 调用栈执行 console.log('Start') → 输出:Start
② setTimeout 连同样回调交给宿主接口登记 → 调用栈继续,不等待
③ Promise.resolve().then(...) 的回调 → 直接放进【微任务队列】
④ 调用栈执行 console.log('End') → 输出:End;此刻调用栈空
⑤ 事件循环先看微任务队列:有货 → 取出执行,输出:Promise
⑥ 微任务清空后看任务队列:超时回调在里面 → 取出执行,输出:Timeout最终输出:Start → End → Promise → Timeout7。
这条推导链值得背下来,因为每一环都是规则而不是巧合:
- 同步语句永远就地执行(①④在两头);
- 异步操作的回调永远不插队,再短的等待也得进队列(②);
- 微任务(Promise 的回调)排在普通任务(
setTimeout)前面(⑤在⑥前)。
第 3 条是本章最值钱的一条:它解释了为什么 Promise 的 .then 看起来"更快"——不是更快,是排的是另一个队,而那个队优先8。原书把这条总结为:同步代码立即执行,异步操作委托给宿主接口,微任务优先于普通任务,事件循环在调用栈空时持续取任务9。
3.4 为什么这套设计是"非阻塞"的
关键在②那一步:启动一个异步操作时,JavaScript 不站在原地等结果,而是登记一个"办完叫我",接着执行后面的代码。等操作办完(计时器到点、网络包到达、用户点了按钮),宿主接口把回调塞进队列,事件循环择机执行它。等待的全程,那条唯一的流水线都在干别的活10。
判断(我们的,不是书里的): 这套"单线程 + 队列调度"的结构,是所有协作式并发的原型:没有人抢占,每个任务自愿让出执行权(通过发起异步操作),调度器只负责排队。 它换来的代价也在 3.3 的⑥里——任何回调都至少晚一轮才执行,且一个贪婪的长任务会把所有队列一起饿死(第 08 章的"阻塞事件循环"错误就是这条的反面)。想在这个结构里找"真正的同时执行",只有第 09 章的 Web Worker 那条路。 如果错,会错在: 如果把宿主接口的等待也算进"执行"(比如 Node.js 里多个网络请求确实是内核并行收的),那么"单线程"说的只是你的 JavaScript 代码不并行,不是整台机器不并行——对读者写代码而言这个区分够用,但对做运行时实现的人不够。
3.5 浏览器与 Node.js:同一套,不同工头
事件循环的概念两边一样,工头不同:浏览器里它和页面绘制绑在一起,任务通常在两次绘帧之间处理,保证画面流畅;Node.js 里它建在一个叫 libuv 的底层库上,多了几个专门处理文件、网络等系统操作的阶段11。入门阶段不用记差异,但要知道有差异——同一段代码在两个环境里的时序细节可能不同——本库后续章节的输出推演,没有特别说明时一律按浏览器模型走。
4. 任务、微任务、回调:三个名字的分工
4.1 任务(也叫宏任务)
"任务"是排队的大件。哪些东西产生任务12:
- 整段
<script>代码的执行; setTimeout/setInterval的回调;- 用户操作(点击、滚动)触发的事件回调;
- 网络事件的回调。
一个任务跑完,浏览器可以趁机画一帧界面——这就是"界面保持响应"的机制来源。
4.2 微任务
"微任务"是小件,挂在每个任务的尾巴上清空。常见的来源13:
- Promise 的
.then()/.catch()/.finally()回调; queueMicrotask()(专门往微任务队列塞东西的函数);- DOM 变动观察器(MutationObserver)的回调。
规则一句话:当前任务一结束,先把微任务队列整个清空,才去取下一个任务、才去画界面。 所以微任务链太长会拖住绘制——这是第 11 章性能反模式之一的地基。
4.3 两者交火:链式 Promise 为什么按顺序出
把任务和微任务放在一起,原书的第二道考题14:
console.log('Script start');
setTimeout(() => { console.log('setTimeout'); }, 0);
Promise.resolve().then(() => { console.log('Promise 1'); })
.then(() => { console.log('Promise 2'); });
console.log('Script end');输出:Script start → Script end → Promise 1 → Promise 2 → setTimeout。
推演要点:整段脚本本身是第一个任务;两个 .then 先后进微任务队列,按先进先出执行;而且第一个 .then 执行后新挂的 .then,继续排进微任务队列——所以 Promise 2 排在 Timeout 前面:清空微任务的规则是"清到队列为空",不是"只清一轮"15。
4.4 回调:站在队列门口的那个东西
不管哪个队列,排进去的都是回调——"传给另一个函数、等时机到了再执行的函数"。它是异步的最小单元:定时器回调、事件回调、Promise 回调,本质都是"某个队列里等着被调用的函数"16。它是老写法(第 02 章的主角),也是 Promise 和 async/await 的地基——后两者只是把"怎么管理回调"这件事 increasingly 自动化了。
4.5 两道自测题:把规则用成直觉
规则背下来不等于会用。下面两道题覆盖本章最容易错的两处,先自己推,再看答案(时刻与顺序按规则推,不依赖机器)。
自测一:微任务里再造微任务。
console.log('A');
Promise.resolve().then(() => {
console.log('B');
Promise.resolve().then(() => console.log('C'));
});
Promise.resolve().then(() => console.log('D'));
console.log('E');推演:整段脚本是第一个任务,先执行 A、E(同步)。脚本结束,事件循环清微任务队列——队里是 B、D 两个(先进先出,先 B 后 D)。执行 B 时又挂了一个微任务 C,而"清空"的规则是清到队列为空,所以 C 排在 D 后面。输出:A → E → B → D → C。
这道题考的正是第 3.3 节⑥那条的另一半:清空微任务是"清到空",不是"清一轮"。如果误记成"一轮",就会错答成 A → E → B → C → D。
自测二:宏任务里造微任务。
setTimeout(() => {
console.log('宏');
Promise.resolve().then(() => console.log('微'));
}, 0);
setTimeout(() => console.log('宏2'), 0);推演:第一个定时器回调是一个任务;执行它时先打"宏",又挂一个微任务"微"。当前任务一结束立刻清微任务——所以"微"排在第二个定时器任务"宏2"前面。输出:宏 → 微 → 宏2。
两题合起来的口诀:每个任务跑完,先把它产生的微任务全部清光,才轮到下一个任务。 微任务的优先级是"任务级"的,不是"全局级"的。
4.6 任务与界面绘制的关系
原书提到一个容易被略过的事实:浏览器在两个任务之间有机会绘制一帧界面17。这条规则把前面几节串起来了:
- 同步代码全在一个任务里跑完——所以它卡住时界面一帧都画不出来(第 08 章错误五的机理);
- 微任务清空后才绘制——所以微任务链太长(比如在
.then里再挂几十个.then)会拖住绘制,界面照样卡; - 任务之间绘制——所以把大计算拆成"一小段任务 + 让出"的节奏,界面就能在缝隙里喘口气(比拆进微任务更友好,因为微任务不给绘制留缝)。
4.7 把整段脚本的队列状态画成一张表
3.3 的推演用文字,这里换成表——同一段五行代码,每行执行后的四个零件状态(栈 / 宿主接口 / 微任务队列 / 任务队列)18:
| 步骤 | 调用栈 | 宿主接口 | 微任务队列 | 任务队列 | 输出 |
|---|---|---|---|---|---|
| 执行第1行 log | log | — | — | — | Start |
| 执行第2行 setTimeout | setTimeout 登记后返回 | 计时器(0ms) | — | — | |
| 执行第3行 Promise.then | then 挂回调后返回 | 计时器 | 打印Promise | — | |
| 执行第4行 log | log | 计时器 | 打印Promise | — | End |
| 脚本任务结束 | 空 | 计时器到点→ | 打印Promise | 打印Timeout | |
| 清微任务 | 打印Promise | — | — | 打印Timeout | Promise |
| 取下一任务 | 打印Timeout | — | — | — | Timeout |
这张表回答了初学者最常问的两个问题:"setTimeout 0ms 为什么不立刻跑?"——因为它不是插队,是排队,排队就要等当前任务结束;"Promise 为什么比它快?"——因为排的是另一个队,而那个队先清。把这张表看熟、能自己重画一遍,第 08 章的"阻塞循环"错误就能从表里直接推出来:while 循环占着调用栈,"脚本任务结束"那一行永远到不了。
4.8 Node.js 的那"几个额外阶段"是什么
3.5 说 Node.js 的循环多了几个阶段,这里给名字和顺序,够你查资料时认路19:
timers(定时器到期) → 系统回调 → poll(取 I/O 结果) → check(setImmediate) → close(关闭事件)
↑______________________________________________|
(一圈结束,回到 timers)与浏览器模型的对应关系:poll 阶段大约扮演"任务队列"的角色,timers 就是 setTimeout 的家。入门阶段只要记住两件事:别在 Node 里死记浏览器的输出题(第 4 章 4.3 的两道题在 Node 里答案可能不同,比如 setImmediate 与 setTimeout(0) 的先后是不稳定的);process.nextTick 是 Node 的"插队专用道",优先级比微任务还高——这是浏览器没有的东西,跨环境的时序差异一半来自它。
4.9 回调的"老"与任务的"新":一个澄清
任务和微任务清单里出现的"用户点击回调""定时器回调",与第 02 章要讲的"回调地狱"是同一个词的两面:回调作为机制(把函数交给别人稍后调用)永远存在,事件循环里的每个任务本质上都是回调被执行;回调作为写法(手动嵌套编排流程)才是被淘汰的那个。第 02 章要淘汰的是写法,不是机制——这个区分能避免"学完 Promise 就鄙视一切回调"的常见偏差。
5. 作者的判断与证据
本章的证据方式依然是"可复现推演":3.3 和 4.3 两个例子,任何人贴进浏览器控制台都能得到同样的输出顺序714。这是讲事件循环唯一能用的证据——它不是观点,是运行时行为,试就是。
作者有一处明确的取舍值得点出:把 Node.js 的循环简化成"与浏览器概念相同、实现略有差异"一句话带过11。对入门读者这是对的取舍——先把浏览器这套讲透,不然 libuv 的阶段细节会把人淹死。
6. 边界与局限
- 没讲 Node.js 事件循环的具体阶段(timers、poll、check 等)与
process.nextTick的插队机制;Node 侧的时序问题本章规则推不出来; - 没讲渲染(绘制)在循环里的确切位置,只说浏览器"可能在一个任务后画一帧";依赖这个细节的优化(强制同步布局)本章覆盖不了;
- "微任务优先"的规则书中没有给形式化的出处(规范文本),只给了行为示例;够了,但别拿它当规范引用。
7. 可带走的
- 事件循环五零件:调用栈(正在执行)、宿主接口(替你去等)、任务队列、微任务队列、事件循环(调度);
- 输出顺序三条规则:同步就地执行;异步回调不插队、进队列;微任务队列清空后才轮到普通任务;
setTimeout(fn, 0)不是"立即执行",是"排到下一个任务"——它一定晚于所有已排队的微任务;- 链式
.then每挂一个就多一个微任务,且"清空微任务"是清到空为止; Start → End → Promise → Timeout这个五行例子,是可以背下来的推导模板;- 单线程不等于机器不并行——你的代码不并行,宿主接口(网络、磁盘)在背后并行;
- 浏览器与 Node.js 的循环工头不同,时序细节不要跨环境死记。
8. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 同步/电话亭比方 | Chapter 1: Understanding Asynchronous JavaScript | text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:13(搜「telephone booth」) |
| setTimeout 输出反直觉 | 同上 | text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:39(搜「Start」) |
| 单线程、一个调用栈 | 同上 | text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:81(搜「single-threaded」) |
| 五个零件 | 同上 | text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:89(搜「Call Stack」) · text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:95(搜「Microtask Queue」) |
| Start/End/Promise/Timeout 推演 | 同上 | text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:103(搜「Start」) · text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:131(搜「Start」) |
| 微任务优先 | 同上 | text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:142(搜「prioritized」) |
| 非阻塞的本质 | 同上 | text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:148(搜「doesn't wait」) |
| 浏览器 vs Node.js(libuv) | 同上 | text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:158(搜「libuv」) |
| 任务清单 | 同上 | text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:168(搜「macrotasks」) |
| 微任务清单与清空规则 | 同上 | text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:198(搜「Microtasks」) · text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:206(搜「first-in-first-out」) |
| 交火例子输出 | 同上 | text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:244(搜「Script start」) |
| 回调定义 | 同上 | text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:260(搜「passed as arguments」) |
(tip:本章所有输出推演都可直接在浏览器控制台粘贴验证,这是事件循环章节独有的"实验器材"。)
Footnotes
-
出处:「Chapter 1: Understanding Asynchronous JavaScript」第 13 段(
text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:13,搜「telephone booth」)。原文:"a line of people waiting to use a single telephone booth",同步执行模式的比方。 ↩ -
出处:同章第 15-27 段(
text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:17,搜「First」)。三个 console.log 的输出"always"按 First、Second、Third 排列,原文称其为同步编程的标志。 ↩ -
出处:同章第 35-51 段(
text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:39,搜「Start」;:51,搜「even though」)。原文指出 "End" 先于超时消息打印,尽管它写在代码更后面。 ↩ -
出处:同章第 79-83 段(
text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:81,搜「single-threaded」)。原文:"it has only one call stack and can do only one thing at a time"。 ↩ -
出处:同章第 87-97 段(
text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:89,搜「Call Stack」)。五个零件的原文列表:Call Stack、Web APIs、Callback Queue(又称 Task Queue)、Microtask Queue、Event Loop。 ↩ -
出处:同章第 103-113 段(
text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:103,搜「Start」)。完整例子含 setTimeout(0) 与 Promise.resolve().then。 ↩ -
出处:同章第 115-134 段(
text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:131,搜「Start」)。原文逐步处理后给出的输出即 Start、End、Promise、Timeout。 ↩ ↩2 -
出处:同章第 142 段(
text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:142,搜「prioritized」)。原文:"Microtasks (like Promise callbacks) are prioritized over regular tasks (like setTimeout callbacks)"。 ↩ -
出处:同章第 136-144 段(
text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:144,搜「continuously checks」)。原文归纳事件循环"continuously checks for tasks to execute when the Call Stack is empty"。 ↩ -
出处:同章第 146-150 段(
text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:148,搜「doesn't wait」)。原文:"JavaScript doesn't wait for it to complete. Instead, it continues executing other code." ↩ -
出处:同章第 152-160 段(
text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:154,搜「implementation」;:158,搜「libuv」)。浏览器循环与绘制绑在一起;Node.js 的循环基于 libuv,含额外阶段。 ↩ ↩2 -
出处:同章第 166-182 段(
text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:168,搜「macrotasks」;:180,搜「setTimeout and setInterval」)。任务的产生来源与"任务间可绘制界面"均出自这两段。 ↩ -
出处:同章第 196-206 段(
text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:200,搜「then()」;:202,搜「queueMicrotask」;:204,搜「MutationObserver」)。微任务三个来源与"先进先出、清空后才轮到下一个任务"的规则。 ↩ -
出处:同章第 226-248 段(
text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:228,搜「Script start」)。输出顺序为 Script start、Script end、Promise 1、Promise 2、setTimeout。 ↩ ↩2 -
出处:同章第 250-256 段(
text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:256,搜「Chained promises」)。原文:"Chained promises create new microtasks that are added to the microtask queue." ↩ -
出处:同章第 258-281 段(
text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:260,搜「passed as arguments」;:281,搜「callback hell」)。回调的定义与其通向回调地狱(因此逼出 Promise)的交代。 ↩ -
出处:「Chapter 1: Understanding Asynchronous JavaScript」第 180-182 段(
text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:182,搜「UI render」)。原文:"When a task is completed, the browser may choose to perform a UI render before moving on to the next task." ↩ -
出处:「Chapter 1: Understanding Asynchronous JavaScript」第 103-134 段(
text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:117,搜「pushed onto」)。五步处理顺序照原书。 ↩ -
出处:「Chapter 1: Understanding Asynchronous JavaScript」第 152-160 段(
text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:158,搜「libuv」)。原文只说 Node 的循环"includes additional phases for handling I/O operations, timers, and other system-level tasks";各阶段的名字与顺序是对 libuv 模型的补充说明(补充,不在书里,来自通用知识)。 ↩