第 25 章

事件循环:Job Queue 与 Task Queue 的规范定义

单线程的 JavaScript 之所以能处理网络请求、用户点击、定时器,不是靠多线程,而是靠一套精密的调度协议——事件循环。理解它,你就理解了 JavaScript 所有"异步"的本质。

🔹 Level 1 · 你需要知道的

JavaScript 引擎本身是单线程的:同一时刻只有一段代码在执行。"并发"的假象来自事件循环(Event Loop)——一个不断检查任务队列、取出任务、执行的循环。

两类任务:宏任务与微任务

维度 宏任务(Task) 微任务(Microtask)
别名 Task Queue、Message Queue Job Queue、Microtask Queue
来源 setTimeoutsetInterval、I/O 回调、UI 渲染事件、postMessage Promise.thenqueueMicrotaskMutationObserver(浏览器)、queueMicrotask
执行时机 每次事件循环取一个 当前任务结束后全部清空
能否产生新任务 可以,加到队列末尾 可以,新微任务在本次清空中继续执行
规范出处 HTML 规范 §8.1.6 ECMAScript 规范 §9.5(Jobs)

最关键的规则: 每个宏任务执行完毕后,引擎会把微任务队列清空到底——包括微任务执行期间新产生的微任务——然后才执行下一个宏任务(可能是渲染)。

执行优先级记忆口诀

同步代码 > 微任务(全部) > 渲染 > 宏任务(一个)> 微任务(全部)> 渲染 > 宏任务…

基础输出预测练习

console.log('start');

setTimeout(() => console.log('timeout'), 0);

Promise.resolve().then(() => console.log('micro'));

console.log('end');

// 输出:start → end → micro → timeout

解释:

  1. console.log('start') — 同步,立即执行
  2. setTimeout — 注册宏任务,放入 Task Queue
  3. Promise.resolve().then — 注册微任务,放入 Microtask Queue
  4. console.log('end') — 同步,立即执行
  5. 同步代码执行完,清空微任务队列 → 输出 micro
  6. 执行下一个宏任务 → 输出 timeout

常见误区澄清

误区1:setTimeout(fn, 0) 会立即执行

setTimeout(fn, 0) 的含义是"最早在0ms后"放入 Task Queue,但要等到当前任务和所有微任务执行完才会轮到它。实测最小延迟在浏览器中通常是 1ms~4ms(HTML 规范规定最小 4ms 节流阈值,嵌套超过5层时生效)。

误区2:微任务和宏任务是 JavaScript 规范定义的

ECMAScript 规范只定义了"Jobs(作业)"概念,对应微任务。"Task Queue"和事件循环的整体模型是 HTML 规范(WHATWG)定义的,Node.js 又做了自己的实现(libuv)。

误区3:每次循环只有一个微任务队列

实际上浏览器可能有多个宏任务队列(如渲染、网络、用户输入各一个队列),调度器决定从哪个队列取任务;但微任务队列只有一个,且每次宏任务结束后必须全部清空。

Node.js 的特殊队列

Node.js 在 ECMAScript 微任务之外,增加了两个特殊队列:

队列 API 优先级
nextTick 队列 process.nextTick 高于 Promise 微任务
Promise 微任务 Promise.then 正常微任务
Timers 阶段 setTimeoutsetInterval 宏任务(libuv 第1阶段)
Check 阶段 setImmediate 宏任务(libuv 第5阶段)

Node.js 中的执行顺序:同步 → nextTick → Promise 微任务 → 宏任务(按 libuv 阶段)。


🔸 Level 2 · 它是怎么运行的

事件循环完整执行模型

