Как сделать синхронную функцию асинхронной

Добавил пользователь Владимир З.
Обновлено: 10.09.2024

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

Что происходит, когда вы хотите сделать что-то, что занимает относительно много времени, например, доступ к файлу на диске, отправка сетевого запроса или ожидание истечения таймера? При синхронном программировании ваш скрипт ничего не может сделать, пока он ждет.

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

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

Возьмем, например, сетевой запрос. Если вы выполняете сетевой запрос на медленный сервер, на который требуется ответить в течение трех секунд, ваш скрипт может активно делать другие вещи, пока этот медленный сервер отвечает. В этом случае три секунды для человека могут ничего не значить, но сервер может отвечать на тысячи других запросов во время ожидания. Итак, как вы обрабатываете асинхронность в Node.js?

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

Давайте рассмотрим очень простой пример. Мы будем использовать встроенный модуль файловой системы Node.js ( fs ). В этом скрипте мы прочитаем содержимое текстового файла. Последней строкой файла является console.log , который ставит вопрос: если вы запустите этот скрипт, вы думаете, что увидите лог до того, как мы увидим содержимое текстового файла?

Поскольку это асинхронно, мы фактически увидим последний console.log перед содержимым текстового файла. Если у вас есть файл с именем a-text-file.txt в том же каталоге, в котором выполняется ваш скрипт node, вы увидите, что значение err равно null , а значение text заполнено содержимым текстового файла.

Если у вас нет файла с именем a-text-file.txt, err возвратит объект Error, а значение text будет undefined . Это приводит к важному аспекту обратных вызовов: вы всегда должны обрабатывать свои ошибки. Чтобы обрабатывать ошибки, вам нужно проверить значение в переменной err ; если значение присутствует, то произошла ошибка. По соглашению аргументы err обычно не возвращают false , поэтому вы можете проверить только на true.

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

Код выглядит довольно неприятно и имеет ряд проблем:

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

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

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

Не беспокойтесь, мы можем решить все эти проблемы (и многое другое) с помощью async.js.

Обратные вызовы с Async.js

Во-первых, давайте начнем с установки модуля async.js.

Async.js можно использовать для склеивания массивов функций либо последовательно, либо параллельно. Перепишем наш пример:

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

Каждая функция должна иметь только один аргумент, обратный вызов (или cb в нашем коде). cb должен выполняться с тем же типом аргументов, что и любой другой обратный вызов, поэтому мы можем поместить его прямо в наши аргументы fs.readFile .

Наконец, результаты отправляются на окончательный обратный вызов, второй аргумент - в async.series . Результаты сохраняются в массиве со значениями, коррелирующими с порядком функций в первом аргументе async.series .

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

А теперь все вместе

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

JavaScript - это в основном однопоточный язык, то есть он может делать только одно действие за раз. Он способен выполнять некоторые задачи в отдельном потоке (например, большинство функций ввода-вывода), и именно здесь асинхронное программирование входит в игру с JS. Не путайте параллелизм с concurrency.

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

Это лучше всего поясняется визуально:

Explaining asynchronous programming
Explaining asynchronous programming
Explaining asynchronous programming

Вот наш предыдущий пример, написанный как параллельный. Единственное отличие состоит в том, что мы используем async.parallel , а не async.series .

Вновь и вновь

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

В этом примере мы будем писать в десять файлов в текущий каталог с последовательными именами файлов и небольшим количеством содержимого. Вы можете изменить число, изменив значение первого аргумента async.times . В этом примере обратный вызов для fs.writeFile создает только аргумент err , но функция async.times также может поддерживать возвращаемое значение. Подобно async.series, оно передается done обратному вызову во втором аргументе в виде массива.

Пришло время сказать, что большинство функций async.js по умолчанию работают параллельно, а не сериями. Таким образом, в приведенном выше примере он начнет создавать файлы и сообщит, когда все они будут полностью созданы и записаны.

