Назад в Центр знаний
Отпечатки

Комбинаторы Promise: первый отказ и вечное ожидание

Два наблюдения, десять случаев: all останавливается на первом отказе, allSettled сообщает все исходы в порядке входа, any собирает ошибки, а пустой race не завершается.

BotBrowser Team

Документация

Нужна структурированная документация по теме Отпечатки?

Эта статья относится к редакционной библиотеке. Для пошаговой настройки, справки и постоянных обновлений переходите сразу в соответствующий раздел docs.

BotBrowser поддерживает повтор разрешенного fixture на том же origin под объявленным профилем и сохраняет ограниченную запись о том, какое обещание завершилось первым и какое не завершилось вовсе, но он не может изменить порядок завершений в среде выполнения, не может превратить отказ в успех и не может заставить обещание без входных данных завершиться. Этот разбор запускает два наблюдения на десяти измеренных случаях и фиксирует, что делает каждый из четырех комбинаторов, когда один вход отклоняется, когда входы завершаются в другом порядке, чем были заданы, когда отклоняются все входы, когда список входов пуст и когда отказ остается необработанным.

Кратко

  • Promise.all отклоняется первым отказом и перестает ждать; наблюдение печатает first; delivered_so_far=0.
  • Остальные входы продолжают работать после этого отказа, поэтому следующая строка показывает delivered=slow,later.
  • Promise.allSettled ждет каждый вход и сохраняет порядок входа, поэтому медленное успешное завершение сообщается первым, а быстрое последним.
  • Promise.all тоже сохраняет порядок входа, поэтому a,b,c приходит в этом порядке, хотя b завершился первым.
  • Promise.any разрешается первым успешным завершением; когда отклоняются все входы, он бросает AggregateError, чей массив errors сохраняет порядок входа.
  • Promise.race берет первое завершение, поэтому быстрый отказ побеждает более медленное успешное завершение.
  • Пустой вход это место, где четыре комбинатора расходятся: all и allSettled успешно завершаются, any отклоняется с AggregateError, а race не завершается никогда.
  • Отказ, который никто не обработал, сообщается обработчику необработанных отказов среды, и наблюдение видит его как orphan.

Содержание

Четыре колонки сравнивают комбинаторы на одних и тех же трех входах: Promise.all отклоняется на первом отказе, а остальные входы продолжают работать, Promise.allSettled возвращает три исхода в порядке входа, Promise.any завершается первым успехом, а иначе бросает AggregateError, и пустой Promise.race остается в ожидании без завершения

Что каждый комбинатор обещает о завершении

Четыре комбинатора принимают один и тот же вход, итерируемое значение из значений и обещаний, и различаются только тем, какое завершение они передают вызывающему коду. Именно это различие определяет все режимы отказа ниже, поэтому четыре контракта стоит сформулировать в одном месте до чтения любого вывода.

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.resolve() перед передачей, выполняет работу, которую комбинатор уже делает.

Почему Promise.all останавливается на первом отказе

Promise.all завершается в момент, когда отклоняется его первый вход, и значение, которое он передает, это причина этого отказа, а не массив. Наблюдение перехватывает ее и сообщает и сообщение, и сколько других входов к этому моменту уже доставили результат:

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

Счет равен нулю, потому что отказ пришел раньше успешного завершения через десять миллисекунд. Важная половина этой строки в том, чего она не говорит: комбинатор сдался, а входы нет. Каждый вход, который уже работал, продолжает работать до своего конца, что наблюдение проверяет, ожидая и затем печатая то, что пришло позже:

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

Оба оставшихся входа завершились, один из них намного позже отказа, который закончил ожидание. Это режим отказа, который удивляет вызывающий код чаще всего. Отклоненный Promise.all не отменяет свои входы, потому что у обещания в языке нет операции отмены; комбинатор просто перестает их наблюдать. Работу, которую вызывающий код хотел остановить, должен остановить код, который ею владеет, обычно через AbortController, переданный каждой операции, и комбинатор в этом не участвует.

То же поведение задает ожидания для очистки. Блок finally на вызове Promise.all выполняется, когда комбинатор завершается, а в случае отказа это происходит рано, поэтому очистка, написанная там, выполняется, пока другие входы еще в полете. Вызывающий код, который закрывает общий ресурс в этом блоке, может закрыть его под работой, которая все еще его использует. Безопасный порядок: сначала остановить входы, дождаться, чтобы они подтвердили остановку, и только затем освобождать ресурс.

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([]) возвращает обещание, которое никогда не завершается, потому что ни один вход не может его завершить.

Случай race это тот, который может подвесить вызывающий код навсегда. Наблюдение не может ожидать это обещание напрямую, поэтому устраивает гонку с таймером и печатает still-pending, когда побеждает таймер, что и есть единственный способ наблюдать обещание без завершения. Вызывающий код, который строит список входов из фильтра, а фильтр может вернуть ничего, имеет путь кода, который никогда не разрешается. Исправление: проверить длину перед вызовом race или всегда включать хотя бы один вход, который гарантированно завершится, например таймер.

Четыре исхода следуют из контрактов в первом разделе, если читать пустой случай как предел. all нуждается в успешном завершении каждого входа и не имеет чего ждать, allSettled нуждается в завершении каждого входа и не имеет чего ждать, any нуждается в одном успешном завершении и никогда его не получит, а race нуждается в одном завершении и никогда его не получит. В спецификации пустой случай ничем не выделен; он вытекает из определений, поэтому вызывающий код, который рассуждает от определений, предсказывает все четыре исхода правильно.