┌──────────────────────────────────────────────────────────┐
│                    Event Loop Tick                        │
│                                                          │
│  ┌─────────────────────────────────────────────────┐    │
│  │  Step 1: 从 Task Queue 取出一个 Task            │    │
│  │          (若队列为空,等待新任务加入)           │    │
│  └───────────────────┬─────────────────────────────┘    │
│                      │                                   │
│  ┌───────────────────▼─────────────────────────────┐    │
│  │  Step 2: 执行该 Task(运行 JS 代码)             │    │
│  │          调用栈从空 → 非空 → 空                  │    │
│  └───────────────────┬─────────────────────────────┘    │
│                      │                                   │
│  ┌───────────────────▼─────────────────────────────┐    │
│  │  Step 3: 清空 Microtask Queue                   │    │
│  │    while (microtaskQueue.length > 0) {           │    │
│  │      task = microtaskQueue.shift()               │    │
│  │      task()   // 可能再次加入新微任务            │    │
│  │    }                                             │    │
│  └───────────────────┬─────────────────────────────┘    │
│                      │                                   │
│  ┌───────────────────▼─────────────────────────────┐    │
│  │  Step 4: 渲染机会(仅浏览器)                   │    │
│  │    if (需要渲染 && 到达帧时间) { render() }      │    │
│  └───────────────────┬─────────────────────────────┘    │
│                      │                                   │
│                      └────────── 回到 Step 1 ───────────┘│
└──────────────────────────────────────────────────────────┘

ECMAScript 规范中的 Job 机制

ECMAScript 规范在第9章(Executable Code and Execution Contexts)定义了 Job 的概念:

Job Queue(作业队列) 是一个先进先出的列表,每个队列有一个名称。规范定义了两个内置队列:

PromiseJobs    — Promise 的 then/catch/finally 回调
ScriptJobs     — eval() 的求值(实际很少用到)

EnqueueJob 抽象操作的语义:

EnqueueJob(queueName, job, arguments):
  1. 断言 queueName 是一个已知队列名称
  2. 创建一个 PendingJob 记录:
     { [[Job]]: job, [[Arguments]]: arguments,
       [[Realm]]: 当前 Realm, [[ScriptOrModule]]: 当前脚本模块 }
  3. 将该记录添加到 queueName 对应队列的末尾
  4. 返回 normal completion

Promise.resolve().then(callback) 执行时,内部调用 EnqueueJob("PromiseJobs", PromiseReactionJob, [reaction, value])

HTML 规范的 Task Queue 模型

HTML 规范的 Processing Model 定义了完整的事件循环算法(伪代码简化版):

while (true) {
  // 选择一个 Task Queue(调度器决定)
  let taskQueue = selectOldestTask(allTaskQueues);
  let task = taskQueue.shift();

  // 执行 Task
  currentTask = task;
  task.steps();
  currentTask = null;

  // 微任务检查点(Microtask Checkpoint)
  performMicrotaskCheckpoint();
  // 等价于:while (microtaskQueue.length > 0) {
  //           microtaskQueue.shift().run();
  //         }

  // 渲染时机(浏览器决定是否渲染)
  if (hasARenderingOpportunity()) {
    render();
  }
}

调用栈、Task Queue、Microtask Queue 的状态追踪

以下面的代码为例,逐步追踪三个区域的状态:

// 代码
setTimeout(() => console.log('T1'), 0);
Promise.resolve()
  .then(() => {
    console.log('M1');
    Promise.resolve().then(() => console.log('M2'));
  });
console.log('sync');
步骤 调用栈 Microtask Queue Task Queue 输出
初始 [main] [] []
setTimeout 执行 [main, setTimeout] [] [T1_cb]
Promise.then 注册 [main] [M1_cb] [T1_cb]
console.log('sync') [main, log] [M1_cb] [T1_cb] sync
main 结束,栈清空 [] [M1_cb] [T1_cb]
取出 M1_cb 执行 [M1_cb] [] [T1_cb] M1
内部 Promise.then 注册 [M1_cb] [M2_cb] [T1_cb]
M1_cb 结束 [] [M2_cb] [T1_cb]
取出 M2_cb 执行 [M2_cb] [] [T1_cb] M2
微任务队列为空,取宏任务 [T1_cb] [] [] T1

输出顺序:sync → M1 → M2 → T1

async/await 的微任务展开

async/await 是 Promise 的语法糖,但细节很重要:

async function foo() {
  console.log('foo start');
  await Promise.resolve();
  console.log('foo after await');  // 在哪里执行?
}

console.log('before');
foo();
console.log('after');

展开等价形式:

// await X 等价于:
// return Promise.resolve(X).then(continuation)
// 其中 continuation 是 await 之后的代码

function foo() {
  console.log('foo start');
  return Promise.resolve(undefined).then(() => {
    console.log('foo after await');
  });
}

执行轨迹:

  1. before — 同步
  2. foo start — 同步(进入 foo 函数体)
  3. await 暂停,注册微任务(foo after await 的部分)
  4. after — 同步(foo 已暂停,控制权返回调用者)
  5. 同步代码结束,清空微任务 → foo after await

