Promise.all 里一个任务失败后,另外 20 个还在干什么:我把并发与取消的账算了一遍
- 作者:Bougie
- 创建于:2026-10-14
有个批处理接口,同时做三件事:写数据库、发通知、刷新缓存。代码看起来很稳妥:
await Promise.all([saveOrder(), sendMessage(), refreshCache()])
某天 saveOrder() 失败,接口立刻返回 500。大家都以为这批操作已经失败了,几秒后用户却收到了通知,缓存也被刷新了。
不是 Promise 失控了。恰恰相反,它严格兑现了承诺:Promise.all 会尽快告诉你「这组结果已经不可能全部成功」,但从没承诺替你停掉那些已经开始的任务。
这两个概念只差一个词:
- fail-fast:尽早返回失败;
- fail-stop:失败后让整组工作停下来。
Promise.all 只有前者。我把后者缺的那半套机制也测了一遍,下面是账本。

所有数据来自本机 Linux x86_64、Node v24.16.0,实验只使用本地定时器和本机 HTTP 服务,不依赖公网。
# 一、30ms 已经失败,另外两个任务照跑到 120ms
先造三个任务:A 在 120ms 成功,B 在 30ms 失败,C 在 80ms 成功。
const task = (name, ms, fail = false) =>
new Promise((resolve, reject) => {
setTimeout(() => {
console.log(name, fail ? 'reject' : 'resolve')
fail ? reject(new Error(name)) : resolve(name)
}, ms)
})
try {
await Promise.all([task('A', 120), task('B', 30, true), task('C', 80)])
} catch (error) {
console.log('Promise.all catch', error.message)
}
实测时间线:
30.2ms B reject
30.3ms Promise.all catch B
80.5ms C resolve
120.6ms A resolve
B 失败后只过了 0.1ms,外层就进入 catch,fail-fast 做得很漂亮。但 C 和 A 的定时器没有消失、回调没有撤销、闭包没有释放,照样在 80.5ms 和 120.6ms 完成。

原因并不神秘。Promise 只是一个未来结果的观察凭证,不是承载工作的进程或线程。Promise.all 能决定自己什么时候 settled,却没有一条通用指令可以对任意输入说「把你背后的定时器、socket、数据库查询和第三方 SDK 全部停掉」。
# 后面再失败,会不会变成 unhandledRejection
我让三个输入分别在 20ms、50ms、80ms 失败,外层只 catch 第一个,同时监听进程的 unhandledRejection:
Promise.all 在约 20ms reject
后面两个输入继续 reject
unhandledRejection count: 0
这是因为 Promise.all 一开始就给每个输入都挂了处理器。后续失败虽然改变不了外层结果,但对运行时来说仍然「有人接住」。所以你通常不会看到两个额外的 unhandledRejection。
这反而更隐蔽:剩余任务失败了,既不会改变已经返回的结果,也未必出现在全局未处理错误监控里。要观察它们,只能由任务自己记日志,或者改用 allSettled 收齐结果。
# 二、Promise.all 不会启动任务——拿到它们时,任务通常已经启动了
下面这段代码常被称作「用 Promise.all 并发执行」:
await Promise.all(ids.map((id) => request(id)))
真实顺序是:
map同步遍历所有 id;- 每次调用
request(id),请求立即开始; map返回一组已经在运行的 Promise;Promise.all接过它们,只负责观察结果。
所以真正把并发拉满的是 map(request),不是 Promise.all。把它拆开就看得更清楚:
const running = ids.map((id) => request(id)) // 到这一行结束,全部已启动
const results = await Promise.all(running) // 从这里开始等结果
# 结果顺序也不是完成顺序
让 A/B/C 分别在 90/20/50ms 完成:
真实完成顺序:B, C, A
Promise.all 结果:A, B, C
Promise.all 按输入位置装结果,不按完成先后排序。它内部相当于给每个输入记住自己的下标:B 可以第一个回来,但仍然写进 results[1]。
# 三、20 个 100ms 任务:快是快,并发也真的有 20
我让 20 个任务各等待 100ms,在任务开始和结束时统计 active 数量:
直接 Promise.all:100.4ms,峰值并发 20
限制并发为 4: 501.1ms,峰值并发 4
第一行几乎只花一个任务的时间,因为 20 个同时开始。第二行分成 5 批,所以约等于 5 × 100ms。这不是性能退化,而是明确买了一份资源上限:最多只占 4 个连接、4 个文件描述符、4 份内存或 4 个第三方 API 配额。

