[Перевод] Побег из ада async/await

Habrahabr 1

Совсем недавно конструкция async/await в JavaScript выглядела как отличное средство для избавления от ада коллбэков. Однако неосмотрительное использование async/await привело к появлению нового ада.

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

Что такое ад async/await

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

Пример: заказ пиццы и напитков

Представим, что нам надо написать скрипт, предназначенный для оформления заказов на пиццу и напитки. Этот скрипт может выглядеть так:
(async () => {
  const pizzaData = await getPizzaData()    // асинхронный вызов
  const drinkData = await getDrinkData()    // асинхронный вызов
  const chosenPizza = choosePizza()    // синхронный вызов
  const chosenDrink = chooseDrink()    // синхронный вызов
  await addPizzaToCart(chosenPizza)    // асинхронный вызов
  await addDrinkToCart(chosenDrink)    // асинхронный вызов
  orderItems()    // асинхронный вызов
})()
На первый взгляд скрипт выглядит вполне нормально, к тому же — он работает так, как ожидается. Однако при внимательном рассмотрении этого кода оказывается, что его реализация хромает, так как тут не учитываются особенности асинхронного выполнения кода. Разобравшись с тем, что именно здесь не так, мы сможем решить проблему этого скрипта.

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

  1. Получение списка видов пиццы.
  2. Получение списка напитков.
  3. Выбор пиццы из списка.
  4. Выбор напитка из списка.
  5. Добавление выбранной пиццы в корзину.
  6. Добавление выбранного напитка в корзину.
  7. Оформление заказа.
Выше сделан акцент на том, что операции в скрипте выполняются строго последовательно. Здесь не используются возможности параллельного выполнения кода. Поразмыслим над следующим: почему мы ожидаем получения списка видов пиццы для того, чтобы начать загрузку списка напитков? Следовало бы выполнять эти задачи одновременно. Однако, для того, чтобы получить возможность выбрать пиццу из списка, сначала надо дождаться загрузки списка видов пиццы. То же самое относится и к процессу выбора напитка.

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

Пример: оформление заказа на основе содержимого корзины

Вот пример кода, в котором осуществляется загрузка данных о содержимом корзины и отправка запроса на формирование заказа:
async function orderItems() {
  const items = await getCartItems()    // асинхронный вызов
  const noOfItems = items.length
  for(var i = 0; i < noOfItems; i++) {
    await sendRequest(items[i])    // асинхронный вызов
  }
}
В данном случае циклу for приходится ждать завершения каждого вызова функции sendRequest() для того, чтобы перейти к следующей итерации. Однако, мы, на самом деле, не нуждаемся в этом ожидании. Мы хотим выполнить все запросы как можно быстрее, а затем дождаться их завершения. Надеюсь, теперь вы приблизились к пониманию сущности ада async/await, и того, насколько сильно он может повлиять на производительность приложений. Теперь подумайте над вопросом, вынесенным в заголовок следующего раздела.

Что если забыть воспользоваться ключевым словом await?

Если забыть воспользоваться ключевым словом await при вызове асинхронной функции, то функция просто начнёт выполняться. Такая функция вернёт промис, который можно использовать позже.
(async () => {
  const value = doSomeAsyncTask()
  console.log(value) // неразрешённый промис
})()
Ещё одно следствие вызова асинхронной функции без await заключается в том, что компилятор не будет знать о том, что программист хочет дождаться полного завершения выполнения функции. В результате компилятор выйдет из программы, не завершив асинхронную задачу. Поэтому не следует забывать о ключевом слове await там, где оно необходимо.

У промисов есть интересное свойство: в одной строке кода промис можно получить, а в другой — дождаться его разрешения. Этот факт и является ключом к побегу из ада async/await.

(async () => {
  const promise = doSomeAsyncTask()
  const value = await promise
  console.log(value) // реальное значение
})()
Как видите, вызов doSomeAsyncTask() возвращает промис. В этот момент данная функция начинает выполняться. Для того чтобы получить результат разрешения промиса, мы используем ключевое слово await, сообщая тем самым системе, что ей не следует немедленно выполнять следующую строку кода. Вместо этого надо дождаться разрешения промиса, а уже потом переходить к следующей строке.

Как выбраться из ада async/await?

Для того чтобы выбраться из ада async/await, можно воспользоваться следующим планом действий.

▍1. Найдите выражения, которые зависят от выполнения других выражений

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

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

В итоге мы поняли, какие именно выражения зависят друг от друга, а какие — нет.

▍2. Сгруппируйте зависимые выражения в отдельных асинхронных функциях

Как мы уже выяснили, процесс выбора пиццы состоит из нескольких шагов: загрузка списка видов пиццы, выбор конкретной пиццы и добавление её в корзину. Именно эти действия и надо собрать в отдельную асинхронную функцию. Не забывая о том, что похожая последовательность действий характерна и для напитков, мы приходим к двум асинхронным функциям, которые можно назвать selectPizza() и selectDrink().

▍3. Выполните полученные асинхронные функции параллельно

Теперь воспользуемся возможностями цикла событий JavaScript для того, чтобы организовать параллельное неблокирующее выполнение полученных асинхронных функций. Тут применяются два распространённых паттерна — ранний возврат промисов и метод Promise.all().

Работа над ошибками

Применим на практике три вышеописанных шага по избавлению от ада async/await. Исправим вышеприведённые примеры. Вот как теперь будет выглядеть первый.
async function selectPizza() {
  const pizzaData = await getPizzaData()    // асинхронный вызов
  const chosenPizza = choosePizza()    // синхронный вызов
  await addPizzaToCart(chosenPizza)    // асинхронный вызов
}

async function selectDrink() {
  const drinkData = await getDrinkData()    // асинхронный вызов
  const chosenDrink = chooseDrink()    // синхронный вызов
  await addDrinkToCart(chosenDrink)    // асинхронный вызов
}

(async () => {
  const pizzaPromise = selectPizza()
  const drinkPromise = selectDrink()
  await pizzaPromise
  await drinkPromise
  orderItems()    // асинхронный вызов
})()

// Задачу можно решить так, как показано выше, но я предпочитаю следующий метод 

(async () => {
  Promise.all([selectPizza(), selectDrink()]).then(orderItems)   // асинхронный вызов
})()
Теперь выражения, относящиеся к пицце и напиткам, сгруппированы в функциях selectPizza() и selectDrink(). Внутри этих функций важен порядок выполнения команд, так как следующие команды зависят от результатов выполнения предыдущих. После того, как функции подготовлены, мы вызываем их асинхронно.

Во втором примере нам приходится иметь дело с неизвестным количеством промисов. Однако решить эту проблему очень просто. А именно, надо создать массив и поместить в него промисы. Затем, используя Promise.all(), можно организовать ожидание разрешения всех этих промисов.

async function orderItems() {
  const items = await getCartItems()    // асинхронный вызов
  const noOfItems = items.length
  const promises = []
  for(var i = 0; i < noOfItems; i++) {
    const orderPromise = sendRequest(items[i])    // асинхронный вызов
    promises.push(orderPromise)    // синхронный вызов
  }
  await Promise.all(promises)    // асинхронный вызов
}

Итоги

Как видите, то, что называется «адом async/await», на первый взгляд выглядит вполне прилично, однако, за внешним благополучием кроется негативное воздействие на производительность. Из этого ада, однако, не так уж и сложно сбежать. Достаточно проанализировать код, выяснить, какие задачи, решаемые с его помощью, можно распараллелить, и внести в программу необходимые изменения.

Уважаемые читатели! Доводилось ли вам видеть ад async/await?