8.5. Тайм-ауты и отмена

Отмена – это название asyncio для действия «прекратить выполнение этой сопрограммы, возбудить внутри неё исключение, чтобы у неё был шанс выполнить очистку, и удалить её из планировщика». Тайм-ауты – самая частая причина что-то отменить; ручной task.cancel() – другая.

8.5.1. Отмена задачи

Вызов Task.cancel планирует возбуждение asyncio.CancelledError внутри выполняющейся сопрограммы в её следующей точке await. Сопрограмма вправе проигнорировать отмену (перехватить исключение и продолжить выполнение) или подчиниться ей – обычный выбор – подчиниться ей после выполнения кода очистки:

async def network_worker(stream):
    try:
        while True:
            data = await stream.read(64)
            # ...
    finally:
        stream.close()

Простое предложение finally – самый чистый шаблон: очистка выполняется независимо от того, завершилась ли сопрограмма нормально, возбудила не связанное исключение или была отменена. CancelledError распространяется обратно вверх через finally, цикл видит, что задача завершена, а вызывающая сторона await task видит отмену.

Явный перехват CancelledError также допустим, когда приложение хочет сделать с ним что-то конкретное – записать в журнал, аккуратно передать ресурс и т. д. Правило таково: повторно возбуждайте его после выполнения очистки. Поглощение CancelledError сохраняет сопрограмму живой, когда вызывающая сторона попросила её остановиться, что почти всегда является ошибкой:

async def worker():
    try:
        await long_running_thing()
    except asyncio.CancelledError:
        log("worker cancelled, cleaning up")
        close_resources()
        raise          # << this line is the important one

8.5.2. Тайм-ауты с wait_for

asyncio.wait_for() оборачивает awaitable-объект в крайний срок. Если awaitable-объект завершается в пределах тайм-аута, возвращается его результат. Если нет, awaitable-объект отменяется, а вызывающая сторона получает asyncio.TimeoutError:

try:
    frame = await asyncio.wait_for(grab_frame(), timeout=2)
except asyncio.TimeoutError:
    print("camera took too long")

Аргумент секунд принимает число с плавающей точкой. Для крайних сроков в миллисекундах asyncio.wait_for_ms() принимает целое число миллисекунд – расширение MicroPython, согласующееся с миллисекундной зернистостью настроек таймингов прошивки.

Внутри wait_for делает ровно то же, что и ручная отмена: когда истекает крайний срок, он вызывает cancel() на обёрнутой задаче, внутри сопрограммы возбуждается CancelledError, выполняются предложения finally, и как только очистка завершена, исключение переводится в TimeoutError для вызывающей стороны.

Это означает, что сопрограмма, которая перехватывает и игнорирует CancelledError, сорвёт тайм-аут – крайний срок истёк, но сопрограмма отказалась останавливаться, а wait_for не может её заставить. Здесь тоже применимо приведённое ранее правило: перехватывайте CancelledError только для выполнения очистки, затем повторно возбуждайте.

8.5.3. Отмена через gather

Отмена распространяется вниз через gather(). Если задача, ожидающая вызов gather, отменяется, каждый awaitable-объект, ещё выполняющийся внутри gather, тоже отменяется – каждый из них получает шанс выполнить очистку через собственные предложения finally, прежде чем отмена поднимется к вызывающей стороне.

В сочетании с тайм-аутами это стандартный способ установить крайний срок на группу операций:

await asyncio.wait_for(
    asyncio.gather(a(), b(), c()),
    timeout=5,
)

Либо каждая подоперация завершается в течение пяти секунд, либо все они отменяются вместе.

8.5.4. Завершение работы приложения

Отмена – это также способ, которым реальное приложение останавливается аккуратно. Этот шаблон одинаков для разных скриптов: main сохраняет дескрипторы запущенных им долгоживущих фоновых задач, выполняет свою работу верхнего уровня, затем отменяет каждый дескриптор и ожидает его в блоке finally:

async def main():
    sender = asyncio.create_task(uplink())
    watcher = asyncio.create_task(button_watcher())
    try:
        await snapshot_loop()
    finally:
        sender.cancel()
        watcher.cancel()
        await asyncio.gather(sender, watcher,
                             return_exceptions=True)

return_exceptions=True – это приём, который не даёт gather повторно возбуждать CancelledError, которое каждая дочерняя задача вот-вот доставит, так что наружу из main всплывает собственная причина выхода приложения – то, что snapshot_loop возбудил или не возбудил.