async и await: зависимости, параллельное ожидание и обработка ошибок
Автор: Казачкин Даниил Михайлович · Обновлено
Синтаксис async/await позволяет читать асинхронную операцию как последовательность шагов, сохраняя модель Promise. Главный вопрос при его использовании — какие действия зависят друг от друга и какие могут выполняться одновременно.
Среда и граница ожидания
Первый блок выполняется как async-demo.mjs в Node.js 22 или новее командой node async-demo.mjs. Он не обращается к сети и не требует библиотек. Все источники данных объявлены рядом, поэтому результат проверяется без внешних условий. Перед уроком разберите состояния Promise, возврат из then и обработку отклонений.
Функция с async всегда возвращает Promise. Обычный return задаёт его успешное значение, а неперехваченное исключение — причину отклонения. До первого await тело выполняется синхронно. На ожидании приостанавливается продолжение конкретной функции; остальная программа получает возможность работать. Это не блокировка всего потока и не запуск оставшейся части в отдельном потоке.
Полный пример: независимые данные и зависимый расчёт
async function loadProfile() {
return { name: 'Ира', dailyMinutes: 30 };
}
async function loadLessons() {
return [{ minutes: 20 }, { minutes: 25 }];
}
async function buildPlan() {
const [profile, lessons] = await Promise.all([
loadProfile(),
loadLessons(),
]);
const total = lessons.reduce((sum, lesson) => sum + lesson.minutes, 0);
return { name: profile.name, days: Math.ceil(total / profile.dailyMinutes) };
}
console.log('before');
const result = buildPlan();
console.log(result instanceof Promise);
console.log('after');
console.log(JSON.stringify(await result));Ожидаются before, true, after, {"name":"Ира","days":2}. Обе загрузки вызываются до общего ожидания. Расчёт числа дней начинается после получения обоих результатов. Даже немедленно возвращённые значения проходят через Promise, поэтому строка after появляется раньше результата плана. В реальном приложении функции загрузки могут использовать API, но зависимость вычисления от двух наборов данных останется той же.
Если сначала написать await loadProfile(), а затем await loadLessons(), второй вызов начнётся только после первого ожидания. Иногда это требуется: например, второй URL содержит идентификатор из первого ответа. Но для независимых данных такая последовательность добавляет ненужное ожидание. Сначала нарисуйте зависимости в обычном списке действий, затем выбирайте расположение await.
Конкурентность не равна неограниченному запуску
Promise.all объединяет результаты уже вызванных операций. Он не создаёт вычислительные потоки и не ограничивает количество запросов. Для короткого фиксированного набора независимых данных это удобно. Для списка из десятков тысяч элементов нужен отдельный контроль нагрузки: очередь работников, порции или серверная пакетная операция.
Не запускайте несколько потенциально отклоняющихся Promise, а затем не ожидайте их по одному без заранее установленной обработки. Пока функция ждёт первый, второй может уже отклониться без обработчика. Объединение через Promise.all или allSettled устанавливает наблюдение за всеми участниками сразу. Выбор между ними зависит от того, нужна ли общая успешность или отчёт о каждом исходе.
Последовательный for...of с await не является ошибкой стиля. Он верно выражает порядок, когда каждый шаг должен завершиться перед следующим: например, применение зависимых изменений. Ускорять такую цепочку конкурентным запуском можно только после проверки бизнес-условий. Быстрая отправка команд в неверном порядке не улучшает производительность полезной операции.
try, catch и return await
Обычный try/catch перехватывает отклонение, если выполнение действительно ожидает Promise внутри блока. Вызов без await возвращает управление раньше будущего отказа. Особенно легко ошибиться в обёртке, которая возвращает Promise прямо из try: локальный catch не увидит его позднее отклонение. Если обёртка должна обработать этот отказ, используйте return await внутри её блока.
async function readRemote() {
throw new Error('temporarily unavailable');
}
async function readWithFallback() {
try {
return await readRemote();
} catch (error) {
return { cached: true, reason: error.message };
}
}
console.log(JSON.stringify(await readWithFallback()));Вывод: {"cached":true,"reason":"temporarily unavailable"}. В этом упражнении запасной результат является частью контракта. В другой задаче маскировать ошибку кэшем нельзя; тогда добавьте контекст и передайте отказ вызывающему коду. Не используйте пустой catch как универсальный способ избавиться от сообщения об ошибке.
finally подходит для освобождения ресурса или завершения локального индикатора. Но в интерфейсе несколько запросов могут пересекаться: finally старого запроса не должен выключать индикатор нового. Для этого требуется идентификатор актуальной операции или другое явное правило владения состоянием. Сам await не защищает общие переменные от устаревшего продолжения; эту гонку подробно разберём с fetch и отменой.
Массивы и потерянное ожидание
forEach не ждёт Promise, возвращённые callback. Запись await items.forEach(async (...) => ...) ожидает возвращённый undefined, а не завершение обработчиков. Для последовательной обработки используйте цикл, для независимой — создайте массив Promise через map и передайте его в подходящее объединение. То же внимание требуется фильтрам: Promise не является готовым логическим результатом.
Размещайте await там, где нужен результат или завершение, а не перед каждым выражением. Ожидание обычного значения тоже разделяет исполнение на продолжения и может сделать порядок менее очевидным. Для уступки браузерной отрисовке повторение await Promise.resolve() не подходит: оно остаётся в механизме микрозадач. Различие между ожиданием и возможностью нарисовать кадр объясняется в следующем уроке.
Практика: строго последовательный журнал
Требуется обработать три шага по порядку и вернуть журнал только после последнего. Каждый шаг здесь асинхронный, но не использует таймер; это делает проверку порядка независимой от скорости компьютера. Первый и второй шаг не должны начать запись завершения одновременно.
async function runSteps(names) {
const log = [];
for (const name of names) {
log.push('start:' + name);
await Promise.resolve();
log.push('done:' + name);
}
return log;
}
console.log((await runSteps(['a', 'b', 'c'])).join(','));Получится start:a,done:a,start:b,done:b,start:c,done:c. Теперь мысленно замените цикл на Promise.all(names.map(async ...)): начала будут записаны раньше завершений. Это не обязательно плохо, но представляет другой контракт. Проверка журнала показывает разницу непосредственно, без ненадёжных утверждений о точном числе миллисекунд.
Частые вопросы
Можно ли использовать await на верхнем уровне?
Да, в ES module, как в файлах этого урока. В обычном скрипте нужен асинхронный контекст. При переносе кода сначала проверьте режим файла и поддержку среды, а не добавляйте случайные обёртки вокруг каждой строки.
Почему try/catch не поймал ошибку callback таймера?
Таймер вызывает функцию позднее, вне синхронного исполнения исходного try. Представьте завершение через Promise и ожидайте его либо обработайте ошибку внутри самого callback. Область текста вокруг регистрации не равна времени выполнения обработчика.
Как понять, что ошибка обработана в правильном месте?
Обработчик должен знать, какое действие возможно дальше: повторить, показать отказ, вернуть допустимый запасной результат или освободить ресурс. Если у уровня нет такого решения, ему часто следует добавить контекст и передать ошибку выше, сохранив наблюдаемость всей операции.
Связанные исследования
- Почему Promise опережает таймер: проверяем модель выполнения JavaScript — Причинная модель очередности Promise и таймеров: границы ECMAScript и HTML, контролируемые трассы, готовность реакции и ограничения выводов о времени исполнения.