Async await что это
Конструкция async/await в JavaScript: сильные стороны, подводные камни и особенности использования
Конструкция async/await появилась в стандарте ES7. Её можно считать замечательным улучшением в сфере асинхронного программирования на JavaScript. Она позволяет писать код, который выглядит как синхронный, но используется для решения асинхронных задач и не блокирует главный поток. Несмотря на то, что async/await — это отличная новая возможность языка, пользоваться ей правильно не так уж и просто. Материал, перевод которого мы публикуем сегодня, посвящён разностороннему исследованию async/await и рассказу о том, как использовать этот механизм правильно и эффективно.
Сильные стороны async/await
Самое важное преимущество, которое получает программист, пользующийся конструкцией async/await, заключается в том, что она даёт возможность писать асинхронный код в стиле, характерном для синхронного кода. Сравним код, написанный с использованием async/await, и код, основанный на промисах.
Привлекательность async/await обеспечивается не только улучшением читабельности кода. Этот механизм, кроме того, пользуется отличной поддержкой браузеров, не требующей каких-либо обходных путей. Так, на сегодняшний день асинхронные функции полностью поддерживают все основные браузеры.
Все основные браузеры поддерживают асинхронные функции (caniuse.com)
Такой уровень поддержки означает, например, что код, использующий async/await, не нужно транспилировать. Кроме того, это облегчает отладку, что, пожалуй, даже более важно, чем отсутствие необходимости в транспиляции.
Отладка асинхронной функции. Отладчик дождётся выполнения await-строки и перейдёт на следующую строку после завершения операции
О неправильном восприятии async/await
В некоторых публикациях конструкцию async/await сравнивают с промисами и говорят о том, что она представляет собой новое поколении эволюции асинхронного программирования на JavaScript. С этим я, при всём уважении к авторам таких публикаций, позволю себе не согласиться. Async/await — это улучшение, но это — не более чем «синтаксический сахар», появление которого не ведёт к полному изменению стиля программирования.
В сущности, асинхронные функции — это промисы. Перед тем, как программист сможет правильно использовать конструкцию async/await, он должен хорошо изучить промисы. Кроме того, в большинстве случаев, работая с асинхронными функциями, нужно использовать и промисы.
Взгляните на функции getBooksByAuthorWithAwait() и getBooksByAuthorWithPromises() из вышеприведённого примера. Обратите внимание на то, что они идентичны не только в плане функционала. У них ещё и совершенно одинаковые интерфейсы.
На самом деле, суть проблемы, о которой мы тут говорим, заключается в неправильном восприятии новой конструкции, когда создаётся обманчивое ощущение того, что синхронную функцию можно конвертировать в асинхронную благодаря простому использованию ключевых слов async и await и ни о чём больше не задумываться.
Подводные камни async/await
Поговорим о наиболее распространённых ошибках, которые можно сделать, пользуясь async/await. В частности — о нерациональном использовании последовательных вызовов асинхронных функций.
Хотя ключевое слово await может сделать код похожим на синхронный, пользуясь им, стоит помнить о том, что код это асинхронный, а значит, надо очень внимательно относиться к последовательным вызовом асинхронных функций.
Этот код, с точки зрения логики, кажется правильным. Однако тут имеется серьёзная проблема. Вот как он работает.
Вот правильный подход к написанию такого кода:
Рассмотрим ещё один пример неправильного использования асинхронных функций. Тут всё ещё хуже, чем в предыдущем примере. Как видите, для того, чтобы асинхронно загрузить список неких элементов, нам надо полагаться на возможности промисов.
Обработка ошибок
▍Конструкция try/catch
Стандартным способом для обработки ошибок при использовании async/await является конструкция try/catch. Я рекомендую пользоваться именно этим подходом. При выполнении await-вызова значение, выдаваемое при отклонении промиса, представляется в виде исключения. Вот пример:
Ошибка, перехваченная в блоке catch — это как раз и есть значение, получающееся при отклонении промиса. После перехвата исключения мы можем применить несколько подходов для работы с ним:
▍Возврат функциями двух значений
Источником вдохновения для следующего способа обработки ошибок в асинхронном коде стал язык Go. Он позволяет асинхронным функциям возвращать и ошибку, и результат. Подробнее об этом можно почитать здесь.
Если в двух словах, то асинхронные функции, при таком подходе, можно использовать так:
Лично мне это не нравится, так как этот способ обработки ошибок привносит в JavaScript стиль программирования на Go, что выглядит неестественно, хотя, в некоторых случаях, это может оказаться весьма полезным.
Для этого подхода характерны две небольших проблемы:
Итоги
Конструкция async/await, которая появилась в ES7, определённо, является улучшением механизмов асинхронного программирования в JavaScript. Она способна облегчить чтение и отладку кода. Однако, для того, чтобы пользоваться async/await правильно, необходимо глубокое понимание промисов, так как async/await — это всего лишь «синтаксический сахар», в основе которого лежат промисы.
Надеемся, этот материал позволил вам ближе познакомиться с async/await, и то, что вы тут узнали, убережёт вас от некоторых распространённых ошибок, возникающих при использовании этой конструкции.
Уважаемые читатели! Пользуетесь ли вы конструкцией async/await в JavaScript? Если да — просим рассказать о том, как вы обрабатываете ошибки в асинхронном коде.
Async/await
Существует специальный синтаксис для работы с промисами, который называется «async/await». Он удивительно прост для понимания и использования.
Асинхронные функции
У слова async один простой смысл: эта функция всегда возвращает промис. Значения других типов оборачиваются в завершившийся успешно промис автоматически.
Например, эта функция возвратит выполненный промис с результатом 1 :
Можно и явно вернуть промис, результат будет одинаковым:
Await
Ключевое слово await заставит интерпретатор JavaScript ждать до тех пор, пока промис справа от await не выполнится. После чего оно вернёт его результат, и выполнение кода продолжится.
В этом примере промис успешно выполнится через 1 секунду:
В данном примере выполнение функции остановится на строке (*) до тех пор, пока промис не выполнится. Это произойдёт через секунду после запуска функции. После чего в переменную result будет записан результат выполнения промиса, и браузер отобразит alert-окно «готово!».
Обратите внимание, хотя await и заставляет JavaScript дожидаться выполнения промиса, это не отнимает ресурсов процессора. Пока промис не выполнится, JS-движок может заниматься другими задачами: выполнять прочие скрипты, обрабатывать события и т.п.
Ошибки не будет, если мы укажем ключевое слово async перед объявлением функции. Как было сказано раньше, await можно использовать только внутри async –функций.
Давайте перепишем пример showAvatar() из раздела Цепочка промисов с помощью async/await :
Получилось очень просто и читаемо, правда? Гораздо лучше, чем раньше.
Можно обернуть этот код в анонимную async –функцию, тогда всё заработает:
В примере ниже, экземпляры класса Thenable будут работать вместе с await :
Для объявления асинхронного метода достаточно написать async перед именем:
Обработка ошибок
Делает то же самое, что и такой:
Но есть отличие: на практике промис может завершиться с ошибкой не сразу, а через некоторое время. В этом случае будет задержка, а затем await выбросит исключение.
Так сделано в строке (*) в примере выше.
Итого
Ключевое слово async перед объявлением функции:
Ключевое слово await перед промисом заставит JavaScript дождаться его выполнения, после чего:
Вместе они предоставляют отличный каркас для написания асинхронного кода. Такой код легко и писать, и читать.
Асинхронное программирование с использованием ключевых слов async и await
Модель асинхронного программирования на основе задач (TAP) предоставляет абстракцию асинхронного кода. Вы пишете код как последовательность операторов, как обычно. Вы можете читать этот код, как если бы каждая инструкция завершалась до начала следующей. Компилятор выполняет множество преобразований, так как некоторые из этих инструкций могут начать работу и вернуть Task, представляющий текущую работу.
Это и есть цель такого синтаксиса: сделать возможным код, который читается как последовательность операторов, но выполняется в гораздо более сложном порядке на основе выделения внешних ресурсов и при завершении задач. Это аналогично тому, как люди дают инструкции для процессов, которые включают асинхронные задачи. В этой статье вы будете использовать пример инструкции для приготовления завтрака, чтобы увидеть, как ключевые слова async и await упрощают понимание кода, который включает в себя серию асинхронных инструкций. Можно написать инструкции аналогично следующему списку, чтобы объяснить, как приготовить завтрак.
Если у вас есть кулинарный опыт, вы бы выполняли эти инструкции асинхронно. Сначала вы бы поставили сковородку на огонь, а затем занялись бы беконом. Потом бы поставили тосты, а вслед за этим принялись бы за яичницу. На каждом этапе процесса необходимо запустить задачу, а затем обратить внимание на другие задачи, которые требуют вашего внимания.
Приготовление завтрака представляет собой хороший пример асинхронной непараллельной работы. Один пользователь (или поток) может обрабатывать все эти задачи. Продолжая аналогию с завтраком, один человек может приготовить завтрак асинхронно путем запуска очередной задачи до завершения предыдущей. Готовка продолжается вне зависимости от того, следит ли за ней кто-либо. Как только вы начали греть сковороду для яичницы, можно заняться обжаркой бекона. Когда бекон будет жариться, можно поместить хлеб в тостер.
Для параллельного алгоритма потребовалось бы несколько поваров (или потоков). Один готовит яйца, один — бекон и т. д. Каждый из них будет заниматься только одной задачей. Каждый повар (или поток) будет заблокирован синхронным ожиданием готовности бекона или тостов.
Теперь рассмотрим эти же инструкции, написанные на C#.
Синхронное приготовление завтрака заняло примерно 30 минут, так как общее время является суммой времен выполнения каждой задачи.
Компьютеры не рассматривают эти инструкции так же, как люди. Компьютер будет задерживаться над каждой инструкцией до момента, когда работа будет завершена, прежде чем перейдет к следующему оператору. Вряд ли такой завтрак вас устроит. Более поздние задачи не будут начаты до завершения предыдущих. Потребуется гораздо больше времени для приготовления завтрака, к тому же часть уже остынет еще до подачи.
Если требуется, чтобы компьютер асинхронно выполнил инструкции выше, необходимо писать асинхронный код.
Эти проблемы важны для программ, которые вы пишете уже сегодня. При написании клиентских программ требуется, чтобы пользовательский интерфейс реагировал на ввод данных пользователем. Приложения не должны блокировать телефон при скачивании данных из Интернета. При написании серверных программ не стоит блокировать потоки. Эти потоки могут обслуживать другие запросы. Использование синхронного кода в ситуации, когда существуют асинхронные альтернативы, мешает масштабированию с минимальными затратами. Вы платите за эти заблокированные потоки.
Успешные современные приложения требуют использования асинхронного кода. Без поддержки языком при написании асинхронного кода требуются обратные вызовы, события завершения или другие способы, заслоняющие исходное назначение кода. Преимуществом синхронного кода является то, что эти пошаговые действия упрощают проверку и анализ. Традиционные асинхронные модели заставляют сосредоточиваться на асинхронности кода, а не на фундаментальных действиях в нем.
Не блокировать, а использовать await
Приведенный выше код демонстрирует дурную практику: использование синхронного кода для выполнения асинхронных операций. В таком виде код блокирует выполняющий поток, не позволяя делать другие действия. Он не будет прерван, пока задачи выполняются. Все равно что стоять и смотреть на тостер, пока поджаривается хлеб. Пока тост не готов, вы всех игнорируете.
Давайте начнем менять этот код, чтобы не блокировать поток во время выполнения задачи. Ключевое слово await позволяет обойтись без блокировки для запуска задачи, а затем продолжить выполнение, когда задача завершается. Простая асинхронная версия кода для приготовления завтрака будет выглядеть так:
Общее затраченное время примерно такое же, как у начальной синхронной версии. Этот код можно улучшить, используя ряд ключевых возможностей асинхронного программирования.
Этот код не блокируется при приготовлении яиц или бекона. Этот код, однако, не запускает других задач. По-прежнему придется поместить тост в тостер и смотреть на него, пока он не выскочит. Но по крайней мере можно отвечать всем, кто хочет вашего внимания. В ресторане, где будет размещаться несколько заказов, повар сможет начать готовить другой завтрак, пока первый готовится.
Теперь поток завтрака не блокируется в ожидании любой запущенной задачи, которая еще не завершена. Для некоторых приложений это изменение — все, что требуется. Приложение с графическим интерфейсом будет отвечать пользователю после этого изменения. Тем не менее в этом сценарии нам нужно больше. Нам не требуется последовательное выполнение каждой из задач компонента. Лучше запускать каждую из задач компонента, не ожидая завершения предыдущей задачи.
Одновременный запуск задач
Во многих случаях требуется запускать сразу несколько независимых задач. Затем, когда каждая задача завершается, можно продолжить другую работу, которая уже готова к этому. В нашей аналогии — так завтрак готовится быстрее. Вы также приготовите все примерно в одно и то же время. Вы получите горячий завтрак.
System.Threading.Tasks.Task и связанные типы — это классы, позволяющие делать выводы о задачах, которые находятся в процессе выполнения. Это позволяет писать код, который точнее определяет, как будет фактически готовиться завтрак. Вы начинаете готовить яйца, бекон и тосты примерно в одно и то же время. По мере необходимости вы обращаете внимание на отдельные задачи, переходите к другим, а затем ждете третьих, которые нуждаются в обработке.
Вы начинаете задачу и удерживаете объект Task, представляющий работу. Вы вызываете await для каждой задачи, прежде чем начать работу с ее результатами.
Давайте внесем эти изменения в код для приготовления завтрака. Первым делом сохраним задачи для отдельных операций при их запуске, чтобы не ждать их:
Затем вы можете переместить инструкции await для бекона и яиц в конец метода, сразу перед подачей завтрака:
Асинхронное приготовление завтрака заняло примерно 20 минут. Это позволило сэкономить время, так как некоторые задачи можно было выполнять параллельно.
Сочетаемость задач
У вас все готово для завтрака в одно и то же время, за исключением тостов. Приготовление тоста — композиция асинхронной операции (поджарить хлеб) и синхронной операции (добавить масло и джем). Обновление этого кода иллюстрирует важную концепцию:
композиция асинхронной операции, за которой следует синхронная задача, является асинхронной операцией. Говоря иначе, если какая-либо часть операции является асинхронной, то и вся операция является асинхронной.
Предыдущее изменение показывает важную методику для работы с асинхронным кодом. Составные задачи можно создавать, разделяя операции в новом методе, который возвращает задачу. Вы можете выбрать, когда следует ожидать выполнения созданной задачи. Одновременно можно запускать другие задачи.
Асинхронные исключения
До этого момента вы неявно предполагали, что все эти задачи были выполнены успешно. Асинхронные методы создают исключения, точно так же, как и синхронные методы. В целом поддержка исключений и обработки ошибок в асинхронном коде преследует те же цели, что и поддержка асинхронного кода в целом: необходимо написать код, который выглядит как последовательность синхронных инструкций. Когда задачи не могут быть успешно завершены, они выдают исключения. Клиентский код может перехватывать эти исключения, когда запущенная задача ожидается ( awaited ). Например, предположим, что тостер загорается во время приготовления тоста. Это можно смоделировать, изменив метод ToastBreadAsync следующим образом:
При компиляции предыдущего кода будет выдано предупреждение о наличии недостижимого кода. Это сделано намеренно, поскольку после того, как тостер загорится, дальнейшие операции не будут выполняться обычным образом.
Запустите приложение после внесения этих изменений, и вы получите следующий результат:
Обратите внимание, что между возгоранием тостера и выдачей исключения выполняется довольно много задач. Если задача, которая выполняется асинхронно, выдает исключение, эта задача завершается с ошибкой. Созданное исключение находится в свойстве Task.Exception объекта Task. Задачи, завершившиеся с ошибкой, выдают исключение, когда эти задачи ожидаются.
Существует два важных механизма, работу которых нужно понимать: как исключение хранится в задаче, завершившейся с ошибкой, и как оно распаковывается и выдается повторно, когда код ожидает задачу, завершившуюся с ошибкой.
Эффективное ожидание задач
После всех этих изменений окончательная версия кода выглядит так:
Окончательная версия асинхронного приготовления завтрака заняла примерно 15 минут, так как в этом случае некоторые задачи выполнялись параллельно. Также одновременно отслеживалось несколько задач из кода, и действия выполнялись только тогда, когда это было необходимо.
Этот итоговый код выполняется асинхронно. Он более точно отражает, как пользователь будет готовить завтрак. Сравните предыдущий код с первым примером кода в этой статье. Основные действия по-прежнему очевидны при прочтении. Этот код можно прочитать так же, как указания по приготовлению завтрака в начале этой статьи. Возможности языка для async и await делают возможными преобразования, которые любой человек производит, выполняя эти инструкции: запуск задач без блокировки в ожидании их завершения.
Разбираем Async/Await в JavaScript на примерах
Автор статьи разбирает на примерах Async/Await в JavaScript. В целом, Async/Await — удобный способ написания асинхронного кода. До появления этой возможности подобный код писали с использованием коллбэков и промисов. Автор оригинальной статьи раскрывает преимущества Async/Await, разбирая различные примеры.
Напоминаем: для всех читателей «Хабра» — скидка 10 000 рублей при записи на любой курс Skillbox по промокоду «Хабр».
Skillbox рекомендует: Образовательный онлайн-курс «Java-разработчик».
Callback
Callback представляет собой функцию, вызов которой отложен на неопределенное время. Раньше обратные вызовы использовались в тех участках кода, где результат не мог быть получен сразу.
Вот пример асинхронного чтения файла на Node.js:
Проблемы возникают в тот момент, когда требуется выполнить сразу несколько асинхронных операций. Давайте представим себе вот такой сценарий: выполняется запрос в БД пользователя Arfat, нужно считать его поле profile_img_url и загрузить картинку с сервера someserver.com.
После загрузки конвертируем изображение в иной формат, например из PNG в JPEG. Если конвертация прошла успешно, на почту пользователя отправляется письмо. Далее информация о событии заносится в файл transformations.log с указанием даты.
Стоит обратить внимание на наложенность обратных вызовов и большое количество >) в финальной части кода. Это называется Callback Hell или Pyramid of Doom.
Недостатки такого способа очевидны:
Положительным моментом промисов стало то, что с ними код читается гораздо лучше, причем сверху вниз, а не слева направо. Тем не менее у промисов тоже есть свои проблемы:
Предположим, что есть цикл for, выводящий последовательность чисел от 0 до 10 со случайным интервалом (0–n секунд). Используя промисы, нужно изменить этот цикл таким образом, чтобы числа выводились в последовательности от 0 до 10. Так, если вывод нуля занимает 6 секунд, а единицы — 2 секунды, сначала должен быть выведен ноль, а потом уже начнется отсчет вывода единицы.
Async-функции
Добавление async-функций в ES2017 (ES8) упростило задачу работы с промисами. Отмечу, что async-функции работают «поверх» промисов. Эти функции не представляют собой качественно другие концепции. Async-функции задумывались как альтернатива коду, который использует промисы.
Async/Await дает возможность организовать работу с асинхронным кодом в синхронном стиле.
Таким образом, знание промисов облегчает понимание принципов Async/Await.
В обычной ситуации он состоит из двух ключевых слов: async и await. Первое слово и превращает функцию в асинхронную. В таких функциях разрешается использование await. В любом другом случае использование этой функции вызовет ошибку.
Async вставляется в самом начале объявления функции, а в случае использования стрелочной функции — между знаком «=» и скобками.
Эти функции можно поместить в объект в качестве методов либо же использовать в объявлении класса.
NB! Стоит помнить, что конструкторы класса и геттеры/сеттеры не могут быть асинхронными.
Семантика и правила выполнения
Async-функции, в принципе, похожи на стандартные JS-функции, но есть и исключения.
Так, async-функции всегда возвращают промисы:
В частности, fn возвращает строку hello. Ну а поскольку это асинхронная функция, то значение строки обертывается в промис при помощи конструктора.
Вот альтернативная конструкция без Async:
В этом случае возвращение промиса производится «вручную». Асинхронная функция всегда обертывается в новый промис.
В том случае, если возвращаемое значение — примитив, async-функция выполняет возврат значения, обертывая его в промис. В том случае, если возвращаемое значение и есть объект промиса, его решение возвращается в новом промисе.
Но что произойдет в том случае, если внутри асинхронной функции окажется ошибка?
Если она не будет обработана, foo() вернет промис с реджектом. В этой ситуации вместо Promise.resolve вернется Promise.reject, содержащий ошибку.
Async-функции на выходе всегда дают промис, вне зависимости от того, что возвращается.
Await влияет на выражения. Так, если выражение является промисом, async-функция приостанавливается до момента выполнения промиса. В том случае, если выражение не является промисом, оно конвертируется в промис через Promise.resolve и потом завершается.
А вот описание того, как работает fn-функция.
Решаем задачу
Ну а теперь давайте рассмотрим решение задачи, которая была указана выше.
Вот решение с выводом чисел, здесь есть два варианта.
А вот решение с использованием async-функций.
Необработанные ошибки обертываются в rejected промис. Тем не менее в async-функциях можно использовать конструкцию try/catch для того, чтобы выполнить синхронную обработку ошибок.
canRejectOrReturn() — это асинхронная функция, которая либо удачно выполняется (“perfect number”), либо неудачно завершается с ошибкой (“Sorry, number too big”).
Поскольку в примере выше ожидается выполнение canRejectOrReturn, то собственное неудачное завершение повлечет за собой исполнение блока catch. В результате функция foo завершится либо с undefined (когда в блоке try ничего не возвращается), либо с error caught. В итоге у этой функции не будет неудачного завершения, поскольку try/catch займется обработкой самой функции foo.
Стоит уделить внимание тому, что в примере из foo возвращается canRejectOrReturn. Foo в этом случае завершается либо perfect number, либо возвращается ошибка Error (“Sorry, number too big”). Блок catch никогда не будет исполняться.
Проблема в том, что foo возвращает промис, переданный от canRejectOrReturn. Поэтому решение функции foo становится решением для canRejectOrReturn. В этом случае код будет состоять всего из двух строк:
А вот что будет, если использовать вместе await и return:
В коде выше foo удачно завершится как с perfect number, так и с error caught. Здесь отказов не будет. Но foo завершится с canRejectOrReturn, а не с undefined. Давайте убедимся в этом, убрав строку return await canRejectOrReturn():
Распространенные ошибки и подводные камни
В некоторых случаях использование Async/Await может приводить к ошибкам.
Такое случается достаточно часто — перед промисом забывается ключевое слово await:
В коде, как видно, нет ни await, ни return. Поэтому foo всегда завершается с undefined без задержки в 1 секунду. Но промис будет выполняться. Если же он выдает ошибку или реджект, то в этом случае будет вызываться UnhandledPromiseRejectionWarning.
Async-функции в обратных вызовах
Нам нужны аккаунты ArfatSalman, octocat, norvig. В этом случае выполняем:
Чрезмерно последовательное использование await
В качестве примера возьмем такой код:
Здесь в переменную count помещается число репо, затем это число добавляется в массив counts. Проблема кода в том, что пока с сервера не придут данные первого пользователя, все последующие пользователи будут находиться в режиме ожидания. Таким образом, в единый момент обрабатывается лишь один пользователь.
Promise.all на входе получает массив промисов с возвращением промиса. Последний после завершения всех промисов в массиве или при первом реджекте завершается. Может случиться так, что все они не запустятся одновременно, — для того чтобы обеспечить одновременный запуск, можно использовать p-map.
Заключение
Async-функции становятся все более важными для разработки. Ну а для адаптивного использования async-функций стоит воспользоваться Async Iterators. JavaScript-разработчик должен хорошо разбираться в этом.