что такое асинхронность в python
Введение в асинхронное программирование на Python
Всем привет. Подготовили перевод интересной статьи в преддверии старта базового курса «Разработчик Python».
Введение
Асинхронное программирование – это вид параллельного программирования, в котором какая-либо единица работы может выполняться отдельно от основного потока выполнения приложения. Когда работа завершается, основной поток получает уведомление о завершении рабочего потока или произошедшей ошибке. У такого подхода есть множество преимуществ, таких как повышение производительности приложений и повышение скорости отклика.
В последние несколько лет асинхронное программирование привлекло к себе пристальное внимание, и на то есть причины. Несмотря на то, что этот вид программирования может быть сложнее традиционного последовательного выполнения, он гораздо более эффективен.
Например, вместо того, что ждать завершения HTTP-запроса перед продолжением выполнения, вы можете отправить запрос и выполнить другую работу, которая ждет своей очереди, с помощью асинхронных корутин в Python.
Асинхронность – это одна из основных причин популярности выбора Node.js для реализации бэкенда. Большое количество кода, который мы пишем, особенно в приложениях с тяжелым вводом-выводом, таком как на веб-сайтах, зависит от внешних ресурсов. В нем может оказаться все, что угодно, от удаленного вызова базы данных до POST-запросов в REST-сервис. Как только вы отправите запрос в один из этих ресурсов, ваш код будет просто ожидать ответа. С асинхронным программированием вы позволяете своему коду обрабатывать другие задачи, пока ждете ответа от ресурсов.
Как Python умудряется делать несколько вещей одновременно?
1. Множественные процессы
Самый очевидный способ – это использование нескольких процессов. Из терминала вы можете запустить свой скрипт два, три, четыре, десять раз, и все скрипты будут выполняться независимо и одновременно. Операционная система сама позаботится о распределении ресурсов процессора между всеми экземплярами. В качестве альтернативы вы можете воспользоваться библиотекой multiprocessing, которая умеет порождать несколько процессов, как показано в примере ниже.
2. Множественные потоки
Еще один способ запустить несколько работ параллельно – это использовать потоки. Поток – это очередь выполнения, которая очень похожа на процесс, однако в одном процессе вы можете иметь несколько потоков, и у всех них будет общий доступ к ресурсам. Однако из-за этого написать код потока будет сложно. Аналогично, все тяжелую работу по выделению памяти процессора сделает операционная система, но глобальная блокировка интерпретатора (GIL) позволит только одному потоку Python запускаться в одну единицу времени, даже если у вас есть многопоточный код. Так GIL на CPython предотвращает многоядерную конкурентность. То есть вы насильно можете запуститься только на одном ядре, даже если у вас их два, четыре или больше.
3. Корутины и yield :
Корутины – это обобщение подпрограмм. Они используются для кооперативной многозадачности, когда процесс добровольно отдает контроль ( yield ) с какой-то периодичностью или в периоды ожидания, чтобы позволить нескольким приложениям работать одновременно. Корутины похожи на генераторы, но с дополнительными методами и небольшими изменениями в том, как мы используем оператор yield. Генераторы производят данные для итерации, в то время как корутины могут еще и потреблять данные.
4. Асинхронное программирование
Четвертый способ – это асинхронное программирование, в котором не участвует операционная система. Со стороны операционной системы у вас останется один процесс, в котором будет всего один поток, но вы все еще сможете выполнять одновременно несколько задач. Так в чем тут фокус?
Asyncio – модуль асинхронного программирования, который был представлен в Python 3.4. Он предназначен для использования корутин и future для упрощения написания асинхронного кода и делает его почти таким же читаемым, как синхронный код, из-за отсутствия callback-ов.
В следующем примере, мы запускаем 3 асинхронных таска, которые по-отдельности делают запросы к Reddit, извлекают и выводят содержимое JSON. Мы используем aiohttp – клиентскую библиотеку http, которая гарантирует, что даже HTTP-запрос будет выполнен асинхронно.
Использование Redis и Redis Queue RQ
Использование asyncio и aiohttp не всегда хорошая идея, особенно если вы пользуетесь более старыми версиями Python. К тому же, бывают моменты, когда вам нужно распределить задачи по разным серверам. В этом случае можно использовать RQ (Redis Queue). Это обычная библиотека Python для добавления работ в очередь и обработки их воркерами в фоновом режиме. Для организации очереди используется Redis – база данных ключей/значений.
В примере ниже мы добавили в очередь простую функцию count_words_at_url с помощью Redis.
Заключение
В качестве примера возьмем шахматную выставку, где один из лучших шахматистов соревнуется с большим количеством людей. У нас есть 24 игры и 24 человека, с которыми можно сыграть, и, если шахматист будет играть с ними синхронно, это займет не менее 12 часов (при условии, что средняя игра занимает 30 ходов, шахматист продумывает ход в течение 5 секунд, а противник – примерно 55 секунд.) Однако в асинхронном режиме шахматист сможет делать ход и оставлять противнику время на раздумья, тем временем переходя к следующему противнику и деля ход. Таким образом, сделать ход во всех 24 играх можно за 2 минуты, и выиграны они все могут быть всего за один час.
Это и подразумевается, когда говорят о том, что асинхронность ускоряет работу. О такой быстроте идет речь. Хороший шахматист не начинает играть в шахматы быстрее, просто время более оптимизировано, и оно не тратится впустую на ожидание. Так это работает.
По этой аналогии шахматист будет процессором, а основная идея будет заключаться в том, чтобы процессор простаивал как можно меньше времени. Речь о том, чтобы у него всегда было занятие.
На практике асинхронность определяется как стиль параллельного программирования, в котором одни задачи освобождают процессор в периоды ожидания, чтобы другие задачи могли им воспользоваться. В Python есть несколько способов достижения параллелизма, отвечающих вашим требованиям, потоку кода, обработке данных, архитектуре и вариантам использования, и вы можете выбрать любой из них.
Асинхронный Python: различные формы конкурентности
Определение терминов:
Прежде чем мы углубимся в технические аспекты, важно иметь некоторое базовое понимание терминов, часто используемых в этом контексте.
Синхронный и асинхронный:
В синхронных операциях задачи выполняются друг за другом. В асинхронных задачи могут запускаться и завершаться независимо друг от друга. Одна асинхронная задача может запускаться и продолжать выполняться, пока выполнение переходит к новой задаче. Асинхронные задачи не блокируют (не заставляют ждать завершения выполнения задачи) операции и обычно выполняются в фоновом режиме.
Например, вы должны обратиться в туристическое агентство, чтобы спланировать свой следующий отпуск. Вам нужно отправить письмо своему руководителю, прежде чем улететь. В синхронном режиме, вы сначала позвоните в туристическое агентство, и если вас попросят подождать, то вы будете ждать, пока вам не ответят. Затем вы начнёте писать письмо руководителю. Таким образом, вы выполняете задачи последовательно, одна за одной. [синхронное выполнение, прим. переводчика] Но, если вы умны, то пока вас попросили подождать [повисеть на телефоне, прим. переводчика] вы начнёте писать e-mail и когда с вами снова заговорят вы приостановите написание, поговорите, а затем допишете письмо. Вы также можете попросить друга позвонить в агентство, а сами написать письмо. Это асинхронность, задачи не блокируют друг друга.
Конкурентность и параллелизм:
Конкурентность подразумевает, что две задачи выполняются совместно. В нашем предыдущем примере, когда мы рассматривали асинхронный пример, мы постепенно продвигались то в написании письма, то в разговоре с тур. агентством. Это конкурентность.
Когда мы попросили позвонить друга, а сами писали письмо, то задачи выполнялись параллельно.
Параллелизм по сути является формой конкурентности. Но параллелизм зависит от оборудования. Например, если в CPU только одно ядро, то две задачи не могут выполняться параллельно. Они просто делят процессорное время между собой. Тогда это конкурентность, но не параллелизм. Но когда у нас есть несколько ядер [как друг в предыдущем примере, который является вторым ядром, прим. переводчика] мы можем выполнять несколько операций (в зависимости от количества ядер) одновременно.
Потоки и процессы
Python поддерживает потоки уже очень давно. Потоки позволяют выполнять операции конкурентно. Но есть проблема, связанная с Global Interpreter Lock (GIL) из-за которой потоки не могли обеспечить настоящий параллелизм. И тем не менее, с появлением multiprocessing можно использовать несколько ядер с помощью Python.
Рассмотрим небольшой пример. В нижеследующем коде функция worker будет выполняться в нескольких потоках асинхронно и одновременно.
А вот пример выходных данных:
Таким образом мы запустили 5 потоков для совместной работы и после их старта (т.е. после запуска функции worker) операция не ждёт завершения работы потоков прежде чем перейти к следующему оператору print. Это асинхронная операция.
В нашем примере мы передали функцию в конструктор Thread. Если бы мы хотели, то могли бы реализовать подкласс с методом (ООП стиль).
Чтобы узнать больше о потоках, воспользуйтесь ссылкой ниже:
GIL был представлен, чтобы сделать обработку памяти CPython проще и обеспечить наилучшую интеграцию с C(например, с расширениями). GIL — это механизм блокировки, когда интерпретатор Python запускает в работу только один поток за раз. Т.е. только один поток может исполняться в байт-коде Python единовременно. GIL следит за тем, чтобы несколько потоков не выполнялись параллельно.
Краткие сведения о GIL:
Эти ресурсы позволят углубиться в GIL:
Чтобы достичь параллелизма в Python был добавлен модуль multiprocessing, который предоставляет API, и выглядит очень похожим, если вы использовали threading раньше.
Давайте просто пойдем и изменим предыдущий пример. Теперь модифицированная версия использует Процесс вместо Потока.
Что же изменилось? Я просто импортировал модуль multiprocessing вместо threading. А затем, вместо потока я использовал процесс. Вот и всё! Теперь вместо множества потоков мы используем процессы которые запускаются на разных ядрах CPU (если, конечно, у вашего процессора несколько ядер).
С помощью класса Pool мы также можем распределить выполнение одной функции между несколькими процессами для разных входных значений. Пример из официальных документов:
Здесь вместо того, чтобы перебирать список значений и вызывать функцию f по одному, мы фактически запускаем функцию в разных процессах. Один процесс выполняет f(1), другой-f(2), а другой-f (3). Наконец, результаты снова объединяются в список. Это позволяет нам разбить тяжелые вычисления на более мелкие части и запускать их параллельно для более быстрого расчета.
Модуль concurrent.futures большой и позволяет писать асинхронный код очень легко. Мои любимчики ThreadPoolExecutor и ProcessPoolExecutor. Эти исполнители поддерживают пул потоков или процессов. Мы отправляем наши задачи в пул, и он запускает задачи в доступном потоке / процессе. Возвращается объект Future, который можно использовать для запроса и получения результата по завершении задачи.
А вот пример ThreadPoolExecutor:
У меня есть статья о concurrent.futures masnun.com/2016/03/29/python-a-quick-introduction-to-the-concurrent-futures-module.html. Она может быть полезна при более глубоком изучении этого модуля.
Asyncio — что, как и почему?
У вас, вероятно, есть вопрос, который есть у многих людей в сообществе Python — что asyncio приносит нового? Зачем нужен был еще один способ асинхронного ввода-вывода? Разве у нас уже не было потоков и процессов? Давай посмотрим!
Зачем нам нужен asyncio?
Процессы очень дорогостоящие [с точки зрения потребления ресурсов, прим. переводчика] для создания. Поэтому для операций ввода/вывода в основном выбираются потоки. Мы знаем, что ввод-вывод зависит от внешних вещей — медленные диски или неприятные сетевые лаги делают ввод-вывод часто непредсказуемым. Теперь предположим, что мы используем потоки для операций ввода-вывода. 3 потока выполняют различные задачи ввода-вывода. Интерпретатор должен был бы переключаться между конкурентными потоками и давать каждому из них некоторое время по очереди. Назовем потоки — T1, T2 и T3. Три потока начали свою операцию ввода-вывода. T3 завершает его первым. T2 и T1 все еще ожидают ввода-вывода. Интерпретатор Python переключается на T1, но он все еще ждет. Хорошо, интерпретатор перемещается в T2, а тот все еще ждет, а затем перемещается в T3, который готов и выполняет код. Вы видите в этом проблему?
T3 был готов, но интерпретатор сначала переключился между T2 и T1 — это понесло расходы на переключение, которых мы могли бы избежать, если бы интерпретатор сначала переключился на T3, верно?
Asyncio предоставляет нам цикл событий наряду с другими крутыми вещами. Цикл событий (event loop) отслеживает события ввода/вывода и переключает задачи, которые готовы и ждут операции ввода/вывода [цикл событий — программная конструкция, которая ожидает прибытия и производит рассылку событий или сообщений в программе, прим. переводчика].
Идея очень проста. Есть цикл обработки событий. И у нас есть функции, которые выполняют асинхронные операции ввода-вывода. Мы передаем свои функции циклу событий и просим его запустить их для нас. Цикл событий возвращает нам объект Future, словно обещание, что в будущем мы что-то получим. Мы держимся за обещание, время от времени проверяем, имеет ли оно значение (нам очень не терпится), и, наконец, когда значение получено, мы используем его в некоторых других операциях [т.е. мы послали запрос, нам сразу дали билет и сказали ждать, пока придёт результат. Мы периодически проверяем результат и как только он получен мы берем билет и по нему получаем значение, прим. переводчика].
Asyncio использует генераторы и корутины для остановки и возобновления задач. Прочитать детали вы можете здесь:
Прежде чем мы начнём, давайте взглянем на пример:
Обратите внимание, что синтаксис async/await предназначен только для Python 3.5 и выше. Пройдёмся по коду:
Делаем правильный выбор
Только что мы прошлись по самым популярным формам конкурентности. Но остаётся вопрос — что следует выбрать? Это зависит от вариантов использования. Из моего опыта я склонен следовать этому псевдо-коду:
Асинхронное программирование в Python: краткий обзор
Когда говорят о выполнении программ, то под «асинхронным выполнением» понимают такую ситуацию, когда программа не ждёт завершения некоего процесса, а продолжает работу независимо от него. В качестве примера асинхронного программирования можно привести утилиту, которая, работая асинхронно, делает записи в лог-файл. Хотя такая утилита и может дать сбой (например, из-за нехватки свободного места на диске), в большинстве случаев она будет работать правильно и ей можно будет пользоваться в различных программах. Они смогут её вызывать, передавая ей данные для записи, а после этого смогут продолжать заниматься своими делами.
Применение асинхронных механизмов при написании некоей программы означает, что эта программа будет выполняться быстрее, чем без использования подобных механизмов. При этом то, что планируется запускать асинхронно, вроде утилиты для логирования, должно быть написано с учётом возникновения нештатных ситуаций. Например, утилита для логирования, если место на диске закончилось, может просто прекратить логирование, а не «обваливать» ошибкой основную программу.
Выполнение асинхронного кода обычно подразумевает работу такого кода в отдельном потоке. Это — если речь идёт о системе с одноядерным процессором. В системах с многоядерными процессорами подобный код вполне может выполняться процессом, пользующимся отдельным ядром. Одноядерный процессор в некий момент времени может считывать и выполнять лишь одну инструкцию. Это напоминает чтение книг. Нельзя читать две книги одновременно.
Если вы читаете книгу, а кто-то даёт вам ещё одну книгу, вы можете взять эту вторую книгу и приступить к её чтению. Но первую придётся отложить. По такому же принципу устроено и многопоточное выполнение кода. А если бы несколько ваших копий читало бы сразу несколько книг, то это было бы похоже на то, как работают многопроцессорные системы.
Если на одноядерном процессоре очень быстро переключаться между задачами, требующими разной вычислительной мощности (например — между некими вычислениями и чтением данных с диска), тогда может возникнуть такое ощущение, что единственное процессорное ядро одновременно делает несколько дел. Или, скажем, подобное происходит в том случае, если попытаться открыть в браузере сразу несколько сайтов. Если для загрузки каждой из страниц браузер использует отдельный поток — тогда всё будет сделано гораздо быстрее, чем если бы эти страницы загружались бы по одной. Загрузка страницы — не такая уж и сложная задача, она не использует ресурсы системы по максимуму, в результате одновременный запуск нескольких таких задач оказывается весьма эффективным ходом.
Асинхронное программирование в Python
Изначально в Python для решения задач асинхронного программирования использовались корутины, основанные на генераторах. Потом, в Python 3.4, появился модуль asyncio (иногда его название записывают как async IO ), в котором реализованы механизмы асинхронного программирования. В Python 3.5 появилась конструкция async/await.
Для того чтобы заниматься асинхронной разработкой на Python, нужно разобраться с парой понятий. Это — корутины (coroutine) и задачи (task).
Корутины
Обычно корутина — это асинхронная (async) функция. Корутина может быть и объектом, возвращённым из корутины-функции.
Если при объявлении функции указано то, что она является асинхронной, то вызывать её можно с использованием ключевого слова await :
Такая конструкция означает, что программа будет выполняться до тех пор, пока не встретит await-выражение, после чего вызовет функцию и приостановит своё выполнение до тех пор, пока работа вызванной функции не завершится. После этого возможность запуститься появится и у других корутин.
Задачи
Задачи позволяют запускать корутины в цикле событий. Это упрощает управление выполнением нескольких корутин. Вот пример, в котором используются корутины и задачи. Обратите внимание на то, что сущности, объявленные с помощью конструкции async def — это корутины. Этот пример взят из официальной документации Python.
Если запустить код примера, то на экран будет выведен текст, подобный следующему:
Обратите внимание на то, что отметки времени в первой и последней строках отличаются на 2 секунды. Если же запустить этот пример с последовательным вызовом корутин, то разница между отметками времени составит уже 3 секунды.
Пример
Итоги
Существуют ситуации, в которых использование задач и корутин оказывается весьма полезным Например, если в программе присутствует смесь операций ввода-вывода и вычислений, или если в одной и той же программе выполняются разные вычисления, можно решать эти задачи, запуская код в конкурентном, а не в последовательном режиме. Это способствует сокращению времени, необходимого программе на выполнение определённых действий. Однако это не позволяет, например, выполнять вычисления одновременно. Для организации подобных вычислений применяется мультипроцессинг. Это — отдельная большая тема.
Уважаемые читатели! Как вы пишете асинхронный Python-код?
Python 3.5 Асинхронное программирование с использованием asyncio
Асинхронное программирование
В последние годы асинхронное программирование приобрело большую популярность. Python 3.5 наконец-то получил некоторые синтаксические функции, закрепляющие концепции асинхронных решений. Но это не значит, что асинхронное программирование стало возможным только начиная с Python 3.5. Многие библиотеки и фреймворки были предоставлены намного раньше, и большинство из них имеют происхождение в старых версиях Python 2. Существует даже целая альтернативная реализация Python, называемая Stackless (см. Главу 1 «Текущее состояние Python»), которая сосредоточена на этом едином подходе программирования. Для некоторых решений, таких как Twisted, Tornado или Eventlet, до сих пор существуют активные сообщества, и их действительно стоит знать. В любом случае, начиная с Python 3.5, асинхронное программирование стало проще, чем когда-либо прежде. Таким образом, ожидается, что его встроенные асинхронные функции заменят большую часть старых инструментов, или внешние проекты постепенно превратятся в своего рода высокоуровневые фреймворки, основанные на встроенных в Python.
При попытке объяснить, что такое асинхронное программирование, проще всего думать об этом подходе как о чем-то похожем на потоки, но без системного планированщика. Это означает, что асинхронная программа может одновременно обрабатывать задачи, но ее контекст переключается внутри, а не системным планировщиком.
Но, конечно, мы не используем потоки для параллельной обработки задач в асинхронной программе. Большинство решений используют разные концепции и, в зависимости от реализации, называются по-разному. Некоторые примеры имен, используемых для описания таких параллельных программных объектов:
Совместная многозадачность и асинхронный ввод / вывод
Совместная многозадачность является ядром асинхронного программирования. В этом смысле многозадачность в операционной системе не обязана инициировать переключение контекста (другому процессу или потоку), а вместо этого каждый процесс добровольно освобождает управление, когда находится в режиме ожидания, чтобы обеспечить одновременное выполнение нескольких программ. Вот почему это называется совместным. Все процессы должны сотрудничать для того, чтобы многозадачность осуществлялась успешно.
Модель многозадачности иногда использовалась в операционных системах, но сейчас ее вряд ли можно найти в качестве решения системного уровня. Это связано с тем, что существует риск того, что одна плохо спроектированная служба может легко нарушить стабильность всей системы. Планирование потоков и процессов с помощью переключателей контекста, управляемых непосредственно операционной системой, в настоящее время является доминирующим подходом для параллелизма на системном уровне. Но совместная многозадачность все еще является отличным инструментом параллелизма на уровне приложений.
Говоря о совместной многозадачности на уровне приложений, мы не имеем дело с потоками или процессами, которым необходимо освободить управление, поскольку все выполнение содержится в одном процессе и потоке. Вместо этого у нас есть несколько задач (сопрограммы, tasklets и зеленые потоки), которые передают управление одной функции, управляющей координацией задач. Эта функция обычно является своего рода циклом событий.
Чтобы избежать путаницы (из-за терминологии Python), теперь мы будем называть такие параллельные задачи сопрограммами. Самая важная проблема в совместной многозадачности — когда передать контроль. В большинстве асинхронных приложений управление передается планировщику или циклу событий при операциях ввода-вывода. Независимо от того, читает ли программа данные из файловой системы или осуществляет связь через сокет, такая операция ввода-вывода всегда связана с некоторым временем ожидания, когда процесс становится бездействующим. Время ожидания зависит от внешнего ресурса, поэтому это хорошая возможность освободить управление, чтобы другие сопрограммы могли выполнять свою работу, пока им тоже не придется ждать, что такой подход несколько похожим по поведению на то, как многопоточность реализована в Python. Мы знаем, что GIL сериализует потоки Python, но он также освобождается при каждой операции ввода-вывода. Основное различие заключается в том, что потоки в Python реализованы как потоки системного уровня, поэтому операционная система может в любой момент выгрузить текущий запущенный поток и передать управление другому.
В асинхронном программировании задачи никогда не прерываются главным циклом событий. Вот почему этот стиль многозадачности также называется не приоритетной многозадачностью.
Конечно, каждое приложение Python работает в операционной системе, где есть другие процессы, конкурирующие за ресурсы. Это означает, что операционная система всегда имеет право выгрузить весь процесс и передать управление другому. Но когда наше асинхронное приложение запускается обратно, оно продолжается с того же места, где оно было приостановлено, когда системный планировщик вмешался. Именно поэтому сопрограммы в данном контексте считаются не вытесняющими.
Ключевые слова async и await в Python
Ключевые слова async и await являются основными строительными блоками в асинхронном программировании Python.
Ключевое слово async, используемое перед оператором def, определяет новую сопрограмму. Выполнение функции сопрограммы может быть приостановлено и возобновлено в строго определенных обстоятельствах. Его синтаксис и поведение очень похожи на генераторы (см. Главу 2 «Рекомендации по синтаксису» ниже уровня класса). Фактически, генераторы должны использоваться в более старых версиях Python для реализации сопрограмм. Вот пример объявления функции, которая использует ключевое слово async:
Функции, определенные с помощью ключевого слова async, являются специальными. При вызове они не выполняют код внутри, а вместо этого возвращают объект сопрограммы:
Объект сопрограммы (coroutine object) ничего не делает, пока его выполнение не запланировано в цикле событий. Модуль asyncio доступен для предоставления базовой реализации цикла событий, а также множества других асинхронных утилит:
Естественно, создавая только одну простую сопрограмму, в нашей программе мы не осуществляем параллелизма. Чтобы увидеть что-то действительно параллельное, нам нужно создать больше задач, которые будут выполняться циклом событий.
Новые задачи можно добавить в цикл, вызвав метод loop.create_task () или предоставив другой объект для ожидания использования функции asyncio.wait (). Мы будем использовать последний подход и попытаемся асинхронно напечатать последовательность чисел, сгенерированных с помощью функции range ():
Функция asyncio.wait () принимает список объектов сопрограмм и немедленно возвращается. Результатом является генератор, который выдает объекты, представляющие будущие результаты (фьючерсы). Как следует из названия, он используется для ожидания завершения всех предоставленных сопрограмм. Причина, по которой он возвращает генератор вместо объекта сопрограммы, заключается в обратной совместимости с предыдущими версиями Python, что будет объяснено позже. Результат выполнения этого скрипта может быть следующим:
Как мы видим, числа печатаются не в том порядке, в котором мы создали наши сопрограммы. Но это именно то, чего мы хотели достичь.
Второе важное ключевое слово, добавленное в Python 3.5, await. Он используется для ожидания результатов сопрограммы или будущего события (поясняется позже) и освобождения контроля над выполнением в цикле событий. Чтобы лучше понять, как это работает, нам нужно рассмотреть более сложный пример кода.
Допустим, мы хотим создать две сопрограммы, которые будут выполнять некоторые простые задачи в цикле:
При выполнении в терминале (с помощью команды времени для измерения времени) можно увидеть:
Как мы видим, обе сопрограммы завершили свое выполнение, но не асинхронно. Причина в том, что они обе используют функцию time.sleep (), которая блокирует, но не освобождает элемент управления в цикле событий. Это будет работать лучше в многопоточной установке, но мы не хотим сейчас использовать потоки. Итак, как мы можем это исправить?
Ответ заключается в том, чтобы использовать asyncio.sleep (), которая является асинхронной версией time.sleep (), и ожидать результата с помощью ключевого слова await. Мы уже использовали это утверждение в первой версии функции main (), но это было только для улучшения ясности кода. Это явно не делало нашу реализацию более параллельной. Давайте посмотрим на улучшенную версию сопрограммы waiter (), которая использует await asyncio.sleep():
Запустив обновленный скрипт, мы увидим, как выходные данные двух функций чередуются друг с другом:
Дополнительным преимуществом этого простого улучшения является то, что код работает быстрее. Общее время выполнения было меньше, чем сумма всех времен сна, потому что сопрограммы поочередно освобождали контроль.
Asyncio в предыдущих версиях Python
Модуль asyncio появился в Python 3.4. Так что это единственная версия Python, которая имеет серьезную поддержку асинхронного программирования до Python 3.5. К сожалению, похоже, что этих двух последующих версий достаточно, чтобы представить проблемы совместимости.
Как ни крути, ядро асинхронного программирования в Python было введено раньше, чем элементы синтаксиса, поддерживающие этот шаблон. Лучше поздно, чем никогда, но это создало ситуацию, когда есть два синтаксиса для работы с сопрограммами.
Начиная с Python 3.5, вы можете использовать async и await:
Однако в Python 3.4, прийдется дополнительно применить asyncio.coroutine декоратор и yield в тексте сопрограммы:
Другим полезным фактом является то, что оператор yield from был введен в Python 3.3, а в PyPI имеется асинхронный бэкпорт. Это означает, что вы также можете использовать эту реализацию совместной многозадачности с Python 3.3.
Практический пример асинхронного программирования
Как уже неоднократно упоминалось в этой главе, асинхронное программирование является отличным инструментом для обработки операций ввода-вывода. Настало время создать что-то более практичное, чем простая печать последовательностей или асинхронное ожидание.
В целях обеспечения согласованности мы попытаемся решить ту же проблему, которую решили с помощью многопоточности и многопроцессорности. Поэтому мы попытаемся асинхронно извлечь некоторые данные из внешних ресурсов через сетевое соединение. Было бы здорово, если бы мы могли использовать тот же пакет python-gmaps, что и в предыдущих разделах. К сожалению, мы не можем.
Создатель python-gmaps был немного ленив и взял лишь название. Чтобы упростить разработку, он выбрал пакет запросов в качестве своей клиентской библиотеки HTTP. К сожалению, запросы не поддерживают асинхронный ввод-вывод с async и await. Есть некоторые другие проекты, которые нацелены на обеспечение некоторого параллелизма для проекта запросов, но они либо полагаются на Gevent (grequests, см. Https://github.com/ kennethreitz / grequests), либо на выполнение пула потоков / процессов (запросы-futures, обратитесь к github.com/ross/requests-futures). Ни один из них не решает нашу проблему.
Прежде чем уперкать, что я ругаю невинного разработчика с открытым исходным кодом, успокойся. Человек, стоящий за пакетом python-gmaps, это я. Плохой выбор зависимостей является одной из проблем этого проекта. Мне просто нравится время от времени публично критиковать себя. Для меня это будет горьким уроком, так как python-gmaps в его последней версии (0.3.1 на момент написания этой книги) не может быть легко интегрирован с асинхронным вводом-выводом Python. В любом случае, это может измениться в будущем, поэтому ничего не потеряно.
Зная об ограничениях библиотеки, которую было так легко использовать в предыдущих примерах, нам нужно создать что-то, что заполнит этот пробел. Google MapsAPI действительно прост в использовании, поэтому мы создадим на скорую руку асинхронную утилиту только для иллюстрации. В стандартной библиотеке Python версии 3.5 по-прежнему отсутствует библиотека, которая бы выполняла асинхронные HTTP-запросы так же просто, как вызов urllib.urlopen (). Мы определенно не хотим создавать полную поддержку протокола с нуля, поэтому мы будем использовать небольшую справку из пакета aiohttp, доступного в PyPI. Это действительно многообещающая библиотека, которая добавляет как клиентские, так и серверные реализации для асинхронного HTTP. Вот небольшой модуль, построенный поверх aiohttp, который создает одну вспомогательную функцию geocode (), которая выполняет запросы геокодирования к службе Google Maps API:
Давайте предположим, что этот код хранится в модуле с именем asyncgmaps, который мы будем использовать позже. Теперь мы готовы переписать пример, используемый при обсуждении многопоточности и многопроцессорности. Ранее мы использовали для разделения всей операции на два отдельных этапа:
Асинхронное программирование отлично подходит для бэкэнд-разработчиков, заинтересованных в создании масштабируемых приложений. На практике это один из наиболее важных инструментов для создания высококонкурентных серверов.
Но реальность печальна. Многие популярные пакеты, которые имеют дело с проблемами ввода-вывода, не предназначены для использования с асинхронным кодом. Основными причинами этого являются:
Другая проблема — длительные операции с привязкой к процессору. Когда вы выполняете операцию ввода-вывода, не проблема выпустить управление из сопрограммы. При записи / чтении из файловой системы или сокета вы, в конце концов, будете ждать, так что вызов с использованием await — лучшее, что вы можете сделать. Но что делать, если вам нужно что-то вычислить, и вы знаете, что это займет некоторое время? Конечно, вы можете разделить проблему на части и отменить контроль каждый раз, когда вы немного продвигаете работу. Но вскоре вы обнаружите, что это не очень хорошая модель. Такая вещь может сделать код беспорядочным, а также не гарантирует хорошие результаты.
Временная привязка должна быть ответственностью интерпретатора или операционной системы.
Объединение асинхронного кода с асинхронным использованием фьючерсов
Так что делать, если у вас есть код, который выполняет длинные синхронные операции ввода-вывода, которые вы не можете или не хотите переписывать. Или что делать, когда вам приходится выполнять некоторые тяжелые операции с процессором в приложении, разработанном в основном с учетом асинхронного ввода-вывода? Ну… тебе нужно найти обходной путь. И под этим я подразумеваю многопоточность или многопроцессорность.
Это может звучать не очень хорошо, но иногда лучшим решением может быть то, от чего мы пытались убежать. Параллельная обработка ресурсоемких задач в Python всегда выполняется лучше благодаря многопроцессорности. И многопоточность может справляться с операциями ввода-вывода одинаково хорошо (быстро и без больших затрат ресурсов), как асинхронное и ожидающее, если правильно настроена и обрабатывается с осторожностью.
Поэтому иногда, когда вы не знаете, что делать, когда что-то просто не подходит вашему асинхронному приложению, используйте фрагмент кода, который откладывает его на отдельный поток или процесс. Вы можете сделать вид, что это сопрограмма, освободить управление для цикла событий и в конечном итоге обработать результаты, когда они будут готовы.
К счастью для нас, стандартная библиотека Python предоставляет модуль concurrent.futures, который также интегрирован с модулем asyncio. Эти два модуля вместе позволяют вам планировать функции блокировки, выполняемые в потоках или дополнительных процессах, как если бы это были асинхронные неблокирующие сопрограммы.
Исполнители (executors) и фьючерсы (futures)
Прежде чем мы увидим, как внедрить потоки или процессы в асинхронный цикл обработки событий, мы подробнее рассмотрим модуль concurrent.futures, который позже станет основным компонентом нашего так называемого обходного пути.
Наиболее важными классами в модуле concurrent.futures являются Executor и Future.
Executor представляет собой пул ресурсов, которые могут обрабатывать рабочие элементы параллельно. Это может показаться очень похожим по назначению на классы из многопроцессорного модуля — Pool и dummy.Pool — но имеет совершенно другой интерфейс и семантику. Это базовый класс, не предназначенный для реализации и имеющий две конкретные реализации:
Если вы хотите использовать метод Executor.map (), он не отличается по использованию от метода Pool.map () класса Pool многопроцессорного модуля:
Использование Executor в цикле событий
Экземпляры класса Future, возвращаемые методом Executor.submit (), концептуально очень близки к сопрограммам, используемым в асинхронном программировании. Вот почему мы можем использовать исполнителей, чтобы сделать гибрид между совместной многозадачностью и многопроцессорностью или многопоточностью.
Ядром этого обходного пути является метод BaseEventLoop.run_in_executor (executor, func, * args) класса цикла событий. Это позволяет планировать выполнение функции func в процессе или пуле потоков, представленном аргументом executor. Самым важным в этом методе является то, что он возвращает новый ожидаемый объект (объект, который можно ожидать с помощью оператора await). Таким образом, благодаря этому вы можете выполнить блокирующую функцию, которая не является сопрограммой в точности как сопрограмма, и она не будет блокировать, независимо от того, сколько времени потребуется, чтобы закончить. Он остановит только функцию, ожидающую результатов от такого вызова, но весь цикл событий будет продолжаться.
И полезным фактом является то, что вам даже не нужно создавать свой экземпляр executor. Если вы передадите None в качестве аргумента executor, класс ThreadPoolExecutor будет использоваться с числом потоков по умолчанию (для Python 3.5 это число процессоров, умноженное на 5).
Итак, давайте предположим, что мы не хотели переписывать проблемную часть пакета python-gmaps, которая была причиной нашей головной боли. Мы можем легко отложить блокирующий вызов до отдельного потока с помощью вызова loop.run_in_executor (), при этом оставляя функцию fetch_place () в качестве ожидаемой сопрограммы:
Такое решение хуже, чем наличие полностью асинхронной библиотеки для выполнения этой работы, но вы знаете, что хоть что-то лучше, чем ничего.
После объяснения того, что такое параллелизм на самом деле, мы приступили к действиям и проанализировали одну из типичных параллельных проблем с помощью многопоточности. Выявив основные недостатки нашего кода и исправив их, мы обратились к многопроцессорной обработке, чтобы посмотреть, как она будет работать в нашем случае.
После этого мы обнаружили, что с многопроцессорным модулем использовать несколько процессов намного проще, чем базовые потоки с многопоточностью. Но только после этого мы поняли, что можем использовать один и тот же API с потоками, благодаря multiprocessing.dummy. Таким образом, выбор между многопроцессорностью и многопоточностью теперь зависит только от того, какое решение лучше соответствует проблеме, а не какое решение имеет лучший интерфейс.
Говоря о подгонке проблемы, мы наконец-то попробовали асинхронное программирование, которое должно быть лучшим решением для приложений, связанных с вводом / выводом, только чтобы понять, что мы не можем полностью забыть о потоках и процессах. Таким образом, мы сделали круг, обратно в то место, где мы начали!
И это приводит нас к окончательному выводу этой главы. Нет решения, устраивающего всех. Есть несколько подходов, которые вы можете предпочесть или любить больше. Есть некоторые подходы, которые лучше подходят для данного набора проблем, но вам нужно знать их все, чтобы быть успешным. В реалистичных сценариях вы можете использовать весь арсенал инструментов и стилей параллелизма в одном приложении, и это не редкость.
Предыдущий вывод — отличное введение в тему следующей главы, Глава 14 «Полезные шаблоны проектирования». Так как нет единого шаблона, который решит все ваши проблемы. Вы должны знать как можно больше, потому что в конечном итоге вы будете использовать их каждый день.