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")

Аргумент seconds приймає число з плаваючою комою. Для дедлайнів у мілісекундах 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, який кожна дочірня задача збирається повернути, тож власна причина завершення застосунку — те, що snapshot_loop підняла або не підняла — є тим, що спливає з main.