一个最小可用的并发限制器,不需要先创建 20 个 Promise,而是让 4 个消费者共同领取下一个任务:
async function mapLimit(items, limit, worker) {
let cursor = 0
const results = new Array(items.length)
async function consume() {
while (true) {
const index = cursor++
if (index >= items.length) return
results[index] = await worker(items[index])
}
}
const consumers = Array.from(
{ length: Math.min(limit, items.length) },
consume
)
await Promise.all(consumers)
return results
}
注意,这段代码只解决限并发,还没解决取消。如果某个 worker 失败,外层仍然只是提前 reject,其他消费者已经领到的任务依旧会继续。限流和取消是两套正交的机制,不能因为用了一个就默认另一个也存在。
# 四、四个组合器,都不会替输家收尸
JavaScript 的四个常用 Promise 组合器,区别只是「外层什么时候 settled」:
| 组合器 | 外层何时 fulfilled | 外层何时 rejected | 会取消剩余任务吗 |
|---|---|---|---|
Promise.all | 全部 fulfilled | 任意一个 rejected | 不会 |
Promise.allSettled | 全部 settled | 不会 rejected | 不会 |
Promise.race | 第一个 settled 的是成功 | 第一个 settled 的是失败 | 不会 |
Promise.any | 第一个 fulfilled | 全部失败,抛 AggregateError | 不会 |
我让 Promise.race 的赢家 20ms 返回,输家在 100ms 修改一个变量:
winner=fast at 20.1ms
等到 130ms 再看:slow side effect=true
race 的「赛跑」只决定领奖台,没把输家从跑道上抬走。Promise.any 也是一样:第一个成功结果出来之后,其他输入仍会继续执行;allSettled 更直白,它本来就是为了等所有任务收尾。
如果任务有副作用,选择哪个组合器都不能代替取消协议。
# 五、AbortController 也不是杀进程,它只负责广播「该停了」
Web 和 Node 里最通用的取消协议是 AbortSignal:
const controller = new AbortController()
runTask({ signal: controller.signal })
controller.abort(new Error('stop now'))
但名字很容易让人误会。controller.abort() 不会扫描事件循环,也不会杀掉任何 Promise。它只同步做三件事:
- 把
signal.aborted设为true; - 把传入的值放进
signal.reason; - 派发一次
abort事件。
我把调用前、监听器和调用后依次记下来:
before -> listener:stop now -> after
signal.throwIfAborted() 抛出 Error: stop now
监听器是在 abort() 返回前同步执行的,不是下一轮微任务。signal 还是一次性的:一旦 aborted,就永远保持 aborted,不能 reset 后拿去服务下一批任务。

真正可取消的任务必须主动配合,至少处理两个时刻:开始前已经取消,以及运行中收到取消。
function abortableDelay(ms, signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason)
return
}
const timer = setTimeout(() => {
cleanup()
resolve()
}, ms)
const onAbort = () => {
clearTimeout(timer)
cleanup()
reject(signal.reason)
}
const cleanup = () => signal.removeEventListener('abort', onAbort)
signal.addEventListener('abort', onAbort, { once: true })
})
}
这里最容易漏的是 cleanup()。{ once: true } 只保证事件触发后自动移除;如果任务先正常完成、signal 一直没 abort,监听器仍会挂在 signal 上。长生命周期 signal 配短任务时,不主动移除就会把闭包越攒越多。
# 六、同样 20 个任务:完成 20,还是取消 20
接下来让 20 个任务分别需要 80 ~ 175ms,另一个任务在 50ms 失败。第一组不传 signal,第二组共享同一个 controller,并在 catch 里广播取消:
const controller = new AbortController()
try {
await Promise.all(tasks.map((task) => task({ signal: controller.signal })))
} catch (error) {
controller.abort(error)
throw error
}
实测:
普通 Promise.all:外层 50.3ms 失败,completed=20,aborted=0
共享 AbortSignal:外层 50.7ms 失败,completed=0, aborted=20
两组对调用者来说都在约 50ms 抛错,表面完全一样;后台实际发生的事正好相反。第一组在 catch 以后继续把 20 份工作做完,第二组在同一时刻清掉 20 个定时器。
生产代码里通常还要再补两点:
- 任务自己失败时也触发组取消:上面的 catch 就是组的所有者,只有它有权决定「一个失败,全组停止」;
- 等待清理完成:有些资源不是清个 timer 就结束,可能还要回滚事务、关闭文件、归还连接。需要严格收尾时,abort 后再用
allSettled等所有子任务进入终态,再向上抛原始错误。
这就是所谓的结构化并发思路:创建子任务的作用域,也负责在离开前把子任务收干净。JavaScript 没有替任意 Promise 自动建立这种所有权,得由应用代码明确传递 signal。
# 七、取消 fetch,只能保证客户端不再等
fetch 原生支持 signal,看起来像最完整的取消:
const controller = new AbortController()
setTimeout(() => controller.abort(new Error('client timeout')), 50)
await fetch(url, { signal: controller.signal })
我在本机起了一个 HTTP 服务:收到请求后,模拟 300ms 的后台工作再返回;客户端在 50ms abort。完整时间线:
8.9ms server got request
54.7ms fetch rejected Error:client timeout
55.4ms server request aborted
55.4ms server response closed
309.6ms server work still finished
客户端确实在 54.7ms 停止等待,连接也在 55.4ms 关闭了。但服务端那个 300ms 的 timer 仍然跑到 309.6ms。换成真实系统,它可能是已经发给数据库的查询、正在生成的报表,或者已经进入消息队列的任务。

