Асинхронное программирование на asyncio
Ассинхроность дает нам плюс к многозадачности.Даваете посмотрим два примера кода:
import time def start(): print(1) def time(): time.sleep(20) print(2) def end(): print(3) def main(): start_time = time.time() start() time() end() end_time = time.time() elapsed_time = end_time — start_time print(f»Elapsed time:
И с ассинхроностью:
import asyncio import time async def start(): print(1) async def time(): await asyncio.sleep(20) print(2) def end(): print(3) async def main(): start_time = time.time() await start() await time() end() end_time = time.time() elapsed_time = end_time — start_time print(f»Elapsed time:
Здесь скорость двух примеров относительна близка,но в больших проектах лучше использовать ассинхроность.
Теперь переидем к обьяснению:
Асинхронность (асинхронное программирование) в программировании означает выполнение задач без явного ожидания завершения предыдущих задач. Вместо того, чтобы блокировать выполнение программы при ожидании завершения операции, асинхронный код позволяет программе продолжать работу и выполнять другие задачи во время ожидания.
import asyncio async def task1(): print(«Task 1 started») await asyncio.sleep(2) # Имитируем длительную операцию print(«Task 1 completed») async def task2(): print(«Task 2 started») await asyncio.sleep(1) # Имитируем длительную операцию print(«Task 2 completed») async def main(): print(«Main started») await asyncio.gather(task1(), task2()) # Запускаем задачи параллельно print(«Main completed») asyncio.run(main())
В этом примере мы используем модуль asyncio для создания асинхронной программы. Есть две асинхронные задачи task1() и task2(), которые имитируют длительные операции, используя await asyncio.sleep() для приостановки выполнения на определенное время.
Функция main() является точкой входа в программу и запускает задачи task1() и task2() параллельно с помощью asyncio.gather(). Важно отметить, что когда одна из задач ожидает (await), другая задача может продолжить выполнение, не блокируя программу.
Выполнение кода показывает, что «Task 1 started» и «Task 2 started» выводятся одновременно, а затем после задержки «Task 2 completed» выводится раньше, чем «Task 1 completed». Это демонстрирует параллельное выполнение асинхронных задач.
Асинхронность позволяет эффективно использовать ресурсы и повышает отзывчивость программы, особенно когда есть операции ввода-вывода (I/O), такие как чтение из сети или запись на диск. Вместо блокировки выполнения при ожидании I/O операций, асинхронный код может переключаться между задачами, позволяя другим задачам продолжать выполнение.
Обратите внимание, что для запуска асинхронной программы мы используем asyncio.run(main()), которая создает цикл событий asyncio и запускает функцию main() в этом цикле событий.
Спасибо за просмотр статьй.
Асинхронное программирование в 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-адресам:
import gevent.monkey from urllib.request import urlopen gevent.monkey.patch_all() urls = ['http://www.google.com', 'http://www.yandex.ru', 'http://www.python.org'] def print_head(url): print('Starting <>'.format(url)) data = urlopen(url).read() print('<>: <> bytes: <>'.format(url, len(data), data)) jobs = [gevent.spawn(print_head, _url) for _url in urls] gevent.wait(jobs)
Как видите, API-интерфейс Gevent выглядит так же, как и потоки. Однако за кадром он использует сопрограммы (coroutines), а не потоки, и запускает их в цикле событий (event loop) для постановки в очередь. Это значит, что вы получаете преимущества потоков, без понимания сопрограмм, но вы не избавляетесь от проблем, связанных с потоками. Gevent — хорошая библиотека, но только для тех, кто понимает, как работают потоки.
Давайте рассмотрим некоторые аспекты асинхронного программирования. Один из таких аспектов — это цикл событий. Цикл событий — это очередь событий/заданий и цикл, который вытягивает задания из очереди и запускает их. Эти задания называются сопрограммами. Они представляют собой небольшой набор команд, содержащих, помимо прочего, инструкции о том, какие события при необходимости нужно возвращать в очередь.
Функция обратного вызова (callback)
В Python много библиотек для асинхронного программирования, наиболее популярными являются Tornado, Asyncio и Gevent. Давайте посмотрим, как работает Tornado. Он использует стиль обратного вызова (callbacks) для асинхронного сетевого ввода-вывода. Обратный вызов — это функция, которая означает: «Как только это будет сделано, выполните эту функцию». Другими словами, вы звоните в службу поддержки и оставляете свой номер, чтобы они, когда будут доступны, перезвонили, вместо того, чтобы ждать их ответа.
Давайте посмотрим, как сделать то же самое, что и выше, используя Tornado:
import tornado.ioloop from tornado.httpclient import AsyncHTTPClient urls = ['http://www.google.com', 'http://www.yandex.ru', 'http://www.python.org'] def handle_response(response): if response.error: print("Error:", response.error) else: url = response.request.url data = response.body print('<>: <> bytes: <>'.format(url, len(data), data)) http_client = AsyncHTTPClient() for url in urls: http_client.fetch(url, handle_response) tornado.ioloop.IOLoop.instance().start()
Предпоследняя строка кода вызывает метод 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-адреса, что и раньше:
import asyncio import aiohttp urls = ['http://www.google.com', 'http://www.yandex.ru', 'http://www.python.org'] @asyncio.coroutine def call_url(url): print('Starting <>'.format(url)) response = yield from aiohttp.get(url) data = yield from response.text() print('<>: <> bytes: <>'.format(url, len(data), data)) return data futures = [call_url(url) for url in urls] loop = asyncio.get_event_loop() loop.run_until_complete(asyncio.wait(futures))
Прим. перев. В примерах используется aiohttp версии 1.3.5. В последней версии библиотеки синтаксис другой.
Несколько особенностей, которые нужно отметить:
- ошибки корректно передаются в стек;
- можно вернуть объект, если необходимо;
- можно запустить все сопрограммы;
- нет обратных вызовов;
- строка 10 не выполнится до тех пор, пока строка 9 не будет полностью выполнена.
Единственная проблема заключается в том, что объект выглядит как генератор, и это может вызвать проблемы, если на самом деле это был генератор.
Async и Await
Библиотека Asyncio довольно мощная, поэтому Python решил сделать ее стандартной библиотекой. В синтаксис также добавили ключевое слово async . Ключевые слова предназначены для более четкого обозначения асинхронного кода. Поэтому теперь методы не путаются с генераторами. Ключевое слово async идет до def , чтобы показать, что метод является асинхронным. Ключевое слово await показывает, что вы ожидаете завершения сопрограммы. Вот тот же пример, но с ключевыми словами async / await:
import asyncio import aiohttp urls = ['http://www.google.com', 'http://www.yandex.ru', 'http://www.python.org'] async def call_url(url): print('Starting <>'.format(url)) response = await aiohttp.get(url) data = await response.text() print('<>: <> bytes: <>'.format(url, len(data), data)) return data futures = [call_url(url) for url in urls] loop = asyncio.get_event_loop() loop.run_until_complete(asyncio.wait(futures))
Программа состоит из метода async . Во время выполнения он возвращает сопрограмму, которая затем находится в ожидании.
Заключение
В Python встроена отличная асинхронная библиотека. Давайте еще раз вспомним проблемы потоков и посмотрим, решены ли они теперь:
- процессорное переключение контекста: Asyncio является асинхронным и использует цикл событий. Он позволяет переключать контекст программно;
- состояние гонки: поскольку Asyncio запускает только одну сопрограмму и переключается только в точках, которые вы определяете, ваш код не подвержен проблеме гонки потоков;
- взаимная/активная блокировка: поскольку теперь нет гонки потоков, то не нужно беспокоиться о блокировках. Хотя взаимная блокировка все еще может возникнуть в ситуации, когда две сопрограммы вызывают друг друга, это настолько маловероятно, что вам придется постараться, чтобы такое случилось;
- исчерпание ресурсов: поскольку сопрограммы запускаются в одном потоке и не требуют дополнительной памяти, становится намного сложнее исчерпать ресурсы. Однако в Asyncio есть пул «исполнителей» (executors), который по сути является пулом потоков. Если запускать слишком много процессов в пуле исполнителей, вы все равно можете столкнуться с нехваткой ресурсов.
Несмотря на то, что Asyncio довольно хорош, у него есть и проблемы. Во-первых, Asyncio был добавлен в Python недавно. Есть некоторые недоработки, которые еще не исправлены. Во-вторых, когда вы используете асинхронность, это значит, что весь ваш код должен быть асинхронным. Это связано с тем, что выполнение асинхронных функций может занимать слишком много времени, тем самым блокируя цикл событий.
Существует несколько вариантов асинхронного программирования в Python. Вы можете использовать зеленые потоки, обратные вызовы или сопрограммы. Хотя вариантов много, лучший из них — Asyncio. Если используете Python 3.5, то вам лучше использовать эту библиотеку, так как она встроена в ядро python.
Асинхронный python без головной боли (часть 1)
Асинхронное программирование традиционно относят к темам для «продвинутых». Действительно, у новичков часто возникают сложности с практическим освоением асинхронности. В случае python на то есть весьма веские причины:
- Асинхронность в python была стандартизирована сравнительно недавно. Библиотека asyncio появилась впервые в версии 3.5 (то есть в 2015 году), хотя возможность костыльно писать асинхронные приложения и даже фреймворки, конечно, была и раньше. Соответственно у Лутца она не описана, а, как всем известно, «чего у Лутца нет, того и знать не надо».
- Рекомендуемый синтаксис асинхронных команд неоднократно менялся уже и после первого появления asyncio . В сети бродит огромное количество статей и роликов, использующих архаичный код различной степени давности, только сбивающий новичков с толку.
- Официальная документация asyncio (разумеется, исчерпывающая и прекрасно организованная) рассчитана скорее на создателей фреймворков, чем на разработчиков пользовательских приложений. Там столько всего — глаза разбегаются. А между тем: «Вам нужно знать всего около семи функций для использования asyncio» (c) Юрий Селиванов, автор PEP 492, в которой были добавлены инструкции async и await
На самом деле наша повседневная жизнь буквально наполнена асинхронностью.
Утром меня поднимает с кровати будильник в телефоне. Я когда-то давно поставил его на 8:30 и с тех пор он исправно выполняет свою работу. Чтобы понять когда вставать, мне не нужно таращиться на часы всю ночь напролет. Нет нужды и периодически на них посматривать (скажем, с интервалом в 5 минут). Да я вообще не думаю по ночам о времени, мой мозг занят более интересными задачами — просмотром снов, например. Асинхронная функция «подъем» находится в режиме ожидания. Как только произойдет событие «на часах 8:30», она сама даст о себе знать омерзительным Jingle Bells.
Иногда по выходным мы с собакой выезжаем на рыбалку. Добравшись до берега, я снаряжаю и забрасываю несколько донок с колокольчиками. И. Переключаюсь на другие задачи: разговариваю с собакой, любуюсь красотами природы, истребляю на себе комаров. Я не думаю о рыбе. Задачи «поймать рыбу удочкой N» находятся в режиме ожидания. Когда рыба будет готова к общению, одна из удочек сама даст о себе знать звонком колокольчика.
Будь я автором самого толстого в мире учебника по python, я бы рассказывал читателям про асинхронное программирование уже с первых страниц. Вот только написали «Hello, world!» и тут же приступили к созданию «Hello, asynchronous world!». А уже потом циклы, условия и все такое.
Но при написании этой статьи я все же облегчил себе задачу, предположив, что читатели уже знакомы с основами python и им не придется втолковывать что такое генераторы или менеджеры контекста. А если кто-то не знаком, тогда сейчас самое время ознакомиться.
Пара слов о терминологии
В настоящем руководстве я стараюсь придерживаться не академических, а сленговых терминов, принятых в русскоязычных командах, в которых мне довелось работать. То есть «корутина», а не «сопрограмма», «футура», а не «фьючерс» и т. д. При всем при том, я еще не столь низко пал, чтобы, скажем, задачу именовать «таской». Если в вашем проекте приняты другие названия, прошу отнестись с пониманием и не устраивать терминологический холивар.
Внимание! Все примеры отлажены в консольном python 3.10. Вероятно в ближайших последующих версиях также работать будут. Однако обратной совместимости со старыми версиями не гарантирую. Если у вас что-то пошло не так, попробуйте, установить 3.10 и/или не пользоваться Jupyter.
2. Первое асинхронное приложение
Предположим, у нас есть две функции в каждой из которых есть некая «быстрая» операция (например, арифметическое вычисление) и «медленная» операция ввода/вывода. Детали реализации медленной операции нам сейчас не важны. Будем моделировать ее функцией time.sleep() . Наша задача — выполнить обе задачи как можно быстрее.
Традиционное решение «в лоб»:
Пример 2.1
import time def fun1(x): print(x**2) time.sleep(3) print('fun1 завершена') def fun2(x): print(x**0.5) time.sleep(3) print('fun2 завершена') def main(): fun1(4) fun2(4) print(time.strftime('%X')) main() print(time.strftime('%X'))
Никаких сюрпризов — fun2 честно ждет пока полностью отработает fun1 (и быстрая ее часть, и медленная) и только потом начинает выполнять свою работу. Весь процесс занимает 3 + 3 = 6 секунд. Строго говоря, чуть больше чем 6 за счет «быстрых» арифметических операций, но в выбранном масштабе разницу уловить невозможно.
Теперь попробуем сделать то же самое, но в асинхронном режиме. Пока просто запустите предложенный код, подробно мы его разберем чуть позже.
Пример 2.2
import asyncio import time async def fun1(x): print(x**2) await asyncio.sleep(3) print('fun1 завершена') async def fun2(x): print(x**0.5) await asyncio.sleep(3) print('fun2 завершена') async def main(): task1 = asyncio.create_task(fun1(4)) task2 = asyncio.create_task(fun2(4)) await task1 await task2 print(time.strftime('%X')) asyncio.run(main()) print(time.strftime('%X'))
Сюрприз! Мгновенно выполнились быстрые части обеих функций и затем через 3 секунды (3, а не 6!) одновременно появились оба текстовых сообщения. Полное ощущение, что функции выполнились параллельно (на самом деле нет).
А можно аналогичным образом добавить еще одну функцию-соню? Пожалуйста — хоть сто! Общее время выполнения программы будет по-прежнему определяться самой «тормознутой» из них. Добро пожаловать в асинхронный мир!
Что изменилось в коде?
- Перед определениями функций появился префикс async . Он говорит интерпретатору, что функция должна выполняться асинхронно.
- Вместо привычного time.sleep мы использовали asyncio.sleep . Это «неблокирующий sleep». В рамках функции ведет себя так же, как традиционный, но не останавливает интерпретатор в целом.
- Перед вызовом асинхронных функций появился префикс await . Он говорит интерпретатору примерно следующее: «я тут возможно немного потуплю, но ты меня не жди — пусть выполняется другой код, а когда у меня будет настроение продолжиться, я тебе маякну».
- На базе функций мы при помощи asyncio.create_task создали задачи (что это такое разберем позже) и запустили все это при помощи asyncio.run
Как это работает:
- выполнилась быстрая часть функции fun1
- fun1 сказала интерпретатору «иди дальше, я посплю 3 секунды»
- выполнилась быстрая часть функции fun2
- fun2 сказала интерпретатору «иди дальше, я посплю 3 секунды»
- интерпретатору дальше делать нечего, поэтому он ждет пока ему маякнет первая проснувшаяся функция
- на доли миллисекунды раньше проснулась fun1 (она ведь и уснула чуть раньше) и отрапортовала нам об успешном завершении
- то же самое сделала функция fun2
Замените «посплю» на «пошлю запрос удаленному сервису и буду ждать ответа» и вы поймете как работает реальное асинхронное приложение.
Возможно в других руководствах вам встретится «старомодный» код типа:
Пример 2.3
import asyncio import time async def fun1(x): print(x**2) await asyncio.sleep(3) print('fun1 завершена') async def fun2(x): print(x**0.5) await asyncio.sleep(3) print('fun2 завершена') print(time.strftime('%X')) loop = asyncio.get_event_loop() task1 = loop.create_task(fun1(4)) task2 = loop.create_task(fun2(4)) loop.run_until_complete(asyncio.wait([task1, task2])) print(time.strftime('%X'))
Результат тот же самый, но появилось упоминание о каком-то непонятном цикле событий (event loop) и вместо одной asyncio.run используются аж три функции: asyncio.wait , asyncio.get_event_loop и asyncio.run_until_complete . Кроме того, если вы используете python версии 3.10+, в консоль прилетает раздражающее предупреждение DeprecationWarning: There is no current event loop , что само по себе наводит на мысль, что мы делаем что-то слегка не так.
Давайте пока руководствоваться Дзен питона: «Простое лучше, чем сложное», а цикл событий сам придет к нам. в свое время.
Пара слов о «медленных» операциях
Как правило, это все, что связано с вводом выводом: получение результата http-запроса, файловые операции, обращение к базе данных.
Однако следует четко понимать: для эффективного использования с asyncio любой медленный интерфейс должен поддерживать асинхронные функции. Иначе никакого выигрыша в производительности вы не получите. Попробуйте использовать в примере 2.2 time.sleep вместо asyncio.sleep и вы поймете о чем я.
Что касается http-запросов, то здесь есть великолепная библиотека aiohttp , честно реализующая асинхронный доступ к веб-серверу. С файловыми операциями сложнее. В Linux доступ к файловой системе по определению не асинхронный, поэтому, несмотря на наличие удобной библиотеки aiofiles , где-то в ее глубине всегда будет иметь место многопоточный «мостик» к низкоуровневым функциям ОС. С доступом к БД примерно то же самое. Вроде бы, последние версии SQLAlchemy поддерживают асинхронный доступ, но что-то мне подсказывает, что в основе там все тот же старый добрый Threadpool. С другой стороны, в веб-приложениях львиная доля задержек относится именно к сетевому общению, так что «не вполне асинхронный» доступ к локальным ресурсам обычно не является бутылочным горлышком.
Внимательные читатели меня поправили в комментах. В Linux, начиная с ядра 5.1, есть полноценный асинхронный интерфейс io_uring и это прекрасно. Кому интересны детали, рекомендую пройти вот сюда.
3. Асинхронные функции и корутины
Теперь давайте немного разберемся с типами. Вернемся к «неасинхронному» примеру 2.1, слегка модифицировав его:
Пример 3.1
import time def fun1(x): print(x**2) time.sleep(3) print('fun1 завершена') def fun2(x): print(x**0.5) time.sleep(3) print('fun2 завершена') def main(): fun1(4) fun2(4) print(type(fun1)) print(type(fun1(4)))
Все вполне ожидаемо. Функция имеет тип , а ее результат —
Теперь аналогичным образом исследуем «асинхронный» пример 2.2:
Пример 3.2
import asyncio import time async def fun1(x): print(x**2) await asyncio.sleep(3) print('fun1 завершена') async def fun2(x): print(x**0.5) await asyncio.sleep(3) print('fun2 завершена') async def main(): task1 = asyncio.create_task(fun1(4)) task2 = asyncio.create_task(fun2(4)) await task1 await task2 print(type(fun1)) print(type(fun1(4)))
Уже интереснее! Класс функции не изменился, но благодаря ключевому слову async она теперь возвращает не , а . Ничто превратилось в нечто! На сцену выходит новая сущность — корутина.
Что нам нужно знать о корутине? На начальном этапе немного. Помните как в python устроен генератор? Ну, это то, что функция начинает возвращать, если в нее добавить yield вместо return . Так вот, корутина — это разновидность генератора.
Корутина дает интерпретатору возможность возобновить базовую функцию, которая была приостановлена в месте размещения ключевого слова await .
И вот тут начинается терминологическая путаница, которая попила немало крови добрых разработчиков на собеседованиях. Сплошь и рядом корутиной называют саму функцию, содержащую await . Строго говоря, это неправильно. Корутина — это то, что возвращает функция с await . Чувствуете разницу между f и f() ?
С генераторами, кстати, та же самая история. Генератором как-то повелось называть функцию, содержащую yield , хотя по правильному-то она «генераторная функция». А генератор — это именно тот объект, который генераторная функция возвращает.
Далее по тексту мы постараемся придерживаться правильной терминологии: асинхронная (или корутинная) функция — это f , а корутина — f() . Но если вы в разговоре назовете корутиной асинхронную функцию, беды большой не произойдет, вас поймут. «Не важно, какого цвета кошка, лишь бы она ловила мышей» (с) тов. Дэн Сяопин
4. Футуры и задачи
Продолжим исследовать нашу программу из примера 2.2. Помнится, на базе корутин мы там создали какие-то загадочные задачи:
Пример 4.1
import asyncio async def fun1(x): print(x**2) await asyncio.sleep(3) print('fun1 завершена') async def fun2(x): print(x**0.5) await asyncio.sleep(3) print('fun2 завершена') async def main(): task1 = asyncio.create_task(fun1(4)) task2 = asyncio.create_task(fun2(4)) print(type(task1)) print(task1.__class__.__bases__) await task1 await task2 asyncio.run(main())
Ага, значит задача (что бы это ни значило) имеет тип . Привет, капитан Очевидность!
А кто ваша мама, ребята? А мама наша — анархия какая-то еще более загадочная футура ( ).
В asyncio все шиворот-навыворот, поэтому сначала выясним что такое футура (которую мы видим впервые в жизни), а потом разберемся с ее дочкой задачей (с которой мы уже имели честь познакомиться в предыдущем разделе).
Футура (если совсем упрощенно) — это оболочка для некой асинхронной сущности, позволяющая выполнять ее «как бы одновременно» с другими асинхронными сущностями, переключаясь от одной сущности к другой в точках, обозначенных ключевым словом await
Кроме того футура имеет внутреннюю переменную «результат», которая доступна через .result() и устанавливается через .set_result(value) . Пока ничего не надо делать с этим знанием, оно пригодится в дальнейшем.
У футуры на самом деле еще много чего есть внутри, но на данном этапе не будем слишком углубляться. Футуры в чистом виде используются в основном разработчиками фреймворков, нам же для разработки приложений приходится иметь дело с их дочками — задачами.
Задача — это частный случай футуры, предназначенный для оборачивания корутины.
Все трагически усложняется
Вернемся к примеру 2.2 и опишем его логику заново, используя теперь уже знакомые нам термины — корутины и задачи:
- корутину асинхронной функции fun1 обернули задачей task1
- корутину асинхронной функции fun2 обернули задачей task2
- в асинхронной функции main обозначили точку переключения к задаче task1
- в асинхронной функции main обозначили точку переключения к задаче task2
- корутину асинхронной функции main передали в функцию asyncio.run
Бр-р-р, ужас какой. Воистину: «Во многой мудрости много печали; и кто умножает познания, умножает скорбь» (Еккл. 1:18)
Все счастливо упрощается
А можно проще? Ведь понятие корутина нам необходимо, только чтобы отличать функцию от результата ее выполнения. Давайте попробуем временно забыть про них. Попробуем также перефразировать неуклюжие «точки переключения» и вот эти вот все «обернули-передали». Кроме того, поскольку asyncio.run — это единственная рекомендованная точка входа в приложение для python 3.8+, ее отдельное упоминание тоже совершенно излишне для понимания логики нашего приложения.
А теперь (барабанная дробь). Мы вообще уберем из кода все упоминания об асинхронности. Я понимаю, что работать не будет, но все же давайте посмотрим что получится:
Пример 4.2 (не работающий)
def fun1(x): print(x**2) # запустили ожидание sleep(3) print('fun1 завершена') def fun2(x): print(x**0.5) # запустили ожидание sleep(3) print('fun2 завершена') def main(): # создали конкурентную задачу из функции fun1 task1 = create_task(fun1(4)) # создали конкурентную задачу из функции fun2 task2 = create_task(fun2(4)) # запустили задачу task1 task1 # запустили task2 task2 main()
Кощунство, скажете вы? Нет, я всего лишь честно выполняю рекомендацию великого и ужасного Гвидо ван Россума:
«Прищурьтесь и притворитесь, что ключевых слов async и await нет»
Звучит почти как: «Наденьте зеленые очки и притворитесь, что стекляшки — это изумруды»
Итак, в «прищуренной вселенной Гвидо»:
Задачи — это «ракеты-носители» для конкурентного запуска «боеголовок»-функций.
А если вообще без задач?
Как это? Ну вот так, ни в какие задачи ничего не заворачивать, а просто эвейтнуть в main() сами корутины. А что, имеем право!
Пример 4.3 (неудачный)
import asyncio import time async def fun1(x): print(x**2) await asyncio.sleep(3) print('fun1 завершена') async def fun2(x): print(x**0.5) await asyncio.sleep(3) print('fun2 завершена') async def main(): await fun1(4) await fun2(4) print(time.strftime('%X')) asyncio.run(main()) print(time.strftime('%X'))
Грусть-печаль. Снова 6 секунд как в давнем примере 1.1, ни разу не асинхронном. Боеголовка без ракеты взлетать отказалась.
Вывод:
В asyncio.run нужно передавать асинхронную функцию с эвейтами на задачи, а не на корутины. Иначе не взлетит. То есть работать-то будет, но сугубо последовательно, без всякой конкурентности.
Пара слов о конкурентности
С точки зрения разработчика и (особенно) пользователя конкурентное выполнение в асинхронных и многопоточных приложениях выглядит почти как параллельное. На самом деле никакого параллельного выполнения чего бы то ни было в питоне нет и быть не может. Кто не верит — погулите аббревиатуру GIL. Именно поэтому мы используем осторожное выражение «конкурентное выполнение задач» вместо «параллельное».
Нет, конечно, если очень хочется настоящего параллелизма, можно запустить несколько интерпретаторов python одновременно (библиотека multiprocessing фактически так и делает). Но без крайней нужды лучше такими вещами не заниматься, ибо издержки чаще всего будут непропорционально велики по сравнению с профитом.
А что есть «крайняя нужда»? Это приложения-числодробилки. В них подавляющая часть времени выполнения расходуется на операции процессора и обращения к памяти. Никакого ленивого ожидания ответа от медленной периферии, только жесткий математический хардкор. В этом случае вас, конечно, не спасет ни изящная асинхронность, ни неуклюжая мультипоточность. К счастью, такие негуманные приложения в практике веб-разработки встречаются нечасто.
5. Асинхронные менеджеры контекста и настоящее асинхронное приложение
Пришло время написать на asyncio не тупой перебор неблокирующих слипов, а что-то выполняющее действительно осмысленную работу. Но прежде чем приступить, разберемся с асинхронными менеджерами контекста.
Если вы умеете работать с обычными менеджерами контекста, то без труда освоите и асинхронные. Тут используется знакомая конструкция with , только с префиксом async , и те же самые контекстные методы, только с буквой a в начале.
Пример 5.1
import asyncio # имитация асинхронного соединения с некой периферией async def get_conn(host, port): class Conn: async def put_data(self): print('Отправка данных. ') await asyncio.sleep(2) print('Данные отправлены.') async def get_data(self): print('Получение данных. ') await asyncio.sleep(2) print('Данные получены.') async def close(self): print('Завершение соединения. ') await asyncio.sleep(2) print('Соединение завершено.') print('Устанавливаем соединение. ') await asyncio.sleep(2) print('Соединение установлено.') return Conn() class Connection: # этот конструктор будет выполнен в заголовке with def __init__(self, host, port): self.host = host self.port = port # этот метод будет неявно выполнен при входе в with async def __aenter__(self): self.conn = await get_conn(self.host, self.port) return self.conn # этот метод будет неявно выполнен при выходе из with async def __aexit__(self, exc_type, exc, tb): await self.conn.close() async def main(): async with Connection('localhost', 9001) as conn: send_task = asyncio.create_task(conn.put_data()) receive_task = asyncio.create_task(conn.get_data()) # операции отправки и получения данных выполняем конкурентно await send_task await receive_task asyncio.run(main())
Создавать свои асинхронные менеджеры контекста разработчику приложений приходится нечасто, а вот использовать готовые из асинхронных библиотек — постоянно. Поэтому нам полезно знать, что находится у них внутри.
Теперь, зная как работают асинхронные менеджеры контекста, можно написать ну очень полезное приложение, которое узнает погоду в разных городах при помощи библиотеки aiohttp и API-сервиса openweathermap.org:
Пример 5.2
import asyncio import time from aiohttp import ClientSession async def get_weather(city): async with ClientSession() as session: url = f'http://api.openweathermap.org/data/2.5/weather' params = async with session.get(url=url, params=params) as response: weather_json = await response.json() print(f': ') async def main(cities_): tasks = [] for city in cities_: tasks.append(asyncio.create_task(get_weather(city))) for task in tasks: await task cities = ['Moscow', 'St. Petersburg', 'Rostov-on-Don', 'Kaliningrad', 'Vladivostok', 'Minsk', 'Beijing', 'Delhi', 'Istanbul', 'Tokyo', 'London', 'New York'] print(time.strftime('%X')) asyncio.run(main(cities)) print(time.strftime('%X'))
«И говорит по радио товарищ Левитан: в Москве погода ясная, а в Лондоне — туман!» (c) Е.Соев
Кстати, ключик к API дарю, пользуйтесь на здоровье.
Внимание! Если будет слишком много желающих потестить сервис с моим ключом, его могут временно заблокировать. В этом случае просто получите свой собственный, это быстро и бесплатно.
Опрос 12-ти городов на моем канале 100Mb занимает доли секунды.
Обратите внимание, мы использовали два вложенных менеджера контекста: для сессии и для функции get . Так требует документация aiohttp , не будем с ней спорить.
Давайте попробуем реализовать тот же самый функционал, используя классическую синхронную библиотеку requests и сравним скорость:
Пример 5.3
import time import requests def get_weather(city): url = f'http://api.openweathermap.org/data/2.5/weather' params = weather_json = requests.get(url=url, params=params).json() print(f': ') def main(cities_): for city in cities_: get_weather(city) cities = ['Moscow', 'St. Petersburg', 'Rostov-on-Don', 'Kaliningrad', 'Vladivostok', 'Minsk', 'Beijing', 'Delhi', 'Istanbul', 'Tokyo', 'London', 'New York'] print(time.strftime('%X')) main(cities) print(time.strftime('%X'))
Работает превосходно, но. В среднем занимает 2-3 секунды, то есть раз в 10 больше чем в асинхронном примере. Что и требовалось доказать.
А может ли асинхронная функция не просто что-то делать внутри себя (например, запрашивать и выводить в консоль погоду), но и возвращать результат? Ту же погоду, например, чтобы дальнейшей обработкой занималась функция верхнего уровня main() .
Нет ничего проще. Только в этом случае для группового запуска задач необходимо использовать уже не цикл с await , а функцию asyncio.gather
Пример 5.4
import asyncio import time from aiohttp import ClientSession async def get_weather(city): async with ClientSession() as session: url = f'http://api.openweathermap.org/data/2.5/weather' params = async with session.get(url=url, params=params) as response: weather_json = await response.json() return f': ' async def main(cities_): tasks = [] for city in cities_: tasks.append(asyncio.create_task(get_weather(city))) results = await asyncio.gather(*tasks) for result in results: print(result) cities = ['Moscow', 'St. Petersburg', 'Rostov-on-Don', 'Kaliningrad', 'Vladivostok', 'Minsk', 'Beijing', 'Delhi', 'Istanbul', 'Tokyo', 'London', 'New York'] print(time.strftime('%X')) asyncio.run(main(cities)) print(time.strftime('%X'))
Красиво получилось! Обратите внимание, мы использовали выражение со звездочкой *tasks для распаковки списка задач в аргументы функции asyncio.gather .
Пара слов о лишних сущностях
Кажется, я совершил невозможное. Настучал уже почти тысячу строк текста и ни разу не упомянул о цикле событий. Ну, почти ни разу. Один раз все-же упомянул: в примере 2.3 «как не надо делать». А между тем, в традиционных руководствах по asyncio этим самым циклом событий начинают душить несчастного читателя буквально с первой страницы. На самом деле цикл событий в наших программах присутствует, но он надежно скрыт от посторонних глаз высокоуровневыми конструкциями. До сих пор у нас не возникало в нем нужды, вот и я и не стал плодить лишних сущностей, руководствуясь принципом дорогого товарища Оккама.
Но рано или поздно жизнь заставит нас извлечь этот скелет из шкафа и рассмотреть его во всех подробностях.
- async
- await
- asyncio
- асинхронность
- асинхронное программирование
- асинхронные задачи
- асинхронные функции
- асинхронный код
Зачем нужна асинхронность
Бекенд-разработчики обычно пишут синхронный код. В нём команды исполняются одна за другой:
# . grand_total = sum(cart_products.values()) print(grand_total) # .
Ничего не требуется предпринимать, если синхронный код работает быстро. Проблемы возникают, когда он подвисает и не реагирует на команды пользователя. Синхронный email-клиент не сможет реагировать на нажатие кнопок, пока он скачивает почту. А скачивание тоже происходит синхронно: пока не ответит первый email-провайдер, клиент не отправит запрос второму.
Асинхронное программирование позволяет сделать несколько быстрых действий, пока не завершится долгое. Email-клиент может показывать пользователю скачанные письма, пока новая почта асинхронно загружается. Вот как это выглядит в псевдокоде:
while True: download_progress = download_emails_step_by_step(download_progress) click = read_click() # .
Асинхронным этот код делает функция, которая названа download_emails_step_by_step . Она скачивает не всю почту за раз, а понемногу. Скачав кусочек, она возвращает управление циклу while True . Этот цикл дальше запустит read_click , которая вернёт клик пользователя, если он был, и None , если не было. Другие функции в этом цикле аналогичные: они делают небольшое действие, останавливаются, возвращают управление циклу, который их снова запускает позже.
Обычные функции в Питоне работают не так, как это принято в асинхронном программировании. Функция input , например, ждёт пользовательского ввода и может долго не возвращать управление вызвавшему её коду. Поэтому в язык добавили библиотеку asyncio, которая содержит инструменты для работы с асинхронным кодом и асинхронные эквиваленты синхронных функций (например, sleep).
Привычные инструменты ломают асинхронный код
Рассмотрим ещё один пример. Есть сервер, который возвращает координаты перемещения автобусов в реальном времени. Для каждого автобуса ведётся трансляция его координат. Асинхронный код поможет отрисовать маршрут движения автобуса до того, как закончится трансляция координат. Нам не обязательно даже знать, закончится ли она, потому что данные обрабатываются по частям, небольшими порциями.
Асинхронный код подходит всегда, когда есть несколько потоков ввода, и достаточно обработать только часть данных, но быстро и одновременно у всех потоков. Для решения этого класса задач и нужен асинхронный Python.
Если потоков данных много, то спасет асинхронность
Попробуйте бесплатные уроки по Python
Получите крутое код-ревью от практикующих программистов с разбором ошибок и рекомендациями, на что обратить внимание — бесплатно.
Переходите на страницу учебных модулей «Девмана» и выбирайте тему.