返回知识中心
指纹

Promise 组合器:谁先失败,谁永不结算

两个 Node 运行时探针覆盖十种实测情形:all 在首次拒绝时结束等待,allSettled 按输入顺序报告全部结果,any 汇总错误,空 race 永不结算。

BotBrowser Team

文档中心

想直接进入 指纹 文档吗?

这篇文章属于知识中心的指南库。若你要步骤化配置、参考说明和持续更新,请直接进入对应 docs 分区。

BotBrowser 支持在声明的 Profile 下重放经授权的同源 fixture,并保留范围受限的记录:哪个 promise 先结算、哪个根本没有结算;但它不能改变运行时排列结算的方式,不能把拒绝变成成功,也不能让没有输入的 promise 结算。这次评审用两个 Node 运行时探针覆盖十种实测情形,记录四种组合器在下列情形中的行为:某个输入拒绝时、输入完成顺序与给出顺序不同时、所有输入都拒绝时、输入列表为空时,以及拒绝始终没人处理时。

摘要

  • Promise.all 带着第一个拒绝而拒绝,并停止等待;探针打印 first; delivered_so_far=0。
  • 那次拒绝之后,其余输入继续运行,所以下一行显示 delivered=slow,later。
  • Promise.allSettled 等待每一个输入并保持输入顺序,因此慢的成功排在前面,快的反而排在最后。
  • Promise.all 同样保持输入顺序,所以即使 b 先完成,a,b,c 仍按该顺序到达。
  • Promise.any 在第一个成功时成功;当所有输入都拒绝时,它抛出 AggregateError,其 errors 数组保持输入顺序。
  • Promise.race 取第一个结算,所以快的拒绝胜过较慢的成功。
  • 空输入是四者分道扬镳之处:all 与 allSettled 成功,any 以 AggregateError 拒绝,而 race 永不结算。
  • 没人处理的拒绝会交给运行时的未处理拒绝钩子,探针把它观察为 orphan。

目录

四列对比同一组三个输入下各组合器的表现:Promise.all 在首次拒绝时拒绝,其余输入继续运行;Promise.allSettled 按输入顺序返回三个结果;Promise.any 在首次成功时成功,否则抛出 AggregateError;空的 Promise.race 保持 pending,完全不结算

四种组合器对完成分别承诺了什么

四种组合器接受同一种输入,即可迭代的值与 promise 集合,区别只在于把哪一种结算交给调用方。正是这一处差异决定了下面所有的失败形态,因此在读任何输出之前,值得把四份约定放在一处说明。

Promise.all 在每个输入都成功时成功,返回按输入顺序排列的数组,并在任一输入拒绝时立即拒绝。Promise.allSettled 在每个输入都结算时成功,按输入顺序为每个输入返回一条记录,并且绝不会因为某个输入拒绝而拒绝。Promise.any 在第一个成功时成功,只有在所有输入都拒绝之后才以 AggregateError 拒绝。Promise.race 以最先结算的那个输入为准,无论它是成功还是拒绝,对其余输入不作判断。

由此直接得到两个推论。第一,只有 all 与 race 会因为单个输入而拒绝;allSettled 完全不会因为这个原因拒绝,而 any 只在最后一个输入拒绝时才拒绝。第二,四者中有三个在结果里保留输入顺序,而 race 只返回单个值,没有数组可供排序,所以需要知道哪个输入胜出的调用方,必须把标签附在输入本身,而不是附在结果上。

探针用同样的三个输入驱动每一份约定:一个慢的成功、一个拒绝、一个快的成功,并给结果打上标签。探针里没有任何部分依赖特定的延迟,只依赖三个定时器的相对顺序,因此只要运行时实现了这四个方法,就能复现同样的形态。

node=v26.9.0
all-rejects-early -> first; delivered_so_far=0
all-later-resolution-still-runs -> delivered=slow,later
allSettled-order -> slow,boom,fast
all-input-order -> a,b,c
any-first-fulfilled -> fast
any-all-reject -> AggregateError; errors=e1,e2
race-first-settlement-wins -> fast-reject
empty-input -> all=fulfilled; allSettled=fulfilled:0; any=rejected:AggregateError; race=still-pending
unhandled-rejection-observed -> orphan
non-promise-input -> [1,"two",{"three":3}]

