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

Семантика отмены: внедрённый CancelledError, баг бессмертия через голый except и уборка, обязанная пере-бросить

task.cancel() бросает CancelledError в следующем await; между await код непрерываем. except Exception его пропускает, голый except глотает — бессмертные задачи. Уборка в finally, await в ней ограничены, затем re-raise. TaskGroup отменяет соседей при первом сбое.

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

Деплой должен был занять девяносто секунд. Сервис-консьюмер получил 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(). Когда задача реально остановится?

Вспомните перед уходом
  1. 01
    Объясните механически, что делает task.cancel(), где исключение может и не может приземлиться, и двустороннее следствие того, что await — единственные точки доставки.
  2. 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-уровень. Открой, попробуй, потом открой ответ.

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

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

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

Примени это

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

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

Trademarks belong to their respective owners. Editorial reference only.