8 lines
21 KiB
JSON
8 lines
21 KiB
JSON
{
|
||
"index": 81,
|
||
"slug": "editorial-2025-10-practice-teaching-engineering",
|
||
"title": "Promise.all не отменяет работу: как объяснить границу и проверить её",
|
||
"excerpt": "Promise.all объединяет исходы асинхронных операций, но не управляет их остановкой. Разбираем порядок результатов, контрпример, AbortSignal и проверку, которую можно воспроизвести локально.",
|
||
"contentHtml": "<p>Симптом появляется после первой же ошибки: один запрос отклонился, обработчик попал в <code>catch</code>, а соседний запрос продолжил выполняться. В логах уже виден общий отказ, но сетевой клиент, чтение файла или внешний сервис ещё получает работу. Цена ошибки — лишний трафик, гонка за общим состоянием и сообщение пользователю, которое не совпадает с реальным состоянием ресурсов.</p>\n<p>Главная граница такова: <code>Promise.all</code> сообщает исход группы promises, но не является командой остановки. Aggregate promise отклоняется при первом отклонении входа, однако остальные входы не получают от этого сигнала отмены. Поэтому в объяснении нужно разделять три вещи: исход aggregate, исход каждого входного promise и состояние внешней операции, которая за ним стоит.</p>\n<h2>Что именно объединяет Promise.all</h2>\n<p>Вызов <code>Promise.all(iterable)</code> создаёт новый promise. Если каждый элемент iterable успешно завершился, новый promise получает массив значений. Позиции массива соответствуют позициям входов, а не скорости их завершения. Если один вход отклоняется, aggregate отклоняется с причиной этого отклонения и больше не ждёт успешного результата группы.</p>\n<p>При этом <code>Promise.all</code> не запускает отмену и не откатывает побочные эффекты. К моменту вызова функции вроде <code>fetchProfile()</code> или <code>readFile()</code> работа обычно уже началась. Комбинатор добавляет обработчики к полученным promises и собирает их исходы; у него нет универсального доступа к сокету, таймеру, файловому дескриптору или транзакции.</p>\n<figure><img src='/assets/editorial/2025/teaching-engineering-2025-misconception-matrix.svg' alt='Матрица: aggregate promise отклонён, оставшийся вход имеет собственный исход, а внешняя операция требует отдельного контракта отмены' loading='lazy' /><figcaption>Схема разделяет уровень aggregate, оставшийся вход и внешнюю операцию. Из красной колонки нельзя делать вывод об остановке без отдельного API.</figcaption></figure>\n<h2>Контрпример с воспроизводимым порядком</h2>\n<p>Сначала проверим только семантику promises, без сети и базы данных. Первый вход отклоняется сразу, второй вручную завершается позднее. Запустите фрагмент в Node.js с поддержкой ES-модулей: команда не требует пакетов и показывает два независимых события.</p>\n<pre><code>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</code></pre>\n<p>Ожидаемый вывод: сначала <code>aggregate rejected</code>, затем <code>right fulfilled</code>. Второй promise не «забыт»: у него остаётся собственный обработчик и возможность перейти из pending в fulfilled. Пример не имитирует поведение сетевого транспорта и не измеряет освобождение ресурсов. Он доказывает более узкую границу: отклонение результата группы не меняет состояние другого promise.</p>\n<p>Порядок значений проверяется отдельно. Если быстрый запрос стоит вторым, он всё равно попадёт во вторую позицию успешного массива. Поэтому при деструктуризации вроде <code>const [profile, limits] = await Promise.all([...])</code> порядок массива должен быть закреплён тестом или заменён явными ключами.</p>\n<h2>Симптом, проверка и действие</h2>\n<div class='table-scroll'><table><caption>Как не перепутать исход группы с остановкой работы</caption><thead><tr><th scope='col'>Симптом</th><th scope='col'>Что реально произошло</th><th scope='col'>Проверка</th><th scope='col'>Действие</th></tr></thead><tbody><tr><td><code>catch</code> сработал, а поздний лог продолжился</td><td>Aggregate уже отклонён, другой вход ещё выполняется</td><td>Добавить метки старта и завершения для каждого входа</td><td>Не называть общий отказ отменой; найти API остановки</td></tr><tr><td>Результаты перепутались</td><td>Порядок входного iterable принят за порядок завершения</td><td>Записать имя операции рядом с индексом результата</td><td>Закрепить порядок или возвращать объект с ключами</td></tr><tr><td>Видна только одна ошибка</td><td><code>Promise.all</code> сообщает первый отказ, а не полный отчёт</td><td>Проверить, нужен ли исход каждого входа</td><td>Для полного отчёта рассмотреть <code>Promise.allSettled</code></td></tr><tr><td>После ошибки соседний <code>fetch</code> продолжился</td><td>Запрос не получил сигнал или исполнитель его не поддерживает</td><td>Проверить передачу <code>signal</code> до транспорта</td><td>Передать общий <code>AbortSignal</code> и проверить событие отмены</td></tr><tr><td>После отмены серверная запись осталась</td><td>Остановлено ожидание ответа, а не уже выполненный побочный эффект</td><td>Проверить состояние на сервере отдельным запросом</td><td>Использовать идемпотентность, транзакцию или компенсацию</td></tr></tbody></table></div>\n<h2>Положительный путь и полный отчёт</h2>\n<p>Для независимых чтений <code>Promise.all</code> подходит, когда следующий шаг требует всех значений. Например, экран можно построить только после получения профиля и лимитов. Вызовы функций ставят работу в очередь до ожидания aggregate: <code>Promise.all([loadProfile(), loadLimits()])</code>. Передача самих функций — ошибка: <code>Promise.all([loadProfile, loadLimits])</code> передаст обычные значения-функции, а не результаты их вызова.</p>\n<p>Если нужны все исходы, включая ошибки, выбирайте другой контракт. <code>Promise.allSettled</code> ждёт, пока каждый вход завершится, и возвращает для каждого статус <code>fulfilled</code> или <code>rejected</code>. Это не добавляет отмену и не делает операции безопаснее; метод лишь меняет то, когда и в каком виде приложение получает отчёт.</p>\n<p>Если работы зависят друг от друга, не следует искусственно объединять их в один массив. Последовательный код с отдельными проверками может быть понятнее и дешевле: сначала получить идентификатор, затем запросить данные по нему. Параллельный запуск оправдан только для действительно независимых операций и при принятом риске их одновременного выполнения.</p>\n<h2>Отмена живёт у исполнителя</h2>\n<p>Отмена появляется там, где конкретный исполнитель принимает сигнал. Для браузерного <code>fetch</code> это обычно <code>AbortController</code> и его <code>signal</code>. Один контроллер можно передать нескольким запросам. Вызов <code>abort()</code> отправит сигнал каждому поддерживаемому запросу, но не превратит произвольную функцию, таймер или уже выполненную запись в отменяемую операцию.</p>\n<pre><code>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}</code></pre>\n<p>Здесь вторая проверка намеренная. <code>fetch</code> обычно выполняется успешно на уровне promise даже при HTTP 404 или 500: сервер ответил, поэтому нужно самостоятельно проверить <code>response.ok</code> или <code>response.status</code>. Если проверка обнаружит HTTP-ошибку, <code>catch</code> вызовет <code>abort()</code> для ещё работающего соседа.</p>\n<p>Фрагмент гарантирует только контракт поддерживаемых <code>fetch</code>-запросов. Успевший запрос может уже получить ответ до вызова <code>abort()</code>. Сервер мог принять данные до отмены клиента. Поэтому «клиент перестал ждать» и «сервер отменил действие» — разные результаты, которые проверяются разными наблюдениями.</p>\n<p>Сигнал нельзя повторно использовать как возобновляемый ресурс: после отмены он остаётся aborted, а новый запрос с ним будет отклонён. Для нового запуска создайте новый контроллер. Таймаут можно выразить через <code>AbortSignal.timeout(milliseconds)</code>, но проверьте поддержку в целевых браузерах и отдельно обработайте причину таймаута, если интерфейсу нужно отличать её от действия пользователя.</p>\n<h2>Как проверить границу в своём коде</h2>\n<p>Проверка должна наблюдать не только состояние aggregate. Для каждого входа запишите имя операции, время старта, время завершения, причину отказа и факт получения сигнала. Если операция меняет внешний ресурс, добавьте проверку состояния ресурса после отказа. Лог «<code>Promise.all</code> отклонён» сам по себе доказывает только исход aggregate.</p>\n<ol><li>Назовите каждую операцию и запишите, читает ли она данные, меняет состояние или делает и то и другое.</li><li>Определите ожидаемый контракт: все успешные значения, первый отказ, полный набор статусов или отмена оставшихся операций.</li><li>Запустите два входа с разными задержками и сохраните отдельные события старта, отказа и завершения.</li><li>Проверьте отрицательный путь: первый вход отклоняется, второй завершается позже и не получает автоматической команды остановиться.</li><li>Если нужна отмена, передайте сигнал в каждый поддерживаемый исполнитель до запуска и вызовите <code>abort()</code> из явной политики: кнопки, таймаута или обработчика ошибки.</li><li>Проверьте, что обработчик отличает обычную ошибку, <code>AbortError</code> и таймаут, если среда предоставляет разные причины.</li><li>Проверьте поздний результат: устаревший ответ не должен перезаписывать новый экран или новое состояние.</li><li>Для побочного эффекта запросите подтверждение внешней системы; не заменяйте его фактом отмены клиентского ожидания.</li></ol>\n<h2>Ограничения применимости</h2>\n<p>Эта модель описывает стандартный combinator, а не политику приложения. Она не решает, сколько запросов можно выполнять одновременно, сколько раз повторять ошибку, что показывать при частичных данных и кто владеет отменой. Эти правила должны быть явными в коде и тестах.</p>\n<p><code>Promise.all</code> не означает параллельные потоки. JavaScript-движок и конкретный API сами определяют, как выполняется работа. Из успешного aggregate нельзя вывести пропускную способность, порядок сетевых пакетов или момент освобождения соединения. Для таких утверждений нужны метрики и документация транспорта.</p>\n<p>Отмена не равна откату. Она может прекратить поддерживаемый <code>fetch</code>, чтение тела ответа или поток, но не возвращает уже отправленный платёж, не удаляет запись и не отменяет произвольную синхронную функцию. Серверную операцию защищают отдельные механизмы: идемпотентный ключ, транзакция, отменяемое задание или компенсационное действие.</p>\n<p>Пример с ручным <code>finishRight</code> проверяет переходы promises в памяти. Пример с <code>fetch</code> проверяет передачу сигнала клиентскому API, но не гарантирует поведение конкретного прокси или сервера. Для старого браузера, Node.js или библиотеки с собственным клиентом сверяйте документацию именно этой версии.</p>\n<h2>Критерий готовности объяснения</h2>\n<p>Материал и реализация готовы, если читатель может ответить на четыре вопроса: какой promise сообщает общий результат; что делает другой вход после первой ошибки; какой объект или API реально принимает отмену; что происходит с уже выполненным побочным эффектом. В тестовом запуске должны быть видны отдельные события aggregate и каждой операции.</p>\n<p>Минимальный набор проверок содержит положительный сценарий с массивом значений, отрицательный сценарий с поздним входом и сценарий с поддерживаемым <code>AbortSignal</code>. Если доказательством остановки служит только <code>catch</code>, проверка неполна. Честный вывод в таком случае: aggregate отклонён, а начатая работа может продолжиться.</p>\n<h2>Проверяемые источники</h2><ul><li><a href='https://tc39.es/ecma262/multipage/control-abstraction-objects.html#sec-promise.all' target='_blank' rel='noopener noreferrer'>ECMAScript Language Specification: Promise.all</a> — нормативный алгоритм создания aggregate promise, успешного массива значений и отклонения с причиной первого отказа.</li><li><a href='https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all' target='_blank' rel='noopener noreferrer'>MDN Web Docs: Promise.all()</a> — порядок значений, fail-fast-поведение, отличие от <code>Promise.allSettled()</code> и прямое указание, что отклонение не отменяет оставшиеся операции.</li><li><a href='https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal' target='_blank' rel='noopener noreferrer'>MDN Web Docs: AbortSignal</a> — передача сигнала в <code>fetch</code>, одноразовость сигнала, <code>AbortSignal.timeout()</code> и обработка причин отмены.</li><li><a href='https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch' target='_blank' rel='noopener noreferrer'>MDN Web Docs: Window.fetch()</a> — promise <code>fetch</code> не отклоняется из-за HTTP-статуса сам по себе; статус нужно проверять через <code>Response.ok</code> или <code>Response.status</code>.</li></ul>"
|
||
}
|