Семантика отмены: внедрённый CancelledError, баг бессмертия через голый except и уборка, обязанная пере-бросить
task.cancel() бросает CancelledError в следующем await; между await код непрерываем. except Exception его пропускает, голый except глотает — бессмертные задачи. Уборка в finally, await в ней ограничены, затем re-raise. TaskGroup отменяет соседей при первом сбое.
Деплой должен был занять девяносто секунд. Сервис-консьюмер получил SIGTERM, записал в лог «shutting down, cancelling 32 workers» — и дальше ничего, десять минут, пока оркестратор не потерял терпение и не прислал SIGKILL. Постмортем нашёл цикл воркера, обёрнутый в голый except:, написанный годы назад под комментарием «воркеры должны переживать всё». Он переживал отмену. Каждый task.cancel() исправно бросал CancelledError в воркер на ближайшем await; голый except ловил его, логировал worker recovered: CancelledError() и возвращался к queue.get(). Тридцать два бессмертных таска, gather(), который не мог вернуться никогда, и — самая дорогая часть — тридцать два неподтверждённых джоба в полёте, которые брокер передоставил после рестарта и которые обработались дважды. Письма клиентам ушли в двух экземплярах. Фикс занял три строки дисциплины исключений. Уроком была сама модель: отмена в asyncio — не рубильник, а исключение, которому ваш код обязан дать пройти, — и ломается всё ровно там, где это доверие нарушают.
Отмена — это внедрённое исключение, а не флаг
task.cancel() ничего не останавливает. Он договаривается с циклом, что тот бросит CancelledError в корутину в её следующей точке приостановки — механически Task возобновляет корутину через coro.throw(CancelledError()) вместо send() (машинерия урока 1, поставленная на боевой взвод). Две вещи определяют всю модель. Первая: код между await непрерываем — это гарантия (обновление словаря или двухшаговая смена состояния никогда не будут разорваны отменой пополам) и одновременно ловушка (30-секундный отрезок чистого CPU неотменяем всю свою длительность, потому что throw негде приземлиться). Вторая: отмена доставляется только на await, которые реально приостанавливают, — различение из урока 1 возвращается с зубами. await завершённой футуры или синхронно цепляющейся корутины — не точка отмены. Для длинных CPU-секций, обязанных оставаться отзывчивыми к отмене, честный приём — периодический await asyncio.sleep(0): он вставляет настоящую приостановку, куда throw может приземлиться, ценой круга через цикл на каждую проверку.
Баг проглатывания, точно
Сколько кодовых баз прячет бессмертную задачу в shutdown? Больше, чем кажется — и механизм умещается в одно неверное слово в блоке исключения. С Python 3.8 CancelledError наследуется от BaseException (базовый класс всех исключений Python, включая системные) — намеренное ломающее изменение, сделанное ради того, чтобы самый частый защитный паттерн стал безопасным:
async def worker(q):
while True:
job = await q.get()
try:
await handle(job)
except Exception: # НЕ ловит CancelledError (3.8+): хорошо
log.exception("джоб упал, продолжаем")
# except: # ловит BaseException: отмена умирает здесь
# except BaseException: # тот же баг, написанный честноexcept Exception пропускает отмену насквозь — задача разматывается и заканчивается в состоянии cancelled, ровно как хотел отменяющий. Голый except (или except BaseException) её ловит, и если обработчик после этого продолжает цикл, задача бессмертна: cancel() вернул True, исключение доставлено — и ничего не умерло. Это и был баг из Хука, а по духу он старше 3.8 — до 3.8 даже except Exception глотал отмену, поэтому старые кодовые базы — минные поля. Дисциплина на случай, когда отмену нужно увидеть: поймать, убраться, пере-бросить:
except asyncio.CancelledError:
await q.nack(job) # вернуть джоб в полёте (ограничьте это! см. ниже)
raise # ВСЕГДА — отменяющему причитается размоткаУ проглатывания без re-raise есть вторая цена, кроме бессмертия: await task после этого возвращается нормально, task.cancelled() — False, и вся машинерия фреймворков, проверяющая «сработал ли мой cancel» — TaskGroup, таймаут-скоупы, — получает ложь.
Код-ревью: воркер оборачивает обработку джоба в `try: await handle(job)` / `except: log('retrying')` и продолжает свой while-True. Что произойдёт, когда shutdown вызовет task.cancel()?
await в finally: ловушка двойной отмены
Уборка, которая сама делает await, — место, где зависает корректно выглядящий код. Отменённая задача выполняет свои finally-блоки по мере размотки исключения — но await внутри finally — это новая точка приостановки, и второй cancel() (нетерпеливый цикл shutdown, истекающий вокруг вас таймаут-скоуп) приземляется ровно туда, бросив остаток уборки. Хуже: сам await уборки может заблокироваться навсегда — nack() мёртвому брокеру — и теперь задача неубиваема-из-вежливости. Дисциплина: ограничивайте каждый await на пути отмены. Оборачивайте уборку в asyncio.timeout(...) (следующий урок разберёт его), или шилдите по-настоящему обязанную завершиться работу; никогда не позволяйте уборке ждать тех же условий, что вызвали отмену.
finally:
with contextlib.suppress(TimeoutError):
async with asyncio.timeout(2): # уборке даётся 2 с, не вечность
await q.nack(job)TaskGroup: структурная отмена и чей это был cancel
Поведение TaskGroup вы знаете из юнита про конкурентность: первый сбой ребёнка отменяет соседей, результаты приходят как ExceptionGroup (тип исключения Python 3.11+, объединяющий несколько исключений в одно). Глубокая деталь — как он отличает внутреннюю отмену (группа отменяет собственных детей) от внешней (кто-то отменил всю группу снаружи): каждая задача держит счётчик отмен — cancel() инкрементирует, uncancel() декрементирует. Когда TaskGroup или таймаут-скоуп отменяет тело по своим причинам, позже он вызывает uncancel(), чтобы погасить свой запрос и решить: счётчик вернулся к нулю — отмена была моя, докладываем сбой соседа (или TimeoutError); всё ещё положительный — в полёте есть и внешняя отмена, и она обязана распространяться дальше. Честно: uncancel() — машинерия фреймворков, прикладной код её почти не вызывает, — но знание о ней объясняет, почему проглатывание CancelledError внутри TaskGroup или таймаут-скоупа портит их бухгалтерию и почему правило re-raise — не этикет, а протокол.
▸Почему это работает
Почему внедрённое исключение, а не рубильник? Потому что отмена обязана сочетаться с уборкой. Жёсткое убийство (фантазия о kill для потока) может прийтись между любыми двумя инструкциями — посреди коммита, с залоченным локом, с полузаписанным состоянием. Исключение, бросаемое только на await, гарантирует, что в каждой точке отмены ваши инварианты целы, бесплатно едет на существующей машинерии try/finally и async-with и делает уборку выразимой на языке, который вы уже знаете. Цена — кооперация: код, ловящий без re-raise или никогда не приостанавливающийся, выписывает себя из отменяемых — поэтому и существует дисциплина этого урока.
Задача на первой секунде 30-секундной чистопитоновой CPU-секции (внутри нет await). Сейчас вызывают task.cancel(). Когда задача реально остановится?
- 01Объясните механически, что делает task.cancel(), где исключение может и не может приземлиться, и двустороннее следствие того, что await — единственные точки доставки.
- 02Сформулируйте дисциплину обработки исключений при отмене и цену каждого нарушения: except Exception, голый except, поимка без re-raise, неограниченные await в finally.
Отмена в asyncio — внедрённое исключение верхом на генераторной машинерии из урока 1: cancel() просит цикл возобновить цель через throw(CancelledError) на её следующем по-настоящему приостанавливающем await, что делает код между await атомарным — настоящая гарантия, на которую молча опираются ваши двухшаговые смены состояния, — и делает длинные CPU-секции неотменяемыми до ближайшей точки приземления, с периодическим await asyncio.sleep(0) как честным приёмом по цене латентности. Правила обработки исключений вытекают из одного факта: с 3.8 CancelledError — это BaseException, поэтому except Exception безопасен, а голый except и except BaseException глотают отмену — инцидент из Хука, где тридцать два воркера логировали «recovered» о собственном смертном приговоре, shutdown висел десять минут до SIGKILL, а неподтверждённые джобы после передоставки обработались дважды. Законная обработка — увидеть, убраться, пере-бросить: всё меньшее лжёт await task, лжёт task.cancelled() и ломает протокол счётчика отмен (cancelling/uncancel), которым TaskGroup отличает свою отмену из-за сбоя соседа от внешней, обязанной распространяться, — машинерию, которую вы редко вызываете, но на которую постоянно полагаетесь. Последняя ловушка — сама уборка: await внутри finally — свежая точка приостановки, куда приземляется вторая отмена, а неограниченный await уборки может повиснуть на той же мёртвой зависимости, что вызвала отмену, — ограничивайте его asyncio.timeout, ровно с этого места начинается следующий урок. Теперь, когда видите shutdown, зависающий до SIGKILL, — ищите голый except первым: там живёт задача, перехватывающая собственный смертный приговор.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.