审查 AI 生成循环:何时串行,何时并发
循环内逐次 await 会让独立请求串行等待,可根据依赖关系改用 Promise.all 或限制并发的批处理。存在前后依赖、严格顺序或限流要求时,串行执行仍然合理。
AI 深度解读
AI 写出的批量请求代码,经常是在 for...of 里逐个 await。它能正确返回结果,却可能把互不依赖的请求排成长队。作者的核心判断是:先检查每轮之间有没有真实依赖,再决定串行、全量并发,还是限制并发。
这对审查 AI 生成的前端、Node 代码很实用。不过,原文的耗时数字只是数学示意,并非性能测试;其中对 forEach 和限流的解释,也需要补上边界。
为什么能跑对的循环,仍然可能很慢
作者认为,AI 倾向于生成串行循环,是因为这种写法容易理解:代码从上往下执行,哪个条目失败容易定位,也不用处理多个请求同时运行的复杂性。这是作者的解释,原文没有提供模型行为实验来验证。
问题出在任务关系上。如果 fetchUser(id) 不需要上一次请求的结果,让它等待上一轮完成,就增加了不必要的等待。
原文用每次调用固定耗时 100 ms 做示意,明确说明这不是基准测试:
- 串行耗时约为
n × latency。 - 全量并发的耗时接近一次调用,因为等待发生了重叠。
- 分批耗时约为
ceil(n / batchSize) × latency。
这些估算依赖很强的假设:调用耗时固定,没有额外排队,也没有服务端资源竞争。真实系统中,并发请求还会受到连接数、带宽和服务端处理能力影响,不能直接把公式当作优化收益。
循环里有 await 不是问题,让独立任务彼此等待才可能是问题。
独立请求怎样并发,才能继续正确等待
对于互不依赖的读取,作者给出的直接改法是先发起调用,再统一等待。下面这段代码解决逐个请求累加等待的问题:
async function loadAll(ids) {
return Promise.all(ids.map(id => fetchUser(id)));
}
关键在 ids.map(...):它先调用 fetchUser,收集返回的 Promise,再交给 Promise.all 等待。
Promise.all 返回的数组按输入顺序排列,不按请求完成顺序排列。因此,即使后面的用户先加载完成,结果仍能与原来的 ids 对应。任意一个 Promise 拒绝时,聚合 Promise 就会拒绝。
这里还要补一句:聚合失败不会自动取消已经发出的其他请求。串行代码遇到失败后,后续请求可能尚未开始;并发代码里,它们通常已经启动。两者虽然都能向调用方报告失败,实际产生的请求和副作用却可能不同。
原文还提醒,不要把串行循环直接换成带 async 回调的 Array.forEach。这个提醒是对的,但它在 FAQ 中说 forEach「不会并行运行任何东西」,表述不准确。
forEach 会执行回调,异步请求仍然可能同时处于进行中。真正的问题是:它忽略回调返回的 Promise,不负责收集结果,也不等待这些异步操作结束。外层函数可能提前返回,异步错误也无法沿预期路径传播。
请求太多时,分批比一次发完更稳妥
独立请求也不能无限增加。原文举例,如果服务拒绝同时到来的 30 个请求,就需要限制并发。
下面的实现把任务切成批次,每批内部并发,上一批全部完成后再启动下一批:
async function runInBatches(items, size, worker) {
const out = [];
for (let i = 0; i < items.length; i += size) {
out.push(...await Promise.all(
items.slice(i, i + size).map(worker)
));
}
return out;
}
要看的是 await Promise.all(...) 所在的位置:它仍在循环内部,等待整批结束。这个 await 恰好是控制调度的关键。
这种方式能限制同时进行的任务数量,但也有批次间的等待:某一批只剩一个慢请求时,下一批仍不能开始。持续补充空闲位置的并发池可以减少这种等待,不过实现更复杂,原文没有展开。
作者说分批可以约束内存和连接数,这里需要分开看。它减少了同时活跃的任务及相关资源占用,但 out 仍会累积全部结果,不能据此认为整体内存不会随列表增长。
串行也有明确适用场景:游标分页需要上一页返回的最后一个 id;连续编辑同一资源需要保证顺序;接口存在单写者约束;副作用必须按规定次序发生。这些情况下,循环内的等待应该保留。
我的判断
这篇最有用的地方,是把代码审查的重点放回数据流。看到循环里的 await,先问下一轮是否需要上一轮的值,比机械套用「全部改成 Promise.all」可靠。
但并发数与请求速率是两个约束。分批限制同时在途的请求,串行保证一次只执行一个请求;它们都不自动保证单位时间内的请求数符合配额。原文提到的 429 响应,仍可能需要额外的节流、等待或重试策略处理。
相较全量并发,分批更容易控制资源;相较分批,并发池更能利用空闲位置。该选哪种,取决于任务规模和接口约束。作者没有给出对照测试,不能据此认定某种写法总是更快。
原文还介绍 Powerduck 能解析周边调用方、展示修改上下文,帮助确认依赖。这属于工具能力陈述,文中没有给出识别准确率或效率对照数据,不宜把它当作经过验证的审查效果。
能拿来做什么
- 审查 AI 生成的前端批量加载或 Node 数据读取代码时,检查每轮是否读取上一轮结果;确认独立后,再考虑
Promise.all。 - 对较长列表先采用明确的并发上限,并核对接口的速率约束;不要把分批执行直接当成完整的限流方案。
- 修改异步循环时,同时检查结果顺序、失败传播和已启动请求的副作用,避免只比较耗时。
- 遇到游标分页、顺序编辑和单写者任务时,保留串行执行,并在审查说明中写清具体依赖。