ЗАМЕР
bench/cancellation/05_taskgroup_cancellation.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.
Скрипт
306 строк"""Отмена в TaskGroup: что происходит с соседями и что видно в ExceptionGroup.
Показывает запуском:
* одна упала — остальные отменены, их finally выполнился, тело async with
тоже прервано;
* две упали — обе приехали в одном ExceptionGroup;
* CancelledError от ребёнка группу НЕ роняет (в отличие от любого Exception);
* внешняя отмена родителя выходит как CancelledError, а не ExceptionGroup;
* ошибка в теле async with попадает в ту же группу;
* create_task после аварии — RuntimeError;
* то же на gather для контраста: соседей никто не отменяет.
python3.13 bench/cancellation/05_taskgroup_cancellation.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)
def describe(exc, indent=" "):
"""Дерево ExceptionGroup в виде строк."""
lines = []
def walk(e, depth):
pad = indent + " " * depth
if isinstance(e, BaseExceptionGroup):
lines.append(f"{pad}{type(e).__name__}: {e.message!r} "
f"({len(e.exceptions)} шт.)")
for sub in e.exceptions:
walk(sub, depth + 1)
else:
lines.append(f"{pad}{type(e).__name__}: {e}")
walk(exc, 0)
return lines
def states(tasks):
out = []
for t in tasks:
if not t.done():
state = "ещё работает"
elif t.cancelled():
state = "cancelled"
elif t.exception() is not None:
state = f"exception {type(t.exception()).__name__}"
else:
state = f"result {t.result()!r}"
out.append(f"{t.get_name():<12} {state}")
return out
# ---------------------------------------------------------------------------
async def good(log, name, seconds):
try:
await asyncio.sleep(seconds)
except asyncio.CancelledError:
log.append(f"{name}: получил отмену")
raise
finally:
log.append(f"{name}: finally")
log.append(f"{name}: доработал")
return name
async def bad(log, name, seconds, exc):
await asyncio.sleep(seconds)
log.append(f"{name}: бросает {type(exc).__name__}")
raise exc
async def self_cancelling(log, name, seconds):
await asyncio.sleep(seconds)
log.append(f"{name}: сам бросает CancelledError")
raise asyncio.CancelledError()
# ---------------------------------------------------------------------------
async def main():
print(f"Python {sys.version.split()[0]}")
head("1. Одна задача упала — соседей отменяют")
log = []
handles = []
try:
async with asyncio.TaskGroup() as tg:
handles.append(tg.create_task(good(log, "мирная-1", 5.0),
name="мирная-1"))
handles.append(tg.create_task(good(log, "мирная-2", 5.0),
name="мирная-2"))
handles.append(tg.create_task(bad(log, "падучая", 0.05,
ValueError("бух")),
name="падучая"))
log.append("тело async with: жду 5 с")
await asyncio.sleep(5.0)
log.append("тело async with: доспало (не должно)")
except* ValueError as eg:
log.append(f"except* ValueError: {len(eg.exceptions)} шт.")
caught = eg
for line in log:
print(f" | {line}")
for line in states(handles):
print(f" | {line}")
print(" ExceptionGroup:")
for line in describe(caught):
print(line)
head("2. Две задачи упали — обе в одном ExceptionGroup")
log = []
handles = []
caught = None
try:
async with asyncio.TaskGroup() as tg:
handles.append(tg.create_task(bad(log, "первая", 0.05,
ValueError("V")), name="первая"))
handles.append(tg.create_task(bad(log, "вторая", 0.05,
KeyError("K")), name="вторая"))
handles.append(tg.create_task(good(log, "мирная", 5.0),
name="мирная"))
await asyncio.sleep(5.0)
except BaseExceptionGroup as eg:
caught = eg
for line in log:
print(f" | {line}")
for line in states(handles):
print(f" | {line}")
print(" ExceptionGroup:")
for line in describe(caught):
print(line)
row("тип", type(caught).__name__)
row("isinstance(eg, Exception)", isinstance(caught, Exception))
print(" разбор через except*:")
try:
raise caught
except* ValueError as sub:
print(f" except* ValueError -> {type(sub).__name__} "
f"{[type(e).__name__ for e in sub.exceptions]}")
except* KeyError as sub:
print(f" except* KeyError -> {type(sub).__name__} "
f"{[type(e).__name__ for e in sub.exceptions]}")
head("3. CancelledError от ребёнка группу НЕ роняет")
log = []
handles = []
result = "группа вышла штатно"
try:
async with asyncio.TaskGroup() as tg:
handles.append(tg.create_task(self_cancelling(log, "самоотменяющаяся",
0.05),
name="самоотмена"))
handles.append(tg.create_task(good(log, "мирная", 0.10),
name="мирная"))
except BaseExceptionGroup as eg:
result = f"ExceptionGroup {[type(e).__name__ for e in eg.exceptions]}"
except asyncio.CancelledError:
result = "CancelledError"
for line in log:
print(f" | {line}")
for line in states(handles):
print(f" | {line}")
row("итог", result)
print(" taskgroups.py:235 — `if task.cancelled(): return`, отменённый")
print(" ребёнок в _errors не попадает и соседей не роняет.")
head("4. Внешняя отмена родителя: наружу CancelledError, не ExceptionGroup")
log = []
handles = []
async def parent():
try:
async with asyncio.TaskGroup() as tg:
handles.append(tg.create_task(good(log, "дитя-1", 5.0),
name="дитя-1"))
handles.append(tg.create_task(good(log, "дитя-2", 5.0),
name="дитя-2"))
await asyncio.sleep(5.0)
except BaseExceptionGroup as eg:
log.append(f"родитель: ExceptionGroup {len(eg.exceptions)}")
raise
except asyncio.CancelledError:
log.append("родитель: CancelledError")
raise
ptask = asyncio.ensure_future(parent())
await spin(4)
ptask.cancel()
try:
await ptask
verdict = "вернулся нормально"
except BaseExceptionGroup as eg:
verdict = f"ExceptionGroup {[type(e).__name__ for e in eg.exceptions]}"
except asyncio.CancelledError:
verdict = "CancelledError"
for line in log:
print(f" | {line}")
for line in states(handles):
print(f" | {line}")
row("await parent ->", verdict)
row("parent.cancelled()", ptask.cancelled())
head("5. Ошибка в теле async with едет в ту же группу")
log = []
handles = []
caught = None
try:
async with asyncio.TaskGroup() as tg:
handles.append(tg.create_task(bad(log, "задача", 0.05,
ValueError("из задачи")),
name="задача"))
await asyncio.sleep(5.0)
except BaseExceptionGroup as eg:
caught = eg
print(" ExceptionGroup:")
for line in describe(caught):
print(line)
log = []
handles = []
caught = None
try:
async with asyncio.TaskGroup() as tg:
handles.append(tg.create_task(good(log, "мирная", 5.0),
name="мирная"))
await asyncio.sleep(0.05)
raise RuntimeError("из тела async with")
except BaseExceptionGroup as eg:
caught = eg
print(" ExceptionGroup:")
for line in describe(caught):
print(line)
for line in states(handles):
print(f" | {line}")
head("6. create_task у аварийной/законченной группы")
async def noop():
return None
outcome = []
try:
async with asyncio.TaskGroup() as tg:
saved = tg
tg.create_task(bad([], "падучая", 0.05, ValueError("бух")))
await asyncio.sleep(5.0)
except* ValueError:
pass
import gc
import warnings
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
coro = noop()
try:
saved.create_task(coro)
outcome.append("RuntimeError НЕ бросили")
except RuntimeError as exc:
outcome.append(f"RuntimeError: {exc}")
del coro
gc.collect()
warned = [str(w.message) for w in caught if "never awaited" in str(w.message)]
outcome.append(f"RuntimeWarning 'never awaited': {warned or 'нет'}")
for line in outcome:
print(f" | {line}")
print(" taskgroups.py:186-194 — три отдельные проверки, и каждая перед")
print(" RuntimeError делает coro.close(), чтобы не осталось «never awaited».")
print(" coro.close() тут появился в 3.13 — на 3.11/3.12 предупреждение есть.")
head("7. Для контраста: gather соседей не отменяет")
log = []
tasks = [
asyncio.create_task(good(log, "мирная-1", 0.20), name="мирная-1"),
asyncio.create_task(good(log, "мирная-2", 0.20), name="мирная-2"),
asyncio.create_task(bad(log, "падучая", 0.05, ValueError("бух")),
name="падучая"),
]
try:
await asyncio.gather(*tasks)
verdict = "gather вернулся"
except ValueError as exc:
verdict = f"gather бросил {type(exc).__name__}: {exc}"
row("сразу после gather", verdict)
for line in states(tasks):
print(f" | {line}")
await asyncio.sleep(0.25)
print(" ...через 0.25 с:")
for line in states(tasks):
print(f" | {line}")
for line in log:
print(f" | {line}")
if __name__ == "__main__":
asyncio.run(main())