Claude Code 生成的并发请求控制器,在我项目里跑了一个多月,零事故。直到上周我整理代码,试着自己解释"这段为什么这么写"------第 3 行就卡住了。
于是我关掉所有工具,把它手写了一遍。写完确认:我当时不懂的语义有 3 个,而这 3 个,恰恰是"不出事就永远不暴露、一出事就查不到根因"的那种。
先放 3 个问题:
- 为什么用
Promise.race等待,而不是更直观的分批Promise.all? - 为什么数组里存的不是任务 promise 本身,而是它派生出来的"清理 promise"?
- 如果其中一个任务失败了,这个池子到底会发生什么?
三个都能答上来的,这篇文章帮你省 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 写的代码,可以信它跑,但不能信它懂。
跑是它的职责,懂是你的。因为线上真出事的时候,救你的不是"我用过",而是"我讲得清"。
回去对着项目里那段异步代码问自己三个问题:怎么补位、失败会怎样、能不能取消。
答不上来的话,手写一遍。值得的。