最后一行最不起眼。Promise.all 接受普通值,所以一个混合了数字、字符串和对象的列表会原样带着这些值成功,任何输入都不必是 promise,组合器照样工作。调用方若在传入之前把每个值都包进 Promise.resolve(),做的正是组合器已经做过的事。

Promise.all 为何在第一次拒绝时停止

Promise.all 在第一个输入拒绝的那一刻结算,交给调用方的是那次拒绝的原因,而不是数组。探针捕获它,并同时报告消息和到那时为止已经交付的其他输入数量:

all-rejects-early -> first; delivered_so_far=0

计数为零,因为拒绝在十毫秒的成功之前就到了。这一行重要的另一半是它没有说出的内容:组合器放弃了,输入并没有。每一个已经在运行的输入都会继续跑到自己的终点,探针通过等待并打印之后到达的结果来验证这一点:

all-later-resolution-still-runs -> delivered=slow,later

剩下的两个输入都完成了,其中一个远在结束等待的那次拒绝之后。这是最常让调用方意外的失败形态。一个被拒绝的 Promise.all 不会取消它的输入,因为这门语言里的 promise 没有取消操作;组合器只是停止观察它们。调用方想停下的工作必须由拥有它的代码停下,通常通过传给每个操作的 AbortController,组合器在这件事里没有任何角色。

同样的行为也决定了清理的预期。挂在 Promise.all 调用上的 finally 块在组合器结算时运行,在拒绝的情形里这很靠前,所以写在那里的清理会在其余输入仍在运行时就执行。在块里关闭共享资源的调用方,可能在工作仍在使用它的时候把它关掉。安全的顺序是先停输入,等它们确认已停,然后才释放资源。

Promise.all 在成功时确实保留输入顺序,即使输入以不同顺序完成也是如此。探针给出三个分别在二十五毫秒、五毫秒和十五毫秒完成的成功,再读回数组:

all-input-order -> a,b,c

最快的输入是 b,数组却仍然以 a 开头。因此按下标把结果与请求配对的调用方是安全的,而想要完成顺序的调用方必须在每个输入内部记录它,不能从结果里推断。

allSettled 如何按输入顺序报告每个结果

Promise.allSettled 绝不会因为某个输入拒绝而拒绝。它等待每个输入结算,并为每个输入返回一条记录,每条记录带一个 status,值为 fulfilled 或 rejected,并分别带 value 或 reason。探针传入同样的三种形态,并按返回顺序打印消息:

allSettled-order -> slow,boom,fast

慢的成功排第一,因为它是第一个输入;拒绝排第二,因为它是第二个输入;快的成功排在最后,尽管它在三者中最先完成。正是这一性质让 allSettled 成为这种批次的正确选择:失败不该掩盖成功,调用方拿到完整报告,而且每条记录都仍附着在它来自的输入上。

代价是时间。allSettled 不可能在最慢的输入结算之前结算,所以一个批次里只要有一个输入永不结算,就永远拿不到报告。探针的输入全部会完成,这掩盖了该风险;需要上限的调用方必须让 allSettled 调用与定时器赛跑,把超时当作部分报告,这也意味着调用方还得决定如何处理仍在运行的输入。这些条目本身是普通对象,所以 structuredClone 与 JSON 说明了其中哪些能经过一次拷贝进入日志记录,哪一个会被静默丢弃。

还有第二处差别值得点名。allSettled 把拒绝当作数据报告,所以被拒绝的条目不会变成异常,除非调用方自己抛出。记录报告后继续前进的调用方,在构造上已经处理了每一次失败;想恢复旧的 all 行为的调用方,必须扫描数组并抛出找到的第一个拒绝。两个组合器是同一种计算,只是对如何处理失败持有两种策略,而策略存在于调用方。

any 与 race 的差别在哪里

