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.