输出:before → foo start → after → foo after await

Node.js 事件循环的 libuv 阶段

Node.js 使用 libuv 库实现事件循环,分为6个阶段:

┌─────────────────────────────────────────────────┐
│              Node.js Event Loop                  │
│                                                  │
│  ┌──────────┐  ┌──────────┐  ┌──────────────┐   │
│  │  timers  │→ │ pending  │→ │    idle,     │   │
│  │setTimeout│  │callbacks │  │   prepare    │   │
│  │setInterval   │          │  │  (内部使用)  │   │
│  └──────────┘  └──────────┘  └──────────────┘   │
│       ↑                                  ↓       │
│  ┌──────────┐  ┌──────────┐  ┌──────────────┐   │
│  │  close   │← │  check   │← │     poll     │   │
│  │callbacks │  │setImmed. │  │   (I/O等待)  │   │
│  └──────────┘  └──────────┘  └──────────────┘   │
│                                                  │
│  每个阶段结束后:nextTick → Promise 微任务       │
└─────────────────────────────────────────────────┘

关键点:Node.js 在每个阶段结束时都会清空 nextTick 队列和 Promise 微任务队列,而不是只在宏任务结束时。

经典面试题完整解析

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}

async function async2() {
  console.log('async2');
}

console.log('script start');

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

async1();

new Promise(function (resolve) {
  console.log('promise1');
  resolve();
}).then(function () {
  console.log('promise2');
});

console.log('script end');

执行分析:

步骤 操作 输出 微任务队列
1 console.log('script start') script start []
2 setTimeout 注册 [],宏任务:[setTimeout_cb]
3 async1() 调用,进入 async1
4 console.log('async1 start') async1 start []
5 await async2() — 调用 async2
6 console.log('async2') async2 []
7 async2 返回,await 注册微任务 [async1_continuation]
8 new Promise 构造函数(同步执行) promise1 [async1_continuation]
9 .then 注册微任务 [async1_continuation, promise2_cb]
10 console.log('script end') script end [async1_continuation, promise2_cb]
11 清微任务:async1 continuation async1 end [promise2_cb]
12 清微任务:promise2_cb promise2 []
13 执行宏任务 setTimeout setTimeout

最终输出: script start → async1 start → async2 → promise1 → script end → async1 end → promise2 → setTimeout


🔺 Level 3 · 规范怎么定义的

ECMAScript 规范:§9.5 Jobs and Host Operations

规范原文(ECMAScript 2024,§9.5):

9.5.1 EnqueueJob ( queueName, job, arguments )

The abstract operation EnqueueJob takes arguments queueName (a String), job (a Job Abstract Closure), and arguments (a List of ECMAScript language values) and returns unused.

  1. Assert: queueName is "PromiseJobs". (Only one job queue is defined in this specification. However, implementations may define additional job queues.)
  2. Let callerContext be the running execution context.
  3. Let callerRealm be callerContext's Realm.
  4. Let callerScriptOrModule be callerContext's ScriptOrModule.
  5. Let pending be the PendingJob Record { [[Job]]: job, [[Arguments]]: arguments, [[Realm]]: callerRealm, [[ScriptOrModule]]: callerScriptOrModule }.
  6. Perform any implementation-defined processing of pending.
  7. Add pending to the end of the Job Queue named by queueName.
  8. Return unused.

关键理解: ECMAScript 规范只定义了 EnqueueJob 操作和 PromiseJobs 队列,至于"什么时候从队列取出执行",规范将其委托给宿主环境(Host Operations)。HTML 规范和 Node.js 各自实现了这个"什么时候取出"的逻辑。

ECMAScript 规范:Agent 的 Job 执行

规范 §9.6(ECMAScript Data Model)定义了 Agent 的概念:

Agent:
  [[LittleEndian]]: Boolean
  [[CanBlock]]: Boolean
  [[Signifier]]: agent 的唯一标识符
  [[IsLockFree1]]: Boolean(1字节原子操作是否无锁)
  [[IsLockFree2]]: Boolean(2字节原子操作是否无锁)
  [[CandidateExecution]]: 候选执行(内存模型相关)
  [[KeptAlive]]: 存活对象列表(WeakRef 相关)

