Главная / Статьи / Распространённые заблуждения относительно async/await, приводящие к ошибкам в производственной среде

Распространённые заблуждения относительно async/await, приводящие к ошибкам в производственной среде

Объясняются девять тонких недопониманий в использовании async/await — от ситуаций конкурентного доступа до неразобранных ошибок — которые тайно разрушают реальные JavaScript-приложения.

3620 слов

В течение долгого времени многие разработчики считают, что хорошо понимают async/await, просто потому что умеют им свободно пользоваться. Знание того, что функция async всегда возвращает обещание, осознание необходимости ставить await перед вызовом, обращающимся к сети, и понимание того, что конструкция try/catch помогает более эффективно справляться с асинхронными ошибками, кажутся достаточными для полного владения этим инструментом. По сравнению с кодом, наполненным кallback-функциями, такой стиль выглядит более упорядоченным, и его читаемость почти сопоставима с обычным синхронным JavaScript: берем пользователя, загружаем его учетную запись, обновляем интерфейс и ловим любые ошибки, возникающие на этом пути. Синтаксис кажется настолько интуитивным, что легко забыть задуматься о математической модели, стоящей за ним.

Настоящая проблема заключается в том, что такое поверхностное владение языком покрывает лишь форму синтаксиса async/await, но не основные правила асинхронного управления. Часто await рассматривают так, будто он замораживает всю программу, предполагают, что вызов асинхронной функции автоматически связывает её результат с вызывающим кодом, и считают, что код, написанный в линейном, сверху-вниз стиле, не может возникнуть в конфликте с самим собой. В небольших демо-примерах такие предположения редко приводят к заметным проблемам, поскольку одновременно выполняется только одна операция, ответы приходят быстро, а имитируемые данные обрабатываются в предсказуемом порядке. Настоящие производственные системы гораздо менее терпимы к подобным ошибкам.

Выявляемые в итоге ошибки не похожи на типичные ошибки асинхронной обработки, описанные в учебниках. Функция возвращает результат до того, как некоторый побочный эффект фактически завершится. Устаревший запрос перезаписывает состояние, установленное более новым запросом. Блок try/catch не позволяет перехватить ошибку. Пакетная задача запускает больше одновременных операций, чем система способна обработать. Каждое обещание по отдельности кажется совершенно нормальным — однако весь рабочий процесс в целом ведет себя совсем не так, как планировалось изначально.

На полное усвоение этого принципа может уйти много времени: async/await не устраняет конкурентности в JavaScript. Он лишь предоставляет отдельной асинхронной функции более понятный способ приостановки работы и её возобновления. Все остальное по-прежнему зависит от того, какие обещания возвращаются, какие ожидаются, какие игнорируются, какие отменяются или пытаются выполниться заново, а также от того, какие из них могут изменять общее состояние в процессе выполнения.

Я думал, что await приостанавливает несколько функций одновременно

Частое первоначальное заблуждение довольно простое. Каждый раз, когда появляется await, возникает соблазн представить, что он приостанавливает всё, что находится рядом с ним. JavaScript никогда не блокирует вкладку браузера или процесс Node.js, но легко ошибочно предположить, что окружающая логика каким-то образом будет ждать своей очереди.

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

async function loadProfile() {
  console.log("Loading profile");

const profile = await fetchProfile();
  console.log("Profile loaded");
  return profile;
}
console.log("Before");
loadProfile();
console.log("After");

Внешний код ничего не ждёт лишь потому, что в функции loadProfile присутствует await. При вызове функции она сразу же запускается и возвращает обещание. Если только тот, кто её вызвал, не решит ожидать это обещание или вернуть его, выполнение просто продолжается без паузы.

Это может показаться незначительной технической деталью, но её последствия выходят далеко за рамки просто неправильно отсортированных логов консоли. Обработчик запросов может отправить свой ответ, пока запись в журнал аудита всё ещё находится в процессе выполнения. Инструмент командной строки может завершить работу, пока запись файла ещё не завершена. Тест может сообщить о успехе до того, как будут выполнены утверждения, находящиеся внутри асинхронного обратного вызова. Асинхронная функция ведёт себя ровно так, как и предусмотрено — настоящая проблема заключается в том, что вызывающий код никогда не связывает завершение своей работы с завершением асинхронного вызова.

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

Я вызывал асинхронные функции, не определив, кто будет отвечать за их завершение

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

