MEASUREMENT
bench/cancellation/02_finally_budget.py
The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.
- Cited in
- /en/interview/python/async-await
- How to run it
CPython 3.11.15 CPython 3.12.3 CPython 3.13.7 <- основной прогон, весь дословный вывод ниже с него CPython 3.14.7
The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.
Record of the run
Отмена в 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.
Script
230 lines"""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()