{ "index": 79, "slug": "editorial-2025-10-field-teaching-engineering", "title": "Promise.all не отменяет работу: как объяснить границу на рабочем примере", "excerpt": "После первой ошибки Promise.all возвращает rejected promise, но уже начатая работа входов не исчезает сама. Разбираем модель, контрпример и проверку, которые не дают перенести рецепт за пределы его условий.", "contentHtml": "
Симптом появляется в момент первой ошибки. Код ждёт несколько операций через await Promise.all(tasks), одна операция отклоняется, обработчик сразу переходит в catch, а автор считает остальные операции остановленными. Позже выясняется, что один запрос всё ещё пишет данные, таймер всё ещё выполняется, а зависимая логика уже очистила состояние. Цена ошибки — повторные записи, лишние запросы и расследование, в котором приходится восстанавливать границу ответственности по логам.
Тезис простой: Promise.all объединяет наблюдаемые исходы promises, но не является протоколом отмены работы. Он сообщает результат aggregate promise. Он не получает автоматически право остановить операцию, которая создала входной promise. Чтобы объяснить такой код без ложной гарантии, нужно разделить три объекта: входной promise, aggregate promise и внешнюю работу.
Рецепт становится опасным, когда его показывают раньше задачи. Возьмём учебный сценарий: нужно получить профиль и настройки, а затем построить экран. Если любой результат недоступен, экран строить нельзя. Здесь Promise.all подходит как условие для зависимого шага: зависимый код запускается только после успешного исхода двух входов.
Но это условие не отвечает на другой вопрос: что делать с операцией, которая уже началась и ещё не завершилась? Объединение результатов и остановка работы относятся к разным контрактам. Первый задаёт JavaScript. Второй должен задавать приложение, библиотека или владелец ресурса.
\nПусть в Promise.all переданы profilePromise и settingsPromise. JavaScript создаёт новый aggregate promise. Он выполнится успешно, если все входы выполнятся. Значения попадут в массив в порядке входного iterable, а не в порядке завершения. Если один вход отклонится, aggregate promise отклонится с первой причиной отказа.
Это не означает, что второй вход получил команду отмены. Второй promise может уже завершиться, продолжить ожидание или скрывать за собой работу, которую можно прервать только отдельным API. Вызов catch наблюдает отказ aggregate. Сам по себе он не меняет жизненный цикл запроса, чтения файла, вычисления или записи.
Отсюда следует отрицательный путь. Если профиль отклонился, нельзя выводить «настройки отменены». Можно вывести только «общий результат недоступен с такой причиной». Дальше код обязан использовать собственный контракт: сигнал отмены, закрытие ресурса, остановку очереди, компенсацию или безопасное ожидание остатка. Если такого контракта нет, честный ответ — отмена не определена.
\nНиже намеренно узкий пример. Он не обращается к сети, диску, часам или данным пользователей. Один вход отклоняется сразу. Второй вход удерживается до явного вызова finishRemaining. Это позволяет увидеть порядок событий и не приписывать JavaScript поведение, которого в коде нет.
let finishRemaining;\nconst remaining = new Promise((resolve) => {\n finishRemaining = () => resolve('settings');\n});\n\nconst combined = Promise.all([\n Promise.reject(new Error('profile failed')),\n remaining,\n]).catch(() => 'aggregate handled');\n\nawait combined;\nconsole.log('aggregate rejected');\nfinishRemaining();\nconsole.log(await remaining);\n// aggregate rejected\n// settings\nПосле первой строки вывода aggregate уже обработал отказ. Но второй promise ещё существует. Функция finishRemaining завершает его позже. Пример доказывает только это: rejected aggregate не равен отмене каждого входа. Он не доказывает, как поведёт себя HTTP-клиент, база данных или очередь сообщений. Для каждого такого ресурса нужна отдельная документация и отдельный тест.
Контрпример полезнее общей фразы «promises выполняются параллельно». Это слово слишком широкое. В JavaScript promise представляет состояние будущего результата; он не является универсальным дескриптором процесса, который можно остановить одним методом. Операции могут стартовать до создания aggregate, а их остановка может быть невозможна или требовать согласия внешнего владельца.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
catch сработал, но запрос продолжился | Aggregate наблюдает отказ, а клиент запроса не получил сигнал отмены | Посмотреть API запроса и его обработчик сигнала | Добавить явный cancel-контракт или признать работу неотменяемой |
| Результаты приходят в неожиданном порядке | Порядок завершения перепутан с порядком iterable | Сравнить индексы входов и trace завершения | Читать массив по исходным индексам, а не по времени |
| После одной ошибки обработчик пишет частичный результат | Зависимый шаг запускается не только после fulfilled aggregate | Проверить место вызова и ветки после catch | Разделить partial result и готовый aggregate |
Заменили all на allSettled, но проблема осталась | Нужен был протокол остановки, а изменили только форму отчёта | Назвать требование: все исходы или остановка работы | Выбрать combinator для отчёта и отдельно спроектировать отмену |
| Текст обещает «остановить всё» | Рецепт подменил причинную модель | Попросить показать объект, который посылает cancel | Сузить утверждение до aggregate и добавить отрицательный путь |
Начните с одной проверяемой фразы: «Экран строится только после двух успешных результатов». Затем назовите aggregate promise и покажите, где читается его результат. После этого добавьте отказ одного входа. Читатель должен увидеть, что зависимый шаг не запускается. Только теперь задайте вопрос о втором входе.
\nОтвет должен быть конкретным. Если второй вход уже создан, у него есть собственное состояние. Promise.all не предоставляет в этом вызове метода cancel. Если вход связан с fetch, автор может передать AbortSignal и вызвать AbortController.abort(). Но это уже договор Fetch и конкретного кода приложения, а не свойство Promise.all. Даже сигнал не превращает любую серверную операцию в гарантированно отменённую: сервер мог принять запрос, а клиент мог лишь прекратить ожидание ответа.
Такой ответ не должен звучать как универсальный рецепт отмены. Для учебного фрагмента достаточно показать место ответственности. В production нужно проверить, что делает клиент после abort, что происходит на сервере, как закрывается ресурс и допустима ли повторная попытка. Если эти условия не описаны, статья должна остановиться на границе знания.
\nPromise.all выбирают, когда нужен общий успех всех входов и ранний отказ aggregate при первой ошибке. Promise.allSettled выбирают, когда нужно дождаться и сохранить статус каждого входа. Это разные требования. Переключение на allSettled не отменяет операции и не исправляет частичную запись.
Например, экран может показывать независимые виджеты. Тогда полезно получить массив состояний и отрисовать ошибку только у одного виджета. Но платёжный сценарий, в котором нельзя продолжать без обязательного ответа, требует другой проверки: dependent action не должна начаться после rejected aggregate. Если же один вызов уже создал побочный эффект, combinator не решает вопрос компенсации. Его должен решить доменный контракт.
\nНе стоит заменять Promise.all на последовательный await только ради иллюзии контроля. Последовательный запуск уменьшает число одновременно начатых операций, но не отменяет первую операцию при ошибке второй. Он также меняет задержку и нагрузку. Сначала зафиксируйте требование, затем выбирайте форму ожидания.
Пример выше фиксирует порядок наблюдений в памяти. Он не моделирует latency, сетевые разрывы, retry, таймауты, серверную обработку, блокировки базы, очередь или пользовательскую сессию. Нельзя переносить его вывод как готовую архитектуру. Его задача уже: показать, почему aggregate rejection не доказывает отмену входа.
\nЕсть и другой отрицательный путь: остановка может быть технически доступна, но логически запрещена. Например, сервер уже принял команду, и отмена клиентского ожидания не должна приводить к повторной команде без idempotency key. В таком случае «прервать ожидание» и «отменить побочный эффект» — два разных решения. Текст, который объединяет их одним словом «cancel», скрывает риск.
\nНе заявляйте результат обучения или production-эффект по одному примеру. Можно проверить, что читатель назвал aggregate, вход и отдельный cancel-контракт. Нельзя из этого вывести, что он безопасно спроектирует любой асинхронный pipeline. Для такого вывода нужна другая проверка, привязанная к конкретной системе.
\nОбъяснение готово, если независимый читатель может без подсказки ответить на четыре вопроса: какой результат объединяет Promise.all; что происходит при первом reject; может ли оставшийся вход закончиться позже; кто именно останавливает внешнюю работу. Код должен проходить тесты для fulfilled-пути и для отказа, а текст — не обещать отмену там, где в API нет такого контракта.
Практическая финальная проверка короткая. Уберите названия методов и попросите восстановить модель по событиям. Затем верните код и сравните каждое утверждение с наблюдаемым результатом. Если для фразы «всё остановилось» нельзя назвать объект, который отправил сигнал остановки, замените её на точное утверждение об aggregate. Такая редактура сохраняет полезный рецепт и не переносит его за пределы условий задачи.
\nPromise.allSettled(). Она не обещает автоматическую отмену входов.