Привет, Хабр! Представляю вашему вниманию перевод статьи «JavaScript Async/Await and Promises: Explained like you’re five years old» автора Jack Pordi.

Каждый, кто считает себя JavaScript-разработчиком, в какой-то момент должен был столкнуться с callback-функциями, промисами или, с недавних пор, с синтаксисом async/await. Если вы пробыли в игре достаточно долго, вы, вероятно, застали времена, когда вложенные callback-функции были единственным способом достижения асинхронности в JavaScript.

Когда я начал изучать и писать на JavaScript, уже существовало миллиард руководств и туториалов, объясняющих, как добиться асинхронности в JavaScript. Тем не менее, многие из них просто объясняли, как преобразовать callback-функции в промисы или промисы в async/await. Для многих этого, вероятно, более чем достаточно, чтобы они «поладили» с ними и начали использовать их в своем коде.

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

Что значит «асинхронный»?


на картинке изображен задумавшийся человек

Как правило, задавая этот вопрос, вы можете услышать что-то из следующего:

  • Существует несколько потоков, которые выполняют код одновременно.
  • Более чем один фрагмент кода выполняется одновременно.
  • Это параллелизм.

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

Пример из жизни


на картинке изображены овощи и кухонный нож

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

  1. Нарежьте морковь.
  2. Нарежьте лук.
  3. Добавьте воду в кастрюлю, включите плиту и подождите, пока она закипит.
  4. Добавьте морковь в кастрюлю и оставьте на 5 минут.
  5. Добавьте лук в кастрюлю и варите еще 10 минут.

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

  • Шаги 3, 4 и 5 фактически не требуют от вас как от шеф-повара ничего делать, кроме как наблюдать за процессом и следить за временем.
  • Шаги 1 и 2 требуют от вас, чтобы вы активно что-то делали.

Следовательно, рецепт более опытного повара может быть следующим:

  1. Начните кипятить кастрюлю с водой.
  2. Пока ждете, когда кастрюля закипит, начните резать морковь.
  3. К тому времени, когда вы закончите измельчать морковь, вода должна закипеть, поэтому добавьте морковь.
  4. Пока морковь готовится в кастрюле, нарежьте лук.
  5. Добавьте лук и готовьте еще 10 минут.

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

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

Теперь давайте перейдем к JavaScript, хорошо?


Итак, придерживаясь того же примера овощного супа, я напишу несколько функций для представления шагов рецепта, описанных выше.

Сначала давайте напишем синхронные функции, которые представляют задачи, не требующие ожидания времени. Это старые добрые функции JavaScript, но обратите внимание, что я описал функции chopCarrots и chopOnions как задачи, требующие активной работы (и времени), позволяя им выполнять некоторые длинные вычисления. Полный код доступен в конце статьи [1].

function chopCarrots() {
  /*
   Тут длинные вычисления...
  */
  console.log("Морковь нарезана!");
}

function chopOnions() {
  /*
   Тут длинные вычисления...
  */
  console.log("Лук нарезан!");
}

function addOnions() {
  console.log("Лук в кастрюлю добавлен!");
}

function addCarrots() {
  console.log("Морковь в кастрюлю добавлена!");
}

Перед тем, как перейти к асинхронным функциям, сначала я быстро объясню, как система типов JavaScript обрабатывает асинхронность: в основном все результаты (включая ошибки) асинхронных операций должны быть обернуты в промис(ы).

Чтобы функция возвращала промис, вы можете:

  • явно вернуть промис, т.е. return new Promise(…);
  • неявно вернуть промис – добавить ключевое слово async к объявлению функции, т.е. async function foo();
  • использовать оба варианта.

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

Итак, наши асинхронные функции, представляющие шаги 3–5 приготовления овощного супа, выглядят следующим образом:

async function letPotKeepBoiling(time) {
  return; // Возвращаем промис, чтобы кастрюля кипела определенное время
}

async function boilPot() {
  return; // Возвращаем промис, чтобы довести суп до состояния кипения
}

Ещё раз, я удалил детали реализации, чтобы на них не отвлекаться, но они опубликованы в конце статьи [1].

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

async function asyncFunction() {
  /* Возвращаем промис... */
}

result = await asyncFunction();

Итак, теперь нам просто нужно собрать все это вместе:

function makeSoup() {
  const pot = boilPot();
  chopCarrots();
  chopOnions();
  await pot;
  addCarrots();
  await letPotKeepBoiling(5);
  addOnions();
  await letPotKeepBoiling(10);
  console.log("Ваш овощной суп готов!");
}

makeSoup();

Но подождите! Это не работает! Вы увидите ошибку SyntaxError: await is only valid in async functions. Почему? Потому что, если вы не объявляете функцию с помощью ключевого слова async, то по умолчанию JavaScript определяет ее как синхронную функцию – а синхронный означает отсутствие ожидания! [3]. Это также значит, что вы не можете использовать await за пределами функции.