Настоящая проблема не в том, что задача выполняется независимо, а в том, что никто на самом деле не определил, кто будет отвечать за её результат.

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

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

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

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

Обращение с forEach, как будто он понимает обещания

Один из первых асинхронных паттернов, который тихо нарушает привычную когнитивную модель, — это использование forEach вместе с асинхронным обратным вызовом. Синтаксис кажется совершенно разумным, из-за чего легко упустить из виду тот факт, что внешняя функция завершается раньше, чем будет выполнена какая-либо реальная работа.

async function saveUsers(users) {
  users.forEach(async (user) => {
    await saveUser(user);
  });

  console.log("All users saved");
}

Сообщение об завершении может появиться до того, как будет сохранена хотя бы одна запись пользователя. Причина в том, что forEach вызывает функцию-обратный звонок, но игнорирует её результат. Асинхронная функция-обратный звонок всегда возвращает обещание, поэтому каждый вызов тихо создаёт такое обещание, к которому никто не сохраняет ссылку. Поскольку нет ничего, на что можно было бы ждать, внешняя функция сразу же завершается, независимо от того, что ещё делают её внутренние вызовы.

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

Та же проблема возникает при использовании map, filter и reduce. Использование map с асинхронным кallback возвращает массив, полный ожидающихся обещаний, а не массив окончательных значений. filter никогда не ждет выполнения асинхронного условия, поэтому он оценивает сами объекты обещаний — которые всегда считаются истинными — вместо результатов, которые они в конечном итоге должны вернуть. Возможно создать асинхронную версию reduce, которая будет работать корректно, но она обычно сложна в понимании, поскольку аккумулятор сам по себе является обещанием, которое необходимо тщательно разобрать при каждой итерации.

Как только это становится ясным, написание корректного кода перестает сводиться к запоминанию того, какой метод массива является «допустимым», и превращается в выбор модели выполнения, которая действительно необходима для задачи. Когда каждый шаг действительно зависит от завершения предыдущего, цикл for...of с использованием await внутри напрямую отражает эту цель. Когда шаги независимы и могут выполняться одновременно, преобразование их в массив обещаний и ожидание их с помощью Promise.all часто является правильным решением. А когда необходимо ограничить параллельность, ни простой цикл, ни неограниченный Promise.all не справятся с задачей.

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

Предположение о том, что Promise.all ускоряет работу без рисков

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

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

Promise.all сам по себе не ограничивает частоту выполнения операций. Большая часть работ начинается сразу после создания обещаний, поэтому обработка нескольких тысяч записей может привести к одновременной отправке нескольких тысяч запросов к базе данных или внешних запросов. Такой код может казаться работоспособным при обработке небольшого локального набора данных, но затем может перегрузить пулы соединений, нарушить лимиты количества запросов или исчерпать память при работе с большими объемами данных в производственных условиях.

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

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

Прежде чем использовать Promise.all, полезно задаться другими вопросами. Сколько операций на самом деле безопасно выполнять одновременно? Являются ли они действительно независимыми друг от друга? Что должно означать одно сбой для остальных задач в группе? Существует ли способ остановить уже начатую работу? В случае повторной попытки могут ли элементы, которые уже были завершены, дублироваться? Действительно ли вызывающему коду нужен каждый результат, или будет приемлем честный частичный успех?

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

Предположение о том, что код, выполняемый сверху вниз, не может сталкиваться с конкурентными ситуациями

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

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

Аналогичная проблема возникает и на фронтенде. Сначала выполняется поиск по старому запросу, затем — поиск по новому. Новый запрос совпадает по времени выполнения и правильно обновляет интерфейс. Старый запрос завершается позже и перезаписывает этот корректный состояние устаревшим результатом. Каждый вызов ожидал своего запроса к данным ровно так, как и планировалось — но отсутствует правило, определяющее, какой из запросов всё ещё имеет право обновлять интерфейс.

Это важно иметь в виду для каждого оператора await, находящегося между чтением состояния и выполнением действий с ним. Пауза — это не просто незначительный промежуток времени; это период, в течение которого могут выполняться другие операции, что может опровергнуть предположения, сделанные функцией непосредственно перед паузой.

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

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

Ожидание того, что try/catch поймает операцию, которая никогда не была ожидана

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

try {
  sendAnalyticsEvent(event);
} catch (error) {
  logError(error);
}

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

Блок catch работает только в том случае, если обещание ожидается прямо внутри него:

