Главная / Статьи

Await видит лишь одно обещание: разработка API с использованием Async/Await, которые сами выполняют свою работу

Что на самом деле отслеживает JavaScript async/await, почему отдельные задачи, обратные вызовы, потоки и Promise.all ускользают от его контроля, и как разрабатывать Promises, которые однозначно указывают на завершение работы.

Издатель: REACTAPP.TOP13 мин чтения3003 слов

Источники: 90% of Developers Don’t Understand What JavaScript `await` Actually Waits For

Легко понимать await как «ожидать завершения этой функции», однако движок выполнения даёт гораздо более узкое обещание: он оценивает одно выражение, рассматривает результат как значение вида Promise и приостанавливает текущую async функцию до тех пор, пока это единственное значение не будет определено. Всё остальное зависит от того, как было получено это значение. Именно эта разница объясняет, почему код с тщательно расположенными ключевыми словами await всё равно может возвращаться слишком рано, работать медленнее, чем необходимо, игнорировать ошибки или использовать устаревшие данные. Пройдя через приведённые ниже примеры, вы сможете рассматривать любой await и задавать один важный вопрос: действительно ли этот Promise представляет ту работу, которая меня интересует?

Проблема: код с await, который кажется завершённым, но на самом деле нет

Ошибки в этой области имеют общую закономерность. Разработчик ожидает выполнения функции и предполагает, что всё, что она запустила, уже завершено. Он ожидает вызова метода массива и считает, что все асинхронные обратные вызовы уже выполнились. Он ожидает настройки таймера, подписки на событие или записи в поток и полагает, что основная операция завершена. Код кажется корректным, потому что там присутствует await.

Но await не имеет представления о «задаче», «запросе» или «всем, что запустила эта функция». Если выражение справа от него не охватывает весь жизненный цикл работы, ничто в самом ключевом слове не может помочь охватить его. Учитывая это, меняется подход к проектированию API, циклов, фоновых задач, кода, основанного на событиях, и обработке ошибок.

Вызов происходит раньше ожидания

Возьмём самую обычную строку в асинхронном JavaScript.

const user = await loadUser();

Среда выполнения не сначала обрабатывает оператор await, а затем вежливо просит функцию loadUser выполниться асинхронно. Она оценивает вызов loadUser() точно так же, как любой другой вызов функции. Этот вызов возвращает значение, которым может быть нативный объект Promise, пользовательский объект, способный к обработке результатов, обычный объект, строка, значение undefined или что-либо ещё. Только после этого оператор await начинает работу с полученным результатом.

В результате фактическая операция обычно начинается уже в момент вызова, ещё до того, как начнётся ожидание. Если вы вызываете две асинхронные функции и сохраняете их объекты Promise до того, как начнёте ожидать результатов хотя бы одной из них, обе операции начинаются практически одновременно:

const userPromise = loadUser();
const settingsPromise = loadSettings();

const user = await userPromise;
const settings = await settingsPromise;

Здесь loadUser() начинает свою работу и возвращает Promise, после чего loadSettings() начинает свою работу. Две строки с await просто собирают результаты по мере их поступления.

Сравните это с версией, которую большинство людей пишут по умолчанию:

const user = await loadUser();
const settings = await loadSettings();

Теперь функция loadSettings() вызывается только после того, как первое обещание будет выполнено и функция возобновит свою работу. Два запроса выполняются один за другим.

Обратите внимание, что сам оператор await не принимает решений относительно параллелизма — решение зависит от порядка вызовов функций. Простой способ запомнить это: вызов асинхронной функции запускает операцию, а await определяет момент, когда текущая функция будет продолжена. Одно предупреждение касательно первого подхода: если settingsPromise отклоняется в то время, когда вы всё ещё ожидаете выполнения userPromise, это отклонение временно остаётся без обработки, что некоторые среды выполнения интерпретируют как ошибку. Promise.all избегает этой проблемы, отслеживая оба обещания одновременно. Для более подробного сравнения этих подходов см. выбор между Promise.all, Promise.race и последовательными await.

Обработка одного обещания, а не всех побочных эффектов

Рассмотрим функцию создания учётной записи, которая сохраняет запись, отправляет письмо с приветствием без ожидания ответа и без его возврата, а затем возвращает учётную запись.