Следовательно, мы просто добавляем ключевое слово async в функцию makeSoup:

async function makeSoup() {
  const pot = boilPot();
  chopCarrots();
  chopOnions();
  await pot;
  addCarrots();
  await letPotKeepBoiling(5);
  addOnions();
  await letPotKeepBoiling(10);
  console.log("Ваш овощной суп готов!");
}

makeSoup();

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

Что происходит во время вызовов await? Ну, ничего… вроде…

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

Но помните: вы (как и ваш процессор) никогда не захотите просто сидеть и ждать чего-то, в то время как можно потратить свое время на другие дела.

Следовательно, вместо того, чтобы только готовить суп, мы могли бы параллельно готовить что-то еще:

makeSoup();
makePasta();

Пока мы ожидаем letPotKeepBoiling, мы можем, например, готовить пасту.

Видите? Синтаксис async/await на самом деле довольно прост в использовании, если вы его понимаете, согласны?

Что насчет явных промисов?


Хорошо, если вы настаиваете, я перейду к использованию явных промисов (прим. перев.: под явными промисами автор подразумевает непосредственно сам синтаксис промисов, а под неявными промисами – синтаксис async/await, т.к. он возвращает промис неявно – не нужно писать return new Promise(…)). Имейте в виду, что методы async/await основаны на самих промисах и, следовательно, оба варианта полностью совместимы.

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

Воспользуйтесь нашей машиной времени, чтобы переместиться в «ад обратных вызовов» (callback hell) [4]:

function callbackHell() {
  boilPot(
    () => {
      addCarrots();
      letPotKeepBoiling(() => {
        addOnions();
        letPotKeepBoiling(() => {
          console.log("Ваш овощной суп готов!");
        }, 1000);
      }, 5000);
    },
    5000,
    chopCarrots(),
    chopOnions()
  );
}

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

И, как и обещал, тот же пример с явными промисами:

function makeSoup() {
  return Promise.all([
    new Promise((reject, resolve) => {
      chopCarrots();
      chopOnions();
      resolve();
    }),
    boilPot()
  ])
    .then(() => {
      addCarrots();
      return letPotKeepBoiling(5);
    })
    .then(() => {
      addOnions();
      return letPotKeepBoiling(10);
    })
    .then(() => {
      console.log("Ваш овощной суп готов!");
    });
}

Как видите, промисы все еще похожи на callback-функции.
Я не буду вдаваться в подробности, но главное:

  • .then — это метод промиса, который берет его результат и передает его в функцию аргумента (по сути, в callback-функцию…)
  • Вы никогда не сможете использовать результат промиса вне контекста .then. По сути, .then похож на асинхронный блок, который ожидает результат, а затем передает его в callback-функцию.
  • Помимо метода .then, в промисах существует еще один метод — .catch. Он нужен для обработки ошибок в промисах. Но я не буду вдаваться в детали, потому что на эту тему уже есть миллиард статей и туториалов.

Заключение


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

Итак, какой из способов вам использовать: промисы или async/await?
Ответ полностью зависит от вас — и я бы сказал, что совмещать их не так уж и плохо, так как оба подхода полностью совместимы друг с другом.

Тем не менее, лично я нахожусь на 100% в лагере async/await, так как для меня код намного понятнее и лучше отражает истинную многозадачность асинхронного программирования.



