Deep Engineering

ЗАМЕР

bench/cancellation/02_finally_budget.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/interview/python/async-await
Как запустить
CPython 3.11.15
CPython 3.12.3
CPython 3.13.7      <- основной прогон, весь дословный вывод ниже с него
CPython 3.14.7

Запись прогона

Отмена в asyncio: воспроизводимые примеры

Здесь доказательство — не время и не байты, а поведение при отмене: что выполнится, что не выполнится, какое исключение куда прилетит и во что превратится. Поэтому файлы лежат целиком: читатель должен получить ровно те же строки, что и мы.

Числа в выводе — только те, что печатает скрипт. Всё, что меняется от запуска к запуску (адреса объектов, loop.time(), номера строк, доли секунды в логе debug-режима), скрипты нормализуют сами и помечают как <...>.

Чем проверено

CPython 3.11.15
CPython 3.12.3
CPython 3.13.7      <- основной прогон, весь дословный вывод ниже с него
CPython 3.14.7

Исходники, на которые ссылаются скрипты, — установленный 3.13.7:

python3.13 -c "import sysconfig;print(sysconfig.get_paths()['stdlib'])"
# /root/.local/share/uv/python/cpython-3.13.7-linux-x86_64-gnu/lib/python3.13

Что где

Файл Что показывает
01_cancellederror_is_baseexception.py CancelledError — не Exception; except Exception её пропускает, except BaseException без raise её убивает
02_finally_budget.py finally при отмене выполняется, но бюджета у него нет; повторный cancel(); проглоченная отмена; счётчик cancelling()
03_timeout_vs_wait_for.py asyncio.timeout против wait_for: внутри CancelledError, снаружи TimeoutError; чем таймаут отличает свою отмену от внешней
04_shield.py что shield защищает (работу) и чего не защищает (ожидающего)
05_taskgroup_cancellation.py отмена соседей в TaskGroup, ExceptionGroup, except*, контраст с gather
06_debug_slow_callback.py debug-режим asyncio и loop.slow_callback_duration: что именно печатается на медленный коллбэк
version_matrix.py один файл на четырёх интерпретаторах: где границы двигались

Запуск:

python3.13 bench/cancellation/01_cancellederror_is_baseexception.py
python3.13 bench/cancellation/02_finally_budget.py
python3.13 bench/cancellation/03_timeout_vs_wait_for.py
python3.13 bench/cancellation/04_shield.py
python3.13 bench/cancellation/05_taskgroup_cancellation.py
python3.13 bench/cancellation/06_debug_slow_callback.py

python3.11 bench/cancellation/version_matrix.py
python3.12 bench/cancellation/version_matrix.py
python3.13 bench/cancellation/version_matrix.py
python3.14 bench/cancellation/version_matrix.py

Все семь файлов запускаются на всех четырёх интерпретаторах и на всех четырёх завершаются с кодом 0.

Дословный вывод: решающие места

Полный вывод каждого файла получается запуском — он длинный, и переписывать его сюда целиком значило бы завести второй экземпляр того, что и так печатается. Ниже только те строки, ради которых файлы написаны.

01 — где CancelledError в иерархии и что из этого следует.

  __mro__                                        ('CancelledError', 'BaseException', 'object')
  issubclass(CancelledError, Exception)          False
  asyncio.CancelledError is concurrent.futures.CancelledError False
  concurrent.futures.CancelledError.__mro__      ('CancelledError', 'Error', 'Exception', 'BaseException', 'object')

  except Exception                               пропустил
  except BaseException                           ПОЙМАЛ

И то, ради чего это меряется, — на живой задаче:

  б) try/except BaseException/finally, БЕЗ raise
      task.cancelled()                           False
      итог                                       await task вернул: 'вернул значение, как будто ничего не было'

  д) contextlib.suppress(CancelledError)
      task.cancelled()                           False
      итог                                       await task вернул: None

Ловушка не в том, что отмену трудно поймать, а в том, что её легко поймать НЕ ТАМ: except Exception пропускает, except BaseException без raise — убивает.

02 — у finally нет бюджета.

  уборка просила                                 0.30 с
  от cancel() до конца задачи прошло             0.30 с

== 3. Повторный cancel() обрывает уборку; счётчик cancelling() растёт
  после 1-го cancel(): task.cancelling()         1
  после 2-го cancel(): task.cancelling()         2
    | finally: уборку оборвали на await

== 6. cancelling()/uncancel() — счётчик, а не флаг
  cancel()                                       1
  uncancel() вернул                              0
  итог                                           бросил CancelledError