服务端想配合取消,必须监听连接状态,并把自己的取消信号继续往下传:
http.createServer(async (request, response) => {
const controller = new AbortController()
response.on('close', () => {
if (!response.writableEnded) {
controller.abort(new Error('client disconnected'))
}
})
try {
const result = await buildReport({ signal: controller.signal })
response.end(result)
} catch (error) {
if (!controller.signal.aborted) throw error
}
})
即便这样,也只能取消支持 signal 的下一层。如果数据库驱动、SDK 或远程服务没有取消接口,应用最多停止等待和丢弃结果,已经发出去的工作仍可能完成。取消从来是一条端到端协议,链路中任何一层不配合,信号就断在那里。
# 八、超时也应该是一种取消,而不是单独跑一个输家
最常见的错误超时写法是:
await Promise.race([
request(),
delay(5000).then(() => Promise.reject(new Error('timeout')))
])
5 秒后外层确实超时,但 request() 仍在后台跑;如果 request 先完成,那个 5 秒 timer 也仍然活着。正确方向是产生一个 signal,让工作本身收到超时:
const signal = AbortSignal.timeout(5000)
await request({ signal })
Node v24.16.0 实测 AbortSignal.timeout(30):
signal.reason.name = TimeoutError
timers/promises.setTimeout 的 rejection = AbortError
rejection.cause.name = TimeoutError
所以判断超时原因时,优先看 signal.reason 或错误的 cause,不要假设所有 API 都直接抛同一个名字。
多个取消来源可以合成一个:
const signal = AbortSignal.any([
requestSignal, // 用户断开连接
shutdownSignal, // 服务准备退出
AbortSignal.timeout(5000) // 本次调用超时
])
await doWork({ signal })
AbortSignal.any 会采用第一个触发者的 reason。这样业务函数只接收一个 signal,不必知道取消来自按钮、超时还是进程退出。
# 九、一套不会自欺欺人的清单
- 把
Promise.all理解成结果收集器,不是任务调度器。 任务在传进去之前通常已经开始。 - fail-fast 不等于 fail-stop。 catch 提前执行,只说明外层结果确定了,不说明后台清空了。
- 限并发和取消分开设计。 一个控制同时跑几个,一个控制已经跑起来的何时停。
- 副作用任务必须显式接收 signal。 定时器要 clear、流要 destroy、请求要 abort、循环要定期
throwIfAborted()。 - 开始前检查一次,运行中监听一次。 只监听事件会漏掉「拿到 signal 时已经 aborted」的情况。
- 任务正常结束也要移除监听器。
{ once: true }不能清理一个从未触发的监听器。 - 超时要取消工作本身。
Promise.race只让等待者离场,不会处理输家。 - 跨网络取消只能尽力而为。 客户端断开不等于服务端、数据库和队列都回滚了;链路要逐层传递。
- 需要严格收尾时,abort 后再 allSettled。 先广播停机,再等所有孩子真正结束,最后抛出原始错误。
Promise.all 没有骗人。它的名字说的是「这些结果要全部成功」,不是「这些工作归我统一调度」。真正让一组异步任务像一个整体那样生、死、收尾的,从来不是 all 这个单词,而是你有没有给它们同一个所有者、同一个取消信号,以及一条能传到底的退出路径。
题图与配图来自 Wikimedia Commons:Loftus Railway Station 4 — J Bar(CC BY-SA 3.0);Running track off Bardon Lane — Alexander P Kapp(CC BY-SA 2.0);Traffic lights at night — ராஜலட்சுமி ராஜேந்திரன்(CC BY-SA 4.0);Emergency stop button on a HAAS CNC — Flygklubben(CC BY-SA 4.0);EFTA00002522, Cluttered server rack — Federal Bureau of Investigation(公有领域)。图片均经过裁剪与压缩。