8.2. Корутины и задачи

Корутины — это единица работы, из которой строится программа asyncio; задачи — это то, как приложение запускает несколько корутин параллельно.

8.2.1. Корутины

Корутина — это функция, объявленная с async def:

import asyncio

async def heartbeat(interval_ms):
    while True:
        print("tick")
        await asyncio.sleep_ms(interval_ms)

Тело выглядит как у обычной функции, с одним дополнительным ингредиентом: await. Везде, где корутине приходится чего-то ждать — задержки, сетевого чтения, установки события — она выполняет await над выражением, которое умеет приостановить корутину до тех пор, пока то, чего она ждёт, не будет готово. На каждом await корутина возвращает управление asyncio; asyncio возобновляет её с той же точки, как только ожидаемая операция завершится.

Модуль asyncio поставляется с двумя видами задержки:

  • asyncio.sleep() — аргумент в секундах, принимает число с плавающей точкой.

  • asyncio.sleep_ms() — аргумент в миллисекундах, принимает целое число. Расширение MicroPython; обычно это правильный выбор на камере, поскольку настройки таймингов в прошивке имеют миллисекундную размерность.

Голый async def сам по себе ничего не делает. Вызов heartbeat(500) не выполняет тело; он возвращает объект корутины, который asyncio должен запланировать. Простейший способ запланировать его — asyncio.run():

asyncio.run(heartbeat(500))

asyncio.run() запускает цикл событий, планирует переданную ей корутину как точку входа верхнего уровня, управляет циклом, пока эта корутина не вернётся, а затем останавливает цикл. Для одной корутины это и есть вся программа. Для нескольких корутин приложение обращается к задачам.

8.2.2. Задачи

Задача — это обёртка asyncio вокруг корутины, которая говорит запланируй её параллельно с текущей и дай мне продолжить. asyncio.create_task() создаёт её и возвращает объект Task, представляющий запланированную работу:

task = asyncio.create_task(heartbeat("fast", 100))

Теперь корутина находится в расписании цикла; вызывающая сторона её не дожидалась. Возвращённый Task — это дескриптор, который вызывающая сторона использует впоследствии для взаимодействия с этой выполняющейся работой.

Получив дескриптор, приложение может сделать с ним три вещи:

  • Дождаться завершения задачи. Task сам по себе является ожидаемым. result = await task приостанавливает текущую корутину до тех пор, пока корутина task не вернётся, затем возобновляется с тем, что эта корутина вернула (или повторно вызывает то, что она вызвала).

  • Отменить задачу. task.cancel() планирует возбуждение asyncio.CancelledError внутри корутины задачи на её следующем await, давая ей возможность выполнить код очистки в блоке finally. Подробности рассматриваются на странице о тайм-аутах и отмене.

  • Идентифицировать её позже. asyncio.current_task() возвращает Task для корутины, которая выполняется в данный момент. Большинство скриптов никогда её не вызывают; она появляется в инструментировании и в обработчиках исключений.

Скрипту не обязательно захватывать дескриптор каждый раз. Одноразовые фоновые задачи, которые приложение запускает и оставляет работать, могут отбросить возвращаемое значение — цикл всё равно их планирует:

import asyncio

async def heartbeat(name, interval_ms):
    while True:
        print(name)
        await asyncio.sleep_ms(interval_ms)

async def main():
    asyncio.create_task(heartbeat("fast", 100))
    asyncio.create_task(heartbeat("slow", 500))
    await asyncio.sleep(5)

asyncio.run(main())

Два вызова create_task планируют оба heartbeat, не дожидаясь ни одного из них. Управление немедленно возвращается в main, который затем выполняет await над пятисекундной задержкой. Пока он спит, две задачи heartbeat продвигаются вперёд; цикл перебирает ту задачу, которая готова к выполнению. Через пять секунд main возвращается, цикл останавливает все ещё живые задачи, и asyncio.run() возвращается вызывающей стороне.

Захватывайте дескриптор всякий раз, когда приложению действительно нужна одна из трёх операций выше. На практике это означает почти всегда, поскольку аккуратное завершение работы приложения означает отмену порождённых им фоновых задач — этот шаблон рассматривается на странице об отмене.

8.2.3. Правило двух строк

Минимальная программа asyncio — это те две строки, которыми заканчиваются примеры выше:

async def main():
    ...

asyncio.run(main())

Всё остальное — задачи, которые создаёт приложение, примитивы, которыми оно их координирует, потоки, которые оно открывает, — происходит внутри main (и внутри корутин, которые main порождает). Когда скрипт перерастает классический цикл камеры while True: csi0.snapshot(), ответ — не вызывать asyncio.run() в нескольких местах; это вложить новую работу в main как дополнительные задачи.