try {
  await sendAnalyticsEvent(event);
} catch (error) {
  logError(error);
}

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

Та же проблема возникает внутри функций-возврата и обработчиков событий. Асинхронная функция-возврат может отклониться внутри себя, но если код, её вызывающий, никогда не ждёт и не проверяет возвращаемое ею обещание, это отклонение теряется без следа. Оборачивание места вызова в блоки try/catch не помогает, поскольку отклонение связано с обещанием, за которым ничего в текущем контексте не наблюдает.

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

Путаница между отменой и обратным действием

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

Это не гарантирует, что работы, уже переданные на сервер, будут отменены.

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

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

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

Отмена касается вопроса о том, нужен ли результат ещё. Она не касается вопроса о том, остаётся ли основная операция согласованной.

Запись асинхронной синтаксиса до определения рабочего процесса

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

Более полезные вопросы возникают раньше. Нужно ли вызывающему коду ждать завершения операции, прежде чем переходить дальше? Могут ли несколько задач безопасно выполняться одновременно? Имеет ли значение их относительный порядок? Как правильно реагировать, когда некоторые операции успешны, а другие — нет? Безопасно ли пытаться выполнить операцию снова? Кто отвечает за обработку ошибок? Возможно ли, что устаревший результат всё равно заменит общее состояние? И когда наступает тайм-аут, означает ли это сбой операции или просто то, что конкретный вызывающий код перестал ждать?

Как только на эти вопросы найдены ответы, сам JavaScript обычно реализуется без особых трудностей.

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

Самая сложная часть — это правильное размещение оператора await. Необходимо определить, что именно означает «завершение» на каждом этапе выполнения операции.

Знание ключевого слова, но не знание синтаксиса

Много лет неправильное использование async/await связано скорее не с забыванием принципов работы синтаксиса, а с тем, что он заставляет асинхронный код выглядеть гораздо более последовательным, локальным и предсказуемым, чем он на самом деле является.

Вид элемента await заставляет предполагать, что всё вокруг него также приостановлено. Легко упустить из виду вызов асинхронной функции без определения того, кто будет отвечать за её окончательное выполнение. Использование синхронных методов массивов с асинхронными обратными вызовами приводит к тому, что они воспринимаются как взаимодействующие друг с другом, хотя на самом деле это не так. Обращение к методу Promise.all без учёта нагрузки или того, что произойдёт, если только некоторые обещания сработают, — это распространённая, но рискованная привычка. Доверие к коду, который выполняется сверху вниз, даже когда несколько вызовов могут фактически перекрываться по времени, скрывает проблемы конкурентного доступа. Ожидание того, что блоки try/catch поймают отклонения от обещаний, которые так и не были ожидаемы, а также ошибочное предположение, что отменённый запрос означает, что на сервере ничего не произошло, — всё это следствие одной и той же слепоты.

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

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

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

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

Научиться ждать выполнения обещания — это простая часть.

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

Связанные материалы

  • Распространённые ошибки JavaScript и TypeScript, которые тайно ломают код — Рассматриваются тонкие проблемы JavaScript и TypeScript — от сравнений с NaN до асинхронного тайминга и принудительной конвертации типов — которые вызывают баги, несмотря на кажущуюся корректность кода.
  • Распространённые заблуждения относительно Node.js и баз данных, приводящие к багам в продакшене — Узнайте, почему конструкции async/await, пулы подключений и ORM не предотвращают автоматически ситуации гонки, исчерпания подключений или внедрения SQL-инъекций в приложениях Node.js.
  • Девять шаблонов Promise для надежного асинхронного JavaScript в производственных условиях — Ознакомьтесь с практическими шаблонами Promise: параллельные запросы, таймауты, повторные попытки, ограничения конкурентности и отмена операций — для создания надежного асинхронного JavaScript промышленного уровня.
  • Устранение ошибок обработки исключений в Async/Await в коде Node.js для производства — Узнайте о пяти распространенных ошибках обработки исключений в Async/Await в JavaScript и Node.js, которые приводят к скрытым сбоям и конкурентным ситуациям, а также о конкретных способах их устранения.
  • Семь распространённых идиом JavaScript, которые тихо приводят к будущим ошибкам — объясняет, как такие повседневные паттерны JavaScript, как проверки на истинность, опциональное цепление и синтаксис распространения, скрывают предположения, которые молча нарушаются по мере развития кода.