news 2026/10/3 7:21:45

前端精读周刊:async/await 是把双刃剑——顺序 await 的并发陷阱与性能优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
前端精读周刊:async/await 是把双刃剑——顺序 await 的并发陷阱与性能优化
  • 文档
  • 技术博客
  • 教程

【免费下载链接】weekly

前端精读周刊。帮你理解最前沿、实用的技术。

项目地址:https://gitcode.com/GitHub_Trending/we/weekly
点击查看免费下载

本篇技术指南源自前端精读周刊对 Aditya Agarwal《Avoiding the Async/Await Hell》一文的精读。文章以现代前端常见的顺序await代码为切入点,剖析 async/await 作为语法糖带来的"隐形串行化"问题:语法变清爽了,异步并发却被悄悄牺牲,性能直接传导到用户体验。读完本文,你将掌握"先发起、后等待"的并行写法、Promise.all的正确姿势,以及回调、async/await、Promise 三种异步形态各自的适用边界,从而在依赖链与并发场景中做出有据可依的选择。

1 引言:async/await 终于也被吐槽了

在 async/await 大行其道的年代,很少有人质疑它的副作用。Aditya Agarwal 却认为,async/await 语法让开发者陷入了新的麻烦之中——它把异步问题从"写法丑"变成了"性能差"。

笔者也早就觉得哪里不对劲,终于有人把实话说了出来:async/await 可能会带来麻烦。本篇精读正是围绕这一观点展开,并结合本仓库中 《AsyncAwait 优越之处》、《Javascript 事件循环与异步》 等姊妹篇,从语法糖本质、并发语义、事件循环三个层面把它讲透。

2 概述:随处可见的"顺序 await"代码

下面是随处可见的现代化前端代码——下单点餐场景,每个操作都标注了同步/异步类型:

(async () => { const pizzaData = await getPizzaData(); // async call const drinkData = await getDrinkData(); // async call const chosenPizza = choosePizza(); // sync call const chosenDrink = chooseDrink(); // sync call await addPizzaToCart(chosenPizza); // async call await addDrinkToCart(chosenDrink); // async call orderItems(); // async call })();

这段代码读起来行云流水,但性能隐患就藏在第一行与第二行之间:await语法本身没有问题,问题在于使用者用错了。当pizzaData与drinkData之间没有依赖关系时,顺序的await会让getPizzaData与getDrinkData变成串行执行——整体执行时间最多会增加一倍(等于getPizzaData的耗时被额外叠加了一次)。

回到我们一直吐槽的回调地狱:虽然回调代码比较丑,但至少两行并列的回调代码并不会带来阻塞。语法的简化换来了性能问题,而且这个性能问题会直接落到用户体验上,是不是值得我们反思一下?

2.1 正确做法一:先发起 Promise,再 await 结果

正确的思路是先同时发起异步函数,再 await 它们的返回值,让异步调用在等待期间并行推进:

(async () => { const pizzaPromise = selectPizza(); const drinkPromise = selectDrink(); await pizzaPromise; await drinkPromise; orderItems(); // async call })();

这里的关键差异在于:selectPizza()与selectDrink()的调用在await之前就已经执行并返回 Promise,两个异步操作随即并行开始;后续的两个await只是等待各自的结果就绪。

2.2 正确做法二:Promise.all 更可读

也可以使用Promise.all让并发意图更直白:

(async () => { Promise.all([selectPizza(), selectDrink()]).then(orderItems); // async call })();

对比三个版本可以看出:顺序await写法最"顺眼",但并发度最差;先发起再等待的写法性能正确;Promise.all则在性能正确的基础上,把"这批操作是并行的"这个意图显式写进了代码里。不要随意地 await,它很可能让你的代码性能降低。

值得注意的是,同样的并发陷阱也出现在 《AsyncAwait 优越之处》 的精读中:一个模块需要发送 3 个互不依赖的请求后再渲染,如果写成

async function mount() { const result1 = await fetch('a.json'); const result2 = await fetch('b.json'); const result3 = await fetch('c.json'); render(result1, result2, result3); }

3 个请求便是顺序执行的,异步优势被完全浪费;真正实现并发仍需Promise.all封装一层:

async function mount() { const result = await Promise.all([ fetch('a.json'), fetch('b.json'), fetch('c.json') ]); render(...result); }

可见"无依赖的异步任务必须显式并行"是 async/await 时代反复踩坑后沉淀下来的通用结论。

3 精读:为什么 async/await 会被滥用

仔细思考 async/await 被滥用的原因,笔者认为是它的功能比较反直觉导致的。

首先,async/await 真的是语法糖,功能也仅是让代码写得舒服一些。先不看它的语法或特性,仅从"语法糖"三个字就能看出:它一定局限了某些能力。

举个例子:我们用 html 标签封装了一个组件,带来便利性的同时,其功能一定是 html 的子集。又比如,某个轮子哥觉得某个组件 API 太复杂,于是基于它封装了一个语法糖,我们多半可以认为这个便捷性是牺牲了部分功能换来的。

功能完整度与使用便利度一直是相互博弈的,很多框架思想的不同开源版本,几乎都是把功能完整度与便利度按照不同比例混合的结果。

那么回到 async/await,它要解决的问题是回调地狱带来的灾难:

a(() => { b(() => { c(); }); });

为了减少嵌套结构太多对大脑造成的冲击,async/await 决定这么写:

await a(); await b(); await c();

虽然层级上一致了,但逻辑上仍然是嵌套关系——b必须等a完成、c必须等b完成。这不是另一个程度上增加了大脑负担吗?而且这个转换还是隐形的,所以许多时候,我们倾向于忽略它,于是造成了语法糖的滥用。

3.1 理解语法糖:await 的"隐形串行"语义

虽然要正确理解 async/await 的真实效果比较反人类,但为了清爽的代码结构,以及防止写出低性能的代码,还是挺有必要认真理解 async/await 带来的改变。

首先,async/await 只能实现一部分回调支持的功能——也就是仅能方便应对层层嵌套的场景。其他场景,就要动一些脑子了。

比如下面两对独立的回调,a与c之间没有任何依赖:

a(() => { b(); }); c(() => { d(); });

如果写成下面的方式,虽然一定能保证功能一致,但变成了最低效的执行方式:

await a(); await b(); await c(); await d();

因为翻译成回调,它就变成了:

a(() => { b(() => { c(() => { d(); }); }); });

然而我们发现,原始代码中,函数c可以与a同时执行,但 async/await 语法会让我们倾向于在b执行完后,再执行c——这正是"隐形串行化"的根源:表面上await让代码变平了,实际上它把原本可并行的独立任务排成了一条依赖链。

所以当我们意识到这一点,可以优化一下性能:

const resA = a(); const resC = c(); await resA; b(); await resC; d();

先同时发起a()与c(),再分别等待。但这个逻辑其实仍然无法完全达到回调的效果:虽然a与c同时执行了,但d原本只需要等待c执行完;现在如果a的执行时间比c长,d就变成了在等a完成之后才执行,等效于:

a(() => { d(); });

也就是d被a的慢速执行"拖累"了,并发收益被部分抵消。

看来只有完全隔离成两个函数,才能恢复原始的并发结构:

(async () => { await a(); b(); })(); (async () => { await c(); d(); })();

或者利用Promise.all把两条独立链路组织起来:

async function ab() { await a(); b(); } async function cd() { await c(); d(); } Promise.all([ab(), cd()]);

这就是 async/await 的可怕之处。回调方式这么简单的过程式代码,换成 async/await 居然写完还要反思一下,再反推着去优化性能,这简直比回调地狱还要可怕。

而且大部分场景的代码是非常复杂的,同步与await混杂在一起,想捋清楚其中的脉络、并正确优化性能往往是很困难的。但是我们为什么要自己挖坑再填坑呢?很多时候还会导致忘了填。

原文作者给出了Promise.all的方式简化逻辑,但笔者持保留态度:不要一味追求 async/await 语法,在必要情况下适当使用回调,是可以增加代码可读性的。

3.2 源码视角:async/await 到底做了什么

从 《AsyncAwait 优越之处》 的源码级分析可以看到,async/await 与 Promise 存在千丝万缕的联系——一个 async 函数总是会返回一个 Promise。因此可以说,async/await 只是 Promise 的语法糖,这解释了它为什么"天生"只能表达 Promise 的表达能力。

下面是最基础的 async/await 例子:

async function test() { const img = await fetch('tiger.jpg'); }

使用 Babel 转换后,会被改写为基于_asyncToGenerator与regeneratorRuntime的状态机代码:

'use strict'; var test = function() { var _ref = _asyncToGenerator(regeneratorRuntime.mark(function _callee() { var img; return regeneratorRuntime.wrap(function _callee$(_context) { while (1) { switch (_context.prev = _context.next) { case 0: _context.next = 2; return fetch('tiger.jpg'); case 2: img = _context.sent; case 3: case 'end': return _context.stop(); } } }, _callee, this); })); return function test() { return _ref.apply(this, arguments); }; }(); function _asyncToGenerator(fn) { return function() { var gen = fn.apply(this, arguments); return new Promise(function(resolve, reject) { function step(key, arg) { try { var info = genkey; var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { return Promise.resolve(value).then(function(value) { step("next", value); }, function(err) { step("throw", err); }); } } return step("next"); }); }; }

从这段转换代码可以推断:await在底层就是Promise.resolve(value).then(...)的逐级衔接,step("next", value)每推进一次,就代表一个await挂起并等待一个 Promise 完成——顺序await的串行化并非引擎优化失误,而是这种状态机推进方式最直接的表达。原来只需 3 行代码解决的问题,被转换成 52 行代码,这还是基于执行环境中已经存在 regenerator 的前提;若要在兼容性不佳的 Web 环境下使用,代码 overhead 成本也必须纳入考虑。

此外,async 函数默认返回 Promise 还带来一个隐藏问题:异常会被静默吞掉。虽然 async/await 能用try...catch...这种符合同步习惯的方式捕获异常,但你依然不得不手动给每个await调用添加try...catch...语句;否则,async 函数返回的只是一个 reject 掉的 Promise 而已——这与本篇文章讨论的"双刃剑"主题互为镜像:便利的另一面,总是隐藏着需要开发者主动补齐的责任。

3.3 事件循环视角:await 在何时恢复执行

要真正理解await为什么不阻塞、以及它的执行时机,可以结合 《Javascript 事件循环与异步》 中的原理:JS 主线程的同步代码全部运行在调用栈(Call Stack)中,异步代码则通过事件循环(Event Loop)调度。Promise之流的任务属于 Microtask,插入当前执行队列的"横排",是最快被执行到的一类;setTimeout之流的 Macrotask 则被添加到新的"纵排",执行优先级更低。

await恢复执行的本质,就是 async 函数内部状态机在对应 Promise 落定(settle)后,通过 Microtask 继续推进step("next", ...)。这也意味着:await并不会让出线程,它只是把函数的剩余部分挂起,等 Promise 就绪后再以 Microtask 的优先级快速恢复。理解这一点,才能解释为什么"先发起、后等待"的写法能并发——因为await挂起的是函数的执行流,而不是异步操作本身;异步操作从函数调用那一刻就已开始。

4 实战准则:何时用 async/await,何时显式并发

综合以上分析,可以沉淀出几条可执行的选型准则:

场景推荐写法原因
严格依赖链(如取到 id 再查详情)顺序await串行是业务语义本身的要求,顺序await最直白
互不依赖的多个异步任务先发起、后await,或Promise.all恢复并发,避免执行时间叠加
依赖链 + 独立链路混杂的复杂流程拆分为多个 async 函数,用Promise.all组织保持每段链路内串行、链路之间并行,可读性与性能兼得
复杂流程控制(中断、进度、暂停等)适当回归回调或引入流程控制库async/await 缺少 abort、progress、pause、resume 等控制能力,硬写会"自己挖坑再填坑"

其中"严格依赖链"的典型例子可见 《AsyncAwait 优越之处》 中的建文档逻辑:先db.post({})得到新文档 id,再db.get(response.id)按 id 查询——两个操作必须串行,此时顺序await就是最优解:

async function createNewDoc() { let response = await db.post({}); // post a new doc return await db.get(response.id); // find by id }

而"串行"本身也有语法层面的选择。在需要"一个接一个"执行 Promise 队列(而非并发)时,《用 Reduce 实现 Promise 串行执行》 给出了两种等价思路:一是用reduce在内存中构造 Promise 队列(同步构造、异步执行,一个事件循环内完成队列搭建):

function runPromiseByQueue(myPromises) { myPromises.reduce( (previousPromise, nextPromise) => previousPromise.then(() => nextPromise()), Promise.resolve() ); }

二是在 async/await 支持下简化为for...of逐项等待:

async function runPromiseByQueue(myPromises) { for (let value of myPromises) { await value(); } }

需要注意两种思路的差异:reduce版本整体是同步函数,先执行完毕构造出 Promise 队列,再在内存异步执行;for...of版本则是把自身改造成异步函数,逐个等待每个 Promise 执行完毕。串行队列在真实业务中往往用得不多,因为串行会阻塞,而用户交互往往是并行的——这也再次印证了本篇的核心观点:并发场景下,串行await是最大的性能隐患。

5 总结:决定代码质量的是思维

async/await 双刃剑的讨论提醒着我们:不要过度依赖新特性,否则可能带来代码执行效率的下降,进而影响到用户体验。同时,也不要过度利用新特性去修复新特性带来的问题,这样反而会导致代码可读性下降。

翻开 redux 刚火起来那段时期的老代码,可以看到许多过度抽象、为了用而用的代码:硬是把两行代码能写完的逻辑,拆到了 3 个文件、分散在 6 行不同位置,只能靠字符串搜索的方式查找线索,最后发现这个抽象整个项目仅用了一次。写出这种代码的可能性只有一个——在精神麻木的情况下,一口气喝完了 redux 提供的全部鸡汤。就像 async/await 地狱一样,看到这种 redux 代码,远不如所谓没跟上时代的老前端写出的 jquery 代码。

决定代码质量的是思维,而非框架或语法。async/await 虽好,但也要适度。

补充说明:经过讨论,笔者把原文的"async/await 地狱"标题改成了"async/await 是把双刃剑"。因为 async/await 并没有回调地狱那么可怕,称它为"地狱"有误导的可能性——它牺牲的不是代码的可用性,而是并发性能与心智负担,二者危害程度并不相同。

6 延伸阅读

  • 精读《AsyncAwait 优越之处》:async/await 的六大优势、Babel 编译产物与并发局限的正面论证
  • 精读《Javascript 事件循环与异步》:Microtask 与 Macrotask 的调度机制,理解await的恢复时机
  • 精读《用 Reduce 实现 Promise 串行执行》:Promise 队列串行的两种实现及其差异
  • readme.md:前端精读周刊的完整目录与全部精读主题索引
  • 文档
  • 技术博客
  • 教程

【免费下载链接】weekly

前端精读周刊。帮你理解最前沿、实用的技术。

项目地址:https://gitcode.com/GitHub_Trending/we/weekly
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 7:21:37

金叉买死叉卖,11年只赚了2262块

前两期复现了海龟和均值回归:一个靠低胜率跑赢躺平,一个胜率75%却输给躺平。 这期来复现最出名的那个——双均线。金叉买、死叉卖,几乎是每个股民学会的第一个技术指标。 11年真实数据跑完,结果有点尴尬:95次买卖&…

作者头像 李华
网站建设 2026/10/3 7:21:36

物联网毕设必过方向帮助

【单片机毕业设计项目分享系列】 🔥 这里是DD学长,单片机毕业设计及享100例系列的第一篇,目的是分享高质量的毕设作品给大家。 🔥 这两年开始毕业设计和毕业答辩的要求和难度不断提升,传统的单片机项目缺少创新和亮点…

作者头像 李华
网站建设 2026/10/3 7:20:33

千笔AI解答:论文AIGC检测与AI降重高频疑问汇总

论文aigc率多少算正常 目前不同高校、期刊对论文AIGC率的合格标准没有统一的规定,主流的要求区间通常控制在10%-30%以内。千笔AI平台结合大量高校送检案例整理了常见的标准参考如下: 场景合理AIGC率区间说明本科毕业论文≤20%部分宽松院校可放宽至30%硕士…

作者头像 李华
网站建设 2026/10/3 7:19:44

【333期】开源输入法,打字顺便学英语,天才思路!

如果你每天都要打字,为什么不能顺便学英语?能想到这个思路的真是个天才。这个开源项目真的是把输入法玩出了新花样。 边打字边记单词 它不是普通的拼音输入法,最大的特点就是你每输入一个中文词,它会在候选词旁边顺手显示对应的英…

作者头像 李华
网站建设 2026/10/3 7:19:22

【YashanDB 认证】从零备考 YCA,我的崖山数据库学习之路

【YashanDB 认证】从零备考 YCA,我的崖山数据库学习之路 最近完成了 YashanDB YCA 认证的学习与考试,这段学习经历让我对国产数据库有了全新的认识,也想把自己的学习过程、踩过的坑和备考经验分享给同样打算备考 YCA 的朋友。 最开始接触 Yas…

作者头像 李华