В следующем примере цикла мы рассмотрим функцию async.until. async.until выполняет асинхронную функцию (последовательно) до тех пор, пока не будет выполнено определенное условие. Эта функция принимает три функции в качестве аргументов.

Первая функция - это тест, в котором вы возвращаете либо true (если вы хотите остановить цикл), либо false (если вы хотите его продолжить). Второй аргумент - асинхронная функция, а последний - обратный вызов done. Взгляните на этот пример:

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

На моей машине я создавал от 6 до 20 файлов за пять миллисекунд. Интересно, что если вы попытаетесь добавить console.log в тестовую функцию или асинхронную функцию, вы получите очень разные результаты, потому что требуется время для записи на консоль. Это просто показывает вам, что в программном обеспечении все имеет стоимость исполнения!

Цикл for - удобная структура - она позволяет вам что-то делать для каждого элемента массива. В async.js это будет функция async.each . Эта функция принимает три аргумента: коллекцию или массив, асинхронную функцию для каждого элемента и обратный вызов done.

В приведенном ниже примере мы берем массив строк (в данном случае типов пород собак) и создаем файл для каждой строки. Когда все файлы были созданы, выполняется обратный вызов done. Как и следовало ожидать, ошибки обрабатываются через объект err в обратном вызове done. async.each запускается параллельно, но если вы хотите запустить его последовательно, вы можете следовать ранее упомянутому шаблону и использовать async.eachSeries вместо async.each .

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

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

async.filter также очень похож на синтаксис async.each и async.map , но с фильтром вы отправляете логическое значение в обратный вызов элемента, а не в значение файла. В обратном вызове done вы получаете новый массив с только теми элементами, для которых вы передали true или truthy значение в обратном вызове для элемента.

В этом примере мы делаем еще несколько вещей. Обратите внимание, как мы добавляем дополнительный вызов функции и обрабатываем нашу собственную ошибку. Шаблон if err и callback(err) очень полезен, если вам нужно манипулировать результатами асинхронной функции, но вы все равно хотите, чтобы async.js обрабатывали ошибки.

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

Над краем скалы

До сих пор мы изучили ряд полезных строительных блоков, которые имеют грубые следствия в синхронном программировании. Давайте погрузиться прямо в async.waterfall , который не имеет эквивалента в синхронном мире.

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

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

waterfall example
waterfall example
waterfall example

В следующем примере мы начнем комбинировать некоторые концепции, используя водопад в качестве клея. В массиве, который является первым аргументом, мы имеем три функции: первая загружает список каталогов из текущего каталога, вторая берет список каталогов и использует async.map для выполнения fs.stat для каждого файла, а третья функция принимает список каталогов из результат работы первой функции и получает содержимое для каждого файла ( fs.readFile ).

async.waterfall запускает каждую функцию последовательно, поэтому всегда будет запускать все функции fs.stat перед запуском любого fs.readFile . В этом первом примере вторая и третья функции не зависят друг от друга, поэтому их можно обернуть в async.parallel , чтобы сократить общее время выполнения, но мы снова изменим эту структуру для следующего примера.

Примечание. Запустите этот пример в небольшом каталоге текстовых файлов, иначе вы долго будете получать мусор в окне терминала.

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

Во-первых, мы можем вывести все анонимные функции в именованные. Это личное предпочтение, но оно делает код немного чище и понятнее (можно использовать повторно для загрузки). Как вы можете себе представить, вам нужно получить размеры, оценить эти размеры и получить только содержимое файлов, превышающее требования к размеру. Это можно легко выполнить с помощью Array.filter , но это синхронная функция, и async.waterfall ожидает функции асинхронного стиля. Async.js имеет вспомогательную функцию, которая может обертывать синхронные функции в асинхронные функции, и она называется async.asyncify .

Нам нужно сделать три вещи, каждую из которых мы будем обертывать async.asyncify . Во-первых, мы возьмем файлы и массивы stat из функции arrayFsStat , и объединим их с помощью map . Затем мы отфильтровываем любые элементы, размер которых меньше 300. Наконец, мы возьмем объединенное имя файла и объект stat и снова используем map , чтобы просто получить имя файла.

