Совсем недавно конструкция 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?

Комментарии (47)


  1. kalininmr
    17.04.2018 15:20
    +1

    чтобы избавится от await нужно перестать использовать await.

    очень странная идея.
    сперва выдумали проблемму(await нужен именно для этого «ада»)
    потом просто решили исользовать старые добрые промисы.


    1. Dreyk
      17.04.2018 15:44

      а async/await — это и есть старые добрые промисы


      и тут мы не отказываемся от await: мы просто используем его по-другому. А чтобы уметь использовать по-другому — см. п. 1 — надо знать, что это просто сахар вокруг промисов.


      Хороший перевод хорошей статьи!


      1. kalininmr
        17.04.2018 20:02
        +3

        я понимаю, что await Promise.all(promises) использует await.
        имхо проблемма надуманная.
        есть масса мест где последовательные await логичны, да и для того они и придуманны.


      1. AxisPod
        17.04.2018 21:12
        +1

        Я бы на вашем месте почитал матчасть. async/await != Promise. Именно так. Не так уже пролетал перевод, с таким вот именно пониманием и очень корявыми примерами использования.


  1. rumkin
    17.04.2018 15:44

    Мой друзья! Лучше использовать инструменты наподобие map библиотеки Bluebird, которые позволяют управлять количеством одновременно запущенных промисов:


    Bluebird.map(
        array, item => doSomethingAsync(item), {concurrency: LIMIT}
    )

    Это защитит память приложения от переполнения при обработке огромных массивов и предотвратит возникновение блокировок.


    Перевод моего комментария к этой статье.


    1. justboris
      17.04.2018 16:34
      +1

      Можно взять маленький модуль p-limit, и не тащить весь большой Bluebird.


    1. morsic
      18.04.2018 12:04

      >Это защитит память приложения от переполнения при обработке огромных массивов
      Может сразу Stream api использовать?
      Промисы по своей сути не особо подходят для больших обьемов данных.


      1. rumkin
        20.04.2018 00:12

        Так-то они тоже теперь на промисы переезжают. Поэтому вполне достаточно использовать Bluebird.map или p-limit, как посоветовали выше.


  1. indestructable
    17.04.2018 15:46
    +2

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

    Разве здесь не нужен await (или убрать фигурные скобки вокруг тела функции)?


    Статья как по мне спорная. Не увидел особого "ада", в основном, начальные примеры показывают, что бывает, если не понимаешь, как работает async/await.


    Как по мне, разобраться с этим уж точно не сложнее, чем с промисами. А код получается на порядки читабельнее.


  1. apapacy
    17.04.2018 15:53
    +1

    У async/await совсем другие выявились недостатки. А именно необходимость заключать их в блоки try/catch которые с промисами выглядят порой немного лаконичнее как вызов функций then()/catch(). Без try/catch код выглядит действительно очень неплохо. C try/catch все опять становится весьма многоэтажно.

    Все это погружать в Promise.all() конечно рационально но сразу теряется наглядность. Я где-то видел более естественное решение

    const promiseSomeWhat = someWhat();
    const promiseAnother = another();
    const someData = await promiseSomeWhat;
    const anotherData = await promiseAnother;
    


    1. AxisPod
      17.04.2018 22:09
      +1

      Один try/catch вместо кучи catch это плохо?


      1. apapacy
        17.04.2018 22:36

        Один try/catch вместо кучи catch() — не знаю. Можно обойтись и одним catch()
        Скорее всего один try/catch это просто само по себе плохо. Т.к. ошибки разные бывают.


        1. kalininmr
          18.04.2018 02:34

          кстати да. в яваскрипте с catch довольно плохо, нельзя ловить по типу


    1. arvitaly
      19.04.2018 05:10

      Я вот написал библиотеку with-error, оборачивающую функции с исключениями и многоэтажность исчезла. А вообще лучше не пользоваться в JS исключениями, крайне неудобная работа с ними, особенно в области типизации.


  1. iShatokhin
    17.04.2018 16:43
    +2

    Последний блок можно написать компактней:

    const promises = items.map(async (item) => {
    	await sendRequest(item);
    });
    await Promise.all(promises);
    


    1. keenondrums
      17.04.2018 17:51
      +3

      Можно и еще компактней
      await Promise.all(items.map(sendRequest))


    1. indestructable
      17.04.2018 20:42

      Потеряли return или лишние фигурные скобки.


      Или бабель добавляет туда return сам? Просто в самой статье тоже была такая же ошибка.


      1. iShatokhin
        17.04.2018 20:48

        А нужен ли там return?
        Последняя строчка не ожидает результатов по завершению Promise.all


        1. apapacy
          17.04.2018 22:58

          Автору оригинальной статьи уже сделали замечание по этому поводу и он принес свои извинения. Правда статью не откорректировал пока.


        1. indestructable
          18.04.2018 09:31

          return нужен внутри map, иначе Promise.all ничего не дождётся.


          1. Alternator
            18.04.2018 09:39

            Отсутствие return означает возврат Promise в данном случае.
            Таким образом в promises попадет массив промисов, которые зарезолвятся в undefined после окончания соответствующих sendRequest.
            Promise.all дождется факта окончания всех операций, и вернет массив undefined-ов, вместо результатов.


  1. keenondrums
    17.04.2018 17:50
    +2

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

    Если уж сделали асинхронной, то почему бы тогда не дождаться резолва Promise.all с помощью await? Promise.all внезано тоже возвращает промис.
    Сами себе каких-то проблем выдумали, сами их себе порешали.


  1. helg1978
    17.04.2018 18:51
    +3

    Не увидел никакого ада, увидел неоптимальные блокировки.


    1. aamonster
      17.04.2018 18:56

      +1. Вспомнил старый добрый make с его зависимостями, не маскирующимися под императивный код.


  1. AxisPod
    17.04.2018 21:26

    Очередной перевод статьи, автор которой не знает async/await, которому лень открыть тот же MDN и хотя бы посмотреть примеры готовки. Уже оставлял в недавнем вашем переводе коммент по этому поводу. async/await это не промисы, не вводите в заблуждение людей переводами таких статей.

    Тут не нужено использовать Promise.all в принципе. Все await в рамках одного выражения отрабатывают параллельно, но никто чего-то этим даже не пытается пользоваться. В итоге мы имеем статьи вида: Чукча не читатель, чукча писатель.

    Скорее всего будет работать нормально вариант

    (async () => {
      const items = [
        choosePizza(await getPizzaData()),
        chosenDrink(await getDrinkData())
      ];
      [await addPizzaToCart(items[0]), await addDrinkToCart(items[1])]
      await orderItems()
    })()

    Код исправлен сильно в силу чрезмерной абстрактности.

    Пример для одного из предыдущих переводов: repl.it/repls/AmusingZealousEnvironment
    Измененный пример для работы с массивами: repl.it/repls/PoliteChocolateHashfunction

    Ну и ответ: Ада не видел, видел г@#$%кодеров.


    1. iShatokhin
      17.04.2018 22:12

      Скорее всего будет работать нормально вариант

      Не совсем. В случае exeption будет Unhandled promise rejection вместо ожидаемого состояния onRejected. Try-catch тоже не сможет поймаль ошибку. Уже обсуждалось:

      habrahabr.ru/post/326442/#comment_10175054


    1. apapacy
      17.04.2018 22:24

      async/await это не промисы, не вводите в заблуждение людей переводами таких статей.


      Ну как же не промисы. Если промисы. Асинхронная функция возвращает промисы. Всегда.
      Оператор await ожидает промис. Об этом совершенно недвусмысленно говорит MDN.
      developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Statements/async_function
      developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Operators/await


      1. mayorovp
        18.04.2018 09:59

        Поправка: вот как раз оператор await ожидает не (только) промис, а любой объект с методом then.


        1. apapacy
          18.04.2018 16:12

          Да это реально так. Толкьо что проверил. Но после этого скрипт не работет как ожидается. то есть функция then вяполняется но дальше конечно ничего ожидаемого не происходит. Кто-нибудь значет что-то об этом? Это баг?

          try {
          
            var a = {};
            a.then=function(){console.log('then'); return 'not promise';};
            a.catch=function(){console.log('catch'); return 'not promise';};
          
            async function test() {
              await a;
            }
          
            Promise.resolve().then(async function(){
              var a = await test();
              console.log('a is', a);
            });
          } catch(ex){
            console.log('error', ex);
          }
          


          Запускаем: node test.js
          Вывод: then

          Почему это так?


          1. mayorovp
            18.04.2018 16:14

            Потому что в then передается колбек который она должна вызвать когда настанет время. А вы его игнорируете.


            1. apapacy
              18.04.2018 16:46

              Да с callback работает.

              try {
              
                var a = {};
                a.then=function(resolve, reject){ console.log('then'); resolve('not promise');};
                a.catch=function(){console.log('catch'); return 'not promise';};
              
                async function test() {
                  return await a;
              
                }
              
                Promise.resolve().then(async function(){
                  var a = await test();
                  console.log('a is', a);
                });
              } catch(ex){
                console.log('error', ex);
              }
              


              Но это весьма неожиданно т.к. иногда в цепи промисов может оказаться объект и все работет как ожидается. Но если вдруг в его свойствах будет then то все перестанет работать. Конечно then не лучшее незвание для свойства. Но все же. Я это всего лишь имя своства и никакой магии от него не хочестся получать. Если Только это не специально созданный объект Promise. Утиная типизация какая-то получается.


              1. faiwer
                18.04.2018 19:21

                Но если вдруг в его свойствах будет then то все перестанет работать

                Да, есть такая засада. По сути система работает не с promise-ми а с любыми thenable объектами. И никаких Symbol.thenable для их идентификации не используется. Когда-нибудь с кем-нибудь это может сыграть злую шутку :)


              1. vvadzim
                18.04.2018 19:35

                Утиная типизация какая-то получается.

                Эмм… Так утиная типизация — почти как второе имя javascript'a. Все методы массивов работают с объектами, у которых просто есть length. Ну и т.д…


      1. Damaskus
        18.04.2018 13:36

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


        1. mayorovp
          18.04.2018 14:27

          Нет, как раз async означает что функция вернет промис. Это гарантируется по построению.


          1. Damaskus
            18.04.2018 18:04

            Давайте не будем столь категоричны.
            В скриптовом языке далеко не все может быть гарантировано.
            Вот тут все работает

            function testable(x) {
                if (x > 10) {
                    return new Promise(resolve => setTimeout(()=>resolve(x), 0));   
                }
                return x;    
            }
            
            async function test() {
                console.assert(await testable(1) === 1);
                console.assert(await testable(1000) === 1000);
                console.assert(await testable(null) === null);
            }
            
            test();
            


            1. apapacy
              18.04.2018 18:14
              +2

              Не совсем понял что Вы имеете в виду.
              Если функция имеет квалификатор async то она точно вернет промис. Всегда и во всех случаях.
              Что имелось в виду подтвердить Вашим примером?

              async f(){;}
              f() — вернет промис
              await f() — вернет undefined


              1. Damaskus
                18.04.2018 18:32

                Да, все правильно. Контекст неуловил.


    1. apapacy
      17.04.2018 22:43
      +1

      Все await в рамках одного выражения отрабатывают параллельно, но никто чего-то этим даже не пытается пользоваться. В итоге мы имеем статьи вида: Чукча не читатель, чукча писатель.


      Откуда эта информация? Ваш код рабочий но работает он не параллельно а последовательно. Пусть даже await будет помещен при фактических параметрах.


    1. PaulMaly
      18.04.2018 00:57

      Так много пафоса однако. MDN:

      > Цель функций async/await упросить использование promises синхронно и воспроизвести некоторое действие над группой Promises. Точно так же как Promises подобны структурированным callback-ам, async/await подобна комбинации генераторов и promises.


    1. mayorovp
      18.04.2018 09:54

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

      Вранье. Вот только что попробовал в консоли Хрома:


      > const foo = async x => {
          console.log("begin " + x);
          await new Promise(resolve => setTimeout(resolve, 100));
          console.log("end " + x);
          return x;
      }
      > [await foo(1), await foo(2)]
      begin 1
      end 1
      begin 2
      end 2
      < (2) [1, 2]
      > await Promise.all([foo(1), foo(2)])
      begin 1
      begin 2
      end 1
      end 2
      < (2) [1, 2]


      1. normas
        18.04.2018 12:04

        Кто-нибудь может объяснить, почему консоль не выдает ошибку SyntaxError в данном случае?

        > [await foo(1), await foo(2)]
        begin 1
        end 1
        begin 2
        end 2
        < (2) [1, 2]
        

        Ведь оператор await используется не в контексте async функции.


        1. mayorovp
          18.04.2018 12:49

          Очевидно, потому что консоль работает «в контексте async функции».


  1. Miron11
    20.04.2018 12:19
    -1

    Все это хорошо, но как решить, особенно в web приложении, кому поручить дождаться промис. Ведь есть соблазн делегировать его ожидание процессу, и тогда скрипач ( await ) не нужен.

    И получается то, что у меня творится на web клиенте почты. Которая то повиснет, то не готова выполнять какие — то запросы, после первого запроса. Что же… выполняешь запрос во второй — третий — иногда четвертый раз. По мере того, как их «резиновая» виртуальная машина прогревает под мои запросы каши, они начинают работать.

    И получается что пропустив await, его совсем даже не пропустили, а просто передали в руки конечного пользователя. Зато, я почти уверен, все показатели performance dashboard у разработчика пакета ПО зашкаливают.


  1. 304
    20.04.2018 12:19

    Псевдосинхронность не добавит нам производительности.
    Если мы захотели бы максимально оптимизировать работу программы, то разбили бы выполнение на воркеры(в ноде)

    Попробуйте так же поиграться с классическими промисами, будет сложнее — эвэиты читаются проще, что даём нам возможность прикладывать меньше усилий для их группировки/оптимизации.
    И, по моему скромному мнению главная проблема async/await — это try/catch hell с бесконечными unhandled promise rejection, который может возникнуть, например, если разработчики не договорились на каком уровне эти ошибки обрабатывать.


    1. kashey
      20.04.2018 13:09

      это try/catch hell с бесконечными unhandled promise rejection, который может возникнуть, например, если разработчики не договорились на каком уровне эти ошибки обрабатывать.


      Вы хотели сказать — используют любую классическую библиотеку на основе промисов?


  1. devlato
    20.04.2018 14:00
    -1

    Блин, при всём уважении, вся статья высосана из пальца. Автор бросается громкими словами вроде "проблемы с производительностью!!!", а по факту приведены банальные ошибки программиста, который зачем-то написал код, выполняющий последовательно вещи, которые можно делать параллельно. Проблема с забывчивостью при использовании await — не проблема синтаксической конструкции async/await, а проблема дизайна языка, которая тоже решается довольно просто — используйте TypeScript или другой компилируемый в JavaScript статически типизированный язык.