Разбить корутину на части

Что не так с асинхронными функциями?

Программа, в которой кода много, а функций совсем нет — это нечитаемая простыня 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

Получите крутое код-ревью от практикующих программистов с разбором ошибок и рекомендациями, на что обратить внимание — бесплатно.

Переходите на страницу учебных модулей «Девмана» и выбирайте тему.