我把 AI 写的并发请求控制器手写了一遍——3 个语义我当时根本讲不清

Claude Code 生成的并发请求控制器,在我项目里跑了一个多月,零事故。直到上周我整理代码,试着自己解释"这段为什么这么写"------第 3 行就卡住了。

于是我关掉所有工具,把它手写了一遍。写完确认:我当时不懂的语义有 3 个,而这 3 个,恰恰是"不出事就永远不暴露、一出事就查不到根因"的那种。

先放 3 个问题:

  1. 为什么用 Promise.race 等待,而不是更直观的分批 Promise.all
  2. 为什么数组里存的不是任务 promise 本身,而是它派生出来的"清理 promise"?
  3. 如果其中一个任务失败了,这个池子到底会发生什么?

三个都能答上来的,这篇文章帮你省 10 分钟;答不上来的,往下看。

AI 写的版本

当时生成的样子,简化后大致是:

ts 复制代码
function createPool(limit: number) {
  const executing: Promise<void>[] = [];
  return async function run<T>(task: () => Promise<T>): Promise<T> {
    const p = Promise.resolve().then(task);
    const e = p.then(() => {
      executing.splice(executing.indexOf(e), 1);
    });
    executing.push(e);
    if (executing.length >= limit) {
      await Promise.race(executing);
    }
    return p;
  };
}

乍一看没毛病:并发有上限、满了自动等、单测全绿。但上面 3 个问题,我当时一个都答不利索,只会说"反正它一直是这么写的"。

语义一:race 是"走完一个补一个",all 是"等整批最慢的"

更直观的分批写法长这样:

ts 复制代码
for (let i = 0; i < tasks.length; i += limit) {
  await Promise.all(tasks.slice(i, i + limit).map(t => t()));
}

问题在边界:一批里 9 个快的跑完、1 个慢的还在跑,这段时间并发掉到 1;批次切换的瞬间,并发甚至短暂掉到 0。100 个请求、limit 5,如果每批最慢的那个总是特别慢,总耗时约等于"每批最慢者之和"。

Promise.race 是任何一个完成就唤醒,立刻补位,并发曲线贴着 limit 走。这是"动态补位"和"分批"的差别------AI 替你选了对的那个,但你要是不懂为什么对,下次改代码时你就不知道这两个里该选哪个。

语义二:executing 里存的是"清理 promise"

注意 const e = p.then(...) 这一行:推进数组的是 e,不是 p。

差别在一个时序保证:e 要在 splice 执行完之后才 resolve。所以 Promise.race(executing) 唤醒的那一刻,名额已经释放完了,补下一个任务一定不会超限。

如果存的是 p,race 唤醒的那一刻,清理的微任务可能还没执行,"现在补一个会不会超限"就得靠人肉推微任务顺序来确认。绝大多数时候结果一样,但代价是:任何在 review 这段代码的人,都得先在脑子里赌一次微任务顺序。存 e,是把"race 返回 = 名额已释放"变成不需要推理的不变量。这是"能跑"和"跑得明白"之间的分水岭。

语义三:失败不是报错,是烧池子

这是我最庆幸在手写时发现、而不是在线上发现的一个。

假设某个任务 reject 了。p 会 reject;那 e 呢?p.then(onFulfilled) 不捕获拒绝,e 会原样 reject,splice 永远不执行

连锁反应:

  • 当前这次 Promise.race(executing) 立刻 reject,调用方拿到错误------这一步还算符合预期;
  • 但那个 e 永远留在 executing 里,名额永久烧掉一个;
  • 之后每个新任务进来,都看到 executing.length >= limit,都去 await race,而 race 会在那个已 reject 的 e 上立刻 reject------池子从这一刻起永久损坏,后续所有任务进来即抛错

一次失败,全员陪葬。单测测不出来,因为单测的 happy path 不写失败用例;而真实世界的接口,是一定会失败的。

我重写的版本

手写之后,我把它改成了"计数即真相、finally 保证释放"的写法:

ts 复制代码
class RequestPool {
  private running = 0;
  private queue: Array<() => void> = [];
  constructor(private limit: number) {}

  async run<T>(task: () => Promise<T>): Promise<T> {
    if (this.running >= this.limit) {
      await new Promise<void>(r => this.queue.push(r));
    }
    this.running++;
    try {
      return await task();
    } finally {
      this.running--;
      this.queue.shift()?.();
    }
  }
}

改动三处:

  • 用数字计数 running 代替数组,"存什么"的问题直接消失;
  • 名额释放放进 finally,失败也释放、队列照常前进,池子不会被烧穿;
  • 错误原样抛给调用方,池子不吞错------单个任务的隔离是调用方的事,包一层 catch 就行。

至于取消:池子只管"队列里的不发了"(组件卸载时清空队列并 reject),在途请求要把 AbortController.signal 传进 fetch。这部分不属于池子、属于业务,但 AI 的版本通常两样都不写------它默认你的需求里没有"取消"这个词。

你写死的并发数,大概率是错的

AI 写的并发控制器,limit 几乎都是写死的 3、5 或 10。但正确的并发数不是常数,它取决于资源:

  • API 请求:3-6。浏览器同域连接上限本来就是 6,你的池子开 10,多出来的只是在浏览器层排队,limit 等于装饰品;
  • 图片等静态资源:6-10,走的是另一个域的连接池,不抢 API 的位;
  • 上传/大 body:1-2,瓶颈在上行带宽,并发越多大家越慢。

还有网络环境:办公室 Wi-Fi 下舒服的 6,在地铁弱网下就是超时风暴。认真做的话,limit 应该跟着网络质量走;至少,把它做成可配参数,而不是常量。

重试也一样。"失败立刻重试"和并发上限叠在一起就是流量风暴------出错的瞬间,重试请求立刻重新占位,等于自己打自己。正确姿势是指数退避 + 重试次数上限,而且重试要重新进池子排队,不能绕开池子直接发。

并发控制自检速查表

回去翻你项目里 AI 写的异步代码,对着这张表问:

问自己 AI 常见写法 风险 改法
并发怎么补位? 分批 chunk + all 批边界并发归 0,整批被最慢的拖死 race 动态补位,或计数+队列
名额状态存什么? 数组存任务 promise 正确性要靠赌微任务顺序确认 存清理 promise,或干脆用计数
一个失败会怎样? race 直接 reject 名额永久烧掉,后续全员即抛 finally 释放,调用方逐任务隔离
能不能取消? 完全不支持 页面卸载后请求还在飞 AbortController 传信号 + 清队列
并发数怎么定? 写死 3 / 5 / 10 弱网 10 并发 = 超时风暴 按资源定:API 3-6,图片 6-10,上传 1-2
重试怎么做? 失败立刻重试 并发上限 + 立刻重试 = 流量风暴 指数退避 + 重试次数上限

写在最后

AI 写的代码,可以信它跑,但不能信它懂。

跑是它的职责,懂是你的。因为线上真出事的时候,救你的不是"我用过",而是"我讲得清"。

回去对着项目里那段异步代码问自己三个问题:怎么补位、失败会怎样、能不能取消。

答不上来的话,手写一遍。值得的。

相关推荐
_codemonster2 小时前
Vue中的ref和reactive到底在干嘛
前端·javascript·vue.js
IT_陈寒2 小时前
为什么我的JavaScript闭包总是漏掉那个变量?
前端·人工智能·后端
两只羊ovo2 小时前
前端八股之 storage & this:两个最容易卡住的点,一次讲透
前端
用户921080262862 小时前
Bubble 打字机效果实践:从项目配置到源码实现
前端
张元清3 小时前
React useEventListener Hook:类型安全的 DOM 事件监听 (2026)
javascript·react.js
weixin_461408583 小时前
npm npx yarn
开发语言·前端·javascript
四千岁3 小时前
RAG系统中的分块
前端·javascript·后端
START_GAME3 小时前
MSSQL$SQL2016
java·服务器·前端