搞懂 JS 异步的底层真相:从 V8 源码看微任务与宏任务

任务队列不是一个,执行顺序不是你以为的那样。本文结合 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 通过 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 微任务 DidProcessTaskMicrotask::PerformCheckpoint processTicksAndRejectionsrunMicrotasks()
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.thenqueueMicrotask
宏任务队列 宿主环境 每轮取一个 setTimeout、I/O 回调

参考源码(全部经过核查)

相关推荐
欧阳的棉花糖1 小时前
React 小误区:派生值 vs useEffect
前端
马可菠萝1 小时前
从零开始,用 Tauri + Vue 3 打造轻量级桌面应用
前端
陆枫Larry1 小时前
JavaScript 字符串处理实战:从 `startsWith` 到链式 `replace` 的避坑指南
前端
天蓝色的鱼鱼2 小时前
你的项目真的需要SSR吗?还是只是你的简历需要?
前端·架构
恋猫de小郭2 小时前
移动端开发稳了?AI 目前还无法取代客户端开发,小红书的论文告诉你数据
前端·flutter·ai编程
文心快码BaiduComate2 小时前
百度云与光本位签署战略合作:用AI Agent 重构芯片研发流程
前端·人工智能·架构
闲云一鹤3 小时前
nginx 快速入门教程 - 写给前端的你
前端·nginx·前端工程化
QCY3 小时前
「完全理解」1 分钟实现自己的 Coding Agent
前端·agent·claude
一拳不是超人4 小时前
Electron主窗口弹框被WebContentView遮挡?独立WebContentView弹框方案详解!
前端·javascript·electron