После того, как мы получим имена файлов размером менее 300, мы будем использовать async.map и fs.readFile для получения содержимого. Существует много способов взломать это яйцо, но в нашем случае оно было разбито, чтобы показать максимальную гибкость и повторное использование кода. Подобное использование async.waterfall иллюстрирует, как вы можете смешивать и сопоставлять синхронный и асинхронный код.

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

Преобразование в async.seq требует лишь нескольких изменений. Во-первых, мы изменим directoryListing , чтобы была возможность принимать аргумент - это будет путь. Во-вторых, мы добавим переменную для хранения нашей новой функции ( directoryAbove300 ). В-третьих, мы возьмем аргумент массива из async.waterfall и переведем его в аргументы async.seq . Наш обратный вызов для водопада теперь используется в качестве обратного вызова done при запуске directoryAbove300 .

Замечание об обещаниях и асинхронных функциях

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

Встроенные модули Node.js используют err -обратные вызовы, а тысячи других модулей используют этот шаблон. Фактически, поэтому в этом руководстве используется fs в примерах - что-то такое же фундаментальное, как доступ к файловой системе в Node.js, использует обратные вызовы, поэтому обратного вызова без обещаний являются важной частью программирования Node.js.

Можно использовать что-то вроде Bluebird для переноса ошибочных обратных вызовов в функции на основе Promise. Async.js предоставляет множество абстракций, которые делают асинхронный код читаемым и управляемым.

Принятие асинхронности

JavaScript стал одним из де-факто языков работы в Интернете. Здесь есть много все, что нужно изучить, а также множество фреймворков и библиотек. Если вы ищете дополнительные ресурсы для изучения или использования в своей работе, посмотрите, что у нас есть на рынке Envato.

Но изучение асинхронности - это нечто совсем другое, и, надеюсь, этот урок показал вам, насколько она полезна.

Проблема, конечно, в том, что func1 каким-то образом должен передавать "контекст", в котором он работает (синхронно или асинхронно), на func2 .

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

Есть ли способ сделать это без реализации асинхронной копии всех моих функций?

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

Я не знаю вашего варианта использования, но вместо того, чтобы требовать, чтобы func1() всегда использовал func2() , не могли бы вы дать ему какую-то логику во входных аргументах для запуска func2() , если он указан (и / или по умолчанию), или использовать другой статический ввод если не дано?

@ G.Anderson Я открыт для любого решения, которое лучше, чем дублирование всего моего кода :)

В этом случае один из вариантов - создать func1 как def func1(use_f2=True): if use_f2: x=func2() else: x=staticvariable , а затем, если вы хотите использовать его независимо, вызовите его с помощью func1(False) .

@ G.Anderson Извините, я не понимаю. func1 не должен использовать статическое значение; он всегда должен вызывать func2 - синхронно или асинхронно, в зависимости от того, как был вызван func1 .

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

Если вы хотите, чтобы func1 всегда зависел от func2 , а также чтобы иметь возможность запускать func1 без запуска func2 , то, боюсь, я не в своей тарелке. Я думаю, что @Torxed на правильном пути.

@Torxed Давайте не будем превращать это в обсуждение того, лучше ли async / await, чем многопоточность. Легко написать функцию run_async , которая запускает func1 в новом потоке; но я хочу знать, возможно ли подобное с async .

