Зачем нужна асинхронность
Бекенд-разработчики обычно пишут синхронный код. В нём команды исполняются одна за другой:
# . 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
Получите крутое код-ревью от практикующих программистов с разбором ошибок и рекомендациями, на что обратить внимание — бесплатно.
Переходите на страницу учебных модулей «Девмана» и выбирайте тему.
Асинхронное программирование на 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: различные формы конкурентности
Это перевод статьи Абу Ашраф Маснуна «Async Python: The Different Forms of Concurrency».
С появлением Python 3 довольно много шума об «асинхронности» и «параллелизме», можно полагать, что Python недавно представил эти возможности/концепции. Но это не так. Мы много раз использовали эти операции. Кроме того, новички могут подумать, что asyncio является единственным или лучшим способом воссоздать и использовать асинхронные/параллельные операции. В этой статье мы рассмотрим различные способы достижения параллелизма, их преимущества и недостатки.
Определение терминов:
Прежде чем мы углубимся в технические аспекты, важно иметь некоторое базовое понимание терминов, часто используемых в этом контексте.
Синхронный и асинхронный:
В синхронных операциях задачи выполняются друг за другом. В асинхронных — задачи могут запускаться и завершаться независимо друг от друга. Одна асинхронная задача может запускаться и продолжать выполняться, пока выполнение переходит к новой задаче. Асинхронные задачи не блокируют (не заставляют ждать завершения выполнения задачи) операции и обычно выполняются в фоновом режиме.
Например, вы должны обратиться в туристическое агентство, чтобы спланировать свой следующий отпуск. Вам нужно отправить письмо своему руководителю, прежде чем улететь. В синхронном режиме, вы сначала позвоните в туристическое агентство, и если вас попросят подождать, то вы будете ждать, пока вам не ответят. Затем вы начнёте писать письмо руководителю. Таким образом, вы выполняете задачи последовательно, одна за другой.
Но, если вы умны, то пока вас попросили подождать, вы начнёте писать письмо, и когда с вами снова заговорят, вы приостановите написание, поговорите, а затем допишете письмо. Вы также можете попросить друга позвонить в агентство, а сами написать письмо. Это асинхронность, задачи не блокируют друг друга.
Конкурентность и параллелизм:
Конкурентность подразумевает, что две задачи выполняются совместно. В нашем предыдущем примере, когда мы рассматривали асинхронный пример, мы постепенно продвигались то в написании письма, то в разговоре с турагентством. Это конкурентность.
Когда мы попросили позвонить друга, а сами писали письмо, то задачи выполнялись параллельно.
Параллелизм по сути является формой конкурентности. Но параллелизм зависит от оборудования. Например, если в CPU только одно ядро, то две задачи не могут выполняться параллельно. Они просто делят процессорное время между собой. Тогда это конкурентность, но не параллелизм. Но когда у нас есть несколько ядер, мы можем выполнять несколько операций (в зависимости от количества ядер) одновременно.
- Синхронность: блокирует операции (блокирующие)
- Асинхронность: не блокирует операции (неблокирующие)
- Конкурентность: совместный прогресс (совместные)
- Параллелизм: параллельный прогресс (параллельные)
Параллелизм подразумевает конкурентность. Но конкурентность не всегда подразумевает параллелизм.
Потоки и процессы
Python поддерживает потоки уже очень давно. Потоки позволяют выполнять операции конкурентно. Но есть проблема, связанная с Global Interpreter Lock (GIL) из-за которой потоки не могли обеспечить настоящий параллелизм. И тем не менее, с появлением multiprocessing можно использовать несколько ядер с помощью Python.
Потоки (Threads)
Рассмотрим небольшой пример. В нижеследующем коде функция worker будет выполняться в нескольких потоках асинхронно и одновременно.
import threading import time import random def worker(number): sleep = random.randrange(1, 10) time.sleep(sleep) print("I am Worker <>, I slept for <> seconds".format(number, sleep)) for i in range(5): t = threading.Thread(target=worker, args=(i,)) t.start() print("All Threads are queued, let's see when they finish!")
А вот пример выходных данных:
$ python thread_test.py All Threads are queued, let's see when they finish! I am Worker 1, I slept for 1 seconds I am Worker 3, I slept for 4 seconds I am Worker 4, I slept for 5 seconds I am Worker 2, I slept for 7 seconds I am Worker 0, I slept for 9 seconds
Таким образом мы запустили 5 потоков для совместной работы, и после их старта (т.е. после запуска функции worker) операция не ждёт завершения работы потоков прежде чем перейти к следующему оператору print. Это асинхронная операция.
В нашем примере мы передали функцию в конструктор Thread. Если бы мы хотели, то могли бы реализовать подкласс с методом (ООП стиль).
Global Interpreter Lock (GIL)
GIL нужен, чтобы сделать обработку памяти CPython проще и обеспечить наилучшую интеграцию с C.
GIL — это механизм блокировки, когда интерпретатор Python запускает в работу только один поток за раз. Это значит, только один поток может исполняться в байт-коде Python единовременно. GIL следит за тем, чтобы несколько потоков не выполнялись параллельно.
Краткие сведения о GIL:
- Одновременно может выполняться один поток.
- Интерпретатор Python переключается между потоками для достижения конкурентности.
- GIL применим к CPython (стандартной реализации). Но, например, Jython и IronPython не имеют GIL.
- GIL делает однопоточные программы быстрыми.
- Операциям ввода/вывода GIL обычно не мешает.
- GIL позволяет легко интегрировать непотокобезопасные библиотеки на C, благодаря GIL у нас есть много высокопроизводительных расширений/модулей, написанных на C.
- Для CPU-зависимых задач интерпретатор делает проверку каждые N тиков и переключает потоки. Таким образом один поток не блокирует другие.
Многие видят в GIL слабость. Я же рассматриваю это как благо, ведь были созданы такие библиотеки, как NumPy, SciPy, которые занимают особое, уникальное положение в научном обществе.
Процессы (Processes)
Чтобы достичь параллелизма, в Python был добавлен модуль multiprocessing, который предоставляет API и выглядит очень похожим, если вы использовали threading раньше.
Давайте просто пойдем и изменим предыдущий пример. Теперь модифицированная версия использует Процесс вместо Потока.
import multiprocessing import time import random def worker(number): sleep = random.randrange(1, 10) time.sleep(sleep) print("I am Worker <>, I slept for <> seconds".format(number, sleep)) for i in range(5): t = multiprocessing.Process(target=worker, args=(i,)) t.start() print("All Processes are queued, let's see when they finish!")
Что же изменилось? Я просто импортировал модуль multiprocessing вместо threading. А затем, вместо потока я использовал процесс. Вот и всё! Теперь вместо множества потоков мы используем процессы, которые запускаются на разных ядрах CPU (если, конечно, у вашего процессора несколько ядер).
С помощью класса Pool мы также можем распределить выполнение одной функции между несколькими процессами для разных входных значений.
Пример из официальных документов:
from multiprocessing import Pool def f(x): return x*x if __name__ == '__main__': p = Pool(5) print(p.map(f, [1, 2, 3]))
Здесь вместо того, чтобы перебирать список значений и вызывать функцию f по одному, мы фактически запускаем функцию в разных процессах.
Один процесс выполняет f(1), другой-f(2), а другой-f (3). Наконец, результаты снова объединяются в список. Это позволяет нам разбить тяжелые вычисления на более мелкие части и запускать их параллельно для более быстрого расчета.
Модуль concurrent.futures
Модуль concurrent.futures большой и позволяет писать асинхронный код очень легко. Мои любимчики — ThreadPoolExecutor и ProcessPoolExecutor. Эти исполнители поддерживают пул потоков или процессов. Мы отправляем наши задачи в пул, и он запускает задачи в доступном потоке / процессе. Возвращается объект Future, который можно использовать для запроса и получения результата по завершении задачи.
А вот пример ThreadPoolExecutor:
from concurrent.futures import ThreadPoolExecutor from time import sleep def return_after_5_secs(message): sleep(5) return message pool = ThreadPoolExecutor(3) future = pool.submit(return_after_5_secs, ("hello")) print(future.done()) sleep(5) print(future.done()) print(future.result())
Asyncio — что, как и почему
У вас, вероятно, есть вопрос, который есть у многих людей в сообществе Python — что asyncio приносит нового? Зачем нужен был еще один способ асинхронного ввода-вывода? Разве у нас уже не было потоков и процессов?
Зачем нам нужен asyncio?
Процессы очень дорогостоящие и требуют много ресурсов для создания. Поэтому для операций ввода/вывода в основном выбираются потоки.
Мы знаем, что ввод-вывод зависит от внешних вещей — медленные диски или неприятные сетевые лаги делают ввод-вывод часто непредсказуемым. Теперь предположим, что мы используем потоки для операций ввода-вывода. 3 потока выполняют различные задачи ввода-вывода. Интерпретатор должен был бы переключаться между конкурентными потоками и давать каждому из них некоторое время по очереди.
Назовем потоки — T1, T2 и T3. Три потока начали свою операцию ввода-вывода. T3 завершает его первым. T2 и T1 все еще ожидают ввода-вывода. Интерпретатор Python переключается на T1, но он все еще ждет. Хорошо, интерпретатор перемещается в T2, а тот все еще ждет, а затем перемещается в T3, который готов и выполняет код. Вы видите в этом проблему?
T3 был готов, но интерпретатор сначала переключился между T2 и T1 — это понесло расходы на переключение, которых мы могли бы избежать, если бы интерпретатор сначала переключился на T3, верно?
Что есть asynio?
Asyncio предоставляет нам цикл событий наряду с другими крутыми вещами. Цикл событий (event loop) отслеживает события ввода/вывода и переключает задачи, которые готовы и ждут операции ввода/вывода.
Идея очень проста. Есть цикл обработки событий. И у нас есть функции, которые выполняют асинхронные операции ввода-вывода. Мы передаем свои функции циклу событий и просим его запустить их. Цикл событий возвращает нам объект Future, словно обещание, что в будущем мы что-то получим. Мы держимся за обещание, время от времени проверяем, имеет ли оно значение, и, наконец, когда значение получено, мы используем его в некоторых других операциях.
Как использовать asyncio?
Прежде чем мы начнём, давайте взглянем на пример:
import asyncio import datetime import random async def my_sleep_func(): await asyncio.sleep(random.randint(0, 5)) async def display_date(num, loop): end_time = loop.time() + 50.0 while True: print("Loop: <> Time: <>".format(num, datetime.datetime.now())) if (loop.time() + 1.0) >= end_time: break await my_sleep_func() loop = asyncio.get_event_loop() asyncio.ensure_future(display_date(1, loop)) asyncio.ensure_future(display_date(2, loop)) loop.run_forever()
Обратите внимание, что синтаксис async/await предназначен только для Python 3.5 и выше. Пройдёмся по коду:
- У нас есть асинхронная функция display_date, которая принимает число (в качестве идентификатора) и цикл обработки событий в качестве параметров.
- Функция имеет бесконечный цикл, который прерывается через 50 секунд. Но за этот период она неоднократно печатает время и делает паузу. Функция await может ожидать завершения выполнения других асинхронных функций (корутин).
- Передаем функцию в цикл обработки событий (используя метод ensure_future).
- Запускаем цикл событий.
Всякий раз, когда происходит вызов await, asyncio понимает, что функции, вероятно, потребуется некоторое время. Таким образом, он приостанавливает выполнение, начинает мониторинг любого связанного с ним события ввода-вывода и позволяет запускать задачи. Когда asyncio замечает, что приостановленный ввод-вывод функции готов, он возобновляет функцию.
Делаем правильный выбор
Только что мы прошлись по самым популярным формам конкурентности. Но остаётся вопрос — что следует выбрать?
Это зависит от вариантов использования. Из моего опыта я склонен следовать этому псевдо-коду:
if io_bound: if io_very_slow: print("Use Asyncio") else: print("Use Threads") else: print("Multi Processing")
Такие сложные материи, как асинхронность, мы проходим на обучении Рython
Асинхронный 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
- асинхронность
- асинхронное программирование
- асинхронные задачи
- асинхронные функции
- асинхронный код