为什么 async/await 并不是真正的同步写法?微任务队列的欺骗性同步

496 阅读4分钟

我们来深入剖析这个极具迷惑性的现象——async/await 到底是不是“同步写法”?

很多人会说:

async/await 是 Promise 的语法糖,它让异步代码像同步代码一样书写。”

但真相是, “像同步” ≠ 同步async/await 只是一场语法幻觉,它在执行机制上仍然根植于 事件循环与微任务队列的异步调度中

下面我们就来揭开 async/await 的“欺骗性同步”的面具,透彻理解它背后的 微任务机制执行顺序差异

第一眼假象:“写起来像同步,运行起来也同步吧?”

你可能会写过这样的代码:

async function foo() {
  console.log("start");
  await Promise.resolve();
  console.log("end");
}

foo();
console.log("outside");

初学者往往以为输出会是:

start
end
outside

但实际上,输出是:

start
outside
end

为什么?因为 await 不是“阻塞当前线程”的同步操作,而是把“之后的代码”包进了一个 微任务(microtask)等当前所有同步代码执行完,再排队执行


async/await 的真实行为:语法糖 + 微任务

要彻底理解 async/await 的机制,先明确两点:

  1. async 函数返回的是 Promise
  2. await 会中断当前 async 函数的执行,把“剩余代码”封装为微任务,放入 microtask queue

这意味着:

await somePromise;
// 相当于
somePromise.then(() => {
  // 剩下的代码在微任务中运行
});

换句话说,await 后的语句不是“顺着执行”,而是“挂起执行”


案例拆解:你以为的同步,其实只是安排好了异步

例子 1:顺序混乱的初体验

console.log("A");

setTimeout(() => {
  console.log("B");
}, 0);

(async () => {
  console.log("C");
  await null;
  console.log("D");
})();

console.log("E");

输出结果是:

A
C
E
D
B

解释:

  • A:同步输出
  • setTimeout 注册了宏任务(下一轮执行)
  • C:立即执行 async 函数内 await 之前的同步语句
  • await null:产生一个微任务(相当于 Promise.resolve().then(...)
  • E:当前宏任务执行完
  • D:await 后的语句作为微任务执行
  • B:下一轮宏任务才执行

await 后面的代码其实被“丢”到了微任务队列里,直到本轮执行结束之后才执行。


微任务:这才是 async/await 真正的幕后黑手

事件循环中,执行顺序如下:

  1. 执行一个宏任务(比如当前 script 脚本)
  2. 在这个宏任务期间注册的所有微任务全部执行(包括 .then()await 之后的部分)
  3. 渲染视图
  4. 开始下一轮宏任务

所以 await 的运行过程如下:

async function foo() {
  console.log("1");
  await bar();   // bar 返回一个 Promise
  console.log("2"); // 被封装为微任务
}

等价于:

function foo() {
  console.log("1");
  bar().then(() => {
    console.log("2"); // 放进微任务队列
  });
}

所以 await 并没有阻塞函数继续执行,而是 “挂起”,把剩下的代码交给微任务队列。


为何这会带来“欺骗性的同步感”?

因为我们写的是:

const data = await fetchData();
doSomething(data);

这种写法读起来就像是:

const data = fetchData();
doSomething(data);

但执行起来:

  • fetchData() 被异步调度
  • doSomething() 不是马上执行,而是变成微任务,在 fetch 完成后才执行

这就像你下楼买咖啡,留了张纸条告诉同事:“我回来再继续做报告”,然后立刻去楼下了。看起来你很“同步”,实际上你溜得飞快。


更深陷阱:forEach + async = 无限痛苦

很多人以为可以这样写:

[1, 2, 3].forEach(async (item) => {
  await someAsyncCall(item);
});

但实际情况是:forEach 不会等待 async 函数的执行完成,也不会顺序执行。

想要顺序执行异步任务,正确写法应是:

for (const item of [1, 2, 3]) {
  await someAsyncCall(item); // 保证按顺序
}

原因就是:await 的语义并不影响原生同步 API 的行为,比如 forEach 不认 async 回调。


更诡异的异步陷阱:两个 await,谁先执行?

async function foo() {
  await console.log("A");
  console.log("B");
}

async function bar() {
  await console.log("C");
  console.log("D");
}

foo();
bar();

输出:

A
C
B
D

这说明 await 后面的语句是被作为微任务注册的,多个 await 并不会“等待前一个 await 执行完”!

每一个 await,都是独立的暂停点。它们的“恢复”顺序,取决于微任务注册的顺序。


总结回顾:async/await 的“假面舞会”

误解真相
await 会阻塞函数继续执行❌ 不会,它只是暂停,余下部分变成微任务
async/await = 同步执行❌ 只是“看起来像同步”,实际上全部是异步调度
多个 await 会按顺序执行✅ 但仅在 for...ofwhile 这种显式结构中,forEach 无效
await 后的语句立即执行❌ 不会,它们要等当前所有同步代码执行完,才在微任务队列中执行

深度建议:写 async/await,别只图省事

在实际开发中,建议:

  1. 知道微任务机制:别指望 await 后代码立即生效,尤其涉及 DOM 或状态变更。
  2. 避免在 forEach/map/filter 中使用 await:这类结构无法处理 async 正确的顺序逻辑。
  3. 调试中用 console.log 检查顺序:验证你的异步是否真的按预期执行。
  4. 需要真正同步顺序控制时,用显式循环:如 for...ofawait。 续加深分析。