[1]: Полный исходный код доступен здесь.
[2]: Статья «Async function vs. a function that returns a Promise».
[3]: Вы можете утверждать, что JavaScript, вероятно, может определять тип async/await по телу функций и рекурсивно проверять, но JavaScript не был разработан для того, чтобы заботиться о безопасности статического типа во время компиляции, не говоря уже о том, что разработчикам намного удобнее явно видеть тип функции.
[4]: Я написал «асинхронные» функции, предполагая, что они работают под тем же интерфейсом, что и setTimeout. Обратите внимание, что callback-функции несовместимы с промисами и наоборот.

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


  1. vlreshet
    06.11.2019 13:47
    +2

    Стоит ли человеку становиться разработчиком, если довольно простую концепцию ему надо объяснять как пятилетнему ребёнку?


    1. JustDont
      06.11.2019 14:05
      +2

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


    1. lovermann
      06.11.2019 15:02

      А зачем человеку обязательно становиться разработчиком? Может, моя мама захочет тоже что-то накодить (разово) для своего блога, где она выкладывает рецепты, и ей просто нужно разобраться, не вникая в тонкости программирования, например.


    1. norguhtar
      07.11.2019 09:40

      Есть две группы программистов, которые знают что такое указатели и те что не знают что такое указатели.


  1. maxzh83
    06.11.2019 14:13

    Ждал увидеть описание как это работает изнутри, как исполняется на одном потоке и т.д.


  1. yarkov
    06.11.2019 14:30

    По-моему только ленивый ещё статью о промисах в JS не писал.


    1. Djaler
      06.11.2019 14:30
      +1

      ждем следующей статьи про map/filter/reduce


  1. dolovar
    06.11.2019 14:45

    принцип асинхронного программирования: вам никогда не захочется сидеть сложа руки, просто ожидая чего-то, в то время как вы могли бы потратить свое время на какие-нибудь другие полезные дела
    Комментарий не по теме статьи, слово заинтересовало. Синхронно — одновременно, совпадение по времени. Асинхронно — не одновременно, то есть в разные, не совпадающие отрезки времени, последовательно или вразнобой, без синхронизации.
    Порезать овощи, потом поставить воду нагреваться — последовательно, события не связаны, в перерывах можно отвлекаться, нет синхронности.
    Поставить воду нагреваться и, пока греется, начать резать овощи — одновременно, параллельно, то есть синхронизированно.
    Однако, где-то произошло переворачивание смысла — распараллеливание назвали асинхронным подходом, хотя именно в этом случае процессы совпадают по времени. Синхронно с выполнением основного потока происходит ожидание события.
    В компьютерном программировании, асинхронными событиями являются те, которые возникают независимо от основного потока выполнения программы. Асинхронные действия — действия, выполненные в неблокирующем режиме, что позволяет основному потоку программы продолжить обработку.
    Говорим об отсутствии блокировки, начинаем ориентироваться на события — называем асинхронностью.
    Сам подход (а также суть статьи) понятны полностью. Не понятно только использование именно этого слова. Понятно, что оно давно используется, привычно, не вызывает разночтений. Но не понятно, почему именно оно — как раз тот момент, который я не смог бы объяснить пятилетнему ребенку.


    1. mayorovp
      06.11.2019 16:05

      Синхронность не означает одновременность, синхронность означает упорядоченность по времени.


      Порезать овощи, потом поставить воду нагреваться — последовательно, т.е. упорядоченность есть.


      Поставить воду нагреваться и, пока греется, начать резать овощи — одновременно, упорядоченности нет: вода может нагреться независимо от того с какой скоростью мы режем овощи (по крайней мере, код на JS работает именно так).


      А вот если резать овощи с натренированной фиксированной скоростью — процесс снова становится синхронным (и к тому же переходит в область "реального времени").


      1. dolovar
        06.11.2019 17:16

        Синхронность не означает одновременность.
        Разные словари утверждают обратное.
        В контексте программирования да — синхронность стала синонимом последовательного, асинхронность стала синонимом параллельного. Но в контексте разглядывания этимологии и словарных значений наблюдается обратная картина, о чем я и написал комментарий.
        Последовательный, блокирующий, упорядоченный, событийно-ориентированный — это не совсем про «хронос», другие слова. Просто любопытно, почему взято именно это слово, причем с переворачиванием значения на обратное. Возможно, здесь есть какой-то нюанс, который поможет мне лучше понять суть происходящего.


        1. Free_ze
          06.11.2019 17:30

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


          1. dolovar
            06.11.2019 18:15

            одновременное завершение очередной задачи к началу следующей
            Это все же про последовательность, а не про одновременность. Точки вполне могут не совпадать — между точками «дорезал лук» и «налил воду в кастрюлю» может влезть другое событие «убрать мусор со стола» или «ответить на звонок жены».
            Зазор между точками «финиш 1» и «старт 2» всегда больше нуля, нужен хотя бы переход на следующий такт.

            Выполнение «синхронное» постулирует обязательность завершения одного процесса до начала второго: не допускается изменение последовательности, не допускается перекрытие временных отрезков. Совпадение точек не обязательно.
            Выполнение «асинхронное» разрешает перекрытие временных отрезков и даже разрешает изменение последовательности. Перекрытие — та самая «параллельность», которая в словарях является синонимом «синхронности», здесь и вижу парадокс.
            Приёмы синхронизации, кстати, насильно приводят к совпадению этих точек.
            Синхронизация колебаний, процессов, данных, передачи данных — очень разные задачи с разными приемами. Тут тоже забавное (возможно только для меня это забавно), должны же быть и приемы асинхронизации. В контексте темы про async/await что именно асинхронизируется?

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

            P.P.S. На тему последовательности вспомнилась старая загадка: если оладья жарится одну минуту с одной стороны и еще одну минуту со второй стороны, а на сковородку помещается только две оладьи, то как за три минуты полностью пожарить три оладьи.


            1. Free_ze
              06.11.2019 18:55

              Зазор между точками «финиш 1» и «старт 2» всегда больше нуля, нужен хотя бы переход на следующий такт.

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

              должны же быть и приемы асинхронизации

              Зачастую синхронизируют именно то, что было рассинхронизировано ранее (запросы к IO, работа в других потоках/процессах). Почему нет?..

              В контексте темы про async/await что именно асинхронизируется?

              Напротив, синхронизируется же) Некий обработчик-continuation стремится совместить свою точку старта с завершением асинхронной операции. Начать выполняться синхронно с ее завершением.


              1. dolovar
                06.11.2019 19:49
                -1

                Такая условность
                Ок, отвлечемся от нуля.
                Синхронный подход не требует даже примерного совпадения точек «финиш 1» и «старт 2», между ними браузер может отвлечься по множеству разных поводов. Синхронный подход предписывает только два условия: неперекрываемость, последовательность.
                А асинхронный подход разрешает перекрывать и нарушать последовательность.

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


                1. dolovar
                  06.11.2019 20:31

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


                1. Free_ze
                  06.11.2019 21:30

                  между ними браузер может отвлечься по множеству разных поводов

                  Вы можете подтвердить это как-либо? Что между срабатыванием «прерывания» (резолва промиса любой природы) и вызовом продолжения может выполниться другой произвольный JS-обработчик. Я сильно сомневаюсь, что пользовательские хэндлеры могут иметь разный приоритет, но тратить время на поиск процедуры обработки очереди в V8 не хотелось бы.

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

                  Реализуя асинхронность мы добиваемся синхронизации…

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


                  1. dolovar
                    06.11.2019 22:12

                    Вы можете подтвердить это как-либо? Что между срабатыванием «прерывания» (резолва промиса любой природы) и вызовом продолжения может выполниться другой произвольный JS-обработчик.
                    Мне видится какое-то разночтение.
                    Отвлечение браузера превратилось в выполнение JS-обработчика.
                    Последовательность из «завершение чистки лука» и «начало наливания воды в кастрюлю» превратилась в последовательность «срабатывание прерывания» и «вызов продолжения».
                    window.performance.now() - window.performance.now();
                    Результат — не константа, я об этом.
                    По каким же критериям синхронизируются разнородные операции в программах, если не по моменту начала и окончания?
                    Не уверен, что в случае последовательного выполнения требуются усилия по синхронизации — не вздумай выполнять все команды одновременно, сначала убедись что завершилась одна и только потом приступай к следующей…
                    Это асинхронность требует усилий по созданию и проверке наличия прерываний, синхронность же, под которой подразумевается просто последовательное выполнение в одном потоке, не требует усилий по прикреплению возвратов из одних функций к вызовам следующих.
                    Асинхронность реализует цикл событий, веб-воркеры и браузерный API, а коллбеки и промисы позволяют рулить синхронизируя этот цирк
                    Можно и так смотреть. Комментарием выше я описал свою точку зрения.


                    1. Free_ze
                      06.11.2019 22:55

                      Результат — не константа, я об этом.

                      Если юзер не сможет использовать эту разницу и она не способна ему навредить, то этой условностью можно пренебречь в случае с JS.

                      Не уверен, что в случае последовательного выполнения требуются усилия по синхронизации

                      В этом случае выполнение уже синхронно настолько, что дальше некуда.


    1. funca
      06.11.2019 22:20

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


  1. GCU
    06.11.2019 16:15
    +1

    У меня тупой вопрос:
    Каким образом при добавлении морковки и лука мы знаем что они уже порезаны?
    По куску кода явных await там нет, хотя это вроде как длительный процесс, первый кандидат на async.
    Как «повар» я сразу могу начать кипятить воду, а также резать морковку и лук.
    При этом когда готовы вода+морковка её можно добавить в воду и продолжать резать лук, который добавить потом, после того как морковка сварится.


    1. JustDont
      06.11.2019 16:32

      Предполагается, что резка овощей реализована обычными функциями (в парадигме «повар один», т.е. основной поток выполнения JS будет у нас «поваром»), т.е. оно всё синхронно выполняется, и после вызовов chop<...>() наши овощи будут гарантировано порезаны.


      1. GCU
        06.11.2019 16:47

        Какой-то не до конца асинхронный пример получился :)
        Нужно как-то ближе к реальности.
        Пока резал морковку — вода уже выкипела, нужно залить новую.
        Не успел порезать лук — морковка переварилась.
        Чтобы резать — нужен нож и доска… и прочие прелести асинхронности


        1. JustDont
          06.11.2019 17:08

          Ну на самом деле если завернуть вообще весь код из статьи в async — он не сильно пострадает от этого.


  1. funca
    07.11.2019 00:00
    +2

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


    Wait в английском означает активное ожидание, Await что-либо — ближе к "предполагать", отложив дальнейшее рассуждения до разрешение ситуации. await в js именно так и работает, прерывая выполнение текущей функции и переключаясь на другую задачу, пока данное обещание не разрешится.