Асинхронное программирование в Python
Асинхронное программирование на Python становится все более популярным. Для этих целей существует множество различных библиотек. Самая популярная из них — Asyncio, которая является стандартной библиотекой Python 3.4. Из этой статьи вы узнаете, что такое асинхронное программирование и чем отличаются различные библиотеки, реализующие асинхронность в Python.
По очереди
В каждой программе строки кода выполняются поочередно. Например, если у вас есть строка кода, которая запрашивает что-либо с сервера, то это означает, что ваша программа не делает ничего во время ожидания ответа. В некоторых случаях это допустимо, но во многих — нет. Одним из решений этой проблемы являются потоки (threads).
Потоки дают возможность вашей программе выполнять ряд задач одновременно. Конечно, у потоков есть ряд недостатков. Многопоточные программы являются более сложными и, как правило, более подвержены ошибкам. Они включают в себя такие проблемы: состояние гонки (race condition), взаимная (deadlock) и активная (livelock) блокировка, исчерпание ресурсов (resource starvation).
Переключение контекста
Хотя асинхронное программирование и позволяет обойти проблемные места потоков, оно было разработано для совершенно другой цели — для переключения контекста процессора. Когда у вас есть несколько потоков, каждое ядро процессора может запускать только один поток за раз. Для того, чтобы все потоки/процессы могли совместно использовать ресурсы, процессор очень часто переключает контекст. Чтобы упростить работу, процессор с произвольной периодичностью сохраняет всю контекстную информацию потока и переключается на другой поток.
Асинхронное программирование — это потоковая обработка программного обеспечения / пользовательского пространства, где приложение, а не процессор, управляет потоками и переключением контекста. В асинхронном программировании контекст переключается только в заданных точках переключения, а не с периодичностью, определенной CPU.
Эффективный секретарь
Теперь давайте рассмотрим эти понятия на примерах из жизни. Представьте секретаря, который настолько эффективен, что не тратит время впустую. У него есть пять заданий, которые он выполняет одновременно: отвечает на телефонные звонки, принимает посетителей, пытается забронировать билеты на самолет, контролирует графики встреч и заполняет документы. Теперь представьте, что такие задачи, как контроль графиков встреч, прием телефонных звонков и посетителей, повторяются не часто и распределены во времени. Таким образом, большую часть времени секретарь разговаривает по телефону с авиакомпанией, заполняя при этом документы. Это легко представить. Когда поступит телефонный звонок, он поставит разговор с авиакомпанией на паузу, ответит на звонок, а затем вернется к разговору с авиакомпанией. В любое время, когда новая задача потребует внимания секретаря, заполнение документов будет отложено, поскольку оно не критично. Секретарь, выполняющий несколько задач одновременно, переключает контекст в нужное ему время. Он асинхронный.
Потоки — это пять секретарей, у каждого из которых по одной задаче, но только одному из них разрешено работать в определенный момент времени. Для того, чтобы секретари работали в потоковом режиме, необходимо устройство, которое контролирует их работу, но ничего не понимает в самих задачах. Поскольку устройство не понимает характер задач, оно постоянно переключалось бы между пятью секретарями, даже если трое из них сидят, ничего не делая. Около 57% (чуть меньше, чем 3/5) переключения контекста были бы напрасны. Несмотря на то, что переключение контекста процессора является невероятно быстрым, оно все равно отнимает время и ресурсы процессора.
Зеленые потоки
Зеленые потоки (green threads) являются примитивным уровнем асинхронного программирования. Зеленый поток — это обычный поток, за исключением того, что переключения между потоками производятся в коде приложения, а не в процессоре. Gevent — известная Python-библиотека для использования зеленых потоков. Gevent — это зеленые потоки и сетевая библиотека неблокирующего ввода-вывода Eventlet. Gevent.monkey изменяет поведение стандартных библиотек Python таким образом, что они позволяют выполнять неблокирующие операции ввода-вывода. Вот пример использования Gevent для одновременного обращения к нескольким URL-адресам:
Как видите, API-интерфейс Gevent выглядит так же, как и потоки. Однако за кадром он использует сопрограммы (coroutines), а не потоки, и запускает их в цикле событий (event loop) для постановки в очередь. Это значит, что вы получаете преимущества потоков, без понимания сопрограмм, но вы не избавляетесь от проблем, связанных с потоками. Gevent — хорошая библиотека, но только для тех, кто понимает, как работают потоки.
Давайте рассмотрим некоторые аспекты асинхронного программирования. Один из таких аспектов — это цикл событий. Цикл событий — это очередь событий/заданий и цикл, который вытягивает задания из очереди и запускает их. Эти задания называются сопрограммами. Они представляют собой небольшой набор команд, содержащих, помимо прочего, инструкции о том, какие события при необходимости нужно возвращать в очередь.
Функция обратного вызова (callback)
В Python много библиотек для асинхронного программирования, наиболее популярными являются Tornado, Asyncio и Gevent. Давайте посмотрим, как работает Tornado. Он использует стиль обратного вызова (callbacks) для асинхронного сетевого ввода-вывода. Обратный вызов — это функция, которая означает: «Как только это будет сделано, выполните эту функцию». Другими словами, вы звоните в службу поддержки и оставляете свой номер, чтобы они, когда будут доступны, перезвонили, вместо того, чтобы ждать их ответа.
Давайте посмотрим, как сделать то же самое, что и выше, используя Tornado:
Предпоследняя строка кода вызывает метод AsyncHTTPClient.fetch , который получает данные по URL-адресу неблокирующим способом. Этот метод выполняется и возвращается немедленно. Поскольку каждая следующая строка будет выполнена до того, как будет получен ответ по URL-адресу, невозможно получить объект, как результат выполнения метода. Решение этой проблемы заключается в том, что метод fetch вместо того, чтобы возвращать объект, вызывает функцию с результатом или обратный вызов. Обратный вызов в этом примере — handle_response .
В примере вы можете заметить, что первая строка функции handle_response проверяет наличие ошибки. Это необходимо, потому что невозможно обработать исключение. Если исключение было создано, то оно не будет отрабатываться в коде из-за цикла событий. Когда fetch выполняется, он запускает HTTP-запрос, а затем обрабатывает ответ в цикле событий. К тому моменту, когда возникнет ошибка, стек вызовов будет содержать только цикл событий и текущую функцию, при этом нигде в коде не сработает исключение. Таким образом, любые исключения, созданные в функции обратного вызова, прерывают цикл событий и останавливают выполнение программы. Поэтому все ошибки должны быть переданы как объекты, а не обработаны в виде исключений. Это означает, что если вы не проверили наличие ошибок, то они не будут обрабатываться.
Другая проблема с обратными вызовами заключается в том, что в асинхронном программировании единственный способ избегать блокировок — это обратный вызов. Это может привести к очень длинной цепочке: обратный вызов после обратного вызова после обратного вызова. Поскольку теряется доступ к стеку и переменным, вы в конечном итоге переносите большие объекты во все ваши обратные вызовы, но если вы используете сторонние API-интерфейсы, то не можете передать что-либо в обратный вызов, если он этого не может принять. Это также становится проблемой, потому что каждый обратный вызов действует как поток. Например, вы хотели бы вызвать три API-интерфейса и дождаться, пока все три вернут результат, чтобы его обобщить. В Gevent вы можете это сделать, но не с обратными вызовами. Вам придется немного поколдовать, сохраняя результат в глобальной переменной и проверяя в обратном вызове, является ли результат окончательным.
Сравнения
Если вы хотите предотвратить блокировку ввода-вывода, вы должны использовать либо потоки, либо асинхронность. В Python вы выбираете между зелеными потоками и асинхронным обратным вызовом. Вот некоторые из их особенностей:
Зеленые потоки
- потоки управляются на уровне приложений, а не аппаратно;
- включают в себя все проблемы потокового программирования.
Обратный вызов
- сопрограммы невидимы для программиста;
- обратные вызовы ограничивают использование исключений;
- обратные вызовы трудно отлаживаются.
Как решить эти проблемы?
Вплоть до Python 3.3 зеленые потоки и обратный вызов были оптимальными решениями. Чтобы превзойти эти решения, нужна поддержка на уровне языка. Python должен каким-то образом частично выполнить метод, прекратить выполнение, поддерживая при этом объекты стека и исключения. Если вы знакомы с концепциями Python, то понимаете, что я намекаю на генераторы. Генераторы позволяют функции возвращать список по одному элементу за раз, останавливая выполнение до того момента, когда следующий элемент будет запрошен. Проблема с генераторами заключается в том, что они полностью зависят от функции, вызывающей его. Другими словами, генератор не может вызвать генератор. По крайней мере так было до тех пор, пока в PEP 380 не добавили синтаксис yield from , который позволяет генератору получить результат другого генератора. Хоть асинхронность и не является главным назначением генераторов, они содержат весь функционал, чтобы быть достаточно полезными. Генераторы поддерживают стек и могут создавать исключения. Если бы вы написали цикл событий, в котором бы запускались генераторы, у вас получилась бы отличная асинхронная библиотека. Именно так и была создана библиотека Asyncio.
Все, что вам нужно сделать, это добавить декоратор @coroutine , а Asyncio добавит генератор в сопрограмму. Вот пример того, как обработать те же три URL-адреса, что и раньше:
Прим. перев. В примерах используется aiohttp версии 1.3.5. В последней версии библиотеки синтаксис другой.
Несколько особенностей, которые нужно отметить:
- ошибки корректно передаются в стек;
- можно вернуть объект, если необходимо;
- можно запустить все сопрограммы;
- нет обратных вызовов;
- строка 10 не выполнится до тех пор, пока строка 9 не будет полностью выполнена.
Единственная проблема заключается в том, что объект выглядит как генератор, и это может вызвать проблемы, если на самом деле это был генератор.
Async и Await
Библиотека Asyncio довольно мощная, поэтому Python решил сделать ее стандартной библиотекой. В синтаксис также добавили ключевое слово async . Ключевые слова предназначены для более четкого обозначения асинхронного кода. Поэтому теперь методы не путаются с генераторами. Ключевое слово async идет до def , чтобы показать, что метод является асинхронным. Ключевое слово await показывает, что вы ожидаете завершения сопрограммы. Вот тот же пример, но с ключевыми словами async / await:
Программа состоит из метода async . Во время выполнения он возвращает сопрограмму, которая затем находится в ожидании.
Заключение
В Python встроена отличная асинхронная библиотека. Давайте еще раз вспомним проблемы потоков и посмотрим, решены ли они теперь:
- процессорное переключение контекста: Asyncio является асинхронным и использует цикл событий. Он позволяет переключать контекст программно;
- состояние гонки: поскольку Asyncio запускает только одну сопрограмму и переключается только в точках, которые вы определяете, ваш код не подвержен проблеме гонки потоков;
- взаимная/активная блокировка: поскольку теперь нет гонки потоков, то не нужно беспокоиться о блокировках. Хотя взаимная блокировка все еще может возникнуть в ситуации, когда две сопрограммы вызывают друг друга, это настолько маловероятно, что вам придется постараться, чтобы такое случилось;
- исчерпание ресурсов: поскольку сопрограммы запускаются в одном потоке и не требуют дополнительной памяти, становится намного сложнее исчерпать ресурсы. Однако в Asyncio есть пул «исполнителей» (executors), который по сути является пулом потоков. Если запускать слишком много процессов в пуле исполнителей, вы все равно можете столкнуться с нехваткой ресурсов.
Несмотря на то, что Asyncio довольно хорош, у него есть и проблемы. Во-первых, Asyncio был добавлен в Python недавно. Есть некоторые недоработки, которые еще не исправлены. Во-вторых, когда вы используете асинхронность, это значит, что весь ваш код должен быть асинхронным. Это связано с тем, что выполнение асинхронных функций может занимать слишком много времени, тем самым блокируя цикл событий.
Существует несколько вариантов асинхронного программирования в Python. Вы можете использовать зеленые потоки, обратные вызовы или сопрограммы. Хотя вариантов много, лучший из них — Asyncio. Если используете Python 3.5, то вам лучше использовать эту библиотеку, так как она встроена в ядро python.
Реализация асинхронности в Python с модулем asyncio
Асинхронное программирование — это особенность современных языков программирования, которая позволяет выполнять операции, не дожидаясь их завершения. Асинхронность — одна из важных причин популярности Node.js.
Представьте приложение для поиска по сети, которое открывает тысячу соединений. Можно открывать соединение, получать результат и переходить к следующему, двигаясь по очереди. Однако это значительно увеличивает задержку в работе программы. Ведь открытие соединение — операция, которая занимает время. И все это время последующие операции находятся в процессе ожидания.
А вот асинхронность предоставляет способ открытия тысячи соединений одновременно и переключения между ними. По сути, появляется возможность открыть соединение и переходить к следующему, ожидая ответа от первого. Так продолжается до тех пор, пока все не вернут результат.
На графике видно, что синхронный подход займет 45 секунд, в то время как при использовании асинхронности время выполнения можно сократить до 20 секунд.
Где асинхронность применяется в реальном мире?
Асинхронность больше всего подходит для таких сценариев:
- Программа выполняется слишком долго.
- Причина задержки — не вычисления, а ожидания ввода или вывода.
- Задачи, которые включают несколько одновременных операций ввода и вывода.
- Парсеры,
- Сетевые сервисы.
Разница в понятиях параллелизма, concurrency, поточности и асинхронности
Параллелизм — это выполнение нескольких операций за раз. Многопроцессорность — один из примеров. Отлично подходит для задач, нагружающих CPU.
Concurrency — более широкое понятие, которое описывает несколько задач, выполняющихся с перекрытием друг друга.
Поточность — поток — это отдельный поток выполнения. Один процесс может содержать несколько потоков, где каждый будет работать независимо. Отлично подходит для IO-операций.
Асинхронность — однопоточный, однопроцессорный дизайн, использующий многозадачность. Другими словами, асинхронность создает впечатление параллелизма, используя один поток в одном процессе.
Составляющие асинхронного программирования
Разберем различные составляющие асинхронного программирования подробно. Также используем код для наглядности.
Сопрограммы
Сопрограммы (coroutine) — это обобщенные формы подпрограмм. Они используются для кооперативных задач и ведут себя как генераторы Python.
Для определения сопрограммы асинхронная функция использует ключевое слово await . При его использовании сопрограмма передает поток управления обратно в цикл событий (также известный как event loop).
Для запуска сопрограммы нужно запланировать его в цикле событий. После этого такие сопрограммы оборачиваются в задачи ( Tasks ) как объекты Future .
Пример сопрограммы
В коде ниже функция async_func вызывается из основной функции. Нужно добавить ключевое слово await при вызове синхронной функции. Функция async_func не будет делать ничего без await .
Асинхронная модель программирования
Большинство языков программирования по умолчанию поддерживают однопоточную синхронную модель программирования: методы выполняются последовательно в едином потоке выполнения, из которого они были вызваны. При использовании данной модели необходимо ждать, пока выполнится каждый метод, прежде чем будет запущен следующий. Если метод выполняется долгое время, например, если он загружает большие объемы данных или ждет ответа от сервера, поток выполнения блокируется, пока этот метод не закончит работу и не вернет управление. Для некоторых программ использование синхронной модели негативно влияет на производительность.
В другом распространенном подходе, асинхронной модели программирования, выполнение методов чередуется: можно перейти к другому методу до того, как текущий закончит работу. Все методы выполняются в едином потоке и явно передают ему управление: в любой момент времени можно быть уверенным, что выполняется ровно один метод. В сущности, асинхронное программирование — это программирование, где порядок исполнения неизвестен заранее.
В сравнении с синхронной моделью асинхронная работает лучше, когда:
— Есть большое количество методов, так что скорее всего постоянно существует хотя бы один метод, который ничего не ожидает и может продвигаться в выполнении.
— Методы выполняют много операций ввода/вывода, что заставило бы синхронную программу потратить много времени в заблокированном состоянии, ожидая данных, пока другие методы могли бы выполняться.
— Методы преимущественно независимы друг от друга, так что нет необходимости взаимодействия между ними (и, следовательно, нет необходимости одному методу ждать другой).
Модуль Asyncio в Python
Модуль asyncio был добавлен в основную библиотеку python в версии 3.4.
Asyncio использует однопоточный, однопроцессный подход, в котором части приложения взаимодействуют, чтобы явно переключать задачи в оптимальное время. Наиболее часто это переключение происходит, когда иначе программа бы была заблокирована, ожидая чтения или записи данных, syncio также включает поддержку планирования запуска кода в определенное время в будущем, для обеспечения ожидания одной сопрограммы (coroutine) завершения другой, для обработки системных сигналов и для распознавания событий, которые могут стать причиной смены исполняемых приложением задач.
Модуль предоставляет инфраструктуру для написания однопоточного конкурентного кода при помощи сопрограмм (corutines), мультиплексирования ввода/вывода данных через сокеты и другие ресурсы, запуска сетевых клиентов и серверов, и другие подобные примитивы.
Asyncio оперирует следующими терминами:
— event loop (цикл событий). Главная функция цикла событий — ожидание какого-либо события и определенная реакция на него. Доступно несколько реализаций цикла для эффективного использования преимуществ каждой операционной системы. Подходящяя реализация цикла по умолчанию выбирается автоматически, однако есть возможность явно выбрать нужную реализацию. Приложение взаимодействует с циклом событий, чтобы зарегистрировать код для выполнения, и дает циклу событий выполнять нужные вызовы кода приложения, когда ресурсы становятся доступны. Например, сетевой сервер открывает сокеты и регистрирует их, чтобы было обработано событие создания нового соединения или получения данных. Цикл событий называется циклом, потому что он постоянно собирает события и циклически проходит по ним, определяя, что делать с каждым событием: вызывает определенный код в ответ на события, которые ему известны.
— coroutine (сопрограмма) — специальная функция, которая возвращает управление объекту, вызвавшему её, сохраняя при этом своё состояние. При вызове функции-сопрограммы она не выполняется. Вместо этого она возвращает исполняемый объект, который передается циклу событий, и уже цикл событий ответственен за выполнение этого объекта немедленно или позже по расписанию. Сопрограммы похожи на функции-генераторы и могут быть реализованы как генераторы в версиях python ранее 3.5, в которых не было поддержки нативного синтаксиса сопрограмм.
— future — структура данных, представляющая собой результат работы, которая еще не была завершена. Цикл событий следит за future-объектами и ждет их завершения. Когда future завершает свою работу, он отмечается выполненым. Помимо этого asyncio поддерживает блокировки (locks) и семафоры (semaphores).
— task — представляет собой обертку сопрограммы и наследуется от Future. Задачу можно запланировать при помощи цикла событий, чтобы она выполнялась при наличии необходимых ресурсов, и производила результат, который мог быть использован другими корутинами.
— async и await — ключевые слова, появившиеся в python версии 3.5 для обозначения функций как сопрограмм для использования циклом событий.
Цикл событий занимается:
— Регистрацией, выполнением и отменой отложенных вызовов (таймаутов);
— Созданием клиентского и серверного “транспорта” для различных видов взаимодействий;
— Запуском подпроцессов и связанного “транспорта” для взаимодействия с внешними программами;
— Передачей дорогостоящих вызовов функций в пул потоков.
Пример простого приложения, использующего asyncio:
import asyncio
async def speak_async():
print(‘Inside coroutine’)loop = asyncio.get_event_loop()
loop.run_until_complete(speak_async())
loop.close()
Чтобы запустить выполнение сопрограмм, необходимо создать цикл событий и вызвать в нем сопрограммы. Вызов метода, определенного как сопрограмма, возвращает объект сопрограммы, который может использоваться циклом событий. Можно приостановить выполнение сопрограммы, используя ключевое слово await (или yield from в ранних версиях python). При выполнении следующего за ключевым словом выражения возможно переключение с текущей сопрограммы на другую или на основной поток выполнения. Выражение после ключевого слова await должно быть awaitable-объектом (другой сопрограммой или специальным объектом, у которого реализован метод __await__). При передаче управления в цикл событий, состояние процесса, приостановившего сопрограмму отслеживается. После его завершения цикл событий передает управление обратно в приостановленную сопрограмму, ожидающую результата, которая продолжает работу.
Асинхронность в Python
Для запуска задач параллельно необходимо использовать библиотеку multiprocessing . Выполнение задач "одновременно"
возможно с помощью концепций КОНКУРЕНТНОСТИ и ПАРАЛЛЕЛЬНОСТИ. Об этом очень неплохо рассказано в видео Роба Пайка: (англ.) линк.
Конкурентность — разбиение задач на блоки и определение того, как будет осуществляться переключение между этими задачами. Другими словами, 2 большие задачи может выполнять один и тот же работник, переключаясь между ними.
Благодаря конкурентности можно поставить одновременно выполняться 2 задачи: большую и маленькую, и маленькая выполнится быстрее, независимо от того, в каком порядке они были вызваны.
Параллельность — выполнение 2 задач одновременно. Один работник не может выполнять параллельно 2 задачи, поэтому используем несколько (например, несколько ядер процессоров)
Еще есть работа в тредах (Threads), или на русском, потоках
В обеих библиотеках есть потоки, где и выполняется код, но отличие Asyncio в том, что тут вся работа явно выполняется в одном потоке (объекте loop)!
И место, где осуществлять передачу управления другой задаче определяет именно программист. В asyncio именно задачи выполняются конкурентно, но используют они общий поток.
А в тредах потоков может быть несколько, и решение какому потоку передать управление — принимает операционная система (ОС)
Из-за этого существуют проблемы с тем, как и какие данные используются в потоках и могут возникать сложно-отслеживаемые
баги и race-condition.
CPU-bound vs I/O-bound
В программировании часто различают эти два типа задач, чтобы определить правильный подход к оптимизации кода:
CPU (central processing unit) — процессор, а cpu-bound задачи это те задачи, которые завязаны на вычислительной мощности процессоров. Если процессор тратит много времени на их решение, то значит нужно ускорять за счет увеличения количества
процессоров, или увеличения скорости его работы.
I/O (Input/Output) — ввод-вывод. Это способ взаимодействия программы с другими ее частями, или со внешним миром.
I/O-bound задачи обычно блокируют выполнение всего кода, потому что они запускают вечный цикл опроса какого-то
устройства (или например, запрос в интернет) и не завершают его, пока не получат ответ.
Библиотека Asyncio (от слов Async и I/O) решает проблему ввода-вывода с помощью асинхронного переключения между
такими задачами. Теперь вечный цикл работает не на одну задачу, а на все сразу.
Для того чтобы сделать это возможным, программист "делит" свою программу на подзадачи и "говорит" где одной функции
приостановиться, а где другой.
Абстрактный пример №1:
В жаркий летний день, перед тем как отправиться домой, курьер доставки взял 2 (точки "B" и "D") заказа, которые
находятся по разные стороны от него. Доставлять заказы он будет на велосипеде.
При этом, по пути к "B", нужно заехать в пиццерию (точка "A"), а по пути к заказу "D" в ресторан (точка "C").
По пути к точке "A" он вспоминает что нужно домой купить продукты, но он не знает какие. Курьер знает, что где-то на
половине маршрута между каждыми 2-мя точками он может приостановиться. Он приостанавливается в точке "F" и отправляет
сообщение жене:
Забрав заказ из пиццерии ("A"), по дороге к пункту "B", курьер останавливается в продуктовом магазине ("G"), покупает
продукты и продолжает свой путь к точке "B".
После того как он выполнил заказ, он направляется в магазин вин (точка "H") и покупает бутылочку вина, а потом забирает
заказ из ресторана (точка "C") и отвозит его клиенту ("D")
В конце концов, курьер направляется домой в точку "I".
В данном случае, курьер, выполняет 3 отдельные задачи:
- B (Заказ из пиццерии):
- D (Заказ из ресторана)
- E (Покупка домой продуктов)
Отличие этих задач в том, что доставку заказов "B", "D" он выполняет последовательно и не может одновременно
двигаться в 2 пункта назначения (предположим, они находятся в разных частях города для наглядности).
Но задачу "E" (доставку домой продуктов) курьер может выполнять конкурентно к этим задачам. Заметьте, что для того, чтобы выполнить какую-то из подзадач задачи "E"* например, "G"), курьеру нужно либо просто приостановиться, либо приостановиться и отклониться от маршрута.
Это работает и в обратную сторону, курьер не может одновременно зайти в продуктовый и в пиццерию. Поэтому, одна из задач должна приостановиться.
С одной стороны, курьер тормозит выполнение заказа своему клиенту, но успевает выполнить поручение жены. В ином случае, если бы курьер сначала выполнил все заказы, а только потом поехал за продуктами, то он бы приехал домой позже, поэтому он решил совместить задачи ["B", "D"] с задачей ["E"].
Эти задачи он совместил КОНКУРЕНТНО.
Рассмотрим другой вариант, когда курьер берет 2 заказа, но отдает второй напарнику, который выезжает с другой стороны
сразу в точку "C".
Он все еще может соединить выполнение задач "B" и "E" конкурентно, но выполнение "B" и "D" он совместил параллельно, использовав второго курьера!
Абстрактный пример №2
В шахматах есть такая форма игры, когда один шахматист одновременно играет с несколькими противниками. Это называется "Сеанс одновременной игры".
Участнику приходится переключаться между разными партиями на время приостанавливая игру на других столах. Он "решает" эти "задачи" конкурентно.
Для того чтобы "решать" их параллельно нужно просто-напросто несколько игроков.
Более подробно этот пример описан тут.
Асинхронность в Python
На параллельной работе мы останавливаться не будем, сразу перейдем к асинхронной работе.
Очень важно, чтобы к этому моменту вы понимали, как работают функции и генераторы
В стандартном примере, функция может возвращать по выполнению какой-то задачи какое-то значение:
Каждый раз, когда вы будете вызывать эту функцию, вы обязаны передать в нее значения ‘a’, ‘b’ и вы получите на выходе
результат их умножения:
Генераторы же позволяют отдавать разные значения при "повторном" их вызове.
Генератор от функции отличает наличие слова yield , которое приостанавливает выполнение функции в этой строке. Также, в этот же момент происходит возврат значений, которые прописаны после yield .
Еще одно отличие генератора от функции: когда происходит вызов функции-генератора, то создается объект генератора. (В случае с простой функцией, у нас возвращался результат ее выполнения).
Обычно этот объект записывается переменную и, чтобы выполнить следующий шаг, происходит вызов функции next на этот объект.
К тому же, объекты-генераторы всегда содержат методы __iter__ и __next__ , то есть по ним можно итерироваться, как по списку.
Попробуем на примере:
В примере выше происходит следующее:
- Обозначается функция, которая принимает аргумент a
- Начинается вечный цикл
- Функция превращается в генератор благодаря наличию yield
- Если выполнить функцию next и передать в нее этот генератор, то вернется значение a
- Если вызвать функцию next повторно на объект генератора, то сначала переменная a перезапишется и увеличится на единицу, а потом перейдет на следующую итерацию цикла
- Если вызвать функцию next (снова), то вернется новое значение a
- И так далее
Получается, несмотря на страшный цикл while True: , этот цикл приостанавливался на строчке с yield . Программа возвращает контроль управления после этой команды. И когда вы в следующий раз запустите функцию next , функция generator продолжит выполнение с того места, где она закончила в прошлый раз.
Главное использовать тот же объект генератора g , так как именно в нем сохраняется предыдущее состояние, а не в самой функции. Нужен именно объект!
Поэтому, в этом примере переменная а сначала увеличится, а потом вернется опять к началу цикла и опять приостановится
на следующем yield .
То, что нам и нужно!
Теперь к библиотеке Asyncio
Если обычные генераторы создаются благодаря наличию yield , то в Python 3.5 обновился синтаксис и появилось наличие слов async, await .
Ранее вы могли встречать другой синтаксис типа декораторов @asyncio.coroutine над обычными функциями, а аналогом await были слова yield from
Теперь, когда вы хотите создать нативную корутину для работы с асинхронностью (не асинхронный генератор, это другое) вы должны делать это так:
Это довольно бесполезный абстрактный код, но важно, чтобы вы поняли аналогию с генераторами. Теперь async def , обозначает, что функция асинхронная и значит в ней МОЖНО применять слово await .
Если вы не прописали async def , то слово await в синхронном коде вызовет синтаксическую ошибку (SyntaxError).
А само слово await позволяет выполнить другую корутину и передать управление дальше. То есть, чтобы сделать ваш синхронный код асинхронным, вам недостаточно просто сделать приставку async перед функцией, а необходимо еще в каком-либо месте передать управление дальше.
К примеру, можно использовать функцию "засыпания" asyncio.sleep . Она отличается от обычного time.sleep тем, что она не останавливает работу всего скрипта, а она просто говорит, что отдается управление в поток до какого-то времени.
Рассмотрим конкурентное выполнение на примере
Сделаем обычный счетчик и 3 таймера, которые будут срабатывать:
- Каждую секунду
- Каждые 5 секунд
- Каждые 10 секунд
Для начала, нам надо импортировать библиотеку asyncio
Теперь начнем писать асинхронную функцию счетчика.
Для того чтобы была возможность выводить значение счетчика в другой асинхронной функции, нам нужно будет создать общий объект. Его я создам чуть позже в общей функции, но чтобы он был действительно общий для 2-х корутин, я буду использовать объект списка и приму его в эту функцию.
После чего я запущу вечный цикл и внутри буду делать паузу на одну тысячную секунды, а потом добавлять один элемент в список. Т.е. у меня цикл будет передавать управление (после каждого отсчета) другим корутинам.
Исходя из этого расчета, у меня за одну секунду не может быть проведено больше 1000 операций, так как пауза делается 1000 раз в секунду. Остальное время отнимется на выполнение других операций в других корутинах.
Напоминаю:
async def count — асинхронная функция
count() — объект корутины
Теперь пропишем функции таймеров, которые будут срабатывать каждые 1, 5, 10 секунд. Разве что, первый таймер еще примет в себя объект счетчика и скажет сколько там сейчас элементов.
Как видно в коде, теперь в каждой функции asyncio.sleep принимает в себя то количество секунд, которое нам нужно.
Теперь нам необходимо создать функцию, которой мы будем запускать все предыдущие функции.
Учитывая что, для того чтобы запустить выполнение корутины нам нужно сделать await , а мы этого сделать в синхронном коде не можем, нам необходимо:
- Создать асинхронную функцию запуска кода —> async def main():
- Сделать из асинхронных функций корутины —> count —> count()
- Сделать из корутин задачи (Task) и запланировать их к запуску:
- Можно сделать с помощью asyncio.create_task —> asyncio.create_task(count()) (без await ), а последний таск выполнить с помощью await —> await print_every_10_sec()
- Можно с помощью asyncio.gather, потому что он сразу принимает в себя список корутин, превращает их в таски и планирует к запуску. Помимо этого, он блокирует код, пока эти задачи не будут выполнены. Передаем корутины в него —> asyncio.gather(count(), print_every_sec(), print_every_5_sec(), . )
- Выполнить эту корутину —> await asyncio.gather(. )
- Выполнить функцию (уже синхронную) asyncio.run , и передать в нее корутину main()
Подробнее про запуск асинхронных тасков можно почитать тут (англ.)
В этом примере я решил это сделать немного по-другому, я создал список корутин tasks , а потом передал и раскрыл ( *tasks ) его в функции gather .
Объект counter был обозначен списком и передан в функции count и print_every_sec .
Все вместе выглядит как-то так:
Запускаем и наслаждаемся результатом:
Все работает так, как и задумано. Вроде бы функции работают параллельно, но на самом деле конкурентно, они просто передают друг другу управление, просто "говоря" общему потоку:
В примере выше, количество записей в списке через 10 секунд не превышает 10 000, т.е. функция подсчета и правда сработала не чаще.
Если мы заменим await asyncio.sleep(1 / 1000) —> await asyncio.sleep(1 / 100) , то результат будет следующим:
Итого, за 10 секунд меньше 1000 операций, как мы и хотели.
Назад в Содержание
Блокирующие или неблокирующие асинхронные функции
Важно понимать один момент: если вы просто пытаетесь запустить корутины одну за одной, то они будут выполняться последовательно, а не конкурентно.
Они будут выполняться как в обычном синхронном блокирующем коде.
Пример:
Если вы сначала запустите корутину await count(counter) (как в примере выше), то она так и продолжит выполняться, а корутины-счетчики не запустятся никогда.
А вот именно с помощью asyncio.gather мы "собираем" все корутины и запускаем их конкурентно.
Кстати говоря, с помощью asyncio.gather у нас каждая переданная корутина формируется в новую задачу (Task), а эти таски уже имеют свой "контекст", где можно использовать контекстные переменные.
Контекстные переменные
Учитывая, что разные задачи выполняются конкурентно, но всё-таки отдельно, в Python есть такое понятие как "контекст".
Если вы в одной корутине вызываете другую, то они делят между собой один контекст, и в этот контекст можно размещать отдельные переменные, чтобы потом их доставать там, где вам это понадобится.
Контекстные переменные — это что-то вроде глобальных переменных, только доступных не во всем коде, а только в необходимом контексте.
Это бывает полезно, когда вы не хотите передавать переменную в другую функцию явно, например, если таких переменных у вас много и вы наверняка не знаете, где именно вам может понадобиться та или иная переменная.
Пример:
Теперь мы должны там, где нам нужно, задавать объект с помощью метода MyCounter.set() и получать с помощью метода MyCounter.get() . Методы сами по себе синхронные, поэтому await не нужен.
Сделаем функцию, которая будет увеличивать наш счетчик:
Теперь сделаем функцию-цикл, где будет вызываться метод increase :
Результат:
Как мы видим, благодаря ContextVar-ам нам необязательно передавать переменную счетчика ( my_counter ) в функцию increase , чтобы с ней что-нибудь сделать.
В данном примере это не совсем оправдывает себя, но если вложенность у вас большая, то такой способ бывает полезен.
Пример с разными контекстами
Давайте примем в функцию count произвольную задержку и запустим 2 разных контекста с помощью функции asyncio.gather
Результат:
Как видно на картинке, счетчики работают конкурентно, иногда какой-то счетчик срабатывает быстрее другого, и значения в них независимы друг от друга.
Опасность
В python-комьюнити не любят, когда в асинхронном фреймворке (вроде aiogram) используют блокирующие синхронные функции, так как смысл от асинхронности теряется.
Попробуем добавить time.sleep в одном из контекстов выше и посмотрим на результат.
Результат
В итоге этот time.sleep заставляет заснуть не просто один контекст, а сразу оба. Поэтому, несмотря на нашу проверку if delay == 0.2: , а также на то, что задержка в счетчиках была разная, блокирующая задержка останавливает весь код.
Помимо этого, произошла еще одна вещь, счетчики синхронизировались (с разницей в 1 сек), потому что блокирующая задержка больше, и обе задачи готовы к запуску сразу же после нее.