Таймауты и shield: scoped cancel с конверсией в TimeoutError, дедлайны по цепочке и shield как половина инструмента
asyncio.timeout отменяет тело по истечении и на границе превращает CancelledError в TimeoutError; счётчик отмен делает вложенность композируемой. Передавайте дедлайны, а не по-хоповые таймауты. shield глушит внешнюю отмену, но работа бежит отвязанной — лишь в паре с таймаутом.
Graceful shutdown был предметом гордости: по SIGTERM экспортер метрик отменял воркеров, потом сбрасывал последний буфер в коллектор — и этот flush был обёрнут в asyncio.shield(), потому что прежний инцидент научил команду, что отмена на остановке съедает финальный батч. Shield сработал. Слишком хорошо. Одним вторником под коллектора умер первым, TCP-соединение стало чёрной дырой, и зашилдованный flush ждал записи, которая не завершится никогда. Отмена не могла его тронуть — в этом и был смысл shield, — так что экспортер провисел весь grace period, получил SIGKILL и всё равно потерял буфер, плюс теперь двадцать минут задержки дренажа нод по всему флоту. Строка из постмортема, которая прижилась: shield решает, кому нельзя прервать работу; о том, сколько работа может длиться, он не говорит ничего. Shield без таймаута — не защита, а бессрочный захват заложников. Этот урок — про пару: scoped-отмена, конвертируемая в TimeoutError на границе, дедлайны, путешествующие вниз по цепочкам вызовов, и shield как ровно половина инструмента.
asyncio.timeout — это scoped cancel, а не секундомер
Когда тянетесь за таймаутом, задайтесь вопросом: вы хотите ограничить когда работа завершится или кто может её прервать? Ответ решает, нужен ли вам asyncio.timeout, shield или оба — и их путаница превращает зашилдованный flush в бессрочного заложника. async with asyncio.timeout(5): (3.11+) делает три вещи, которые вы теперь можете назвать точно по урокам 1 и 2. Он ставит call_at-таймер в кучу цикла; по истечении этот таймер отменяет текущую задачу — обычный внедрённый CancelledError, приземляющийся в следующей точке приостановки тела; а на выходе из скоупа, если пришедший CancelledError был его собственных рук делом, он конвертирует его в TimeoutError (встроенное исключение Python, сигнализирующее о превышении отведённого времени). Снаружи скоупа вы видите чистый таймаут; внутри тело пережило обычную отмену — поэтому весь урок 2 действует внутри таймаут-скоупа дословно: finally-блоки выполняются, уборка должна быть ограничена, проглатывание ломает всё.
Решение о конверсии — глубокая часть: откуда скоуп знает, что отмена была его? Учёт счётчика отмен. Каждый cancel() инкрементирует счётчик отмен задачи; когда cancel выстрелил таймер скоупа, скоуп на выходе вызывает uncancel() — если счётчик вернулся к нулю, отмена принадлежала этому скоупу и становится TimeoutError; если остался положительным, в полёте есть и внешняя отмена (или внешний таймаут), и скоуп пере-бросает CancelledError нетронутым, чтобы внешняя заявка победила. Именно это делает вложенность композируемой:
async with asyncio.timeout(10): # внешний бюджет
async with asyncio.timeout(2): # внутренний, строже
await fetch() # занимает 5 с
# внутренний скоуп: его таймер выстрелил, его uncancel() обнуляет счётчик -> TimeoutError
# внешний скоуп: не выстрелил, видит пролетающее обычное исключение, остаётся взведённымwait_for — старший брат: он оборачивает одиночный awaitable (создавая Task для голой корутины), тогда как timeout() скоупит блок. Исторически у wait_for были настоящие краевые случаи вокруг гонки «результат пришёл во время отмены» — завершённая работа могла потеряться или отмена утечь; с 3.12 он переписан поверх asyncio.timeout и наследует учёт счётчика. Современный код предпочитает скоуп: он композируется, накрывает несколько await и не требует аллокации Task.
Вложенные скоупы: снаружи timeout(10), внутри timeout(2), а awaited-вызов fetch занимает 5 с. Что всплывёт и на какой границе?
Дедлайны путешествуют; таймауты — нет
Константный по-хоповый таймаут — джуниорская версия идеи. Запрос, обязанный ответить за 2 с и делающий три последовательных вызова вниз, не может дать каждому по 2 с — бюджеты обязаны делить дедлайн, и каждый хоп вычисляет остаток. Это тот же паттерн, который Go институционализировал как context-дедлайны (урок про дисциплину контекста в go-треке); в asyncio вы несёте дедлайн и выводите из него скоупы — asyncio.timeout_at(when) принимает абсолютное время часов цикла ровно для этого:
async def call_with_deadline(deadline, op):
loop = asyncio.get_running_loop()
if deadline - loop.time() <= 0:
raise TimeoutError("бюджет исчерпан ещё до вызова")
async with asyncio.timeout_at(deadline): # абсолютный, общий, тающий
return await op()
async def handler():
deadline = asyncio.get_running_loop().time() + 2.0 # клиентский бюджет
user = await call_with_deadline(deadline, fetch_user)
orders = await call_with_deadline(deadline, fetch_orders) # получает ОСТАТОК
return render(user, orders)Классический инцидент с перепутанным порядком: внешний timeout(5) вокруг retry-цикла, у которого по-попыточный бюджет 10 с и три попытки. Внешний скоуп всегда стреляет посреди первой попытки; по-попыточный таймаут не срабатывает никогда; retry-цикл — недостижимый код с честным лицом: метрики вечно показывают retries == 0, хотя постмортем рассчитывал на три попытки. Правило, которое стоит приколоть: бюджеты обязаны вкладываться строго — по-попыточный внутри по-запросного внутри клиентского, — и единственная структура, которая обеспечивает это автоматически, — вывод каждого внутреннего скоупа из одного тающего дедлайна.
shield: кому нельзя прервать — но не «сколько можно»
asyncio.shield(thing) оборачивает awaitable в Task и даёт вам охраняемый await на него. Когда приходит внешняя отмена, она бьёт по ожиданию шилда, а не по внутренней задаче: ваш await shield(...) немедленно бросает CancelledError, а внутренняя задача продолжает бежать, отвязанной. В этом предложении и фича, и режим отказа. Фича: уже летящий коммит, сброс write-behind-кэша — работа, чьё бросание стоит дороже завершения, — переживает шторм отмен вокруг. Режим отказа — проблема сироты: после смерти ожидания шилда — кто будет await-ить внутреннюю задачу? Не сохранили ссылку — её результат и исключения испаряются на кладбище задач (в лучшем случае лог «Task exception was never retrieved»). А Хук — второй отказ: shield ограничивает прерывание, не длительность — зашилдованная работа, ждущая мёртвого пира, бежит вечно. Дисциплина — пара:
async def flush_on_shutdown(exporter):
flush = asyncio.ensure_future(exporter.flush()) # именованная: не сирота
try:
await asyncio.shield(flush) # внешняя отмена встаёт ЗДЕСЬ
except asyncio.CancelledError:
async with asyncio.timeout(5): # ограничить защищённую работу
await flush # дать ей закончить — или нет
raise # затем уважить отменуШилдовать всё — паттерн злоупотребления: сервис, чьи хэндлеры поголовно шилдят свои тела, попросту неубиваем — отмена, таймауты и graceful shutdown деградируют до SIGKILL. Shield — для узкого класса работы «обязана завершиться», всегда с ограничителем, всегда с сохранённой ссылкой.
▸Почему это работает
Зачем 3.11 вообще добавил счётчик отмен? Потому что до него вложенные источники отмены не могли различить свои отмены. Баги эпохи wait_for были настоящими: задача, отменённая таймаутом в тот же момент, когда пришёл её результат, могла результат потерять — или превратить внешнюю отмену в TimeoutError, выдав сознательный shutdown за медленный апстрим. Счётчик cancelling()/uncancel() даёт каждому скоупу квитанцию: конвертируй только то, что сам запросил. Поэтому же урок 2 запретил глотать CancelledError — съешьте один, и какой-то скоуп выше погасит запрос, который так и не был доставлен, испортив счёт всем внешним.
Хэндлер запроса оборачивает запись в БД в asyncio.shield() без сохранённой ссылки. Клиент отключается, фреймворк отменяет хэндлер. Что произойдёт с записью?
- 01Объясните полный механизм asyncio.timeout, включая то, как граница выбирает между TimeoutError и пере-бросом CancelledError, и почему это чинит то, что ломал wait_for.
- 02Сформулируйте дисциплину shield: что он реально гарантирует, два режима отказа при использовании в одиночку и паттерн дедлайнов для многохоповых бюджетов запроса.
asyncio.timeout — это отмена из урока 2, одетая в бюджет: call_at-таймер отменяет собственное тело скоупа, тело разматывается через обычный CancelledError (правила уборки в комплекте), а граница конвертирует в TimeoutError только после того, как uncancel() подтвердит, что отмена своя, — учёт счётчика отмен, который делает вложенные скоупы детерминированно композируемыми и пропускает внешнюю отмену непресвоенной: ровно та машинерия, которой не хватало wait_for, гонявшему результаты против отмен (в 3.12 его пересобрали на скоупе). Продакшен-бюджеты — дедлайны, а не константы: клиентские 2 с, разделённые последовательными хопами, означают один абсолютный дедлайн и каждый внутренний скоуп из остатка (timeout_at), при строгой иерархии — по-попыточный внутри по-запросного внутри клиентского, — потому что перевёрнутая версия тихо удаляет ваш retry-цикл, пока метрики рапортуют ноль ретраев. shield — вторая половина инструмента: он отделяет ожидающего от работы, так что внешняя отмена убивает ваш await немедленно, а внутренняя задача бежит отвязанной — законно для летящего коммита и write-behind-сброса, но только с сохранённой ссылкой (иначе результат и сбои сироты испаряются) и только в паре с ограничителем, потому что shield лимитирует, кто может прервать, и молчит о том, сколько можно длиться, — зашилдованный flush в мёртвый коллектор, повесивший дренаж флота, и есть то, ради предотвращения чего существует пара. Зашилдуйте всё — и вы построили неубиваемый сервис; ограничьте всё — и отмена остаётся тем, чем обязана быть: быстрой, честной и структурной. Теперь, когда тянетесь к asyncio.shield, добавьте таймаут и сохраните ссылку в том же коммите — именно эти две пропущенные детали превратили graceful shutdown в задержку дренажа всего флота.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.