每个 Agent 有一个单一的运行执行上下文栈关联的 Job 队列。Job 队列中的 Job 按顺序执行,且每个 Job 完整执行后才执行下一个(规范语义,非并发)。

HTML 规范:Processing Model for Event Loop

HTML 规范(WHATWG)的事件循环 Processing Model(§8.1.7.3)用伪算法描述了完整流程:

Run these steps forever:
  1. Let taskQueue be one of the event loop's task queues, chosen
     in an implementation-defined manner.
  2. Let oldestTask be the first runnable task in taskQueue, and
     remove it from taskQueue.
  3. Set the event loop's currently running task to oldestTask.
  4. Run oldestTask's steps.
  5. Set the event loop's currently running task to null.
  6. Perform a microtask checkpoint.
  7. ...(更新渲染)

Microtask Checkpoint(§8.1.7.4)

To perform a microtask checkpoint:
  1. If the event loop's performing a microtask checkpoint is true, return.
  2. Set the performing a microtask checkpoint flag to true.
  3. While the event loop's microtask queue is not empty:
     a. Let oldestMicrotask be the result of dequeuing from the queue.
     b. Set the event loop's currently running task to oldestMicrotask.
     c. Run oldestMicrotask's steps.
     d. Set the currently running task back to null.
  4. ...(notify about rejected promises)
  5. Set the performing a microtask checkpoint flag to false.

这解释了为什么"微任务里产生的新微任务会在本次 checkpoint 中执行"——因为步骤3是一个 while 循环,只要队列不空就继续。

PromiseReactionJob:微任务的实际执行单元

当 Promise resolve 时,规范执行:

PromiseReactionJob ( reaction, argument ):
  1. Let promiseCapability be reaction.[[Capability]].
  2. Let type be reaction.[[Type]].
  3. Let handler be reaction.[[Handler]].
  4. If handler is empty, then:
     a. If type is Fulfill, let handlerResult be NormalCompletion(argument).
     b. Else: let handlerResult be ThrowCompletion(argument).
  5. Else: let handlerResult be Completion(Call(handler, undefined, « argument »)).
  6. If promiseCapability is undefined, return unused.
  7. If handlerResult is a normal completion:
     return ? Call(promiseCapability.[[Resolve]], undefined, « handlerResult.[[Value]] »).
  8. Else:
     return ? Call(promiseCapability.[[Reject]], undefined, « handlerResult.[[Value]] »).

这个 Job 被 EnqueueJob("PromiseJobs", PromiseReactionJob, [reaction, value]) 推入队列。


💎 Level 4 · 边界与陷阱

陷阱1:微任务无限循环饿死宏任务

// 这段代码会让页面永远无法响应
function drainAllMicrotasks() {
  queueMicrotask(drainAllMicrotasks);  // 微任务产生微任务,永不停止
}
drainAllMicrotasks();

// setTimeout 的回调永远不会执行
// 渲染帧永远不会触发
// 页面完全冻结
setTimeout(() => console.log('will never run'), 0);

原因: 事件循环在清空微任务队列之前不会执行下一个宏任务。如果微任务队列持续有新任务加入,宏任务永远等不到执行机会。这是一个真实的性能陷阱——即使每个微任务执行时间很短,只要数量无限,效果等同于死循环。

实际场景: 某些 polyfill 或框架代码如果在 Promise.then 里触发状态更新,状态更新又触发新的 Promise,就可能形成这种循环。

陷阱2:Promise.resolve vs new Promise 的执行时机差异

// 案例A:直接 resolve 一个原始值
Promise.resolve(42).then(v => console.log('A', v));

// 案例B:resolve 一个已经 resolved 的 Promise
const p = Promise.resolve(42);
Promise.resolve(p).then(v => console.log('B', v));

// 案例C:在 Promise 构造函数里 resolve 一个 thenable
new Promise(resolve => {
  resolve(Promise.resolve(42));
}).then(v => console.log('C', v));

// 案例D:直接 resolve 原始值
new Promise(resolve => {
  resolve(42);
}).then(v => console.log('D', v));

输出顺序:A → B → D → C

分析:

微任务队列状态演变:
初始:[A_then, B_then, NewPromiseResolveThenableJob_C, D_then]
执行 A_then → 输出 A
执行 B_then → 输出 B
执行 NewPromiseResolveThenableJob_C → 注册 [C_then]
执行 D_then → 输出 D
执行 C_then → 输出 C

