open atlas
↑ К треку
Python для JS/TS-разработчиков PY · 11 · 03

Таймауты и shield: scoped cancel с конверсией в TimeoutError, дедлайны по цепочке и shield как половина инструмента

asyncio.timeout отменяет тело по истечении и на границе превращает CancelledError в TimeoutError; счётчик отмен делает вложенность композируемой. Передавайте дедлайны, а не по-хоповые таймауты. shield глушит внешнюю отмену, но работа бежит отвязанной — лишь в паре с таймаутом.

PY Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

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() без сохранённой ссылки. Клиент отключается, фреймворк отменяет хэндлер. Что произойдёт с записью?

Вспомните перед уходом
  1. 01
    Объясните полный механизм asyncio.timeout, включая то, как граница выбирает между TimeoutError и пере-бросом CancelledError, и почему это чинит то, что ломал wait_for.
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.