Сколько уборка попросила — столько цикл и ждал. Предел появляется только тогда, когда отменяющая сторона отменяет ещё раз.

03 — одно событие, две ветки except.

    | изнутри блока прилетел CancelledError
    | снаружи блока: TimeoutError
    | цепочка: TimeoutError <-cause- CancelledError

== 3. Проглотили отмену внутри блока — таймаут молча не сработал
    | тело досчиталось до конца, хотя таймаут истёк
    | TimeoutError НЕ прилетел
    | блок с лимитом 0.05 с занял 0.15 с

04 — что shield защищает.

== 1. Отменяем ОЖИДАЮЩЕГО: внутренняя задача не замечает
  outer.cancelled()                              True
  inner.cancelled()                              False
  inner.result()                                 'внутренняя-результат'

== 4. shield под asyncio.timeout
    | снаружи: TimeoutError через 0.05 с
    | защищённая всё же доработала за 0.25 с

Таймаут отменил ожидание, но не работу. Не зная этого, получают «таймаут сработал, а запрос всё равно ушёл».

05 — отмена соседей и что выходит наружу.

== 2. Две задачи упали — обе в одном ExceptionGroup
    ExceptionGroup: 'unhandled errors in a TaskGroup' (2 шт.)
      ValueError: V
      KeyError: 'K'

== 3. CancelledError от ребёнка группу НЕ роняет
  итог                                           группа вышла штатно

== 4. Внешняя отмена родителя: наружу CancelledError, не ExceptionGroup
  await parent ->                                CancelledError

06 — debug-режим и порог.

  новый цикл: get_debug()                        False
  новый цикл: slow_callback_duration             0.1

  б) debug включён, порог по умолчанию 0.1 с
    | WARNING:asyncio:Executing <Handle blocking_callback() ...> took <N> seconds

  в) debug включён, порог поднят до 1.0 с — блокировка не заметна
    (предупреждения нет)

== 3. Тот же блок, но внутри задачи — жалуются на Task, а не на Handle
    | WARNING:asyncio:Executing <Task finished name='Task-<n>' coro=<blocking_step() ...>> took <N> seconds

Режим и порог — две независимые ручки: без debug порог не смотрится вовсе, а с порогом 1,0 с блокировка на 0,25 с не видна. Формат строки один, меняется только объект: Handle для call_soon, TimerHandle для call_later, Task для шага задачи — последний и означает «корутина заблокировала цикл».

Таблица версий

Строки, где выводы четырёх интерпретаторов разошлись:

3.11.15 3.12.3 3.13.7 3.14.7
hasattr(asyncio, 'eager_task_factory') False True True True
hasattr(asyncio.Queue, 'shutdown') False False True True
import asyncio.graph ModuleNotFoundError ModuleNotFoundError ModuleNotFoundError ok
get_event_loop() без работающего цикла вернул цикл, без предупреждений вернул цикл + DeprecationWarning вернул цикл + DeprecationWarning RuntimeError
wait_for: корутина выполняется в той же задаче False True True True
uncancel() снимает ещё не доставленную отмену нет нет да да
create_task у не-входившей TaskGroup: RuntimeWarning 'never awaited' есть есть нет нет

Строки, одинаковые на всех четырёх (граница здесь — 3.11, и она не двигалась):

hasattr(asyncio, 'timeout')                          True
hasattr(asyncio, 'timeout_at')                       True
hasattr(asyncio, 'TaskGroup')                        True
hasattr(asyncio, 'shield')                           True
hasattr(asyncio.Task, 'cancelling')                  True
hasattr(asyncio.Task, 'uncancel')                    True
builtins.ExceptionGroup                              True
builtins.BaseExceptionGroup                          True
синтаксис except*                                    ok
CancelledError.__mro__                               ('CancelledError', 'BaseException', 'object')
issubclass(CancelledError, Exception)                False
asyncio.TimeoutError is builtins.TimeoutError        True
TimeoutError.__mro__                                 ('TimeoutError', 'OSError', 'Exception', 'BaseException', 'object')
Task реализован на C                                 True
wait_for отдаёт значение, если отмену съели          'проглотил и вернул'
ошибка ребёнка под внешним asyncio.timeout           ExceptionGroup['ValueError']
две вложенные TaskGroup падают разом                 ExceptionGroup -> ['ValueError(внешняя)', 'ValueError(внутренняя)']

Три различия из семи — не про наличие имён, а про поведение, и каждое ловится только запуском.

wait_for и «та же задача». На 3.11 wait_for заворачивал переданное в отдельную задачу и ждал её через коллбэк; на 3.12 и дальше он реализован как async with timeouts.timeout(timeout): return await fut (Lib/asyncio/tasks.py:506 на 3.13.7), то есть тело выполняется прямо в вызывающей задаче. Наблюдается это через asyncio.current_task().get_name(): Task-4 против Task-1.

uncancel() и ещё не доставленная отмена. До 3.13 uncancel() уменьшал только счётчик; с 3.13 он ещё и снимает флаг _must_cancel (Lib/asyncio/tasks.py:256-259). Скрипт ловит это так: отменить задачу до её первого шага (тогда _fut_waiter ещё None и отмена только «назначена»), сразу вызвать uncancel() — на 3.11/3.12 задача всё равно отменяется, на 3.13/3.14 доживает до конца и возвращает результат.

create_task у неактивной группы. С 3.13 переданная корутина закрывается, и RuntimeWarning: coroutine ... was never awaited больше не появляется. На 3.11/3.12 предупреждение есть — скрипт ловит его через warnings.catch_warnings(record=True), а не по тексту в stderr.

Про «зависание вложенных TaskGroup». Проба «две вложенные TaskGroup падают разом» обёрнута сторожевым asyncio.timeout(2) — если бы группа повисла, в таблице стояло бы ПОВИСЛО. На всех четырёх версиях она не виснет. Это не опровергает исправление 3.13: сценарий из gh-116720 воспроизводится не любым вложением, и доказать здесь можно только то, что эта проба ведёт себя одинаково. Такой пробы для той ошибки у нас нет.

Первоисточники

Дословные цитаты с переводами стоят во frontmatter статей, которые на эти файлы ссылаются, — там же, где они нужны читателю. Здесь только адреса: документация asyncio (asyncio-task, asyncio-exceptions, asyncio-dev, asyncio-eventloop), PEP 654 и «Что нового в 3.13». Текст снят с .rst-исходников CPython на теге v3.13.7 — это тот же текст, что рендерится на docs.python.org/3.13, и он привязан к точной версии.

Ключевые места в исходниках 3.13.7

Пути от корня установки: .../lib/python3.13/asyncio/.

Файл:строка Что там
exceptions.py:10 class CancelledError(BaseException): — одна строка, из которой растёт весь сюжет
exceptions.py:14 TimeoutError = TimeoutError # make local alias for the standard exception
tasks.py:115-116 self._num_cancels_requested = 0 и self._must_cancel = False — два поля состояния отмены
tasks.py:198 def cancel(self, msg=None)
tasks.py:223 self._num_cancels_requested += 1
tasks.py:224-228 закомментированный if self._num_cancels_requested > 1: return False с пометкой «These two lines are controversial» и ссылкой на обсуждение
tasks.py:236-237 self._must_cancel = True — путь, когда задача ещё не ждёт ни на чём
tasks.py:240 def cancelling(self)
tasks.py:248 def uncancel(self)
tasks.py:256-259 уменьшение счётчика и, с 3.13, сброс _must_cancel при нуле
tasks.py:459 async def wait_for(fut, timeout)
tasks.py:506-507 async with timeouts.timeout(timeout): return await fut — вся реализация wait_for с 3.12
tasks.py:918 def shield(arg)
tasks.py:930-935 «…the task running in something() is not cancelled… If something() is cancelled by other means this will still cancel shield()»
tasks.py:946-949 «Save a reference to tasks passed to this function…»
timeouts.py:88-94 __aenter__: запоминает self._cancelling = self._task.cancelling()
timeouts.py:112 if self._task.uncancel() <= self._cancelling and exc_type is not None: — вот чем своя отмена отличается от чужой
timeouts.py:115-116 if issubclass(exc_type, exceptions.CancelledError): raise TimeoutError from exc_val
timeouts.py:127-132 _on_timeout: self._task.cancel() и переход в состояние EXPIRING
taskgroups.py:35 self._tasks = set() — отсюда невоспроизводимый порядок отмены соседей
taskgroups.py:144-149 if self._parent_cancel_requested: if self._parent_task.uncancel() == 0: propagate_cancellation_error = None
taskgroups.py:169-176 uncancel + повторный cancel родителя, затем raise BaseExceptionGroup('unhandled errors in a TaskGroup', self._errors)
taskgroups.py:186-194 три проверки в create_task, каждая с coro.close() перед RuntimeError
taskgroups.py:221-226 _abort: for t in self._tasks: if not t.done(): t.cancel()
taskgroups.py:235-236 if task.cancelled(): return — отменённый ребёнок группу не роняет
taskgroups.py:257-278 отмена родительской задачи при первой ошибке ребёнка
runners.py:198-206 _cancel_all_tasks: cancel() всем и run_until_complete(gather(...)) без ограничения по времени
base_events.py:432 self.set_debug(coroutines._is_debug_mode())
base_events.py:437 self.slow_callback_duration = 0.1
base_events.py:2038-2047 ветка if self._debug: в _run_once и logger.warning('Executing %s took %.3f seconds', ...)
log.py:7 logger = logging.getLogger(__package__) — логгер называется asyncio, к нему и цепляется обработчик в 06

Оговорки

3.14 здесь — 3.14.7. Финальной сборки на машине нет. В таблицу с 3.14 попали только поведенческие факты (есть ли модуль, что вернёт вызов, какое исключение), и от rc до финала они меняться не должны — но «не должны» это не «проверено».

Числа в выводе — это asyncio.sleep, а не замер. 0,05 / 0,15 / 0,20 / 0,25 / 0,30 с воспроизводятся на любой машине с точностью до второго знака, потому что это заказанная длительность сна, а не измеренная стоимость работы. Единственное место, где стоит настоящее измерение, — секунды в логе debug-режима, и они нормализованы до <N>.

Порядок отмены соседей в TaskGroup не воспроизводим (set в taskgroups.py:35). Сам факт отмены — воспроизводим, порядок — нет.

Про исправление вложенных TaskGroup в 3.13 доказана только рамка. Проба в version_matrix.py показывает, что на всех четырёх версиях этот конкретный сценарий не виснет; воспроизведения самой ошибки gh-116720 у нас нет, и в статье это должно называться цитатой из whatsnew, а не «проверено запуском».

Вывод 06 нормализован. Секунды, адреса, номера строк и имена задач заменены на <...> самим скриптом — иначе дословное сравнение между запусками невозможно. Всё, что не в угловых скобках, — ровно то, что печатает asyncio.

Скрипт

230 строк
"""finally при отмене: выполняется, но бюджета у него нет.

Показывает запуском:
  * finally после отмены выполняется;
  * await внутри finally РАБОТАЕТ и никем не ограничен — отмена ждёт столько,
    сколько попросит уборка;
  * повторный cancel() уборку обрывает, и счётчик cancelling() это видит;
  * проглоченная отмена делает задачу «успешной»;
  * asyncio.run на выходе отменяет остатки и тоже ждёт их без ограничения.

    python3.13 bench/cancellation/02_finally_budget.py
"""

import asyncio
import sys


def row(name, value):
    print(f"  {name:<46} {value}")


def head(title):
    print()
    print(f"== {title}")


async def spin(n=3):
    for _ in range(n):
        await asyncio.sleep(0)


# ---------------------------------------------------------------- 1 -------

async def cleanup_without_await(log):
    try:
        await asyncio.sleep(10)
    finally:
        log.append("finally: без await, отработал целиком")


# ---------------------------------------------------------------- 2 -------

async def cleanup_with_long_await(log, loop, seconds):
    try:
        await asyncio.sleep(10)
    finally:
        t0 = loop.time()
        log.append("finally: начал уборку")
        await asyncio.sleep(seconds)          # обычный await, он РАБОТАЕТ
        log.append(f"finally: доспал {loop.time() - t0:.2f} с и дошёл до конца")


# ---------------------------------------------------------------- 3 -------

async def cleanup_interrupted(log):
    try:
        await asyncio.sleep(10)
    finally:
        log.append("finally: начал уборку")
        try:
            await asyncio.sleep(10)           # сюда прилетит второй cancel
        except asyncio.CancelledError:
            log.append("finally: уборку оборвали на await")
            raise
        log.append("finally: эта строка не выполнится")


# ---------------------------------------------------------------- 4 -------

async def cleanup_shielded(log):
    try:
        await asyncio.sleep(10)
    finally:
        log.append("finally: начал уборку под shield")
        try:
            await asyncio.shield(asyncio.sleep(0.10))
        except asyncio.CancelledError:
            log.append("finally: shield не спас ОЖИДАЮЩЕГО — CancelledError")
            raise
        log.append("finally: уборка дошла до конца")


# ---------------------------------------------------------------- 5 -------

async def swallows(log):
    try:
        await asyncio.sleep(10)
    except asyncio.CancelledError:
        log.append("проглотил CancelledError и пошёл дальше")
    await asyncio.sleep(0)
    log.append("продолжил работать ПОСЛЕ отмены")
    return "результат"


# ---------------------------------------------------------------------------

async def start(coro):
    task = asyncio.ensure_future(coro)
    await spin()
    return task


async def finish(task):
    try:
        return "вернул " + repr(await task)
    except asyncio.CancelledError:
        return "бросил CancelledError"
    except BaseException as exc:              # noqa: BLE001
        return f"бросил {type(exc).__name__}"


async def main():
    loop = asyncio.get_running_loop()
    print(f"Python {sys.version.split()[0]}")

    head("1. finally без await выполняется целиком")
    log = []
    task = await start(cleanup_without_await(log))
    task.cancel()
    outcome = await finish(task)
    for line in log:
        print(f"    | {line}")
    row("task.cancelled()", task.cancelled())
    row("итог", outcome)

    head("2. await внутри finally работает; отмену он задерживает")
    for seconds in (0.05, 0.30):
        log = []
        task = await start(cleanup_with_long_await(log, loop, seconds))
        t0 = loop.time()
        task.cancel()
        outcome = await finish(task)
        dt = loop.time() - t0
        print()
        row("уборка просила", f"{seconds:.2f} с")
        for line in log:
            print(f"    | {line}")
        row("от cancel() до конца задачи прошло", f"{dt:.2f} с")
        row("task.cancelled()", task.cancelled())
        row("итог", outcome)
    print()
    print("  Никакого предела уборке нет: сколько попросила — столько цикл и ждал.")

    head("3. Повторный cancel() обрывает уборку; счётчик cancelling() растёт")
    log = []
    task = await start(cleanup_interrupted(log))
    task.cancel()
    row("после 1-го cancel(): task.cancelling()", task.cancelling())
    await spin()                              # даём finally дойти до своего await
    task.cancel()
    row("после 2-го cancel(): task.cancelling()", task.cancelling())
    outcome = await finish(task)
    for line in log:
        print(f"    | {line}")
    row("task.cancelled()", task.cancelled())
    row("итог", outcome)

    head("4. shield внутри finally не спасает саму уборку")
    log = []
    task = await start(cleanup_shielded(log))
    task.cancel()
    await spin()
    task.cancel()                             # второй cancel — в shield-обёртку
    outcome = await finish(task)
    for line in log:
        print(f"    | {line}")
    row("task.cancelled()", task.cancelled())
    row("итог", outcome)
    print("  shield защищает ВНУТРЕННЮЮ операцию, а не того, кто её ждёт;")
    print("  ждёт её здесь сам finally — его и обрывают.")

    head("5. Проглоченная отмена: задача считается успешной")
    log = []
    task = await start(swallows(log))
    task.cancel()
    row("после cancel(): task.cancelling()", task.cancelling())
    outcome = await finish(task)
    for line in log:
        print(f"    | {line}")
    row("task.cancelled()", task.cancelled())
    row("task.exception()", task.exception())
    row("итог", outcome)

    head("6. cancelling()/uncancel() — счётчик, а не флаг")
    victim = await start(asyncio.sleep(10))
    row("старт", victim.cancelling())
    victim.cancel()
    row("cancel()", victim.cancelling())
    victim.cancel()
    row("cancel() ещё раз", victim.cancelling())
    row("uncancel() вернул", victim.uncancel())
    row("uncancel() вернул", victim.uncancel())
    row("итог", await finish(victim))
    print("  uncancel() до нуля задачу НЕ воскресил: CancelledError уже был")
    print("  доставлен в await. Счётчик — учёт запросов, а не отмена отмены.")
    print("  Это тот счётчик, по которому asyncio.timeout и TaskGroup отличают")
    print("  «отменил я сам» от «нас отменили снаружи» (Lib/asyncio/tasks.py:248).")


def shutdown_demo():
    """asyncio.run на выходе отменяет остатки и ждёт их уборку без ограничения."""
    import time

    async def long_cleanup():
        try:
            await asyncio.sleep(100)
        finally:
            print("    | остаток: finally, прошу 0.30 с")
            await asyncio.sleep(0.30)
            print("    | остаток: уборка дошла до конца")

    async def body():
        asyncio.ensure_future(long_cleanup())
        await spin()
        return "main вернулся"

    head("7. Выход из asyncio.run: остаток отменяется и его ждут")
    t0 = time.perf_counter()
    result = asyncio.run(body())
    dt = time.perf_counter() - t0
    row("asyncio.run вернул", repr(result))
    row("asyncio.run занял", f"{dt:.2f} с")
    print("  Lib/asyncio/runners.py:198 _cancel_all_tasks — cancel() всем,")
    print("  затем loop.run_until_complete(gather(...)) без всякого таймаута.")


if __name__ == "__main__":
    asyncio.run(main())
    shutdown_demo()