读完这篇你能拿到一个可直接复用的 Promise 并发池实现,以及一套「什么时候该用池、什么时候不该用」的判断标准。
第一步:先把接口约定定死
并发池的核心签名只有一行:
// pool(tasks, limit) => Promise<Array<{status, value|reason}>>
// tasks: Array<() => Promise<any>>
关键在于 `tasks` 里放的是函数,不是 Promise 实例。
注意:这是最容易踩的坑。`fetch(url)` 一旦被调用就已经发出去了,你把它塞进数组再排队,请求早就跑完了,池子等于没写。所以必须是 `() => fetch(url)` 这种懒执行的形态。
第二步:用 worker 模式实现池
比 `Promise.race` 版更短、更不容易错:只开 `limit` 个 worker,每个 worker 循环从队列里领任务。
async function pool(tasks, limit = 5) {
if (!tasks.length) return [];
const results = new Array(tasks.length);
let cursor = 0;
const worker = async () => {
while (cursor < tasks.length) {
const i = cursor++; // 取号是同步的,不会重复
try {
results[i] = { status: 'fulfilled', value: await tasks[i]() };
} catch (reason) {
results[i] = { status: 'rejected', reason };
}
}
};
const size = Math.min(limit, tasks.length);
await Promise.all(Array.from({ length: size }, worker));
return results;
}
用法:
const results = await pool(urls.map(u => () => fetch(u).then(r => r.json())), 6);
注意:`cursor++` 必须放在 `await` 之前。如果写成 `const i = cursor` 再 `cursor++` 但中间插了 await,多个 worker 会抢到同一个下标。
第三步:决定失败策略
上面的版本是「全部继续」,任何一个失败都不影响其它任务——批量上传、批量导出这类场景要的就是这个。
如果你要快速失败,改 worker 里的 catch:
let aborted = false;
// catch 里:
aborted = true;
throw reason;
// while 条件改成:
while (cursor < tasks.length && !aborted) { ... }
注意:快速失败时必须给 while 加 `!aborted` 判断。否则一个任务挂了,剩下几十个 worker 还会把队列里所有任务全部跑完,只是结果被丢弃,白白打服务端。
需要重试的话,在 worker 里包一层:
async function withRetry(fn, times = 3, base = 300) {
let last;
for (let i = 0; i < times; i++) {
try { return await fn(); }
catch (e) { last = e; await new Promise(r => setTimeout(r, base * 2 ** i)); }
}
throw last;
}
指数退避很重要,瞬间重试只会让服务端更难受。
第四步:想清楚四个取舍
1)并发数怎么定
HTTP/1.1 同一域名浏览器只给 6 条连接,你设 20 也是排队;HTTP/2 多路复用可以调高,但服务端扛不扛得住是另一回事。建议起步 4-8,压测后再调。
注意:并发调大不等于更快。把 200 个请求一次打出去,通常换来 429、限流和更长的尾延迟。真正的瓶颈一般在服务端或数据库。
2)池 vs 分批(chunk)
分批是「每批等最慢的那个」,一批里有慢请求,其余 worker 就空转。池是滑动窗口,谁先回来谁接下一个,吞吐明显更好。只有当批与批之间需要逻辑隔断(比如分页写入后要落一次汇总)时才用分批。
3)自己写 vs p-limit
`p-limit` 只有几十行、API 极简,够用就别重复造。但你需要取消、进度、重试、结果索引对齐时,自己写这 20 行更省事。
4)要不要能取消
给每个任务配 `AbortController`,页面上用户点了「取消」时统一 abort,否则请求还在后台跑,回调回来时组件都卸载了。
const ac = new AbortController();
const tasks = urls.map(u => () => fetch(u, { signal: ac.signal }));
// 取消:ac.abort();
第五步:加进度反馈
在 worker 的 finally 里计数即可:
let done = 0;
// results[i] 赋值后:
done++; onProgress?.(done, tasks.length);
注意:进度回调密度很高,直接 `setState` 会拖慢渲染。做个 100ms 节流,或者只在 `done % 5 === 0` 时刷新。
小结
- 池的入参必须是返回 Promise 的函数,否则排队无意义。
- worker 模式比 `Promise.race` 递归更短更稳,取号一定要在 `await` 之前。
- 默认「失败不中断」,需要快速失败就加 `aborted` 标志并让 while 提前退出。
- 并发数 4-8 起步,配合指数退避重试和 `AbortController` 取消。
- 分批适合有隔断的场景,纯吞吐用滑动窗口的池。