Promise.all 里一个任务失败后,另外 20 个还在干什么:我把并发与取消的账算了一遍

有个批处理接口,同时做三件事:写数据库、发通知、刷新缓存。代码看起来很稳妥:

await Promise.all([saveOrder(), sendMessage(), refreshCache()])

某天 saveOrder() 失败,接口立刻返回 500。大家都以为这批操作已经失败了,几秒后用户却收到了通知,缓存也被刷新了。

不是 Promise 失控了。恰恰相反,它严格兑现了承诺:Promise.all 会尽快告诉你「这组结果已经不可能全部成功」,但从没承诺替你停掉那些已经开始的任务。

这两个概念只差一个词:

  • fail-fast:尽早返回失败;
  • fail-stop:失败后让整组工作停下来。

Promise.all 只有前者。我把后者缺的那半套机制也测了一遍,下面是账本。

多条任务轨道在一个失败节点后仍继续向前: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)))

真实顺序是:

  1. map 同步遍历所有 id;
  2. 每次调用 request(id),请求立即开始;
  3. map 返回一组已经在运行的 Promise;
  4. 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。它只同步做三件事:

  1. 把 signal.aborted 设为 true;
  2. 把传入的值放进 signal.reason;
  3. 派发一次 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,不必知道取消来自按钮、超时还是进程退出。

# 九、一套不会自欺欺人的清单

  1. 把 Promise.all 理解成结果收集器,不是任务调度器。 任务在传进去之前通常已经开始。
  2. fail-fast 不等于 fail-stop。 catch 提前执行,只说明外层结果确定了,不说明后台清空了。
  3. 限并发和取消分开设计。 一个控制同时跑几个,一个控制已经跑起来的何时停。
  4. 副作用任务必须显式接收 signal。 定时器要 clear、流要 destroy、请求要 abort、循环要定期 throwIfAborted()。
  5. 开始前检查一次,运行中监听一次。 只监听事件会漏掉「拿到 signal 时已经 aborted」的情况。
  6. 任务正常结束也要移除监听器。 { once: true } 不能清理一个从未触发的监听器。
  7. 超时要取消工作本身。 Promise.race 只让等待者离场,不会处理输家。
  8. 跨网络取消只能尽力而为。 客户端断开不等于服务端、数据库和队列都回滚了;链路要逐层传递。
  9. 需要严格收尾时,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(公有领域)。图片均经过裁剪与压缩。