@Torxed Когда я говорю async , я имею в виду ключевое слово async в python, а когда говорю async (без форматирования кода), я имею в виду асинхронное выполнение (:

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

@Torxed: multiprocessing по своей сути неплох, но на некоторых платформах он относительно фундаментально сломан (например, в последних версиях macOS). threading по своей сути неплох, но как парадигма программирования потоки сложно получить правильно, а CPython не может оптимально обрабатывать несколько потоков. Асинхронность - это не то же самое, что параллелизм, и для операций, которые чувствительны к задержке, но привязаны к вводу-выводу, асинхронные операции могут превзойти наивно параллельные операции, особенно за счет уменьшения нагрузки на ЦП и память каждой операции. Ключевое слово async было добавлено не зря!

В качестве небольшой сноски я хотел бы извиниться, я полностью пропустил ключевое слово async в async def func1_async() . Я, должно быть, устал или очень торопился. И это обоснованные проблемы @DanielPryden и краткое объяснение преимуществ и недостатков каждого из вариантов.

@Torxed Усталым был на самом деле я, забыл добавить ключевое слово async в первоначальная редакция. Прости за это :(

Ответы 2

Вот мой "не-ответ-ответ", который, как я знаю, любит Stack Overflow .

Is there any way to do this without having to implement an asynchronous copy of all my functions?

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

Совершенно верно, что автоматическое создание асинхронной синхронной функции практически невозможно, но что, если мы сделаем наоборот? Если я определю func1 и func2 с помощью async def , наверняка должен быть способ превратить их в синхронные функции? (Или хотя бы сделать их появляться синхронными с вызывающим?)

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

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

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

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

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

Позаботьтесь об этом!

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

@BradSolomon Не могли бы вы уточнить? Судя по таймингу, похоже, он делает именно то, что я хочу.

Опять же, рискуя показаться чисто критическим (я действительно заинтригован этим вопросом и попыткой): я думаю, вы можете спутать асинхронность с параллелизмом. loop.run_until_complete(coro) в "синхронном" __call__ запускает сопрограмму, а не встроенную функцию. По той же причине, по которой async for выполняет расписание параллельного выполнения нет, он просто заставляет for работать с сопрограммами.

Другими словами, вызов loop.run_until_complete(coro); loop.run_until_complete(coro) может удвоить время до завершения и, следовательно, дать вам "внешний вид" синхронного кода, но он по-прежнему является асинхронным по определению.

@BradSolomon Спасибо за объяснение. Поскольку я новичок в asyncio , я не уверен, что понимаю все, что вы сказали, но я действительно думаю, что решение, которое я придумал, работает. Я понимаю, что оболочка __call__ - это всего лишь фасад, и все под капотом по-прежнему асинхронно, но я не вижу в этом проблемы. Все, что я хотел, - это иметь интерфейс, который ведет себя как любая другая синхронная функция, чтобы пользователи, не знакомые с асинхронным программированием, могли без проблем использовать мои функции.

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

хорошо, что из пути, как мне сделать это, чтобы я мог:

примеры (или отсутствие все они используют библиотеки и / или компиляторы, оба из которых не являются жизнеспособными для этого решения. Мне нужен конкретный пример того, как сделать его блокирующим (например, не оставлять функцию doSomething до тех пор, пока не будет вызван обратный вызов), не замораживая пользовательский интерфейс. Если такое возможно в JS.

"не говорите мне о том, как я должен просто сделать это "правильный путь" или что"

ОК. но вы действительно должны сделать это правильный путь. или что-то еще

мне нужен конкретный пример того, как это сделать блок . Без замораживания пользовательского интерфейса. Если такое возможно в JS."

нет, невозможно заблокировать запущенный JavaScript без блокировка пользовательского интерфейса.

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

все это предполагает, что вы можете изменить doSomething() . Я не знаю, есть ли это в картах.

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

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

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

конечно, если это единственное, что делает обратный вызов, вы просто передадите func непосредственно.

Обложка: Асинхронность в программировании

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

При вызове методов read() и write() текущий поток исполнения будет прерван в ожидании ввода-вывода по сети. Причём большую часть времени программа будет просто ждать. В высоконагруженных системах чаще всего так и происходит — почти всё время программа чего-то ждёт: диска, СУБД, сети, UI, в общем, какого-то внешнего, независимого от самой программы события. В малонагруженных системах это можно решить созданием нового потока для каждого блокирующего действия. Пока один поток спит, другой работает.

Но что делать, когда пользователей очень много? Если создавать на каждого хотя бы один поток, то производительность такого сервера резко упадёт из-за того, что контекст исполнения потока постоянно сменяется. Также на каждый поток создаётся свой контекст исполнения, включая память для стека, которая имеет минимальный размер в 4 КБ. Эту проблему может решить асинхронное программирование.

Асинхронность

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

Callbacks

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

В wait_connection() мы всё ещё ждём чего-то, но теперь вместе с этим внутри функции wait_connection() может быть реализовано подобие планировщика ОС, но с callback-функциями (пока мы ждём нового соединения, почему бы не обработать старые? Например, через очередь). Callback-функция вызывается, если в сокете появились новые данные — лямбда в async_read() , либо данные были записаны — лямбда в async_write() .

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

У такого подхода есть несколько проблем. Первую в шутку называют callback hell. Достаточно погуглить картинки на эту тему, чтобы понять, насколько это нечитаемо и некрасиво. В нашем примере всего две вложенные callback-функции, но их может быть намного больше.

Async/Await

Пройдём по программе построчно:

  • Ключевое слово async в заголовке функции говорит компилятору, что функция асинхронная и её нужно компилировать по-другому. Каким именно образом он будет это делать, написано ниже.
  • Первые три строки функции: создание и ожидание соединения.
  • Следующая строка делает асинхронное чтение, не прерывая основной поток исполнения.
  • Следующие две строки делают асинхронный запрос в базу данных и чтение файла. Оператор await приостанавливает текущую функцию, пока не завершится выполнение асинхронной задачи чтения из БД и файла.
  • В последних строках производится асинхронная запись в сокет, но лишь после того, как мы дождёмся асинхронного чтения из БД и файла.

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

Корутины

Далее будут описаны различные виды и способы организации сопрограмм.

Несколько точек входа

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

Для большего понимания рассмотрим код на языке Python:

Программа выведет всю последовательность чисел факториала с номерами от 0 до 41.

Функция async_factorial() вернёт объект-генератор, который можно передать в функцию next() , а она продолжит выполнение корутины до следующего оператора yield с сохранением состояния всех локальных переменных функции. Функция next() возвращает то, что передаёт оператор yield внутри корутины. Таким образом, функция async_factorial() в теории имеет несколько точек входа и выхода.

Stackful и Stackless

В зависимости от использования стека корутины делятся на stackful, где каждая из корутин имеет свой стек, и stackless, где все локальные переменные функции сохраняются в специальном объекте.

Так как в корутинах мы можем в любом месте поставить оператор yield , нам необходимо где-то сохранять весь контекст функции, который включает в себя фрейм на стеке (локальные переменные) и прочую метаинформацию. Это можно сделать, например, полной подменой стека, как это делается в stackful корутинах.

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

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


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

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

Будет преобразован в следующий псевдокод:

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

Симметричные и асимметричные

Корутины также делятся на симметричные и асимметричные.

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

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

Вывод

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

JavaScript функции async и await – то, что важно понимать web-разработчику в 2019 году. В статье примеры кода и детальное погружение в тему.

Вначале были обратные вызовы.

Обратный вызов – функция, которая выполняется позднее.

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

Так выглядит асинхронное чтение файла в Node.js:

Проблемы возникают, когда асинхронная операция не одна. Вот сценарий, где каждая операция асинхронная:

Код выглядит так:

Глубокое погружение в асинхронные JavaScript функции

Обратите внимание на вложенность обратных вызовов и лестницу из >) в конце. Это ласково называется Ад обратных вызовов или Пирамида Судьбы (Pyramid of Doom). Главные недостатки:

  • Код становится труднее читать, потому что читать приходится слева направо.
  • Обработка ошибок сложна и часто приводит к ужасному коду.

Для решения этой проблемы боги JavaScript JS создали Promise. Теперь вместо вложенности обратных вызовов получаем цепочку.

Глубокое погружение в асинхронные JavaScript функции

Поток стал привычным – сверху вниз, а не слева направо, как в обратных вызовах, что плюс. Тем не менее, с Promise по-прежнему проблемы:

  • Нуждаемся в обратном вызове для каждого .then .
  • Вместо try/catch приходится использовать .catch для обработки ошибок.
  • Организация циклов с множественными Promise в последовательности бросает вызов.

Для демонстрации последнего пункта примем этот вызов!

Задача

Предположим, цикл for выводит от 0 до 10 с произвольными интервалами (от 0 до n секунд). Требуется изменить поведение с использованием Promise так, чтобы числа печатались последовательно от 0 до 10. Например, если 0 отображается за 6 секунд, а 1 – за две секунды, то 1 ждёт печати 0 и так далее.

Само собой разумеется, не используйте JavaScript функции async и await или sort . Решение будет к концу.

После ES2017(ES8) JavaScript основы языка дополнились асинхронными функциями, которые упростили работу с Promise.

  • Асинхронные функции JavaScript работают поверх Promise.
  • Это не диаметрально другая концепция.
  • Функции рассматриваются как альтернативный способ написания кода на основе Promise.
  • С использованием async и await избегаем создания цепочки Promise.
  • В итоге получаем асинхронное выполнение при сохранении нормального синхронного подхода.

Следовательно, требуется понимание Promise для осознания концепции async/await .

Синтаксис

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

Видите async в начале объявления функции? Если функция стрелочная, async ставится после знака = и перед скобками.

Асинхронные функции используются и как методы объектов или в объявлениях класса. Это иллюстрируют JavaScript примеры:

Примечание: конструкторы классов, геттеры и сеттеры не могут быть асинхронными.

Семантика и выполнение

Асинхронные функции – обычные функции JavaScript с такими отличиями:

Асинхронные JavaScript функции всегда возвращают Promise.

Функция fn возвращает 'привет' . Поскольку использовали async , возвращаемое значение 'привет' оборачивается в Promise посредством Promise.resolve .

Теперь посмотрим на эквивалентное альтернативное представление без использования async :

В этом случае вручную возвращаем Promise вместо использования async .

Точнее сказать, возвращаемое значение асинхронной функции JavaScript всегда оборачивается в Promise.resolve .

Для примитивов Promise.resolve возвращает обёрнутое в Promise значение. Но для объектов Promise возвращается тот же объект без оборачивания.

Что происходит, когда бросаем ошибку внутри асинхронной функции?

foo() вернёт отклонённый ( rejected ) Promise, если ошибку не перехватили. Вместо Promise.resolve Promise.reject оборачивает и возвращает ошибку. Смотрите раздел Обработка ошибок дальше.

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

Асинхронные функции останавливаются на каждом await .

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

Теперь рассмотрим функцию fn построчно:

  • Когда выполняется fn , первой отработает строка const a = await 9; . Она внутри преобразуется в const a = await Promise.resolve(9); .
  • Поскольку используем await , fn делает паузу, пока переменная a не получит значение. В этом случае Promise назначит ей результат 9 .
  • delayAndGetRandom(1000) заставляет fn приостанавливаться до тех пор, пока не выполнится функция delayAndGetRandom , что происходит через 1 секунду. Таким образом, fn делает паузу на 1 секунду.
  • Кроме того, delayAndGetRandom резолвится со случайным значением. Что бы ни передавалось в функцию resolve , значение присваивается переменной b .
  • c получает значение 5 аналогичным образом, и снова задержка на 1 секунду из-за await delayAndGetRandom(1000) . В этом случае не используем конечное значение.
  • Наконец, вычисляем результат a + b * c , который обёрнут в Promise с использованием Promise.resolve . Эта обёртка возвращается.

Решение

Воспользуемся async/await для решения гипотетической задачи, поставленной в начале статьи:

Глубокое погружение в асинхронные JavaScript функции

Создаём асинхронную функцию finishMyTask и используем await для ожидания результата таких операций, как queryDatabase , sendEmail и logTaskInFile .

Если сравним с первым решением на базе Promise, обнаружим, что это примерно та же строчка кода. Тем не менее, async/await упростил синтаксис. Отсутствуют множественные обратные вызовы и .then / .catch .

Теперь решим задачу с числами, приведенную выше. Вот две реализации:

Если хотите, запустите код самостоятельно в консоли repl.it.

Использование асинхронной функции с самого начала упростило бы задачу намного.

Обработка ошибок

Помните, что необработанная Error() оборачивается в отклонённый Promise? Несмотря на это, допускается использование try-catch в асинхронных функциях для синхронной обработки ошибок. Начнём с этой служебной функции:

canRejectOrReturn() – асинхронная функция, которая либо выполняется с результатом 'идеальное число' , либо отклоняется с Error('Извините, слишком большое число') .

Смотрите пример кода:

Поскольку ожидаем canRejectOrReturn , его собственное отклонение превращается в ошибку, и блок catch выполняется. То есть, foo завершится либо с результатом undefined (потому что ничего не возвращаем в try ), либо с 'ошибка перехвачена' . Отклонения не произойдёт, так как использовали блок try-catch для обработки ошибки внутри функции foo .

Ещё один пример:

На этот раз возвращаем (а не ожидаем) canRejectOrReturn из foo . foo либо выполнится с результатом 'идеальное число' , либо отклонится с Error('Извините, слишком большое число') . Блок catch не будет выполнен.

Почему так? Просто возвращаем Promise, который вернул canRejectOrReturn . Следовательно, выполнение foo становится выполнением canRejectOrReturn . Разделим return canRejectOrReturn() на две строки для большей ясности. Обратите внимание на отсутствие await в первой строке:

И посмотрим, как использовать await и return вместе:

В этом случае foo завершится либо с результатом 'идеальное число' , либо с 'ошибка перехвачена' . Здесь нет отклонения. Это как первый пример, только с await . За исключением того, что получаем значение, которое создаёт canRejectOrReturn , а не undefined .

Прервём return await canRejectOrReturn(); , чтобы увидеть эффект:

Отсутствие await

Иногда забываем добавить ключевое слово await перед Promise или вернуть его. Вот пример:

Обратите внимание, что не используется await или return . foo всегда завершается с результатом undefined без ожидания 1 секунду. Тем не менее, Promise начинает выполнение. Это запустит побочные эффекты. Если появится ошибка или отклонение, будет выдано UnhandledPromiseRejectionWarning .

Асинхронные функции в обратных вызовах

Часто используем асинхронные функции в .map или .filter в качестве обратных вызовов. Рассмотрим пример. Предположим, функция fetchPublicReposCount(username) извлекает количество общедоступных GitHub-репозиториев пользователя. Три пользователя для обработки. Посмотрим код:

Хотим получить количество репозиториев ['ArfatSalman', 'octocat', 'norvig'] . Сделаем так:

Обратите внимание на async в обратном вызове .map . Ожидаем, что переменная counts будет содержать количество репов. Но асинхронные функции возвращают Promise. Следовательно, counts на самом деле – массив из Promise. .map запускает анонимный обратный вызов для каждого username , и при каждом вызове возвращается Promise, который .map хранит в результирующем массиве.

Слишком последовательное использование await

Смотрите на такое решение:

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

Если для одной выборки требуется 300 мс, то fetchAllCounts будет занимать ~ 900 мс для 3 пользователей. Как видим, время линейно растёт с увеличением количества пользователей. Поскольку выборка репов не взаимозависимая, распараллелим операцию.

Получаем пользователей одновременно, а не последовательно с использованием .map и Promise.all .

Promise.all принимает массив Promise на входе и возвращает Promise на выходе. Конечный Promise получает массив результатов всех Promise или становится rejected при первом отклонении. Для частичного параллелизма смотрите p-map.

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

Читайте также: