Разбить корутину на части
Что не так с асинхронными функциями?
Программа, в которой кода много, а функций совсем нет — это нечитаемая простыня write-only кода. В норме программа состоит из большого числа функций, и большинство из них являются надстройками: первая запускает вторую, а та — третью и четвертую. Если с синхронными функциями все просто: поставили скобки, передали аргументы и вот он результат —, то что делать с асинхронным кодом? Ведь вместо результата асинхронная функция возвращает объект корутины. Как вызвать асинхронную функцию из другой асинхронной функции?
Настоящая сила await
Рассмотрим пример с часами в консоли. Это таймер, который отсчитывает секунды и в конце подает звуковой сигнал:
import asyncio
async def run_timer(secs):
for secs_left in range(secs, 0, -1):
print(f'Осталось {secs_left} сек.')
await asyncio.sleep(1)
print('Время вышло!')
print('\a') # says beep with ASCII BEL symbol
coroutine = run_timer(3)
asyncio.run(coroutine) # requires python 3.7
Корутину coroutine
запускает функция asyncio.run
— внутри она вызывает метод coroutine.send(…)
и делает секундные паузы, ориентируюсь по await asyncio.sleep(1)
. Эти две функции — run
и sleep
— тесно связаны между собой, работают в паре, но не по отдельности. Исключение из правил — это asyncio.sleep(0)
с нулем в качестве аргумента. Она похожа на пустое значение None и не требует для своей работы asyncio.run(…)
. Этой особенностью мы воспользовались во вводной статье про корутины.
Вернемся к программе. Она отсчитает 3 секунды, и затем завершит свою работу:
Осталось 3 сек.
Осталось 2 сек.
Осталось 1 сек.
Время вышло!
Теперь научим программу отсчитывать минуты. Пока времени остается много, пользователю не важны секунды, ему достаточно отсчета минут:
import asyncio
async def run_five_minutes_timer():
for minutes_left in range(5, 1, -1):
print(f'Осталось {minutes_left} мин.')
await asyncio.sleep(60) # one minute
# TODO добавить отсчет секунд, как в async def run_timer
print('Время вышло!')
print('\a') # says beep with ASCII BEL symbol
coroutine = run_five_minutes_timer()
asyncio.run(coroutine)
Этот таймер отсчитает пять минут. Первые четыре минуты он спит по 60 секунд, а затем переключается на секундный отсчет. По крайней мере, так задумано, но как это сделать без копирования кода?
И тут на сцену выходит await
. Он умеет работать не только с asyncio.sleep
, но и с любой другой асинхронной функцией. Код нашего таймера теперь выглядит так:
import asyncio
async def countdown_minutes_till_one_left(minutes):
for minutes_left in range(minutes, 1, -1):
print(f'Осталось {minutes_left} мин.')
await asyncio.sleep(60) # one minute
print(f'Осталась 1 мин.')
async def countdown_seconds(secs):
for secs_left in range(secs, 0, -1):
print(f'Осталось {secs_left} сек.')
await asyncio.sleep(1)
async def run_five_minutes_timer():
await countdown_minutes_till_one_left(5)
await countdown_seconds(60)
print('Время вышло!')
print('\a') # says beep with ASCII BEL symbol
coroutine = run_five_minutes_timer()
asyncio.run(coroutine)
Все самое интересное происходит внутри async def run_five_minutes_timer
. Эта асинхронная функция сразу же передает управление в корутину countdown_minutes_till_one_left(5)
и ждет её завершения, пока та не истощится. Затем то же происходит с отсчетом секунд: управление передается в countdown_seconds(60)
. В итоге, до первого вызова print('Время вышло!')
программа добирается целых 5 минут времени:
Осталось 5 мин.
Осталось 4 мин.
Осталось 3 мин.
Осталось 2 мин.
Осталось 1 мин.
Осталось 60 сек.
Осталось 59 сек.
…
Осталось 3 сек.
Осталось 2 сек.
Осталось 1 сек.
Время вышло!
Команду await
можно использовать с любой корутиной. Но обязательно помещайте await
внутрь асинхронной функции:
async def countdown_seconds(secs):
# TODO do stuff
await countdown_seconds(60) # SyntaxError: 'await' outside function
Команда await
даёт нам мощный инструмент декомпозиции. С ней корутины можно вызывать так же, как обычные функции, достаточно поставить await
. Возможно собрать цепочку вызовов, в которой одна корутина авейтит вторую, а та — третью и так до бесконечности.
Чем закончится кроличья нора?
В примере выше цепочка корутин непременно заканчивалась вызовом asynio.sleep(…)
— это тоже корутина, но как она реализована внутри? В ней тоже есть await с еще одной корутиной ?
Да, внутри asyncio.sleep
тоже есть await
, но авейтит он не корутину. Инструкция await
умеет работать и с другими объектами. Всех вместе их называют awaitable-объектами.
Подобно тому, как функция str(my_obj)
перепоручает работу методу объекта my_obj.__str__()
, так же и await my_obj
передает управление методу my_obj.__await__()
. Все объекты, у которых есть метод __await__
, называют awaitable-объектами, и все они совместимы с await
. Библиотека asyncio предоставляет большой набор таких объектов: Future, Task, Event и пр. В этой статье мы не будем вдаваться в детали, это предмет отдельной статьи. Если нужны подробности — читайте документацию по asyncio.
Любая цепочка корутин заканчивается вызовом await для некорутины
await vs event loop
В программе может быть только один event loop, но много await
. Возьмем функцию с таймером и запустим сразу три корутины:
loop = asyncio.get_event_loop()
# просим event loop работать с тремя корутинами
loop.create_task(run_five_minutes_timer())
loop.create_task(run_five_minutes_timer())
loop.create_task(run_five_minutes_timer())
# запускаем evet loop, оживают корутины
loop.run_forever()
В этой программе один event loop и он находится внутри метода loop.run_forever()
. Event loop контролирует исполнение сразу трех корутин — таймеров, по-очереди вызывая их методы coroutine.send()
. Так он создает иллюзию их параллельного исполнения:
Осталось 5 мин.
Осталось 5 мин.
Осталось 5 мин.
Осталось 4 мин.
Осталось 4 мин.
Осталось 4 мин.
…
Удивительно, но хотя первая же строка кода функции run_five_minutes_timer
передает управление в другую корутину с помощью await countdown_minutes_till_one_left(5)
, это не мешает работе других таймеров. Они словно не замечают, что первый таймер “завис” и ждет исчерпания корутины countdown_minutes_till_one_left(5)
.
Неверно воспринимать await
как маленький самостоятельный event loop. Команда await
больше похожа на посредника, который помогает подняться по стеку вызовов корутин и достучаться до верхнего event loop.
Оживляет корутины только event loop, await
ему помогает
await vs loop.create_task
С точки зрения той корутины, которую запускают, особой разницы нет. Оба варианта сработают:
async def run_first_function():
await run_five_minutes_timer()
...
async def run_second_function():
loop.create_task(run_five_minutes_timer())
...
В обоих случаях корутина run_five_minutes_timer()
запустится и начнет отсчет времени с минутными интервалом. Однако, большая разница есть для внешних функций: run_first_function
и run_second_function
.
Первая — run_first_function
— не сможет продолжить работу прежде, чем завершится run_five_minutes_timer()
. Команда await
будет исправно возвращать управление в event loop, но не в функцию run_first_function
.
Вторая — run_second_function
— продолжит свое выполнение сразу после вызова loop.create_task()
. Корутина run_five_minutes_timer()
будет добавлена в event loop и начнет свою работу, но функция run_second_function
не будет её ждать.
Корутина где есть await
блокируется до исчерпания вложенной корутины
На этом различия между await
и loop.create_task
не заканчиваются, а только начинаются. Они возвращают разные результаты, они по-разному обрабатывают исключения, они могут принимать разные аргументы. Эта тема тянет на отдельную статью.
Попробуйте бесплатные уроки по Python
Получите крутое код-ревью от практикующих программистов с разбором ошибок и рекомендациями, на что обратить внимание — бесплатно.
Переходите на страницу учебных модулей «Девмана» и выбирайте тему.