跳转至

事件循环 — 单线程凭什么同时推进很多事

这一章回答一个问题: JavaScript 明明"一次只能干一件事",为什么三个网络请求还能同时进行? 答案是:语言只负责"干",而"什么时候轮到谁干"由一个叫事件循环的外围机制调度。 读完本章,你能看着一段没见过的代码,准确说出它的输出顺序——这是全书第一个硬技能。

1. 这一章讲什么

第 00 章说了异步"是什么",这一章拆异步"靠什么转"。内容分三块:

  1. 同步与异步两种执行方式的对照(第 2 节);
  2. 事件循环的五个零件和一段代码的逐步推演(第 3 节——本章核心);
  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。

这条推导链值得背下来,因为每一环都是规则而不是巧合:

  1. 同步语句永远就地执行(①④在两头);
  2. 异步操作的回调永远不插队,再短的等待也得进队列(②);
  3. 微任务(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. 可带走的

  1. 事件循环五零件:调用栈(正在执行)、宿主接口(替你去等)、任务队列、微任务队列、事件循环(调度);
  2. 输出顺序三条规则:同步就地执行;异步回调不插队、进队列;微任务队列清空后才轮到普通任务;
  3. setTimeout(fn, 0) 不是"立即执行",是"排到下一个任务"——它一定晚于所有已排队的微任务;
  4. 链式 .then 每挂一个就多一个微任务,且"清空微任务"是清到空为止;
  5. Start → End → Promise → Timeout 这个五行例子,是可以背下来的推导模板;
  6. 单线程不等于机器不并行——你的代码不并行,宿主接口(网络、磁盘)在背后并行;
  7. 浏览器与 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:本章所有输出推演都可直接在浏览器控制台粘贴验证,这是事件循环章节独有的"实验器材"。)


  1. 出处:「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",同步执行模式的比方。 ↩

  2. 出处:同章第 15-27 段(text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:17,搜「First」)。三个 console.log 的输出"always"按 First、Second、Third 排列,原文称其为同步编程的标志。 ↩

  3. 出处:同章第 35-51 段(text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:39,搜「Start」;:51,搜「even though」)。原文指出 "End" 先于超时消息打印,尽管它写在代码更后面。 ↩

  4. 出处:同章第 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"。 ↩

  5. 出处:同章第 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。 ↩

  6. 出处:同章第 103-113 段(text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:103,搜「Start」)。完整例子含 setTimeout(0) 与 Promise.resolve().then。 ↩

  7. 出处:同章第 115-134 段(text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:131,搜「Start」)。原文逐步处理后给出的输出即 Start、End、Promise、Timeout。 ↩↩

  8. 出处:同章第 142 段(text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:142,搜「prioritized」)。原文:"Microtasks (like Promise callbacks) are prioritized over regular tasks (like setTimeout callbacks)"。 ↩

  9. 出处:同章第 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"。 ↩

  10. 出处:同章第 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." ↩

  11. 出处:同章第 152-160 段(text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:154,搜「implementation」;:158,搜「libuv」)。浏览器循环与绘制绑在一起;Node.js 的循环基于 libuv,含额外阶段。 ↩↩

  12. 出处:同章第 166-182 段(text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:168,搜「macrotasks」;:180,搜「setTimeout and setInterval」)。任务的产生来源与"任务间可绘制界面"均出自这两段。 ↩

  13. 出处:同章第 196-206 段(text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:200,搜「then()」;:202,搜「queueMicrotask」;:204,搜「MutationObserver」)。微任务三个来源与"先进先出、清空后才轮到下一个任务"的规则。 ↩

  14. 出处:同章第 226-248 段(text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:228,搜「Script start」)。输出顺序为 Script start、Script end、Promise 1、Promise 2、setTimeout。 ↩↩

  15. 出处:同章第 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." ↩

  16. 出处:同章第 258-281 段(text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:260,搜「passed as arguments」;:281,搜「callback hell」)。回调的定义与其通向回调地狱(因此逼出 Promise)的交代。 ↩

  17. 出处:「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." ↩

  18. 出处:「Chapter 1: Understanding Asynchronous JavaScript」第 103-134 段(text/04-ch01-chapter-1-understanding-asynchronous-javascript.txt:117,搜「pushed onto」)。五步处理顺序照原书。 ↩

  19. 出处:「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 模型的补充说明(补充,不在书里,来自通用知识)。 ↩