{ "index": 81, "slug": "editorial-2025-10-practice-teaching-engineering", "title": "Promise.all не отменяет работу: как объяснить границу и проверить её", "excerpt": "Promise.all объединяет исходы асинхронных операций, но не управляет их остановкой. Разбираем порядок результатов, контрпример, AbortSignal и проверку, которую можно воспроизвести локально.", "contentHtml": "

Симптом появляется после первой же ошибки: один запрос отклонился, обработчик попал в catch, а соседний запрос продолжил выполняться. В логах уже виден общий отказ, но сетевой клиент, чтение файла или внешний сервис ещё получает работу. Цена ошибки — лишний трафик, гонка за общим состоянием и сообщение пользователю, которое не совпадает с реальным состоянием ресурсов.

\n

Главная граница такова: Promise.all сообщает исход группы promises, но не является командой остановки. Aggregate promise отклоняется при первом отклонении входа, однако остальные входы не получают от этого сигнала отмены. Поэтому в объяснении нужно разделять три вещи: исход aggregate, исход каждого входного promise и состояние внешней операции, которая за ним стоит.

\n

Что именно объединяет Promise.all

\n

Вызов Promise.all(iterable) создаёт новый promise. Если каждый элемент iterable успешно завершился, новый promise получает массив значений. Позиции массива соответствуют позициям входов, а не скорости их завершения. Если один вход отклоняется, aggregate отклоняется с причиной этого отклонения и больше не ждёт успешного результата группы.

\n

При этом Promise.all не запускает отмену и не откатывает побочные эффекты. К моменту вызова функции вроде fetchProfile() или readFile() работа обычно уже началась. Комбинатор добавляет обработчики к полученным promises и собирает их исходы; у него нет универсального доступа к сокету, таймеру, файловому дескриптору или транзакции.

\n
Матрица: aggregate promise отклонён, оставшийся вход имеет собственный исход, а внешняя операция требует отдельного контракта отмены
Схема разделяет уровень aggregate, оставшийся вход и внешнюю операцию. Из красной колонки нельзя делать вывод об остановке без отдельного API.
\n

Контрпример с воспроизводимым порядком

\n

Сначала проверим только семантику promises, без сети и базы данных. Первый вход отклоняется сразу, второй вручную завершается позднее. Запустите фрагмент в Node.js с поддержкой ES-модулей: команда не требует пакетов и показывает два независимых события.

\n
node --input-type=module <<'EOF'\nlet finishRight;\n\nconst right = new Promise((resolve) => {\n  finishRight = () => {\n    console.log('right fulfilled');\n    resolve('cache value');\n  };\n});\n\nconst combined = Promise.all([\n  Promise.reject(new Error('left failed')),\n  right,\n]).catch(() => {\n  console.log('aggregate rejected');\n});\n\nawait combined;\nfinishRight();\nawait right;\nEOF
\n

Ожидаемый вывод: сначала aggregate rejected, затем right fulfilled. Второй promise не «забыт»: у него остаётся собственный обработчик и возможность перейти из pending в fulfilled. Пример не имитирует поведение сетевого транспорта и не измеряет освобождение ресурсов. Он доказывает более узкую границу: отклонение результата группы не меняет состояние другого promise.

\n

Порядок значений проверяется отдельно. Если быстрый запрос стоит вторым, он всё равно попадёт во вторую позицию успешного массива. Поэтому при деструктуризации вроде const [profile, limits] = await Promise.all([...]) порядок массива должен быть закреплён тестом или заменён явными ключами.

\n

Симптом, проверка и действие

\n
Как не перепутать исход группы с остановкой работы
СимптомЧто реально произошлоПроверкаДействие
catch сработал, а поздний лог продолжилсяAggregate уже отклонён, другой вход ещё выполняетсяДобавить метки старта и завершения для каждого входаНе называть общий отказ отменой; найти API остановки
Результаты перепуталисьПорядок входного iterable принят за порядок завершенияЗаписать имя операции рядом с индексом результатаЗакрепить порядок или возвращать объект с ключами
Видна только одна ошибкаPromise.all сообщает первый отказ, а не полный отчётПроверить, нужен ли исход каждого входаДля полного отчёта рассмотреть Promise.allSettled
После ошибки соседний fetch продолжилсяЗапрос не получил сигнал или исполнитель его не поддерживаетПроверить передачу signal до транспортаПередать общий AbortSignal и проверить событие отмены
После отмены серверная запись осталасьОстановлено ожидание ответа, а не уже выполненный побочный эффектПроверить состояние на сервере отдельным запросомИспользовать идемпотентность, транзакцию или компенсацию
\n

Положительный путь и полный отчёт

\n

Для независимых чтений Promise.all подходит, когда следующий шаг требует всех значений. Например, экран можно построить только после получения профиля и лимитов. Вызовы функций ставят работу в очередь до ожидания aggregate: Promise.all([loadProfile(), loadLimits()]). Передача самих функций — ошибка: Promise.all([loadProfile, loadLimits]) передаст обычные значения-функции, а не результаты их вызова.

\n

Если нужны все исходы, включая ошибки, выбирайте другой контракт. Promise.allSettled ждёт, пока каждый вход завершится, и возвращает для каждого статус fulfilled или rejected. Это не добавляет отмену и не делает операции безопаснее; метод лишь меняет то, когда и в каком виде приложение получает отчёт.

\n

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

\n

Отмена живёт у исполнителя

\n

Отмена появляется там, где конкретный исполнитель принимает сигнал. Для браузерного fetch это обычно AbortController и его signal. Один контроллер можно передать нескольким запросам. Вызов abort() отправит сигнал каждому поддерживаемому запросу, но не превратит произвольную функцию, таймер или уже выполненную запись в отменяемую операцию.

\n
async function loadDashboard() {\n  const controller = new AbortController();\n  const { signal } = controller;\n\n  const json = (url) => fetch(url, { signal }).then((response) => {\n    if (!response.ok) {\n      throw new Error(url + ': HTTP ' + response.status);\n    }\n    return response.json();\n  });\n\n  try {\n    return await Promise.all([\n      json('/api/profile'),\n      json('/api/limits'),\n    ]);\n  } catch (error) {\n    controller.abort();\n    throw error;\n  }\n}
\n

Здесь вторая проверка намеренная. fetch обычно выполняется успешно на уровне promise даже при HTTP 404 или 500: сервер ответил, поэтому нужно самостоятельно проверить response.ok или response.status. Если проверка обнаружит HTTP-ошибку, catch вызовет abort() для ещё работающего соседа.

\n

Фрагмент гарантирует только контракт поддерживаемых fetch-запросов. Успевший запрос может уже получить ответ до вызова abort(). Сервер мог принять данные до отмены клиента. Поэтому «клиент перестал ждать» и «сервер отменил действие» — разные результаты, которые проверяются разными наблюдениями.

\n

Сигнал нельзя повторно использовать как возобновляемый ресурс: после отмены он остаётся aborted, а новый запрос с ним будет отклонён. Для нового запуска создайте новый контроллер. Таймаут можно выразить через AbortSignal.timeout(milliseconds), но проверьте поддержку в целевых браузерах и отдельно обработайте причину таймаута, если интерфейсу нужно отличать её от действия пользователя.

\n

Как проверить границу в своём коде

\n

Проверка должна наблюдать не только состояние aggregate. Для каждого входа запишите имя операции, время старта, время завершения, причину отказа и факт получения сигнала. Если операция меняет внешний ресурс, добавьте проверку состояния ресурса после отказа. Лог «Promise.all отклонён» сам по себе доказывает только исход aggregate.

\n
  1. Назовите каждую операцию и запишите, читает ли она данные, меняет состояние или делает и то и другое.
  2. Определите ожидаемый контракт: все успешные значения, первый отказ, полный набор статусов или отмена оставшихся операций.
  3. Запустите два входа с разными задержками и сохраните отдельные события старта, отказа и завершения.
  4. Проверьте отрицательный путь: первый вход отклоняется, второй завершается позже и не получает автоматической команды остановиться.
  5. Если нужна отмена, передайте сигнал в каждый поддерживаемый исполнитель до запуска и вызовите abort() из явной политики: кнопки, таймаута или обработчика ошибки.
  6. Проверьте, что обработчик отличает обычную ошибку, AbortError и таймаут, если среда предоставляет разные причины.
  7. Проверьте поздний результат: устаревший ответ не должен перезаписывать новый экран или новое состояние.
  8. Для побочного эффекта запросите подтверждение внешней системы; не заменяйте его фактом отмены клиентского ожидания.
\n

Ограничения применимости

\n

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

\n

Promise.all не означает параллельные потоки. JavaScript-движок и конкретный API сами определяют, как выполняется работа. Из успешного aggregate нельзя вывести пропускную способность, порядок сетевых пакетов или момент освобождения соединения. Для таких утверждений нужны метрики и документация транспорта.

\n

Отмена не равна откату. Она может прекратить поддерживаемый fetch, чтение тела ответа или поток, но не возвращает уже отправленный платёж, не удаляет запись и не отменяет произвольную синхронную функцию. Серверную операцию защищают отдельные механизмы: идемпотентный ключ, транзакция, отменяемое задание или компенсационное действие.

\n

Пример с ручным finishRight проверяет переходы promises в памяти. Пример с fetch проверяет передачу сигнала клиентскому API, но не гарантирует поведение конкретного прокси или сервера. Для старого браузера, Node.js или библиотеки с собственным клиентом сверяйте документацию именно этой версии.

\n

Критерий готовности объяснения

\n

Материал и реализация готовы, если читатель может ответить на четыре вопроса: какой promise сообщает общий результат; что делает другой вход после первой ошибки; какой объект или API реально принимает отмену; что происходит с уже выполненным побочным эффектом. В тестовом запуске должны быть видны отдельные события aggregate и каждой операции.

\n

Минимальный набор проверок содержит положительный сценарий с массивом значений, отрицательный сценарий с поздним входом и сценарий с поддерживаемым AbortSignal. Если доказательством остановки служит только catch, проверка неполна. Честный вывод в таком случае: aggregate отклонён, а начатая работа может продолжиться.

\n

Проверяемые источники

" }