Что на самом деле гарантирует async/await и что оставляет на ваше усмотрение.
Понять, что на самом деле приостанавливает оператор await, как избежать сериализованных запросов и почему для обработки ошибок, отмены операций, упорядочивания и повторных попыток требуются решения, выходящие за рамки async/await.
Функция, полная выражений await, напоминает скрипт, выполняющийся строка за строкой, и именно это впечатление является началом многих асинхронных ошибок. Медленные панели управления, скрытые ошибки, устаревшие результаты поиска и двойное списание средств часто возникают из-за ожидания от async/await гарантий, которых он никогда не предоставлял. Как только вы поймёте точные, довольно простые правила, лежащие в основе этой синтаксиса, вы сможете с первого взгляда определить, какое поведение обусловлено самим языком, а какое ещё требует вашей реализации.
Вот функция, которая кажется однозначно последовательной:
async function loadDashboard(userId) {
const user = await fetchUser(userId);
const projects = await fetchProjects(userId);
const notifications = await fetchNotifications(userId);
return {
user,
projects,
notifications,
};
}
Естественное понимание заключается в следующем: сначала получить информацию о пользователе, подождать, затем получить информацию о проектах, снова подождать, после этого — информацию о уведомлениях, и только потом вернуть результат. Такое понимание не совсем неверно, но оно скрывает те аспекты, которые становятся важными, когда коду необходимо работать быстро, параллельно, быть возможным к отмене или устойчивым к сбоям.
await ничего не говорит о том, как выполняется основная работа. Он лишь обозначает место, где вложенная асинхронная функция должна приостановиться до тех пор, пока обещание не будет реализовано. Пока эта функция приостановлена, среда выполнения продолжает обрабатывать другие задачи. Множество неприятных ситуаций возникает из-за приписывания поведения async/await тому, что на самом деле принадлежит обещаниям, среде выполнения, API вроде fetch, стратегии конкурентности или самому приложению.
await приостанавливает одну функцию, а не всю среду выполнения
Рассмотрим эту небольшую программу, которая ведет логирование во время сетевого запроса:
async function loadUser() {
console.log("A");
const user = await fetch("/api/user");
console.log("B");
return user;
}
console.log("1");
loadUser();
console.log("2");
При вызове loadUser() его тело начинает выполняться сразу, поэтому сначала записывается "A", а затем "1". Когда выполнение достигает оператора await, функция не блокирует поток во время обработки запроса. Она приостанавливает свою работу и возвращает управление вызывающему коду, поэтому сначала появляется "2", а затем "B". Как только обещание от fetch будет выполнено, остальная часть функции loadUser() будет запущена, и только тогда будет записано "B". В результате получается порядок: 1, A, 2, B.
Следовательно, правильный перевод оператора await — это не «остановить JavaScript здесь». Более точный вариант — «всё, что находится после этой строки, зависит от этого значения, поэтому продолжить выполнение функции позже».
Это справедливо даже тогда, когда ожидаемое значение уже доступно. Ожидание выполнения обещания или простого значения, не являющегося обещанием, по-прежнему откладывает выполнение остальной части функции на более поздний микрозадачу вместо того, чтобы продолжить её выполнение немедленно, как описано в документации MDN. Именно поэтому добавление кажущихся безвредными дополнительных выражений await может изменить расписание выполнения: каждое из них добавляет ещё одну границу микрозадачи.
Практическим следствием этого является то, что невозможно понять программу, рассматривая каждое await как блокирующий вызов в синхронной функции. Необходимо отслеживать, что уже было запущено, какие функции в настоящее время приостановлены и что ещё может выполняться до возобновления работы определённой функции.
async не переносит работу с главного потока
Ключевое слово async порождает ещё одно заблуждение. Рассмотрим этот цикл, зависящий от производительности CPU:
async function calculateTotal(items) {
let total = 0;
for (const item of items) {
total += expensiveCalculation(item);
}
return total;
}
Ничто в этом коде не является конкурентным. Добавление ключевого слова async не перемещает функцию expensiveCalculation() на другой поток, не параллелизует цикл и не предотвращает того, чтобы тяжелая синхронная работа блокировала выполнение других операций в том же потоке.
async изменяет способ возврата значения функцией. При ее вызове всегда создается обещание, а возврат обычного значения приводит к выполнению этого обещания с указанным значением. Спецификация ECMAScript описывает обработку асинхронных функций с точки зрения возможности работы с обещаниями, которые становятся результатом выполнения функции.
async function getNumber() {
return 42;
}
const result = getNumber();
console.log(result);
// Promise
Логирование значения result показывает либо ожидающееся, либо выполненное обещание Promise, а не число 42. Чтобы получить это число, необходимо либо дождаться выполнения обещания, либо вызвать метод .then() у него.
Однако возврат обещания не делает тело функции асинхронным. Всё, что находится до первого await, выполняется синхронно в момент вызова. Если вы поместите ресурсоемкую обработку внутрь асинхронной функции и ожидаете, что интерфейс будет оставаться отзывчивым, вы разочаруетесь: функция в конечном итоге вернёт обещание, но тяжёлая работа всё равно сначала займёт поток. Для действительно ресурсоемкой работы используются веб-рабочие процессы в браузере, worker_threads в Node.js или разделение работы на части.
Короче говоря, async/await — это способ написания цепочек обещаний, которые выполняются сверху вниз. Он планирует продолжение выполнения; он никогда не заставляет блокирующий код работать одновременно.
Место размещения await может сериализовать независимую работу
Вернёмся к загрузчику панели управления:
async function loadDashboard(userId) {
const user = await fetchUser(userId);
const projects = await fetchProjects(userId);
const notifications = await fetchNotifications(userId);
return {
user,
projects,
notifications,
};
}
Предположим, что функция fetchProjects() не нуждается в параметре user, а функция fetchNotifications() не нуждается ни в одном из предыдущих результатов. Тем не менее функция выполняет три независимых запроса подряд, поскольку второй вызов производится только после выполнения первого обещания, а третий ждёт второго. Общая задержка равна сумме всех трёх:
fetch user
████████
fetch projects
███████████
fetch notifications
███████
Это решение обычно описывается как «использование Promise.all() для параллельной обработки». Более точное описание заключается в том, что все независимые операции должны быть запущены до того, как функция будет ждать их результатов. Первый вызов трёх функций создаёт три обещания, находящихся в процессе выполнения, и только затем код ждёт совокупного результата:
async function loadDashboard(userId) {
const userPromise = fetchUser(userId);
const projectsPromise = fetchProjects(userId);
const notificationsPromise =
fetchNotifications(userId);
const [user, projects, notifications] =
await Promise.all([
userPromise,
projectsPromise,
notificationsPromise,
]);
return {
user,
projects,
notifications,
};
}
Теперь запросы выполняются одновременно, и общая задержка примерно соответствует времени выполнения самого медленного из них:
fetch user
████████
fetch projects
███████████
fetch notifications
███████
all required results available
Promise.all() принимает набор обещаний и возвращает одно обещание, которое будет выполнено, когда все входные обещания будут выполнены. Массив результатов сохраняет порядок входных данных, независимо от того, какая операция завершилась первой, поэтому деструктуризация в переменные user, projects и notifications остается корректной.
Обратите внимание, что различие между последовательным и параллельным кодом никогда не заключалось в наличии await. Разница кроется в зависимостях между шагами. Когда одному этапу действительно нужен результат другого, последовательное ожидание является правильным решением:
const user = await fetchUser(userId);
const permissions = await fetchPermissions(user.role);
Когда такой зависимости нет, ожидание завершения каждой операции перед началом следующей приводит к задержкам и не обеспечивает большей точности. Поэтому полезный вопрос при анализе кода звучит не «Следует ли использовать Promise.all()?», а «Какие операции зависят от ранее полученных результатов, и какие могут выполняться одновременно?»
Promise.all() координирует результаты; он не отменяет выполнение операций
Открыв для себя Promise.all(), многие разработчики формируют новые представления о том, что происходит при сбое. Рассмотрим следующую версию:
const [user, projects, notifications] =
await Promise.all([
fetchUser(userId),
fetchProjects(userId),
fetchNotifications(userId),
]);
Если функция fetchProjects() сразу же возвращает ошибку, объединённое обещание также сразу же возвращает ошибку по этой причине, вместо того чтобы дожидаться остальных операций. Может показаться, что два других запроса теперь остановлены. Но это не так. Возврат ошибки объединённого обещания не отменяет ничего, что уже началось, как прямо указано в MDN. Остальные операции продолжают выполняться, а их результаты просто игнорируются.
При чтении это в основном приводит к расточительству ресурсов. При записи это может иметь серьёзные последствия:
await Promise.all([
updateProfile(),
writeAuditLog(),
sendWebhook(),
]);
Если функция writeAuditLog() возвращает ошибку, функция updateProfile() уже могла сохранить изменения, а функция sendWebhook() уже может находиться в процессе передачи данных. Обнаружение ошибки не отменяет ни одну из этих операций.
Это проблема проектирования системы, а не синтаксическая проблема. Если эти шаги должны либо успешно выполниться вместе, либо провалиться совместно, вам нужен настоящий механизм атомарности: транзакция базы данных для изменений в одной базе или, для удаленных систем, какая-то комбинация компенсирующих действий, идемпотентных операций и сохраняемого состояния рабочего процесса. Promise.all() обещает сообщить вам, когда группа обещаний будет решена. Он не обещает отката, отмены или того, что побочные эффекты произойдут как единое целое. Эти гарантии должны исходить откуда-то еще.
Связанным инструментом является Promise.allSettled(), который ждет каждый входной элемент и сообщает о результате каждого отдельно. Он полезен, когда вам нужно точно знать, какие шаги были успешными, но он также не обеспечивает отката.
try/catch видит только те отказы, которые проходят через него
Одной из причин популярности async/await является возможность обработки ошибок обещаний с помощью знакомых конструкций try/catch:
async function loadUser(userId) {
try {
const user = await fetchUser(userId);
return user;
} catch (error) {
console.error("Failed to load user", error);
throw error;
}
}
Когда ожидаемое обещание отклоняется, выражение await выбрасывает причину отклонения внутри функции, и находящийся рядом блок catch принимает её так же, как и синхронную исключение.
Это не означает, что try отслеживает каждую асинхронную операцию, запущенную внутри его скобок. Рассмотрим создание учётной записи:
async function createAccount(input) {
try {
await saveUser(input);
sendWelcomeEmail(input.email);
return { success: true };
} catch (error) {
console.error(error);
return { success: false };
}
}
saveUser() ожидается, поэтому его сбой попадает в блок catch. sendWelcomeEmail() — нет. Если он возвращает обещание, которое позже отклоняется, это отклонение никогда не попадает в этот поток управления, поскольку ничто не ожидает и не возвращает это обещание. К моменту сбоя при отправке электронного письма createAccount() уже может вернуть { success: true }, и сбой проявляется отдельно, обычно в виде неразобранного отклонения. В текущих версиях Node.js неразобранные отклонения по умолчанию приводят к завершению процесса, поэтому это не является лишь внешним проблемным аспектом.
Игнорирование оператора await не всегда является ошибкой. Иногда электронное письмо намеренно не включается в путь запроса. Однако в производственных решениях подобная работа обычно отправляется в долговечную очередь, а не запускается как обещание без наблюдения. Настоящая ошибка заключается в предположении, что блок try создаёт границу обработки ошибок для будущей асинхронной работы только потому, что вызов находится внутри него.
Та же ловушка существует и в месте вызова. Этот вызывающий код кажется защищённым, но на самом деле это обычный JavaScript без оператора await:
try {
loadDashboard(userId);
} catch (error) {
console.error("Dashboard failed");
}
loadDashboard() сразу возвращает обещание. Любая асинхронная ошибка приводит к отклонению этого обещания позже, после того как блок try уже завершился, поэтому блок catch так и не выполняется. Вызывающий код должен участвовать в цепочке обещаний:
try {
await loadDashboard(userId);
} catch (error) {
console.error("Dashboard failed");
}
В качестве альтернативы можно привязать обработчик отклонений с помощью .catch(). Основной принцип остается простым, если перестать рассматривать асинхронные функции как обычные функции, случайно содержащие await: их вызывающие стороны получают обещания, а ошибки передаются по этому контракту обещаний.
Отмена — это отдельный протокол
Представьте поле поиска, в котором пользователь вводит по одному символу за раз:
r
re
rea
reac
react
Простая реализация отправляет запрос при каждом нажатии клавиши, даже когда предыдущие запросы ещё находятся в ожидании:
async function search(query) {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`
);
return response.json();
}
В самом await нет ничего, что указывало бы на необходимость прекращения запроса к символу "r" из-за того, что теперь важен запрос к "react". Предыдущий запрос выполняется до конца, даже если интерфейс больше не нуждается в его результатах.
Для метода fetch отмена запроса осуществляется с помощью объекта AbortController и его свойства AbortSignal. Эта версия прерывает предыдущий запрос перед началом нового:
let controller;
async function search(query) {
controller?.abort();
controller = new AbortController();
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{
signal: controller.signal,
}
);
return response.json();
}
Объект контроллера предоставляет сигнал, на который прослушиваются совместимые API. Прерывание этого сигнала указывает fetch на необходимость остановки, что касается как самого запроса, так и чтения тела ответа. Следует учитывать один момент: прерванный запрос возвращает ошибку AbortError, поэтому тот, кто вызывает функцию search(), должен распознать эту ошибку и проигнорировать её, вместо того чтобы показывать её пользователю.
Фраза «совместимые API» имеет важное значение. Объекты Promise не предусматривают универсальной функции отмены, а оператор await не позволяет остановить процесс, в ожидании которого он находится. Отмена запроса возможна только в том случае, если вызываемая операция её поддерживает и передаёт сигнал тому компоненту, который выполняет фактическую работу.
Ваши собственные функции могут использовать тот же интерфейс, принимая сигнал и проверяя его между шагами:
async function processFile(file, { signal }) {
for (const chunk of file.chunks) {
signal.throwIfAborted();
await processChunk(chunk);
}
}
signal.throwIfAborted() выбрасывает причину прерывания, если была запросена отмена, поэтому цикл останавливается до обработки следующего фрагмента. Теперь отмена является частью интерфейса функции, а не чем-то, на что вызывающий код надеется от await. Для более тонкой настройки вы также можете передавать сигнал в processChunk(), чтобы длинный фрагмент мог остановиться на полпути.
Таймауты работают по той же логике. Использование Promise.race() для сопоставления операции с таймером позволяет вызывающему коду прекратить ожидание, но основная операция продолжается, если ей не было дано указание остановиться. Прекращение наблюдения и прекращение работы — это две разные вещи.
Локальное порядок не является глобальным порядком
Последовательное использование await действительно гарантирует порядок выполнения внутри одной функции:
await saveOrder(order);
await sendConfirmation(order);
sendConfirmation() вызывается только после того, как saveOrder() завершит свою работу. Однако эта локальная гарантия мало что говорит о всей системе в целом. Представьте два запроса на обновление одного профиля. Один из них отправляется:
await updateProfile({
name: "Umar",
});
Через несколько миллисекунд отправляется ещё один запрос:
await updateProfile({
name: "Umar Dev",
});
Каждый вызывающий код корректно ожидает обновления своего профиля. Ничто из этого не определяет, какой запрос достигнет базы данных последним, как будут происходить повторные попытки с обеих сторон, обрабатываются ли запросы разными серверами или могут ли устаревшие данные перезаписать более свежие.
Версия этой проблемы в браузере — это классическая гонка за данные. Запрос A начинается раньше запроса B, но завершается позже, и код, отображающий полученные результаты, выводит устаревшую информацию на экран:
const results = await search(query);
render(results);
Никакие дополнительные операторы await не смогут решить эту проблему. Приложению требуются правила, касающиеся релевантности или порядка: отмена старых поисков, маркировка запросов версией и удаление устаревших ответов, сравнение идентификаторов перед отображением результатов или обязательная проверка версий в месте хранения данных. Следует помнить, что await управляет порядком выполнения операций внутри одной функции; он не создает глобального порядка между независимыми асинхронными операциями.
Повторные попытки раскрывают то, чего никогда не обещал await
Именно при повторных попытках неполная картина ситуации становится проблематичной. Начнем с вызова для оплаты:
async function submitPayment(payment) {
const response = await fetch("/api/payments", {
method: "POST",
body: JSON.stringify(payment),
});
return response.json();
}
Предположим, запрос истекает, и разработчик добавляет простую повторную попытку:
try {
return await submitPayment(payment);
} catch {
return await submitPayment(payment);
}
Это предполагает, что неудачная попытка ничего не изменила. Таймаут означает лишь то, что клиент не получил ответ вовремя. Сервер мог списать средства с карты и потерять ответ, или соединение могло прерваться после того, как побочный эффект уже был реализован. Слепое повторение попыток может привести к двойной оплате у клиента.
await не может сообщить, безопасно ли повторять попытку. Обещание (promise) сообщает, привела ли данная попытка к успешному выполнению или отклонению, которое может заметить вызывающий код. Оно не указывает, выполнялась ли дистанционной системой необратимая операция до получения такого результата.
Для операций с побочными эффектами безопасность повторных попыток должна быть заложена в саму операцию, обычно с помощью идемпотентности. Клиент генерирует стабильный ключ один раз на каждую логическую попытку оплаты и отправляет его при каждом повторении:
await fetch("/api/payments", {
method: "POST",
headers: {
"Idempotency-Key": paymentAttemptId,
},
body: JSON.stringify(payment),
});
Ключ полезен только в том случае, если сервер его учитывает: он должен сохранять ключ вместе с результатом, и при повторном поступлении того же ключа возвращать первоначальный результат вместо повторной обработки. Ключ также должен оставаться неизменным при повторных попытках одной операции; генерация нового ключа для каждого запроса сводит на нет его цель. Реализация отличается в разных системах, но принцип остается прежним: семантика повторных попыток относится к самой операции и системам, выполняющим её побочные эффекты, а не к async/await. Обработка на стороне сервера рассматривается в статье «Понимание ключей идемпотентности в Node.js POST-эндпоинтах».
Из этого следует важная привычка при написании JavaScript-кода. Каждый раз, когда ожидаемый вызов терпит неудачу, разделяйте два утверждения: «Я не получил сигнал о успехе» и «операция определенно не произошла». Это не одно и то же утверждение.
Более простая и точная когнитивная модель
Чтобы правильно понимать механизм async/await, вам не нужно запоминать спецификацию ECMAScript. Вам достаточно простого правила: асинхронная функция возвращает обещание; её тело выполняется нормально до момента достижения оператора await; оператор await приостанавливает выполнение функции до тех пор, пока не будет получено значение от ожидаемого вызова, после чего запускается продолжение выполнения.
Всё остальное — это отдельный вопрос с собственным ответом:
- Несколько операций: когда начинается каждая из них и зависит ли она от другой? Это поможет понять, является ли последовательность выполнения намеренной или случайной.
try/catch то, что вы считаете.await не могут?Основные выводы
async/await преднамеренно прост в использовании. Он делает асинхронный поток управления более читаемым, что имеет огромную ценность, но именно эта читаемость заставляет код выглядеть более синхронным, чем он на самом деле является. Когда вы сталкиваетесь с await, не следует понимать это как «программа ждет здесь». Скорее рассматривайте это как сигнал: эта функция приостанавливает свою работу до поступления значения, поэтому необходимо определить, какие операции уже выполняются, что может произойти за это время и какие гарантии должен обеспечить ваш собственный дизайн. Этот вопрос соответствует тому, что на самом деле делает синтаксис, и помогает принять решения по проектированию, которые предотвращают ошибки в рабочей среде.
Связанные материалы
- Async/Await против Promises: В чём на самом деле разница — Объясняет реальные отличия в выполнении, использовании памяти и трассировке стека между async/await и Promises, а также когда следует использовать чистые API Promises.
- Распространённые заблуждения относительно async/await, вызывающие ошибки в продакшене — Рассматривает девять тонких недопониманий, связанных с async/await — от конкурентных ситуаций до неразобранных ошибок, — которые тайно ломают реальные JavaScript-приложения.