{ "index": 81, "slug": "editorial-2025-10-practice-teaching-engineering", "title": "Promise.all не отменяет работу: как объяснить границу и проверить её", "excerpt": "Promise.all объединяет исходы асинхронных операций, но не управляет их остановкой. Разбираем порядок результатов, контрпример, AbortSignal и проверку, которую можно воспроизвести локально.", "contentHtml": "
Симптом появляется после первой же ошибки: один запрос отклонился, обработчик попал в catch, а соседний запрос продолжил выполняться. В логах уже виден общий отказ, но сетевой клиент, чтение файла или внешний сервис ещё получает работу. Цена ошибки — лишний трафик, гонка за общим состоянием и сообщение пользователю, которое не совпадает с реальным состоянием ресурсов.
Главная граница такова: Promise.all сообщает исход группы promises, но не является командой остановки. Aggregate promise отклоняется при первом отклонении входа, однако остальные входы не получают от этого сигнала отмены. Поэтому в объяснении нужно разделять три вещи: исход aggregate, исход каждого входного promise и состояние внешней операции, которая за ним стоит.
Вызов Promise.all(iterable) создаёт новый promise. Если каждый элемент iterable успешно завершился, новый promise получает массив значений. Позиции массива соответствуют позициям входов, а не скорости их завершения. Если один вход отклоняется, aggregate отклоняется с причиной этого отклонения и больше не ждёт успешного результата группы.
При этом Promise.all не запускает отмену и не откатывает побочные эффекты. К моменту вызова функции вроде fetchProfile() или readFile() работа обычно уже началась. Комбинатор добавляет обработчики к полученным promises и собирает их исходы; у него нет универсального доступа к сокету, таймеру, файловому дескриптору или транзакции.
Сначала проверим только семантику promises, без сети и базы данных. Первый вход отклоняется сразу, второй вручную завершается позднее. Запустите фрагмент в Node.js с поддержкой ES-модулей: команда не требует пакетов и показывает два независимых события.
\nnode --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.
Порядок значений проверяется отдельно. Если быстрый запрос стоит вторым, он всё равно попадёт во вторую позицию успешного массива. Поэтому при деструктуризации вроде const [profile, limits] = await Promise.all([...]) порядок массива должен быть закреплён тестом или заменён явными ключами.
| Симптом | Что реально произошло | Проверка | Действие |
|---|---|---|---|
catch сработал, а поздний лог продолжился | Aggregate уже отклонён, другой вход ещё выполняется | Добавить метки старта и завершения для каждого входа | Не называть общий отказ отменой; найти API остановки |
| Результаты перепутались | Порядок входного iterable принят за порядок завершения | Записать имя операции рядом с индексом результата | Закрепить порядок или возвращать объект с ключами |
| Видна только одна ошибка | Promise.all сообщает первый отказ, а не полный отчёт | Проверить, нужен ли исход каждого входа | Для полного отчёта рассмотреть Promise.allSettled |
После ошибки соседний fetch продолжился | Запрос не получил сигнал или исполнитель его не поддерживает | Проверить передачу signal до транспорта | Передать общий AbortSignal и проверить событие отмены |
| После отмены серверная запись осталась | Остановлено ожидание ответа, а не уже выполненный побочный эффект | Проверить состояние на сервере отдельным запросом | Использовать идемпотентность, транзакцию или компенсацию |
Для независимых чтений Promise.all подходит, когда следующий шаг требует всех значений. Например, экран можно построить только после получения профиля и лимитов. Вызовы функций ставят работу в очередь до ожидания aggregate: Promise.all([loadProfile(), loadLimits()]). Передача самих функций — ошибка: Promise.all([loadProfile, loadLimits]) передаст обычные значения-функции, а не результаты их вызова.
Если нужны все исходы, включая ошибки, выбирайте другой контракт. Promise.allSettled ждёт, пока каждый вход завершится, и возвращает для каждого статус fulfilled или rejected. Это не добавляет отмену и не делает операции безопаснее; метод лишь меняет то, когда и в каком виде приложение получает отчёт.
Если работы зависят друг от друга, не следует искусственно объединять их в один массив. Последовательный код с отдельными проверками может быть понятнее и дешевле: сначала получить идентификатор, затем запросить данные по нему. Параллельный запуск оправдан только для действительно независимых операций и при принятом риске их одновременного выполнения.
\nОтмена появляется там, где конкретный исполнитель принимает сигнал. Для браузерного fetch это обычно AbortController и его signal. Один контроллер можно передать нескольким запросам. Вызов abort() отправит сигнал каждому поддерживаемому запросу, но не превратит произвольную функцию, таймер или уже выполненную запись в отменяемую операцию.
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() для ещё работающего соседа.
Фрагмент гарантирует только контракт поддерживаемых fetch-запросов. Успевший запрос может уже получить ответ до вызова abort(). Сервер мог принять данные до отмены клиента. Поэтому «клиент перестал ждать» и «сервер отменил действие» — разные результаты, которые проверяются разными наблюдениями.
Сигнал нельзя повторно использовать как возобновляемый ресурс: после отмены он остаётся aborted, а новый запрос с ним будет отклонён. Для нового запуска создайте новый контроллер. Таймаут можно выразить через AbortSignal.timeout(milliseconds), но проверьте поддержку в целевых браузерах и отдельно обработайте причину таймаута, если интерфейсу нужно отличать её от действия пользователя.
Проверка должна наблюдать не только состояние aggregate. Для каждого входа запишите имя операции, время старта, время завершения, причину отказа и факт получения сигнала. Если операция меняет внешний ресурс, добавьте проверку состояния ресурса после отказа. Лог «Promise.all отклонён» сам по себе доказывает только исход aggregate.
abort() из явной политики: кнопки, таймаута или обработчика ошибки.AbortError и таймаут, если среда предоставляет разные причины.Эта модель описывает стандартный combinator, а не политику приложения. Она не решает, сколько запросов можно выполнять одновременно, сколько раз повторять ошибку, что показывать при частичных данных и кто владеет отменой. Эти правила должны быть явными в коде и тестах.
\nPromise.all не означает параллельные потоки. JavaScript-движок и конкретный API сами определяют, как выполняется работа. Из успешного aggregate нельзя вывести пропускную способность, порядок сетевых пакетов или момент освобождения соединения. Для таких утверждений нужны метрики и документация транспорта.
Отмена не равна откату. Она может прекратить поддерживаемый fetch, чтение тела ответа или поток, но не возвращает уже отправленный платёж, не удаляет запись и не отменяет произвольную синхронную функцию. Серверную операцию защищают отдельные механизмы: идемпотентный ключ, транзакция, отменяемое задание или компенсационное действие.
Пример с ручным finishRight проверяет переходы promises в памяти. Пример с fetch проверяет передачу сигнала клиентскому API, но не гарантирует поведение конкретного прокси или сервера. Для старого браузера, Node.js или библиотеки с собственным клиентом сверяйте документацию именно этой версии.
Материал и реализация готовы, если читатель может ответить на четыре вопроса: какой promise сообщает общий результат; что делает другой вход после первой ошибки; какой объект или API реально принимает отмену; что происходит с уже выполненным побочным эффектом. В тестовом запуске должны быть видны отдельные события aggregate и каждой операции.
\nМинимальный набор проверок содержит положительный сценарий с массивом значений, отрицательный сценарий с поздним входом и сценарий с поддерживаемым AbortSignal. Если доказательством остановки служит только catch, проверка неполна. Честный вывод в таком случае: aggregate отклонён, а начатая работа может продолжиться.
Promise.allSettled() и прямое указание, что отклонение не отменяет оставшиеся операции.fetch, одноразовость сигнала, AbortSignal.timeout() и обработка причин отмены.fetch не отклоняется из-за HTTP-статуса сам по себе; статус нужно проверять через Response.ok или Response.status.