陷阱3:async/await 的微任务数量(规范历史变化)

在 V8 v7.2 之前(对应 Node.js 11 之前),await 会产生2个微任务;此后优化为1个微任务。这影响了执行顺序题的答案。

async function foo() {
  await 1;
  console.log('after await');
}

// 旧规范(Node 10)等价于:
function foo_old() {
  return new Promise(resolve => {
    Promise.resolve(1).then(() => {
      // 微任务1:展开 await 的值
      resolve();
    });
  }).then(() => {
    // 微任务2:执行 await 之后的代码
    console.log('after await');
  });
}

// 新规范(Node 12+)等价于:
function foo_new() {
  return Promise.resolve(1).then(() => {
    // 只有1个微任务
    console.log('after await');
  });
}

实际影响:

async function test() {
  await 1;
  return 'done';
}

test().then(console.log);
Promise.resolve().then(() => console.log('plain micro'));

// Node 10:plain micro → done(await 多一个微任务层级)
// Node 12+:done → plain micro(await 只有1个微任务,与 Promise.resolve 同级)

这是一个会影响测试用例的真实 Breaking Change。

陷阱4:Node.js process.nextTick 与 Promise.then 的顺序

Promise.resolve().then(() => console.log('promise 1'));
process.nextTick(() => console.log('nextTick 1'));
Promise.resolve().then(() => console.log('promise 2'));
process.nextTick(() => console.log('nextTick 2'));

// 输出:nextTick 1 → nextTick 2 → promise 1 → promise 2

原因: Node.js 的微任务处理顺序:先清空 nextTick 队列,再清空 Promise 队列。这是 Node.js 特有的行为,与浏览器不同。

Node.js 微任务阶段:
  Phase 1: 清空 nextTick queue(全部)
  Phase 2: 清空 Promise microtask queue(全部)
  如果 Phase 1 或 Phase 2 产生了新的 nextTick,重新从 Phase 1 开始

陷阱: 如果在 process.nextTick 里递归调用 process.nextTick,同样会饿死 Promise 回调(nextTick 优先级更高)。Node.js 文档建议尽量用 setImmediate 而非 process.nextTick,除非有明确需要在当前循环阶段末尾执行的场景。

陷阱5:React 的 Automatic Batching 与微任务

React 18 引入了 Automatic Batching(自动批处理):在同一个事件处理器或异步代码中多次调用 setState,React 会将它们合并为一次渲染。

// React 18 之前:异步代码中每次 setState 都触发渲染
setTimeout(() => {
  setCount(c => c + 1);  // 触发1次渲染
  setFlag(f => !f);      // 再触发1次渲染(共2次)
}, 1000);

// React 18:自动批处理,只触发1次渲染
setTimeout(() => {
  setCount(c => c + 1);  // 收集
  setFlag(f => !f);      // 收集
  // setTimeout 回调结束后批量处理,触发1次渲染
}, 1000);

React 18 的实现机制: React 使用 MessageChannel(宏任务)调度渲染,而状态更新的收集发生在当前宏任务内。React 内部的 Scheduler 用 MessageChannel.postMessage 来将渲染工作放入下一个宏任务,而非立即渲染。

React 18 调度流程:
  setState() → 标记需要更新(同步)
  setState() → 标记需要更新(同步)
  当前宏任务结束 → 微任务阶段 → React 通过 MessageChannel 调度渲染
  下一个宏任务 → React 批量处理所有待更新 → 一次 render

这解释了为什么 act() 在测试中需要刷新微任务和宏任务才能确保渲染完成。

小结

  1. 事件循环每次取一个宏任务,执行完后清空所有微任务(含微任务产生的微任务)。
  2. ECMAScript 规范只定义 PromiseJobs(微任务),事件循环整体由 HTML 规范或宿主实现。
  3. async/await 在现代规范(ES2020+,V8 v7.2+)中只产生1个微任务,旧版本产生2个。
  4. Node.js 的 process.nextTick 优先级高于 Promise.then,两者都是"微任务"但不同队列。
  5. 微任务无限递归会导致宏任务(包括渲染)永久阻塞,这是生产环境中真实存在的性能问题。
本章评分
4.7  / 5  (5 评分)

💬 留言讨论