⭐ 推荐:社区规则条款 V1.0

前端请求并发控制:Promise 池的实现与取舍

yipeng
yipeng 正式会员正式会员认证极客认证极客
发布于 2026-09-22 21:53 ·96 浏览 ·0 回复
内容摘要

前端并发请求可用 Promise 池控制,核心是把任务写成懒执行函数而非已发出的 Promise,并以 worker 模式按 limit 循环取号执行;默认失败不中断适合批量上传导出,快速失败需设置 aborted 并在循环条件中判断以免继续空跑,必要时可在 worker 内加入重试。

读完这篇你能拿到一个可直接复用的 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 取消。
  • 分批适合有隔断的场景,纯吞吐用滑动窗口的池。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-563.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
CLARA轻量论坛系统
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 0

还没有回复,来抢沙发~