async function createAccount(input) {
  const account = await accountRepository.save(input);

  sendWelcomeEmail(account.email);
  return account;
}

Затем звонящий пишет очевидную строку:

const account = await createAccount(input);

Этот вызов возобновляется, когда Promise, возвращаемый функцией createAccount, выполняется, что происходит после завершения сохранения данных и возврата функции. Он ничего не говорит о почтовом адресе. Функция sendWelcomeEmail создала отдельную асинхронную операцию, чей Promise так и не был связан с тем, который возвращает createAccount, поэтому с точки зрения вызывающего кода такая операция не существует.

В этом и заключается суть недопонимания. Оператор await следует только за цепочкой Promises, доступными из значения, которое ему было передано. Он не сканирует все операции, запущенные функцией, и уж точно не ждет, пока процесс станет неактивным.

Практические последствия имеют три аспекта:

  • Если Promise с почтовым адресом отклоняется, блок try/catch в вызывающем коде никогда этого не увидит.
  • Если процесс прерывается, письмо может просто так и не быть отправлено.
  • Если обработчик HTTP отвечает с кодом успеха, это не гарантирует ничего надежного относительно выполнения отдельной задачи.
  • Все это не означает, что каждый побочный эффект должен завершаться в рамках одного запроса. Многие операции действительно должны выполняться в фоновом режиме. Настоящая проблема заключается в неопределенности ответственности. Важные задачи следует возвращать и ожидать их завершения, явно обозначать их как необязательные или передавать в надежную очередь или систему задач. Создание Promise и простое игнорирование его состояния — это не фоновая обработка; это лишь надежда.

    Асинхронная функция может завершиться, пока ее внутренние операции все еще выполняются

    Существует связанное предположение, что ключевое слово async заставляет JavaScript отслеживать каждую асинхронную операцию, запущенную внутри функции. На самом деле это не так. Функция async генерирует ровно один объект Promise, и этот Promise связан только с тем, что функция явно возвращает или ожидает в рамках собственного потока управления. Все остальное может продолжать выполняться после того, как этот Promise будет реализован.

    Классическим примером служит метод forEach:

    async function processUsers(users) {
      users.forEach(async (user) => {
        await updateUser(user);
      });
    }
    

    Каждое вызов асинхронного кallback-функции возвращает объект Promise, но метод forEach просто игнорирует эти значения. Он не собирает их, не ждёт их и не отображает их; вместо этого он возвращает undefined. Поэтому функция processUsers почти мгновенно достигает своей закрывающей скобки, и поскольку асинхронная функция, завершающаяся ею, разрешается с значением undefined, вызывающий код продолжает работу задолго до того, как любые обновления будут завершены:

    await processUsers(users);
    console.log("All users processed");
    

    Утверждение в логе просто неверно. Запрашивающий код ждал завершения выполнения функции processUsers при планировании обратных вызовов, а не выполнения самих обратных вызовов.

    Чтобы это исправить, дочерние обещания необходимо связать с родительской операцией. В зависимости от ситуации это может означать использование цикла for...of с оператором await для строгой последовательности, вызов Promise.all(users.map(updateUser)) для полной параллельности или применение ограничителя конкурентности, когда объем коллекции может быть настолько велик, что приведет к перегрузке базы данных или API. Более общее правило распространяется далеко за пределы forEach: родительское обещание ждет только той работы дочерних обещаний, которая является частью его цепочки. Инструменты вроде правил no-floating-promises и no-misused-promises в typescript-eslint автоматически выявляют многие из этих ошибок.

    Обратные вызовы недоступны для оператора await

    Значительная часть экосистемы JavaScript существовала ещё до Promises и сообщала о завершении работы с помощью обратных вызовов. Это приводит к коду вроде этого:

    await setTimeout(() => {
      console.log("Timer completed");
    }, 1000);
    

    Он не останавливается ни на секунду. Функция setTimeout регистрирует обратный вызов и сразу возвращает идентификатор таймера (число в браузерах, объект Timeout в Node.js). Этот идентификатор не является объектом Promise, описывающим момент выполнения таймера. Команда await принимает обычное значение, считает его уже готовым к использованию и продолжает выполнение функции на следующем микрозадании, задолго до того, как произойдет вызов обратной функции.

    Аналогичная проблема возникает с обработчиками событий, устаревшими API файловой системы, драйверами баз данных, потоками и реализованными вручную SDK. Если функция возвращает объект подписки, идентификатор запроса, логическое значение или эмиттер событий, ожидание этого значения не превращает последующий обратный вызов в завершение объекта Promise.

    Чтобы использовать механизм await для обработки поведения, основанного на обратных вызовах, необходим объект Promise, который будет решаться или отклоняться именно тогда, когда обратный вызов сообщит о результатах:

    function delay(milliseconds) {
      return new Promise((resolve) => {
        setTimeout(resolve, milliseconds);
      });
    }
    
    await delay(1000);
    

    Теперь ожидаемое значение действительно соответствует событию, которое вас интересует: Promise остается в состоянии ожидания до тех пор, пока таймер не вызовет метод resolve. Современные платформы часто уже предоставляют подобные обёртки; например, Node.js предлагает Promise-ориентированный вариант setTimeout в модуле node:timers/promises и функцию util.promisify для функций в стиле callback.

    Это поднимает дизайнерскую проблему, скрытую за каждым оператором await: представляет ли возвращаемое значение завершение операции или лишь её регистрацию, запуск или указатель на работу, которая всё ещё в процессе? Если это один из последних вариантов, ожидание этого значения не приведёт к получению результата, который вы себе представляете.

    Thenables, обычные значения и граница микозадач

    await не требует наличия настоящего экземпляра Promise. Если значение является объектом, способным выполнять действия (т.н. thenable), то есть объектом с вызываемым методом .then, await использует механизм выполнения этого объекта. Именно это позволяет библиотекам, совместимым с Promise, и пользовательским асинхронным объектам напрямую встраиваться в функции async, а также делает возможным асинхронное поведение объектов без необходимости использования new Promise.

    Для значений, которые не являются thenable, await всё равно вводит асинхронный шаг:

    async function example() {
      console.log("before");
      await 42;
      console.log("after");
    }
    
    console.log("start");
    example();
    console.log("end");
    

    Этот код выводит start, before, end, а уже затем after, хотя значение 42 было доступно сразу. Функция возвращает результат в момент await, синхронный код после вызова продолжает работу, а оставшаяся часть example выполняется позже в качестве микозадачи.

    Следовательно, ожидание простого значения не является синхронным процессом. Оно может изменить порядок выполнения кода по отношению к окружающим синхронным операциям и другим очередным микозадачам, и в сценариях, чувствительных к порядку выполнения, ненужное await создаёт дополнительную границу планирования. В плане производительности задержка обычно незначительна, но концептуальная идея остаётся прежней: код после await никогда не продолжает выполняться на той же непрерывной стеке вызовов.

    Отклонение — часть процесса обработки

    Люди часто говорят, что await ожидает решения Promise. Более точно сказать, что он ожидает завершения работы Promise, причем к завершению относится и отклонение. Когда ожидаемый Promise отклоняется, await выбрасывает причину отклонения внутри текущей async функции, и именно это позволяет использовать обычные конструкции try/catch для обработки асинхронных ошибок:

    try {
      const order = await loadOrder(orderId);
      return order;
    } catch (error) {
      logger.error(error);
      throw error;
    }
    

    Эта защита действует только в том случае, если отклонённый Promise действительно ожидается внутри блока try. Promise, созданный и оставленный без обработки, полностью выходит за рамки этой защиты:

    try {
      loadOrder(orderId);
    } catch (error) {
      console.error("This does not catch the Promise rejection");
    }
    

    Здесь сам вызов прошёл успешно: он вернул Promise без возникновения исключения. Отклонение появляется позже, через этот Promise, после того как блок try уже давно завершил свою работу.

    Это особенно опасно, когда await намеренно опускается, чтобы сделать процесс «неблокирующим», в то время как разработчик по-прежнему считает, что окружающие его блоки try/catch защищают его. Это не так. Каждая асинхронная операция требует четкого ответственного за её сбой: вызывающего кода, который её ожидает или возвращает, явного обработчика .catch или надежной системы, берущей на себя ответственность. Promise без ответственного лица — это не более быстрая архитектура; это путь возникновения ошибки, отрезанный от кода, который понимает, что пошло не так. Статья о том, что на самом деле гарантирует async/await рассматривает ту же идею ответственности с точки зрения обработки ошибок.

    Promise.all координирует результаты, а не итоги

    Promise.all обычно описывается как «ожидание нескольких Promises одновременно». Это подходит в качестве первой приблизительной характеристики, но важны и детали. Он возвращает новый Promise, который сбывается, когда сбываются все входные обещания, и отклоняется сразу же при отклонении хотя бы одного из них.

    const results = await Promise.all(tasks);
    

    Даже здесь у оператора await есть единая цель: агрегированное обещание, созданное функцией Promise.all.

    Когда какая-либо из задач завершается с ошибкой, весь агрегат тоже завершается с этой ошибкой, и функция переходит к обработке сбоя. Остальные задачи не отменяются; они продолжают выполняться и могут вызывать побочные эффекты даже после того, как вызывающий код уже считает весь пакет неудачным. Представьте пакет, содержащий несколько внешних обновлений: одно из них сразу же терпит неудачу, вызывающий код сообщает о «неудаче пакета», тогда как остальные обновления успешно выполняются, в результате чего система меняется лишь частично.

    Функция Promise.all обеспечивает координацию результатов, но не поддерживает транзакции или отмену задач. Выбирайте инструмент, соответствующий необходимой семантике:

    • Если допустима частичная удача, соберите все результаты (например, с помощью Promise.allSettled) и проверьте каждый из них.
    • Если требуется режим «всё или ничего», основные операции должны иметь настоящий механизм отката или компенсации.
    • Если оставшаяся работа должна прекратиться после первой ошибки, каждая операция должна поддерживать отмену, обычно с помощью общего объекта AbortSignal.

    Объект Promise не может отменить уже начатую работу. Его состояние определяется только тем, что позволяет его структура.

    Стримы и ресурсы с длительным выполнением

    Стримы также часто вызывают путаницу. Метод write стрима может возвращать логическое значение, указывающее, может ли внутренний буфер принять дополнительные данные. Вызов .pipe() возвращает целевой стрим. Парсер, работающий на основе событий, может вернуть результат сразу после подключения слушателей. Ожидание любого из этих значений не означает ожидания завершения работы стрима, его очистки, закрытия или записи всех байтов.

    Завершение обычно сигнализируется с помощью таких событий, как finish, end, close или error, и для надежного ожидания его эти события должны преобразовываться в Promise. Многие библиотеки, а также сам Node.js через pipeline в модуле node:stream/promises, предоставляют инструменты, которые генерируют единственный Promise, чье выполнение охватывает весь поток обработки, включая возможные ошибки.

    Этот принцип применим к любым ресурсам с длительным сроком существования:

    • Начало загрузки — это не её завершение.
    • Открытие соединения — это не завершение запроса.
    • Запланирование задачи — это не её выполнение.
    • Добавление сообщения в очередь — это не его обработка.

    Значение, возвращаемое API, определяет, что может наблюдать await. Прежде чем доверять await, выясните, какую границу жизненного цикла на самом деле обозначает его Promise.

    Завершение обработки не означает актуальность результата

    Представьте поле поиска, в которое пользователь вводит «JavaScript», а затем быстро меняет его на «TypeScript». Отправляются два запроса. Ответ на запрос к TypeScript приходит первым, и на экране отображаются правильные результаты; затем приходит более медленный ответ на запрос к JavaScript, который перезаписывает их.

    Каждая функция в этом потоке обработки правильно использовала await. Ошибка возникает потому, что ожидание результата и его актуальность — это разные аспекты. await возобновляет выполнение, когда Promise выполняется; он не проверяет, нужен ли результат кому-то ещё, заменил ли его более новый запрос или теперь компонент отображает другую запись. Код, кажущийся последовательным, лишь упорядочивает шаги внутри одного вызова, при этом несколько вызовов одной и той же функции могут свободно перекрываться друг другом.

    Для решения этой проблемы необходимо четкое понимание того, какая операция в данный момент актуальна. Можно отменять устаревшие запросы, сравнивать каждый ответ с идентификатором последнего запроса, использовать проверки версий или опираться на слой загрузки данных, который сортирует ответы по параметрам, которые их генерировали. Оператор await не может определить, какой результат относится к текущему моменту. Для пошагового руководства, сосредоточенного на React, см. как устранить ситуации соревнования, которые невозможно решить с помощью дебаунсинга.

    То, что запрос выполнен, не означает, что система согласована

    Запись в базу данных может завершиться успешно, даже если реплики ещё не обновились. Опубликование сообщения обычно считается завершённым после того, как брокер его принимает, а не после того, как им воспользовался потребитель. Отправка электронного письма считается завершённой, когда провайдер принимает запрос, а не когда кто-либо его получает. Легко интерпретировать оператор await как гарантию большего количества подтверждений, чем на самом деле предоставляет API.

    await publishEvent(event);
    

    Эта строка может гарантировать, что событие дошло до брокера. Однако это не обязательно означает, что проекции, уведомления или поисковые индексы ниже в цепочке уже обработали его.

    await sendEmail(message);
    

    Аналогичным образом, это может означать лишь то, что поставщик принял сообщение. Это не гарантирует его доставки, того, что получатель его прочитал, или даже того, что поставщик сохранил его после дальнейшей обработки.

    То, что означает состояние «исполнено», определяется контрактом API, генерирующего Promise, и оператор await не может усилить этот контракт. Проблемы возникают, когда следующий код предполагает, что уже произошло более значительное изменение состояния, например, когда конечная точка публикует событие и сразу же запрашивает модель для чтения, управляемую потребителем, ожидая, что новые данные уже там. Promise был исполнен корректно; просто архитектура ещё не успевает за ним. В зависимости от продукта правильный дизайн может возвращать статус «в ожидании», читать данные из модели для записи, ждать явного подтверждения или использовать концепцию конечной согласованности в интерфейсе.

    Дизайн Promise, соответствующий их названию

    Большинство недопониманий, связанных с await, исчезает, как только становится привычкой задавать один вопрос: представляет ли значение, которое я ожидаю, завершение всей операции, которая меня интересует? Рассмотрев предыдущие примеры, ответ однозначно «нет» в следующих случаях:

    • функция создаёт дочерние Promises, не возвращая их и не ожидая их результата;
    • метод массива игнорирует Promises, возвращаемые его асинхронными обратными вызовами;
    • API возвращает обработчик и сообщает о завершении через события;
    • источник данных завершает работу раньше, чем потребители выполнят свою часть.

    Наличие await лишь подтверждает, что текущая функция приостановилась до тех пор, пока значение, похожее на Promise, не будет определено. Оно не гарантирует, что связанная работа завершена, что побочные эффекты произошли ровно один раз, что более ранняя работа отменена, что ошибки остаются связанными или что результат всё ещё актуален.

    Вот почему правильная реализация асинхронного кода во многом является проблемой проектирования API. Объект Promise, возвращаемый функцией, должен стать готовым в момент, соответствующем задаче, о которой говорит её название. Функция с именем saveDocument должна четко указывать, отвечает ли её Promise за локальное хранение данных, удалённую синхронизацию или и то, и другое. Функция с именем scheduleReport допускает завершение работы при принятии задания, а не при его выполнении, но её название и тип возвращаемого значения должны ясно указывать на это, например, путём возврата идентификатора задания, который может использоваться вызывающим кодом для мониторинга.

    Что может и чего не может обещать оператор await

    • await оценивает одно выражение и ждёт, пока это значение станет готовым; он ничего не знает о «задании» в целом.
    • Место вызова асинхронных функций определяет, будет ли работа выполняться параллельно; await определяет лишь момент возобновления выполнения.
    • Работа, которая не возвращается и не ожидается, отсоединяется: её ошибки, время выполнения и завершение остаются невидимыми для вызывающего кода.
    • API для обратных вызовов, событий и потоков должны быть обёрнуты в Promises, которые фиксируют реальное завершение, прежде чем await будет иметь смысл.
    • Promise.all не отменяет и не откатывает выполнение; выбирайте allSettled, механизм компенсации или AbortSignal в зависимости от необходимой семантики.
    • Завершение, актуальность и согласованность — это три разных гарантии, причём только первая обеспечивается с помощью await.
    • Рассматривайте каждый возвращаемый вами Promise как контракт, и пусть его фиксация означает именно то, что подразумевается названием функции.

    Связанная литература

  • Сериализация асинхронных операций в JavaScript с использованием Promise mutex: Используйте цепочку обещаний AsyncLock, чтобы клиентские запросы выполнялись по принципу FIFO и состояние интерфейса оставалось упорядоченным без необходимости в встроенных блокировках потоков.