Deep Engineering

ЗАМЕР

bench/cancellation/04_shield.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.

Скрипт

155 строк
"""asyncio.shield: что он защищает и чего НЕ защищает.

Показывает запуском:
  * внешняя отмена доходит до ОЖИДАЮЩЕГО немедленно — shield его не спасает;
  * при этом внутренняя задача продолжает работать и досчитывает до конца;
  * прямая отмена внутренней задачи проходит сквозь shield наружу;
  * shield поверх голой корутины создаёт задачу — ссылку надо держать самому;
  * shield под asyncio.timeout: TimeoutError снаружи, работа продолжается.

    python3.13 bench/cancellation/04_shield.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)


async def payload(log, name, seconds):
    log.append(f"{name}: начал")
    try:
        await asyncio.sleep(seconds)
    except asyncio.CancelledError:
        log.append(f"{name}: ОТМЕНЁН")
        raise
    log.append(f"{name}: доработал до конца")
    return f"{name}-результат"


async def waiter(log, inner):
    try:
        result = await asyncio.shield(inner)
        log.append(f"ожидающий: получил {result!r}")
    except asyncio.CancelledError:
        log.append("ожидающий: CancelledError, ждать перестал")
        raise


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

    head("1. Отменяем ОЖИДАЮЩЕГО: внутренняя задача не замечает")
    log = []
    inner = asyncio.ensure_future(payload(log, "внутренняя", 0.20))
    outer = asyncio.ensure_future(waiter(log, inner))
    await spin()
    t0 = loop.time()
    outer.cancel()
    try:
        await outer
        verdict_outer = "вернулся нормально"
    except asyncio.CancelledError:
        verdict_outer = "CancelledError"
    dt_outer = loop.time() - t0
    log.append(f"ожидающий закончился через {dt_outer:.2f} с после cancel()")
    inner_result = await inner
    log.append(f"внутренняя закончилась через {loop.time() - t0:.2f} с после cancel()")
    for line in log:
        print(f"    | {line}")
    row("outer: await -> ", verdict_outer)
    row("outer.cancelled()", outer.cancelled())
    row("inner.cancelled()", inner.cancelled())
    row("inner.result()", repr(inner_result))
    print("  Вот и вся защита: shield спасает РАБОТУ, но не спасает ждущего.")

    head("2. Отменяем ВНУТРЕННЮЮ задачу: shield не помогает вообще")
    log = []
    inner = asyncio.ensure_future(payload(log, "внутренняя", 0.20))
    outer = asyncio.ensure_future(waiter(log, inner))
    await spin()
    inner.cancel()
    try:
        await outer
        verdict = "outer вернулся нормально"
    except asyncio.CancelledError:
        verdict = "outer получил CancelledError"
    for line in log:
        print(f"    | {line}")
    row("итог", verdict)
    row("inner.cancelled()", inner.cancelled())
    row("outer.cancelled()", outer.cancelled())
    print("  Lib/asyncio/tasks.py:934-935 — «If something() is cancelled by other")
    print("  means this will still cancel shield()».")

    head("3. shield поверх голой корутины делает задачу")
    async def probe():
        await asyncio.sleep(0)
        return 42
    coro = probe()
    before = len(asyncio.all_tasks())
    sh = asyncio.shield(coro)
    after = len(asyncio.all_tasks())
    row("type(shield(coro))", type(sh).__name__)
    row("это та же корутина?", sh is coro)
    row("задач в цикле до shield / после", f"{before} / {after}")
    row("значение", await sh)
    print("  Наружу отдан Future-обёртка, а корутина ушла в НОВУЮ задачу, на")
    print("  которую у вызывающего ссылки нет.")
    print("  ensure_future создаёт задачу, а цикл держит на задачи только слабые")
    print("  ссылки — ссылку на inner надо хранить самому (tasks.py:946).")

    head("4. shield под asyncio.timeout")
    log = []
    inner = asyncio.ensure_future(payload(log, "защищённая", 0.25))
    t0 = loop.time()
    try:
        async with asyncio.timeout(0.05):
            await asyncio.shield(inner)
    except TimeoutError:
        log.append(f"снаружи: TimeoutError через {loop.time() - t0:.2f} с")
    res = await inner
    log.append(f"защищённая всё же доработала за {loop.time() - t0:.2f} с "
               f"и вернула {res!r}")
    for line in log:
        print(f"    | {line}")
    row("inner.cancelled()", inner.cancelled())
    print("  Таймаут отменил ожидание, но не работу. Если этого не знать —")
    print("  получится «таймаут сработал, а запрос всё равно ушёл».")

    head("5. Двойная отмена ожидающего")
    log = []
    inner = asyncio.ensure_future(payload(log, "внутренняя", 0.15))
    outer = asyncio.ensure_future(waiter(log, inner))
    await spin()
    outer.cancel()
    outer.cancel()
    row("outer.cancelling()", outer.cancelling())
    try:
        await outer
    except asyncio.CancelledError:
        pass
    row("inner.done() сразу после", inner.done())
    res = await inner
    for line in log:
        print(f"    | {line}")
    row("inner.result()", repr(res))
    print("  Сколько раз ни отменяй ожидающего — внутренняя не отменится.")


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