任务队列不是一个,执行顺序不是你以为的那样。本文结合 V8、Chromium、Node.js 源码,彻底讲清楚异步任务的调度本质。所有代码均经过源码核查,每处均附对应链接。
一、全局视角:谁在管理任务?
scss
┌──────────────────────────────────────────────────────────────────┐
│ V8 引擎 │
│ ┌─────────────────┐ ┌──────────────────────────────┐ │
│ │ 调用栈 │ │ 微任务队列 MicrotaskQueue │ │
│ │ Call Stack │ │ (环形缓冲区,V8 原生维护) │ │
│ └─────────────────┘ └──────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
│ PerformCheckpoint() / PerformMicrotaskCheckpoint()
▼
┌──────────────────────────────────────────────────────────────────┐
│ 宿主环境 │
│ ┌───────────────────────┐ ┌───────────────────────────┐ │
│ │ 浏览器 │ │ Node.js │ │
│ │ Blink Scheduler │ │ libuv 事件循环 │ │
│ │ - 多优先级任务队列 │ │ - timers │ │
│ │ - Render Pipeline │ │ - pending/idle/prepare │ │
│ │ - rAF 队列 │ │ - poll / check / close │ │
│ └───────────────────────┘ │ - nextTick Queue(额外) │ │
│ └───────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
核心分工 :V8 维护调用栈 + 微任务队列;宿主环境维护宏任务队列 + 事件循环,两者通过 PerformCheckpoint 接口联结。
二、V8 内部:微任务队列的实现
数据结构:环形缓冲区
源码在 src/execution/microtask-queue.h:
arduino
// src/execution/microtask-queue.h
// https://chromium.googlesource.com/v8/v8/+/lkgr/src/execution/microtask-queue.h
class MicrotaskQueue final : public v8::MicrotaskQueue {
public:
int RunMicrotasks(Isolate* isolate); // 清空执行
void EnqueueMicrotask(Tagged<Microtask> microtask); // 入队
intptr_t capacity() const { return capacity_; }
intptr_t size() const { return size_; }
intptr_t start() const { return start_; }
private:
// 环形缓冲区注释原文:
// ring_buffer_[(start_ + i) % capacity_] contains |i|th Microtask
intptr_t size_ = 0; // 当前任务数
intptr_t capacity_ = 0;
intptr_t start_ = 0; // 队头指针
Address* ring_buffer_ = nullptr;
};
RunMicrotasks:微任务的执行机制
现代 V8 的 RunMicrotasks 不是一个简单的 C++ while 循环,而是委托给 CSA(CodeStubAssembler)内置函数 RunMicrotasksDrainQueue 执行,这是一次性能优化------将 JS 与 C++ 之间的切换降到最少(约 60% 的性能提升):
php
// src/execution/microtask-queue.cc(精简)
// https://chromium.googlesource.com/v8/v8/+/lkgr/src/execution/microtask-queue.cc
int MicrotaskQueue::RunMicrotasks(Isolate* isolate) {
// 实际调用 Execution::RunMicrotasks()
// 后者会进入 CSA 内置函数 RunMicrotasksDrainQueue
// 循环逻辑在 CSA 中实现,直到 size_ 归零
MaybeHandle<Object> maybe_result =
Execution::RunMicrotasks(isolate, ...);
// 执行终止时的清理
if (maybe_result.is_null() && maybe_exception.is_null()) {
size_ = 0; start_ = 0; capacity_ = 0;
return -1;
}
return finished_microtask_count_;
}
连锁执行的本质 :CSA 内置函数在处理每个微任务前都会检查 size_,执行过程中若新产生微任务(size_ 增大),会继续循环,直到队列彻底清空。
微任务触发时机:MicrotasksPolicy
kotlin
// include/v8-microtask-queue.h
// https://chromium.googlesource.com/v8/v8/+/lkgr/include/v8-microtask-queue.h
enum class MicrotasksPolicy {
kExplicit, // 宿主显式调用
kScoped, // 作用域退出时
kAuto // 调用栈清空自动触发 ← 默认值,见 microtask-queue.h
// microtasks_policy_ = v8::MicrotasksPolicy::kAuto
};
V8 暴露给宿主的触发入口是 MicrotaskQueue::PerformCheckpoint(v8::Isolate*),宿主每完成一个任务,就调用它触发微任务清空。
三、Promise 与微任务的关联
.then() 的回调为什么是微任务?真实的调用链:
scss
Promise.resolve()
→ FulfillPromise() ← 修改 Promise 状态
→ TriggerPromiseReactions() ← 触发所有 .then 回调
→ EnqueueMicrotask() ← ★ 真正入队微任务
入队发生在 promise-abstract-operations.tq:
scss
// src/builtins/promise-abstract-operations.tq
// https://github.com/v8/v8/blob/main/src/builtins/promise-abstract-operations.tq
// .then(fn) 的回调被包装成 PromiseReactionJobTask 入队
EnqueueMicrotask(handlerContext, promiseReactionJobTask);
关键认知 :.then(fn) 注册时,fn 只是挂在 Promise 对象上 ,不在任何队列里。只有 Promise 被 resolve 的那一刻,TriggerPromiseReactions 才将 fn 包装成 PromiseReactionJobTask 放入微任务队列。网络请求的回调为什么"等请求完成才入队",原因正在于此。
四、浏览器的事件循环
浏览器事件循环遵循 HTML Living Standard,由 Blink Scheduler 驱动。
浏览器的任务队列:多任务源
Blink 定义了 80+ 种任务类型(TaskType 枚举),每种任务源有独立的队列和优先级:
ini
// third_party/blink/public/platform/task_type.h
// https://chromium.googlesource.com/chromium/src/third_party/+/master/blink/public/platform/task_type.h
enum class TaskType : unsigned char {
kUserInteraction = 2, // 用户交互(点击、键盘)← 高优先级
kNetworking = 3, // 网络响应(fetch/XHR)
kNetworkingUnfreezableRenderBlockingLoading = 83, // 阻塞渲染的资源加载(优先级高于渲染)
kJavascriptTimerImmediate = 72, // setTimeout(fn,0),嵌套层级 < 5
kJavascriptTimerDelayedHighNesting = 10, // 嵌套层级 >= 5,强制至少 4ms 延迟
kDatabaseAccess = 16, // IndexedDB ← 低优先级
kMicrotask = 9, // 微任务入口
kIdleTask = 21, // requestIdleCallback
kMainThreadTaskQueueInput = 40, // 输入事件(最高优先级队列)
// ...共 80+ 种
};
setTimeout(fn, 0) 嵌套层级 < 5 走 kJavascriptTimerImmediate,>= 5 走 kJavascriptTimerDelayedHighNesting 并强制至少 4ms 延迟,这就是深度嵌套 setTimeout(fn, 0) 会变慢的根本原因。
浏览器事件循环的执行顺序
arduino
一轮事件循环:
┌─────────────────────────────────────────────────┐
│ 1. Blink Scheduler 从最高优先级任务队列取一个任务 │
│ 2. 交给 V8 执行(调用栈) │
│ 3. MicrotaskQueue::PerformCheckpoint() │ ← 通知 V8 清空微任务
│ 4. 执行 requestAnimationFrame 回调 │
│ 5. 渲染:Style → Layout → Paint → Composite │ ← 不是每轮都有
│ 6. 回到步骤 1 │
└─────────────────────────────────────────────────┘
Blink 如何通知 V8 清空微任务
Blink 通过 WebThread::TaskObserver::DidProcessTask 在每个 Task 结束后调用 blink::Microtask::PerformCheckpoint,即 MicrotaskQueue::PerformCheckpoint(isolate),触发 V8 清空微任务队列。
五、Node.js 的事件循环
Node.js 用 libuv 驱动事件循环,比浏览器多了更细粒度的阶段划分,且额外引入了 process.nextTick 队列。
Node.js 的完整队列体系
scss
每个阶段切换前,Node.js 都会先执行:
┌────────────────────────────────────────────────┐
│ 【nextTick 队列】 process.nextTick 回调 │ ← Node.js 独有
│ 【微任务队列】 Promise.then 回调 │ ← V8 维护
└────────── 两者都清空后,才进入下一阶段 ────────────┘
libuv 事件循环各阶段(uv_run 实际调用顺序):
1. timers uv__run_timers() setTimeout / setInterval 到期回调
2. pending I/O uv__run_pending() 上一轮延迟的 I/O 错误回调
3. idle/prepare uv__run_idle() / uv__run_prepare() 内部使用
4. poll uv__io_poll() ★ 等待新 I/O 事件(网络响应在此阶段到达)
5. check uv__run_check() setImmediate 回调
6. close uv__run_closing_handles() 关闭事件回调
libuv uv_run 真实结构
scss
// deps/uv/src/unix/core.c(精简)
// https://github.com/nodejs/node/blob/main/deps/uv/src/unix/core.c
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
while (r != 0 && loop->stop_flag == 0) {
uv__update_time(loop);
uv__run_timers(loop); // 1. timers
uv__run_pending(loop); // 2. pending I/O(早期文档常遗漏此步)
uv__run_idle(loop); // 3. idle
uv__run_prepare(loop); // 3. prepare
uv__io_poll(loop, timeout); // 4. poll:阻塞等待 I/O
uv__metrics_update_idle_time(loop);
uv__run_check(loop); // 5. check:setImmediate
uv__run_closing_handles(loop); // 6. close
}
return r;
}
// nextTick/微任务清空 由 Node.js 层(非 libuv)
// 在各阶段回调执行完后通过 processTicksAndRejections() 触发
nextTick 与 Promise 微任务的优先级
scss
// lib/internal/process/task_queues.js
// https://github.com/nodejs/node/blob/main/lib/internal/process/task_queues.js
function processTicksAndRejections() {
let tock;
do {
while ((tock = queue.shift()) !== null) {
// 先清空 nextTick 队列(AsyncContextFrame 等为现代版本附加字段)
const asyncId = tock[async_id_symbol];
emitBefore(asyncId, tock[trigger_async_id_symbol], tock);
try {
const callback = tock.callback;
callback(); // 执行 nextTick 回调
} finally {
emitAfter(asyncId);
}
}
// nextTick 全部清空后,触发 V8 清空 Promise 微任务
runMicrotasks();
} while (!queue.isEmpty() || processPromiseRejections());
}
javascript
// 验证优先级
process.nextTick(() => console.log('1: nextTick'));
Promise.resolve().then(() => console.log('2: Promise'));
process.nextTick(() => console.log('3: nextTick'));
// 输出:1: nextTick → 3: nextTick → 2: Promise
setImmediate vs setTimeout(fn, 0)
javascript
// I/O 回调内:setImmediate 稳定先于 setTimeout
// 因为 check 阶段(5)在下一轮 timers(1)之前
fs.readFile('file', () => {
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
});
// 输出:setImmediate → setTimeout(稳定)
// 主模块顶层:顺序不确定
// 取决于事件循环初始化完成时 timers 是否已到期
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
// 输出:不确定
六、浏览器 vs Node.js 对比
| 维度 | 浏览器 | Node.js |
|---|---|---|
| 事件循环驱动 | Blink Scheduler | libuv |
| 规范依据 | HTML Living Standard | 无规范,libuv 实现定义 |
| 宏任务队列 | 80+ 种任务源(按优先级) | 6 个阶段(顺序固定) |
| 微任务队列 | V8 MicrotaskQueue | V8 MicrotaskQueue(同) |
| 额外队列 | 无 | nextTick 队列(优先级高于 Promise) |
| 渲染时机 | 微任务后、下一宏任务前 | 无渲染 |
| 触发 V8 微任务 | DidProcessTask → Microtask::PerformCheckpoint |
processTicksAndRejections → runMicrotasks() |
setImmediate |
不支持 | check 阶段,I/O 后稳定先于 setTimeout |
setTimeout(fn,0) 嵌套 |
嵌套 ≥ 5 层强制 4ms | 同 HTML 规范行为 |
七、async/await 的本质
javascript
// 你写的
async function foo() {
console.log('A');
await bar();
console.log('C');
}
// V8 内部等价(概念示意)
function foo() {
console.log('A');
return bar().then(() => {
console.log('C'); // → EnqueueMicrotask → 微任务队列
});
}
await 暂停 = 将后续代码通过 TriggerPromiseReactions → EnqueueMicrotask 注册为微任务 await 恢复 = V8 从微任务队列取出,恢复 Generator 继续执行
结论 :每个 await 就是一次微任务的入队 与出队。
八、完整执行链路:以 fetch 请求为例
javascript
console.log('start');
fetch('/api/data')
.then(res => res.json()) // cb1
.then(data => console.log(data)); // cb2
console.log('end');
scss
① 同步执行(调用栈)
log('start') → fetch() → .then(cb1).then(cb2)【挂在 Promise 上,不在任何队列】
→ log('end') → 调用栈清空
② 网络等待(后台线程,主线程空闲)
浏览器:Blink 网络线程处理 HTTP
Node.js:libuv 线程池 / poll 阶段等待
③ 响应到达 → 包装为宏任务入队
宿主将「resolve Promise」包装为 Task 放入宏任务队列
④ 宏任务执行 → V8
FulfillPromise() → TriggerPromiseReactions() → EnqueueMicrotask(cb1)
cb1 进入 V8 微任务队列
⑤ 宏任务结束 → PerformCheckpoint()
RunMicrotasks: cb1 执行(res.json() 返回新 Promise)
→ EnqueueMicrotask(cb2)
→ RunMicrotasks 继续: cb2 执行(console.log(data))
→ size_ 归零,清空完毕
⑥ cb1/cb2 对象失去引用 → GC 回收
核心认知 :回调不是"在队列里等待请求完成",而是请求完成后才被放入队列。
九、经典输出题解析
javascript
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve()
.then(() => console.log('3'))
.then(() => console.log('4'));
console.log('5');
// 输出:1 → 5 → 3 → 4 → 2
| 步骤 | 调用栈 | V8 微任务队列 | 宿主宏任务队列 | 输出 |
|---|---|---|---|---|
| 1 | log('1') |
[] |
[] |
1 |
| 2 | setTimeout |
[] |
[cb2] |
- |
| 3 | Promise.then |
[cb3] |
[cb2] |
- |
| 4 | log('5') |
[cb3] |
[cb2] |
5 |
| 5 | 栈空 → PerformCheckpoint → cb3 | [cb4] |
[cb2] |
3 |
| 6 | RunMicrotasks → cb4 | [] |
[cb2] |
4 |
| 7 | size_=0 → 宿主取 cb2 | [] |
[] |
2 |
十、总结
arduino
任务调度的本质:两套系统 + 一个接口
V8: MicrotaskQueue(环形缓冲区,CSA 内置函数驱动)
│
│ MicrotaskQueue::PerformCheckpoint()
│
宿主: 宏任务队列
浏览器 → Blink Scheduler(80+ TaskType,多优先级)
Node.js → libuv 6阶段(timers/pending/idle/poll/check/close)
执行顺序口诀:
同步代码
→ nextTick(Node.js 独有)
→ 清空微任务(连锁,直到 size_ 归零)
→ 渲染(浏览器)
→ 取下一个宏任务
→ 重复
| 队列 | 维护者 | 每轮执行量 | 典型 API |
|---|---|---|---|
| 调用栈 | V8 | 全部同步代码 | 函数调用 |
| nextTick 队列 | Node.js | 全部清空 | process.nextTick |
| 微任务队列 | V8 | 全部清空(连锁) | Promise.then、queueMicrotask |
| 宏任务队列 | 宿主环境 | 每轮取一个 | setTimeout、I/O 回调 |
参考源码(全部经过核查)
- V8 微任务队列头文件:microtask-queue.h
- V8 微任务队列实现:microtask-queue.cc
- V8 Promise 操作:promise-abstract-operations.tq
- Blink 任务类型定义:task_type.h
- Node.js nextTick 实现:task_queues.js
- libuv 事件循环:uv/src/unix/core.c
- HTML 事件循环规范:html.spec.whatwg.org