Как проявляется отказ, который никто не обработал

Отказ, к которому не прикреплен обработчик, не исчезает. Среда сообщает о нем тому, кто слушает необработанные отказы, и наблюдение слушает ровно один оборот, чтобы его увидеть:

unhandled-rejection-observed -> orphan

Наблюдение прикрепляет слушатель, создает отклоненное обещание без catch, ждет тридцать миллисекунд и удаляет слушатель. Сообщение приходит, значит, отказ все еще был необработанным, когда оборот закончился. В браузере то же событие это unhandledrejection на window, а в Node это событие процесса, которое использует наблюдение, поэтому форма наблюдения одна и та же, хотя имя API различается.

Комбинатор такой ситуации не создает, и наблюдение проверяет это напрямую. Второе наблюдение завершает Promise.race успехом и затем дает более медленному входу отклониться; отдельно оно дает Promise.all отклониться рано, пока другой вход отклоняется позже. Ни один из этих поздних отказов не доходит до слушателя необработанных отказов, тогда как голый отказ без обработчика доходит:

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

Причина в том, что каждый комбинатор прикрепляет свой обработчик к каждому входу в момент передачи, поэтому вход, который отклоняется после завершения комбинатора, все равно наблюдается, просто отбрасывается. Это реальное отличие от наивного ожидания: отказ от пакета не рассыпает необработанные отказы, и среда, которая молчит после неудачного пакета, говорит правду.

Что вызывающему коду все же нужно делать, так это сохранять исход. Комбинатор отбрасывает позднее завершение, поэтому отказ, который приходит задним числом, теряется, если сам вход его не записал. catch на каждом входе, который записывает отказ и возвращает сигнальное значение, сохраняет эту запись и оставляет семантику комбинатора нетронутой, и стоит одну строку на вход. Кто его пропускает, получает чистый результат и вовсе никакого следа отброшенного отказа, а это тише и труднее для отладки, чем был бы необработанный отказ.

Что завершившийся комбинатор оставляет работать

Вторая строка наблюдения это доказательство утверждения, которое легко сформулировать и трудно вспомнить на код-ревью: завершившийся комбинатор перестал наблюдать, а не остановил работу. Были запущены три входа, один отклонился сразу, и оба других все равно дошли до своего конца. Обещание комбинатора было завершено все это время.

Этот разрыв имеет цену в пакете, который делит ресурс. Десять запросов, которые делят один пул соединений, один файловый дескриптор или один бюджет ограничения частоты, продолжат его расходовать после того, как комбинатор уже отклонился, потому что каждый из них все еще внутри своей операции. Бюджет, который вызывающий код планировал на один пакет, тратится пакетом, который он уже бросил, и следующий пакет начинается с меньшим, чем ожидает.

Способ закрыть разрыв: дать каждому входу способ остановиться. 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. Гонка, используемая как тайм-аут, завершается на таймере, а реальная работа продолжает идти без ожидающего, поэтому вызывающий код, который прерывает контроллер, когда побеждает таймер, делает правильно обе половины: срок соблюден и брошенная работа остановлена. Тот, кто только устраивает гонку и идет дальше, соблюл срок и оставил работу. Оборот, в котором срабатывает таймер, сам по себе вопрос порядка, который освещают микрозадачи и таймеры.

Запишите показание комбинатора

Показание комбинатора подтверждает узкое утверждение: эта среда, эти входы, этот порядок прихода дали эти завершения. Пять полей сохраняют его проверяемым:

  1. Среда, потому что событие необработанного отказа и форма AggregateError обе предоставляются средой.
  2. Комбинатор и число входов, потому что пустой случай и случай одного входа ведут себя иначе, чем общий.
  3. Порядок прихода входов, потому что all и allSettled сообщают порядок входа, и запись без него не может объяснить, почему медленный вход пришел первым.
  4. Какие входы все еще работали, когда комбинатор завершился, потому что это различие между остановленным ожиданием и остановленной работой.
  5. Был ли отказ обработан, потому что необработанный появляется в другом канале, чем результат.

Публичные источники

Практический вывод

Выбирайте комбинатор по политике отказа, которая нужна вызывающему коду, а не по форме входа: all, когда один отказ должен закончить ожидание, allSettled, когда нужно сообщить каждый исход, any, когда достаточно одного успеха, и race, когда ответ это первое завершение любого рода. Помните, что завершившийся комбинатор не останавливает свои входы, поэтому остановите их сами и обработайте каждый отказ, за которым вот-вот перестанете наблюдать. Проверяйте пустой список перед вызовом race, потому что гонка без входов никогда не завершается. О порядке, в котором приходят эти завершения, читайте микрозадачи и таймеры; о том, что переживает копирование данных, которые несет завершенное обещание, читайте structuredClone против JSON.

#Promise.all#Promise.allSettled#Promise.any#AggregateError#Необработанный Отказ

Эта статья входит в руководство: Что такое отпечаток браузера?

Переведите BotBrowser из исследований в продакшн

Используйте эти руководства, чтобы понять модель, а затем перейти к кроссплатформенной валидации, изолированным контекстам и масштабируемому браузерному развертыванию.