异步不只是 await 语法:竞态、错误聚合与并发控制
await fetch()、套一层 try/catch、错误打个日志——这套写法能跑起来,但离“异步写对了”还差得远。回调地狱、Promise 链、async/await 的演进脉络讲得再熟,也替代不了对几个具体机制的把握:请求返回的顺序和发出的顺序可能不一致,多个异步任务里一个失败未必该拖累其他几个,循环里漏写的 await 不会报错、只会悄悄丢数据。这几件事分别对应三类真实场景,逐一拆开看。
竞态:为什么搜索框有时显示的是上一次的结果
最容易被忽视的是请求返回顺序和发出顺序不一致的问题。一个带实时搜索的输入框,用户输入就发请求、请求回来就渲染,逻辑直白得不能再直白,本地测试也一切正常。但只要输入够快就会暴露问题:用户先打了 react,很快又改成 next,两个请求都发了出去,偏偏 react 那个网络抖动回得更晚,于是它的结果覆盖了 next 的渲染结果,屏幕上显示的关键词和列表对不上。
await 只保证这一段代码按顺序执行,它根本不管“哪个请求先回来”——网络延迟是不确定的,发出顺序和到达顺序完全可以颠倒。修法是让新请求发起时,把上一个还在路上的请求取消掉:
1let currentController = null 2 3async function search(keyword) { 4 currentController?.abort() 5 currentController = new AbortController() 6 7 try { 8 const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`, { 9 signal: currentController.signal, 10 }) 11 if (!res.ok) throw new Error('search failed') 12 return await res.json() 13 } catch (error) { 14 if (error.name === 'AbortError') return null // 被新请求取消,正常 15 throw error 16 } 17}
放进 React 里,我会在 effect 的清理函数里取消,配合依赖切换:
1useEffect(() => { 2 const controller = new AbortController() 3 fetch(`/api/orders?status=${status}`, { signal: controller.signal }) 4 .then((r) => r.json()) 5 .then(setOrders) 6 .catch((e) => { if (e.name !== 'AbortError') setError(e) }) 7 return () => controller.abort() 8}, [status])
这段代码看着啰嗦,但它解决的是实打实的体验问题:页面不该被一个过期的请求改写。后来凡是“快速切换会触发新请求”的地方——搜索、筛选、tab、路由切换——我都会先想一句:旧请求要不要取消。
不过 abort 也不是万能的。加上取消逻辑之后,监控里容易冒出一大堆 AbortError 上报,看起来像新 bug,实际上取消本身就是预期行为,不该被当成真错误上报,归一错误的时候得显式把它摘出去——这也是为什么上面 catch 里要先判 error.name === 'AbortError'。后端那边也有个容易被忽略的点:浏览器虽然断开了连接,但请求可能已经打到服务端、数据库写操作照样执行了,对于非幂等的接口(比如 POST 提交),客户端 abort 并不等于服务端没做事。更稳妥的原则是:abort 只用在“纯读、可重复发”的 GET 上,写操作宁可在前端做按钮置灰加请求去重,也不轻易取消。
如果不想自己维护 controller,还有一种更声明式的写法是用“最新请求 id”来兜底,即使旧请求回来了也直接丢弃,适合那些拿不到 signal 的老接口:
1let latestSeq = 0 2 3async function search(keyword) { 4 const seq = ++latestSeq 5 const data = await rawSearch(keyword) // 假设这个底层没法传 signal 6 if (seq !== latestSeq) return // 我已经不是最新的了,结果作废 7 render(data) 8}
这两种思路可以叠加:abort 负责省掉无谓的网络开销,seq 负责兜住“取消没生效、请求还是回来了”的边界情况。真要较真,单靠 abort 是有缝的——fetch 的 abort 是尽力而为,已经在解析 body 的请求未必能立刻停下。
错误聚合:一个失败该不该拖累其他任务
Promise.all 和 Promise.allSettled 的核心区别不是 API 用法,而是“一个失败要不要影响整体”这个判断。工作台首页要同时拉好几块数据:用户信息、待办、通知,还有一块“推荐内容”。用 Promise.all 写起来干净利落:
1const [profile, todos, notifications, recommend] = await Promise.all([ 2 fetchProfile(), fetchTodos(), fetchNotifications(), fetchRecommend(), 3])
问题是 Promise.all 只要有一个 reject,整体就 reject。推荐服务一旦抖动,结果就是整个首页白屏——一个最不重要的模块,把用户信息和待办这些核心内容一起拖下了水。
这里要分清两种场景。强依赖、缺一不可的数据(比如订单详情里的订单、商品、支付信息),用 Promise.all 是对的,少一个这页就没意义。但首页这种“各模块相互独立、坏一个不该影响其他”的,应该用 Promise.allSettled,让每块自己决定成功还是降级:
1const results = await Promise.allSettled([ 2 fetchProfile(), fetchTodos(), fetchNotifications(), fetchRecommend(), 3]) 4const widgets = results.map((r) => 5 r.status === 'fulfilled' 6 ? { ok: true, data: r.value } 7 : { ok: false, error: normalizeError(r.reason) } 8)
做聚合请求前先问一句:这一组里有没有“可以坏、坏了页面还能用”的成员?有,就别用 all 把它们绑死在一起。
这件事还有个被忽略的尾巴:Promise.all 早退(fast-fail)只是“不再等”,并不会去取消另外那几个还在飞的请求。换句话说推荐服务挂了那次,用户信息、待办其实照样请求完了,只是结果被丢进了垃圾桶。如果这些请求带副作用——比如某个接口会埋点、会扣配额——你以为 all reject 了就万事大吉,实际它们都偷偷执行完了。要真正连带取消,得自己用一个共享的 AbortController:
1const controller = new AbortController() 2const opts = { signal: controller.signal } 3try { 4 await Promise.all([ 5 fetchProfile(opts), fetchTodos(opts), fetchRecommend(opts), 6 ]) 7} catch (e) { 8 controller.abort() // 一个挂了,把其余在途的也停掉,省带宽也避免脏副作用 9 throw e 10}
容易被忽略的一点:allSettled 永远不会 reject,所以外层那个 try/catch 是抓不到任何东西的——很容易写个 catch 在那儿,然后纳闷为什么降级逻辑从来不触发。它的“失败”全藏在每个元素的 status: 'rejected' 里,得自己遍历。另外 allSettled 是 ES2020 才进的标准,之前的项目里想要等价效果得手写 polyfill,本质就是把每个 promise 包一层永不 reject:
1const settle = (p) => 2 Promise.resolve(p).then( 3 (value) => ({ status: 'fulfilled', value }), 4 (reason) => ({ status: 'rejected', reason }), 5 ) 6const results = await Promise.all(list.map(settle))
理解了这个 polyfill,就明白 allSettled 为什么不可能 reject——它内部每个分支都被 catch 住了,根本没有冒泡的机会。
Promise 静态方法里还有两个容易和 all/allSettled 搞混的兄弟,语义完全不同。Promise.race 是“谁先有结果就用谁的”,不管是成功还是失败,常用来做超时兜底(和真正的请求 race 一个 setTimeout reject);Promise.any 是 ES2021 加入的,语义是“只要有一个成功就返回那个成功的结果”,全部失败才会 reject,reject 时抛出的是一个 AggregateError,里面的 errors 数组装着所有失败原因。多数据源里选最快返回的那个(比如同时向几个 CDN 节点探活,谁先响应就用谁)用 any 比 race 更贴切,因为 race 遇到一个快速失败的源会直接把整体判负,而 any 会耐心等到有一个真正成功为止。
1// race:超时兜底,谁先完成就是谁 2const result = await Promise.race([ 3 fetchData(), 4 new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)), 5]) 6 7// any:多个镜像地址里,只要有一个探活成功就用它 8try { 9 const fastest = await Promise.any([pingA(), pingB(), pingC()]) 10} catch (aggErr) { 11 console.error('全部节点都不可用', aggErr.errors) // AggregateError.errors 12}
循环里的 await:forEach 为什么等不到结果
另一类更隐蔽的问题出在循环体里的 await 到底有没有被真正等待。配置批量导入功能如果这样写,看起来没什么问题:
1items.forEach(async (item) => { 2 await saveItem(item) 3}) 4showToast('导入完成')
小数据量测试时一切正常,数据量一大就会暴露:导入提示弹出来了,但数据只进去了一部分,中途失败的那几条没有任何提示。原因是 forEach 根本不会等里面的 await——它一口气把所有回调同步跑完就返回了,showToast 在任何一条真正保存完之前就已经执行。
往底层看一眼就更清楚了。async 函数的返回值永远是一个 Promise,函数体执行到第一个 await 就会“挂起并立刻 return 一个 pending 的 Promise”给调用方。forEach 的实现大致是 for (i...) callback(item),它拿到的就是这堆 pending 的 Promise,但它的签名里压根没有“收集返回值”这一说,更别提 await 了——它把返回值直接扔掉。于是真实的执行顺序是:同步地把 N 个 saveItem 都发起(停在各自第一个 await 上),forEach 返回,showToast 执行,然后这帮异步任务才在微任务/宏任务队列里陆续完成。失败的那几条 reject,没人接,就变成 unhandledrejection 飘走了。
更容易被误判为“已处理”的是 .map(async ...) 不带 Promise.all 收口的写法——它至少返回了 Promise 数组,看起来处理过了,但只要没去 await 那个数组,行为和 forEach 是一模一样的。比较可靠的做法是在 ESLint 里开 no-floating-promises(TypeScript 项目用 @typescript-eslint/no-floating-promises),让“漏掉的 await”在编译期就报红,比等用户反馈数据少了才排查要可靠得多。
要按顺序就老老实实用 for...of,要并行就用 Promise.all 收口,两种都会真正等待:
1// 串行:一条接一条 2for (const item of items) { 3 await saveItem(item) 4} 5 6// 并行:但要等全部结束 7await Promise.all(items.map((item) => saveItem(item)))
与漏 await 相伴的还有一个坏习惯:默默吞错。catch 里只 console.error,调用方拿不到任何失败信号,还以为成功了。更稳妥的做法是把结果明确返回出去,让上层知道到底成没成:
1try { 2 await submitForm() 3 return { ok: true } 4} catch (error) { 5 reportError(error) 6 return { ok: false, error: normalizeError(error) } 7}
fetch 有个容易被忽略的设定:HTTP 错误不算错误
上面几类问题都涉及错误处理,而 fetch 本身有个容易让人栽跟头的设定:只要服务器有响应,哪怕是 404、500,Promise 也是 fulfilled 的,只有网络断了、跨域失败、请求被取消才会 reject。所以光 await fetch() 再 .json() 是不够的,必须自己看 response.ok,并且把各种来源的错误归一成同一个结构,上层才能用同一套 toast 和上报去处理。
1async function request(url, options) { 2 const res = await fetch(url, options) 3 const body = await res.json().catch(() => null) 4 if (!res.ok) throw normalizeApiError(res.status, body) 5 return body 6}
串行太慢、全并行又把后端打挂:限流的中间地带
for...of 和 Promise.all 是两个极端,数据量一大问题就都暴露出来:一千多条数据用 for...of 串行,跑上一两分钟很正常,用户会以为页面卡死;换成 Promise.all 一把梭,一千个请求同时怼出去,后端连接池直接被打满。
两个极端中间需要一个“并发窗口”——同时最多跑 N 个,完成一个补一个。下面是一个不依赖任何库的小工具:
1async function mapLimit(items, limit, worker) { 2 const results = new Array(items.length) 3 let cursor = 0 4 5 async function run() { 6 while (cursor < items.length) { 7 const i = cursor++ // 先占位再 await,避免两个 worker 抢到同一个 index 8 try { 9 results[i] = { ok: true, value: await worker(items[i], i) } 10 } catch (error) { 11 results[i] = { ok: false, error } 12 } 13 } 14 } 15 16 // 启 limit 个“工人”,它们共享同一个游标,谁空了谁去领下一个 17 await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run)) 18 return results 19} 20 21// 用法:最多 5 条并发,单条失败不影响整体 22const results = await mapLimit(items, 5, (item) => saveItem(item)) 23const failed = results.filter((r) => !r.ok) 24if (failed.length) showToast(`完成,但有 ${failed.length} 条失败`)
关键在那句 const i = cursor++:必须在 await 之前把 index 锁死,否则多个 worker 并发推进时会抢到同一个下标,要么漏处理要么重复处理。这类 bug 的典型表现是“偶尔少存几条”,复现概率还和网络延迟挂钩,排查起来相当费时间——症状随机,日志里每次缺的又是不同那一条,很容易被误判成后端偶发问题。并发数设成多少不能拍脑袋,得看后端给的限流配额、再结合观察到的响应延迟去调。
event loop 补充:await 到底让出了什么
以上时序问题的根源都在 event loop 的调度规则上。有个对比能一秒看清宏任务和微任务的差别:
1console.log('1 同步') 2setTimeout(() => console.log('2 宏任务 setTimeout'), 0) 3Promise.resolve().then(() => console.log('3 微任务 then')) 4;(async () => { 5 console.log('4 同步(async 函数 await 之前是同步执行的)') 6 await null 7 console.log('5 微任务(await 之后等价于 .then 里的代码)') 8})() 9console.log('6 同步') 10// 输出顺序:1 → 4 → 6 → 3 → 5 → 2
这里有两个反直觉点需要留意。其一,async 函数在第一个 await 之前是同步跑的,把代码塞进 async 函数不会让它自动变得不卡主线程——一段重计算放在 await 之前,照样卡。其二,await 后面那一截是微任务,会插在当前宏任务尾巴、下一个 setTimeout 之前执行;这意味着如果在一个紧凑的循环里疯狂产出微任务(比如递归 await Promise.resolve()),UI 渲染(属于宏任务之间的间隙)可能一直得不到机会,页面看着就像“假死”。进度条卡死不动往往就是这个原因:一坨连续微任务把渲染时机挤没了,往循环里塞一个 await new Promise(r => setTimeout(r)) 主动让出一个宏任务,进度条就能正常推进。
写异步之前要想清楚的几件事
把上面几类场景归纳一下,异步代码是否可靠,取决于几个问题有没有被认真回答:这几个任务之间有没有依赖,决定串行还是并行;其中有没有可以坏掉、坏了也不影响整体的,决定用 all 还是 allSettled;结果会不会过期被覆盖,决定要不要引入取消机制;错误有没有被明确地往上抛,而不是在某个 catch 里悄悄咽下去。
回调、Promise、async/await 解决的只是“怎么把异步写顺”;请求会不会晚到、任务失败要不要连累其他任务、取消有没有做到位、错误有没有被归一处理,这些才决定“代码在真实流量下会不会出问题”。前者能让 await fetch() 跑起来,但只有想清楚请求会不会晚到、循环里的 await 有没有被真正等到,搜索框才不会在用户手速快的时候,显示出上一个关键词的结果。