Прямой ответ
Отмена — базовый механизм asyncio, а не экзотика: task.cancel() вбрасывает CancelledError внутрь корутины в ближайшей точке await (отмена сотрудничает с кодом — «убить на произвольной строке» нельзя — корутина получает шанс убраться в finally/except), а с Python 3.11 есть штатные обёртки: asyncio.timeout(10) (контекст-менеджер — блок дольше 10 с отменяется и превращается в TimeoutError — заменил самописные wait_for-обвязки) и TaskGroup (структурная конкуренция: группа задач живёт/умирает вместе — ошибка одной отменяет сестёр, выход из блока — все завершены — «набор fire-and-forget задач, которые переживают запрос» — классическая утечка — закрывается TaskGroup'ом). Четыре правила корректного кода под отмену: (1) не глотать CancelledError (except Exception его не ловит (BaseException с 3.8 — уже хорошо), но явный except asyncio.CancelledError: pass или голый except: — саботаж отмены — задача «не отменяется», graceful shutdown висит — shutdown-механика); если перехватили — перебросить (raise) после уборки; (2) finally должен быть отменоустойчив (закрытие ресурсов в finally при отмене — await внутри finally во время отмены — рискованно: повторный cancel прервёт уборку — критичную финализацию защищать asyncio.shield() или делать синхронной/идемпотентной — «при отмене соединение осталось открытым» — отсюда); (3) таймаут у каждого внешнего вызова (httpx timeout, БД-драйвер, очередь — без таймаута отмена — единственная защита, а она приходит поздно); (4) отмена ≠ немедленность (CPU-цикл без await не отменяется вовсе — долгие вычисления дробить точками await/выносить в процессы — GIL/процессы). И понимать семантику: отменённая задача завершается CancelledError (не «тихо исчезает»), task.uncancel()/счётчик отмен — для библиотечного кода (прикладному редко нужен), «подавленная» отмена вложенной задачи ломает внешнюю — отменяемость сквозная по замыслу (structured concurrency).
Механика: как отмена работает внутри
Последовательность: task.cancel() → планировщик помечает задачу → на следующем входе корутины в await-точку вбрасывается CancelledError → распространяется вверх по стеку как обычное исключение (finally-блоки исполняются) → задача завершается статусом cancelled (task.cancelled() == True); нюансы: cancel во время другого исключения — CancelledError вытесняет/объединяется (контекст сохраняется — трассировка читаема); повторный cancel — если код перехватил первый и тянет (уборка/игнор) — повторный cancel бьёт снова (защита «неотменяемых» задач — но и уборку прерывает — баланс); точка отмены — только await (синхронный участок (JSON-парсинг мегабайта, регулярка) атомарен относительно отмены — «отмена сработала через 2 секунды» — внутри был длинный синхронный блок — дробление/executor); await после отмены — разрешён (уборка), но новый «внешний» cancel прервёт и его; TaskGroup и вложенность: async with asyncio.TaskGroup() as tg: tg.create_task(...) — выход из блока ждёт все; исключение в одной → отмена остальных → ExceptionGroup на выходе (PEP 654 — except* для разбора группы — новый синтаксис обработки); отмена самой TaskGroup-задачи — отменяет всех детей (структурность — дети не переживают родителя — гарантия, которой нет у create_task без присмотра). asyncio.timeout (3.11+): async with asyncio.timeout(5): await ... — TimeoutError на выходе (внутри был CancelledError — преобразован — различайте «мой таймаут» и «меня отменили снаружи» — asyncio.timeout это умеет (чужая отмена остаётся CancelledError)); async with asyncio.timeout_at(when) — абсолютный дедлайн; wait_for (старшая классика) — то же, но оборачивает задачу (лишний слой — timeout предпочтительнее в новом коде); timeout без отмены — не существует (таймаут в asyncio реализуется отменой — «подождать и бросить ждать, пусть доделывается» — fire-and-forget с последующей проверкой — другая схема, не таймаут).
Уборка: finally, shield, контекст-менеджеры
Отменоустойчивая финализация — главная тонкость практики: синхронная уборка в finally безопасна (close() файл/сокет-объект — не await — отмена не прервёт); await в finally — окно риска (открывающее соединение закрывается асинхронно — при отмене close- await может быть прерван повторным cancel — решения: asyncio.shield(coro) (внутренняя корутина защищена от внешней отмены — но shield сама бросает CancelledError наружу — «щит защищает работу, не вызывающего» — семантику читать внимательно), повторная защита через try/finally внутри shield, либо библиотеки с корректной aclose-идемпотентностью — httpx/asyncpg закрываются надёжно); асинхронные контекст-менеджеры (async with httpx.AsyncClient()) — их aexit исполняется при отмене (уборка входит в стек распространения — поэтому «всё через контекст-менеджеры» — не стиль, а гарантия уборки — httpx-практика); exit stack (contextlib.AsyncExitStack — динамические ресурсы — закрытие в обратном порядке при отмене — тот же контракт); фоновые задачи с уборкой (воркер: try: await process() except CancelledError: log; cleanup-sync; raise — перехват для логирования/метрики с обязательным перебросом — «поглотил и вернулся» — задача жива-неотменена — shutdown зависнет — антипаттерн №1); graceful shutdown сервера — uvicorn шлёт отмену воркерам при SIGTERM (сигнальная механика) — запросы дорабатывают в пределах grace-периода, потом отмена — корректность finally определяет, останутся ли висящие соединения/незакоммиченные транзакции; БД-транзакции — rollback в finally/контекст-менеджере (сессии SQLAlchemy — async-сессии закрываются контекстом — отмена не оставляет «полутранзакций»).
Практический сценарий: «сервис не останавливается» — разбор и фикс
Исходник: асинхронный воркер уведомлений (asyncio + httpx + Redis), деплой в K8s (поды) — поды висят 30 с после SIGTERM и умирают по SIGKILL (graceful не работает — terminationGracePeriod исчерпан — shutdown-статья описывает контракт, здесь — реализация сломана). Диагностика (логи с --log-level=debug + asyncio debug mode): после SIGTERM отмена рассылана задачам, но 4 задачи «не умирают»; разбор кода нашёл четыре разных дефекта: (1) except Exception: continue в главном цикле внутри except-ветки ловил CancelledError косвенно? нет — хуже: явный except asyncio.CancelledError: logger.info("retry"); continue — отмена перехвачена и подавлена ради «ретрая» — задача бессмертна (фикс: переброс после лога — raise); (2) синхронный блок парсинга 8-мегабайтного JSON внутри обработчика — отмена ждала его завершения 6+ с (фикс: orjson быстрее (сериализация) + крупные payload вынесены в process-pool (executor) — отмена мгновенна); (3) finally: await client.aclose() — при двойной отмене уборка прерывалась, соединения текли (фикс: AsyncExitStack на уровне задачи + shield на критичном закрытии Redis-пула); (4) fire-and-forget asyncio.create_task(send(...)) без ссылок — задачи вне жизненного цикла воркера (GC рисковал + отмена не доставала — фикс: TaskGroup на батч уведомлений — структурность); плюс таймауты: httpx без timeout на одном из вызовов — единственная «вечная» точка (фикс: Timeout(10) глобально — httpx); asyncio.timeout(25) на обработку одного сообщения (внешний дедлайн короче grace-периода — отмена успевает убраться до SIGKILL). Проверка: локальный прогон «запуск → нагрузка → SIGTERM» — остановка за 3.2 с (было 30+SIGKILL), соединения закрыты (netstat-проверка — диагностика), незавершённых транзакций нет (Redis/БД-логи чисты); тесты на отмену добавлены в набор (pytest): «задача отменяется за <1 с», «finally закрыл клиент» (мок-клиент с флагом close), «TaskGroup отменяет сестёр при ошибке» — регресс-стражи контракта; в CI — деплой на стейдж под нагрузкой с проверкой grace-остановки (e2e-подход в инфраструктурном варианте). Итог: деплои без SIGKILL, «залипшие» поды исчезли; правило в ревью-чек-лист (чек-лист): «перехватил CancelledError — перебрось; finally с await — защити; create_task — в TaskGroup; внешний вызов — с таймаутом».
FAQ
Почему except Exception не ловит CancelledError, и хорошо ли это?
С 3.8 CancelledError наследуется от BaseException (не Exception) — именно чтобы except Exception в бизнес-коде не глотал отмену случайно (до 3.8 — глотали массово — «неотменяемые» задачи были нормой); это хорошо (отмена проходит сквозь обработчики ошибок), но требует осознанности: голый except: и except BaseException: по-прежнему ловят — их в асинхронном коде почти не должно быть; «хочу уборку на отмену» — явный except asyncio.CancelledError: с обязательным raise в конце (или try/finally без перехвата — уборка без гашения).
asyncio.timeout против wait_for против своего таймера?
asyncio.timeout (3.11+) — канон: контекст-менеджер, корректно различает свою/чужую отмену, комбинируется (timeout_at — дедлайны на всю операцию — «запрос должен уложиться в 30 с суммарно» — дедлайн прокидывается через вложенные вызовы одним контекстом); wait_for — легаси-обёртка (работает, но лишний слой задачи и тонкости с подавлением отмены — новый код на timeout); «свой таймер» (asyncio.sleep + флаг) — не отменяет работу (задача продолжает жечь ресурсы «в фоне» — таймаут-иллюзия) — таймаут должен отменять, не игнорировать; библиотека без async (БД-драйвер) — таймауты драйвера + executor, не asyncio-обвязка.
Что такое asyncio.shield и когда он нужен? shield защищает внутреннюю работу от внешней отмены (внешний вызов получает CancelledError, защищённая корутина продолжается/завершается уборкой): кейсы — критичная финализация (закоммитить/закрыть/записать чекпоинт — «отменили запрос, но аудит-запись должна лечь»), фоновое завершение после отказа клиента (клиент отвалился — операция доделывается — но это уже скорее create_task+ссылка, не shield); ловушки: shield не делает работу «невидимой» (её надо await'ить/хранить), повторная отмена снаружи не трогает внутренность, но и результат уже некому забирать — fire-and-forget с shield требует присмотра (ссылка/TaskGroup); «защитить весь обработчик щитом» — ломает graceful shutdown — shield точечен, не тотален.
Как тестировать отмену и таймауты?
Подходы: явная отмена в тесте (task.cancel(); await asyncio.gather(task, return_exceptions=True) — проверка статуса cancelled и побочных эффектов (закрыт ли клиент — мок с флагом)); таймауты — сжатые значения (таймаут 0.05 с против сна 1 с — быстрый детерминированный тест — freeze-time не нужен); «долгая синхронная работа» — отдельный тест на отзывчивость (отмена во время CPU-блока — проверка, что дробление работает); TaskGroup-сценарии (ошибка одной — сёстры отменены — ExceptionGroup проверен except*); хаос-уровень — случайные отмены под нагрузкой на стейдже (chaos-подход — «kill -TERM во время пика» — еженедельный джоб); критерий: после любых отменочных сценариев — ноль утечек (соединения/файлы/задачи — метрики/weakref-проверки — утечки).
Источники: документация Python asyncio (tasks, dev), PEP 654 — ссылки в шапке.