先记住这个答案
是串行。await 会暂停当前 async 函数,循环也跟着停在这一轮,等 Promise 完成才继续下一轮,所以请求是一个接一个发出去的,总耗时是所有请求耗时之和。要改成并发,就先用 map 把所有请求同步发出去,拿到一组 Promise,再用 await Promise.all(promises) 统一等待,总耗时约等于最慢的那个请求。
- 循环里
await是串行,总耗时是各请求之和 - 并发的关键是先发起,再统一
await Promise.all结果顺序和传入顺序一致- 请求很多时要限制并发数,别一次全发
先看串行到底慢多少
比如详情页要根据三个用户 id 拉用户信息,每个请求耗时 100 毫秒。最顺手的写法是 for...of 里逐个 await。
awaitJavaScriptconst request = (id) => new Promise((resolve) => setTimeout(() => resolve('用户' + id), 100))
const ids = [1, 2, 3]
async function serial() {
const start = performance.now()
const users = []
for (const id of ids) {
users.push(await request(id)) // 等上一个回来才发下一个
}
console.log(users.join(','), '耗时', performance.now() - start)
}
serial()查看输出与解释
用户1,用户2,用户3 耗时 300三个请求互相没有依赖,却排着队一个一个发,耗时 300 毫秒。请求数量越多,差距越明显,十个请求就是 1 秒。
原因很直接:request(2) 这行代码要等 await request(1) 结束、进入下一轮循环时才会执行。请求是在调用 request 的那一刻发出的,调用被推迟了,请求自然也被推迟了。
改成并发:先发起,再等待
并发的思路是把「发请求」和「等结果」拆开。发请求是同步的,一口气全发出去;等结果交给 Promise.all。
Promise.all 一起等JavaScriptconst request = (id) => new Promise((resolve) => setTimeout(() => resolve('用户' + id), 100))
const ids = [1, 2, 3]
async function parallel() {
const start = performance.now()
const promises = ids.map((id) => request(id)) // 三个请求同时发出
const users = await Promise.all(promises) // 只等一次
console.log(users.join(','), '耗时', performance.now() - start)
}
parallel()查看输出与解释
用户1,用户2,用户3 耗时 100耗时从 300 降到 100 毫秒。map 执行完时三个请求都已经在路上了,Promise.all 只是等它们全部回来。结果数组的顺序和 ids 一致,不管谁先回来。
- 不用
Promise.all也能并发 - 只要先创建 Promise 再
await,就是并发。比如const a = request(1)和const b = request(2)先各写一行,再依次await a和await b,两个请求同样是同时发出的。这说明决定串行还是并发的是发起时机,Promise.all只是让「统一等待」写起来更方便,并能在失败时尽早返回。
什么时候必须串行,什么时候要限流
不是所有循环都该改成并发。下一个请求依赖上一个的结果时,比如分页接口要用上一页返回的游标拉下一页,只能串行。有顺序要求的写操作,比如按顺序上传分片后再合并,也要串行。
- 请求很多时控制并发数
- 一次给几百个 id 拉数据,全部并发会同时打出几百个请求。浏览器对同一个域名的 HTTP/1.1 连接数有上限,多出来的请求会排队,服务端也可能因为瞬时压力返回
429。常见做法有两种。一种是分批,每批 5 个用Promise.all,一批完成再下一批,缺点是一批里最慢的那个会拖住整批。另一种是并发池,始终保持最多 5 个在跑,完成一个就补一个,吞吐更稳定,p-limit这类库做的就是这个。
容易答错的地方
- 以为
Promise.all会帮你发起请求 Promise.all只负责等,不负责调用。传给它的必须是已经发出去的 Promise。有人为了「统一管理」写成Promise.all(ids.map((id) => () => request(id))),传进去的是一组函数,Promise.all会把函数当普通值原样返回,一个请求都没发。反过来,请求是否并发也和Promise.all无关,而是看map那一步有没有同步地把请求都调用了。- 用
Promise.all后以为失败的那个不影响其他结果 Promise.all只要有一个拒绝就立刻拒绝,其他请求成功的结果你一个都拿不到,虽然它们照样发完了。页面上各块数据互不影响时,改用Promise.allSettled,每一项都会返回status为fulfilled或rejected的结果,再分别处理。- 先创建一组 Promise,再在循环里逐个
await - 这种写法是并发的,但有个隐患:如果第三个请求比第一个先失败,而循环还停在等第一个,第三个的拒绝暂时没有任何处理,会被报成未处理的拒绝。用
Promise.all或Promise.allSettled就不会有这个问题,它们会立刻给每个 Promise 挂上处理。
面试官还会怎么问?
能手写一个限制并发数的函数吗?
思路是维护一个正在运行的计数。先启动 limit 个任务,每个任务完成时从队列里取下一个启动,同时把结果按原下标存进结果数组。所有任务完成后返回结果数组。关键点是结果要按下标存,不能按完成顺序 push。
串行场景除了 for...of,还有什么写法?
可以用 reduce 把任务串成一条 Promise 链,每一项都挂在上一项的 then 后面。效果和 for...of 加 await 一样,只是可读性差一些,现在基本只在老代码里见到。
并发请求里怎么做到一个失败就取消其他的?
Promise.all 本身不会取消任何请求。可以让所有请求共用一个 AbortController 的 signal,在 catch 里调用 controller.abort(),还没完成的请求就会被中断。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。