Promise.any 与 Promise.race 都接受多个输入,都只返回一个结果,区别在于它们接受哪一种结算。any 忽略拒绝,直到所有输入都拒绝,所以即使拒绝先到,它也会以第一个成功来成功。race 接受任意一种结算中的第一个,所以先到的拒绝会胜出。探针把两者都展示出来:

any-first-fulfilled -> fast
race-first-settlement-wins -> fast-reject

any 的情形里有一个慢的成功和一个快的成功,它以快的那个成功,这正是名字所暗示的。race 的情形里有一个慢的成功和一个快的拒绝,它拒绝了。因此,用 race 实现超时的调用方在超时触发时会得到拒绝,这通常正是调用方想要的;用 any 实现「哪个镜像先答就用哪个」的调用方会得到成功,这通常也正是调用方想要的。互换两者会改变整个调用的失败语义。

当 any 的每个输入都拒绝时,组合器以 AggregateError 拒绝,其 errors 数组按输入顺序保存各个原因:

any-all-reject -> AggregateError; errors=e1,e2

正是这个数组让 any 值得取代手写循环。只保留最后一个拒绝的手写循环会丢掉更早的原因,而 AggregateError 保留全部原因,并给调用方一个稳定的报告顺序。只记录 error.message 的调用方看到的是笼统消息,会丢掉细节;有用的字段是 error.errors。

race 没有这样的汇总,因为它只在一个输入上结算,永远不知道其余输入的情况。一个拒绝的 race 对调用方不透露其他输入的任何信息,需要知道它们是否也失败的调用方必须单独检查。这种不对称不是任一方法的缺陷;它是「等待某一类结算的方法」与「取任意种类中第一个结算的方法」之间的区别。

空输入对每种组合器意味着什么

空的可迭代对象是这四个方法接受的最小输入,也正是它们的约定看起来不再相似的情形:

empty-input -> all=fulfilled; allSettled=fulfilled:0; any=rejected:AggregateError; race=still-pending

Promise.all([]) 立即以空数组成功,因为没有输入时「每个输入都已成功」这个条件已经为真。Promise.allSettled([]) 同样如此,返回空数组,所以对空批次的结果求和会得到零而不是错误。Promise.any([]) 以 AggregateError 拒绝,其 errors 数组为空,因为「至少有一个输入成功」这个条件永远不可能为真。Promise.race([]) 返回一个永不结算的 promise,因为没有输入能结算它。

race 这一种可能让调用方永久挂起。探针无法直接等待那个 promise,于是让它与定时器赛跑,并在定时器胜出时打印 still-pending,这是观察一个没有结算的 promise 的唯一办法。如果调用方从过滤器构建输入列表,而该过滤器可能什么都不返回,它就有一条永不完成的代码路径。修法是调用 race 之前检查长度,或者始终包含至少一个保证会结算的输入,例如一个定时器。

一旦把空的情形读作一种极限,四种结果都可以从第一节的约定推出。all 需要每个输入成功,而没有东西可等;allSettled 需要每个输入结算,而没有东西可等;any 需要一个成功,却永远得不到;race 需要一个结算,也永远得不到。规范里没有任何针对空输入的特殊处理;它从定义中自然得出,所以从定义出发推理的调用方能正确预测全部四种结果。

没人处理的拒绝如何浮现

没有挂上处理器的拒绝不会消失。运行时会把它报告给正在监听未处理拒绝的对象,探针正好监听一轮来观察它:

unhandled-rejection-observed -> orphan

探针挂上监听器,创建一个没有 catch 的已拒绝 promise,等待三十毫秒,然后移除监听器。消息到达了,说明这一轮结束时该拒绝仍未被处理。在浏览器里,同一个事件是 window 上的 unhandledrejection;在 Node 里,它是探针使用的进程事件。API 名称不同,观察到的形态却一致。

组合器并不会造成这种局面,探针直接验证了这一点。第二个探针让 Promise.race 在一个成功上结算,然后让较慢的输入随后拒绝;另外单独让 Promise.all 提前拒绝,而另一个输入稍后拒绝。这两个后续拒绝都没有到达未处理拒绝监听器,而一个没有处理器的裸拒绝会到达:

after-race-fail=false
after-all-fail=false
control-fail=true

原因在于每个组合器在接收输入时就给每个输入挂上了自己的处理器,所以组合器结算之后才拒绝的输入仍然被观察,只是被丢弃。这与直觉期待有真实差别:放弃一个批次不会散出未处理拒绝,失败批次之后保持安静的环境说的是实话。

调用方仍然要做的是保留结果。组合器丢弃较晚的结算,所以事后到达的拒绝会丢失,除非输入自己把它记下来。在每个输入上挂一个 catch,记录失败并返回哨兵值,就能保住这条记录,同时不破坏组合器的语义,代价是每个输入一行代码。省掉它的调用方会得到干净的结果,也完全没有被放弃失败的痕迹,这比一个未处理拒绝更安静,也更难调试。

已结算的组合器留下了什么在运行

探针的第二行是一条断言证据,这条断言容易陈述,却很难在代码评审中记住:已结算的组合器停止的是观察,不是工作。启动了三个输入,一个立刻拒绝,另外两个仍然各自跑到终点,而组合器的 promise 始终处于已结算状态。

在共享资源的批次里,这个差距是有代价的。十个请求共享一个连接池、一个文件句柄或一份限流预算,它们都会在组合器已经拒绝之后继续消耗它,因为每一个都仍在自己的操作内部。调用方为一个批次规划的预算,被一个已经放弃的批次花掉,下一个批次起步时就比预期更少。

弥补差距的办法是给每个输入一个停止的方式。每个批次一个 AbortController,把它的 signal 传给每个操作,就能把组合器的提前结算变成明确的停止:

const controller = new AbortController();
try {
  await Promise.all(tasks.map(task => task.run(controller.signal)));
} catch (error) {
  controller.abort();
  throw error;
}

abort 调用发生在拒绝的同一轮里,所以其余输入在下一次检查时就会得知,而不是等到自然的终点。这就是「会停的批次」与「只是不再被等待的批次」之间的区别,也是这个模式里组合器不会替调用方做的那一部分。

同样的推理适用于 race。用作超时的 race 在定时器上结算,真正的工作继续运行却没人等待,因此在定时器胜出时中止控制器的调用方把两半都做对了:截止时间被强制执行,被放弃的工作也被停下。只做 race 然后继续前进的调用方执行了截止时间,却把工作留在了身后。那个定时器触发时所属的一轮本身就是排序问题,微任务与定时器对此有说明。

记录一次组合器读数

一次组合器读数只支持一个很窄的断言:这个运行时、这些输入、这个到达顺序,产生了这些结算。五个字段让它可核查:

  1. 运行时,因为未处理拒绝事件和 AggregateError 的形态都由运行时提供。
  2. 组合器与输入数量,因为空的情形和单输入情形的行为与一般情形不同。
  3. 输入的到达顺序,因为 all 与 allSettled 报告的是输入顺序,缺少它的记录无法解释为什么慢的输入排在前面。
  4. 组合器结算时哪些输入仍在运行,因为这正是「停止的等待」与「停止的工作」之间的差别。
  5. 拒绝是否被处理,因为未处理的拒绝出现在与结果不同的通道里。

公开来源

实用结论

按调用方需要的失败策略来选组合器,而不是按输入的形状:一次失败就该结束等待时用 all,每个结果都该被报告时用 allSettled,一次成功就够时用 any,任意种类的第一个结算就是答案时用 race。记住已结算的组合器不会停下它的输入,所以你要自己停下它们,并处理每一个你即将停止观察的拒绝。调用 race 之前守住空列表,因为没有输入的 race 永不结算。关于这些结算到达的顺序,请读微任务与定时器;关于一个已结算 promise 携带的数据经过拷贝后还剩什么,请读 structuredClone 与 JSON。

#Promise.all#Promise.allSettled#Promise.any#AggregateError#未处理的拒绝

本文属于指南: 什么是浏览器指纹?原理、风险、信号与隐私边界

让 BotBrowser 从研究走向生产

先用这些指南理解模型,再进入跨平台验证、隔离上下文和面向规模化的浏览器部署。