ЗАМЕР
bench/async-await/when_it_runs.py
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/interview/python/async-await
- Как запустить
for v in 3.11 3.12 3.13 3.14; do echo "== $v"; python$v cost.py; done
Запись прогона
Замеры для урока «async/await»
| скрипт | что показывает |
|---|---|
identity.py |
async def создаёт объект того же типа function, но с флагом CO_COROUTINE; вызов возвращает корутину и НЕ выполняет тело; байт-код await; корутину нельзя дождаться дважды |
forgotten_await.py |
забытый await превращает проверку в if <объект> — всегда истину; когда именно печатается RuntimeWarning и почему -W error его не останавливает |
blocking.py |
пять «запросов» по 0,1 с: asyncio.sleep против time.sleep против последовательного await; отдельно — что происходит с остальным приложением |
groups.py |
gather против TaskGroup в момент первой ошибки: кого увидит вызывающий и кто доработает |
fire_and_forget.py |
попытка воспроизвести предупреждение из документации про потерянные задачи — и почему она не удалась |
cost.py |
цена await против обычного вызова, asyncio.sleep(0) и create_task |
when_it_runs.py |
когда начинается тело: задача из create_task исполняется до await task, с eager_task_factory — прямо внутри create_task, asyncio.run ведёт корутину без await; await завершившегося Future и корутины без приостановки хода соседу не даёт. Журнал событий, от тулчейна не зависит: runs/when_it_runs-3.14.txt (3.14.10) и runs/when_it_runs-3.13.txt (3.13.16) совпадают со второй строки |
Запускать на всех версиях, которые есть:
for v in 3.11 3.12 3.13 3.14; do echo "== $v"; python$v cost.py; done
Практика урока
practice.py — источник ответов двух практических задач урока, а runs/practice.txt —
дословная запись его прогона. Ответ задачи не сочиняется: сборка сверяет
заявленное с этой записью (scripts/validate-practice.mjs) и не проходит,
если они разошлись.
Прогон снят 30.08.2026 на CPython 3.13.7 (Clang 20.1.4). Абсолютные числа — этой машины; переносится кратность, и задача «во сколько раз» стоит именно на ней.
Кратность устойчива только потому, что формы меряются ВПЕРЕМЕЖКУ: в каждом круге меряются все, минимум для каждой берётся по кругам. Пока замеры шли подряд, просадка машины в окне одной формы целиком доставалась ей, и отношение гуляло в полтора раза от запуска к запуску (измерено на декораторах: 5,9 / 7,1 / 7,5 / 9,2). После перехода на чередование расхождение между прогонами не выходит за несколько процентов. Перезаписывать запись прогона имеет смысл только вместе с проверкой задачи: если после перезапуска ответ изменился, менять нужно задачу, а не файл.
Что здесь сравнивать можно, а что нельзя
Общее правило замеров: время между версиями не сравнивается вообще.
Сравнивается только то, что измерено внутри одного запуска одного
интерпретатора. Дело не только в компиляторе: 3.11 и 3.12 собраны GCC 13.3.0,
3.13.7 и 3.14.7 — Clang 20.1.4, но и эти две сборки различаются между собой,
причём ровно тем флагом (--with-tail-call-interp), которому «Что нового в
3.14» приписывает «a geometric mean of 3-5% faster».
В cost.py все сравнения делаются внутри одного запуска одного
интерпретатора: обычный вызов, await, asyncio.sleep(0) и create_task
меряются подряд одним процессом. Такое сравнение честно всегда. Числа 3.13.7
и 3.14.7 приводятся рядом как два независимых результата, а не как
сравнение.
identity.py, forgotten_await.py, groups.py и fire_and_forget.py от
тулчейна не зависят вовсе: они смотрят на типы, флаги, имена инструкций,
состав исключений и поведение сборщика мусора. Их вывод совпадает на всех
четырёх версиях слово в слово.
blocking.py меряет время, но меряет ожидание: длительность задают таймеры
ОС, а не скорость интерпретатора, и числа во всех четырёх версиях совпадают
до сотых. Сравнение внутри запуска — тем более честное.
Разброс между запусками
cost.py — лучшее из семи прогонов. Три повторных запуска подряд на 3.13.7
дали 43,6 / 41,8 / 42,0 нс на обычном вызове и 5248 / 5306 / 5320 нс на
create_task, то есть разброс около ±2 %. Разрыв между 3.13.7 и 3.14.7
(create_task 5,3 против 4,2 мкс) в этот разброс не укладывается, но и
версией языка не объясняется: сборки различаются --with-tail-call-interp,
и разделить эти два вклада здесь нечем.
blocking.py устойчив в первых трёх строках: восемь прогонов подряд дали
одно и то же с точностью до 0,01 с. Четвёртая строка, asyncio.to_thread,
устойчива не всегда: обычно 0,10 с и худшая пауза 10–23 мс, но в редком
прогоне встречались 0,23 с и 130 мс. Это создание пула потоков, которого в
первых трёх строках нет вовсе; в уроке поэтому названо «возвращает к 0,1 с»,
а не приведено точное число.
Отрицательный результат, который остаётся в репозитории
fire_and_forget.py пытается воспроизвести то, о чём предупреждает
документация asyncio.create_task: «The event loop only keeps weak
references to tasks. A task that isn't referenced elsewhere may get garbage
collected at any time, even before it's done». Двести задач без единой
ссылки, gc.collect() между шагами — и все двести доработали, ни одна не
собрана. Причина видна там же: пока задача ждёт, на неё ссылается
TaskStepMethWrapper, который держит таймер цикла, и ссылка эта сильная.
Скрипт оставлен именно поэтому. Совет из документации верен, но проверяется он не тестом: ошибка не воспроизводится по заказу, а значит, в проекте проявится однажды и не там, где её будут искать. Замер, который ничего не поймал, здесь такой же результат, как и любой другой, — и в уроке сказано ровно это, без обещания «у вас задачи пропадут».
Чего в этих замерах НЕТ
Здесь не меряется настоящая сеть. Все «запросы» — это asyncio.sleep, то
есть чистое ожидание без данных. Так сделано намеренно: с настоящим сокетом
в число попали бы разрешение имени, состояние канала и работа собеседника, а
показать надо цену самого механизма. По той же причине нет сравнения с
потоками и с процессами: это сравнение способов организовать ожидание, и
ему место в статье про GIL и параллелизм, а не в уроке про синтаксис.
Скрипт
136 строк"""Когда начинается тело корутины и когда `await` действительно уступает ход.
ЗАЧЕМ ЭТОТ ФАЙЛ. Короткий ответ урока говорил: «выполнение начинается только
там, где этот объект ждут, и там же работа уступает управление другим».
Аудит 01.11.2026 (Q-02) возразил двумя контрпримерами:
1. `asyncio.create_task(coro())` планирует корутину, и её тело может
исполниться до любого `await task` — достаточно, чтобы текущая задача
приостановилась на чём-то другом;
2. `await` уже завершившегося Future (или корутины, которая сама ни разу не
приостановилась) не обязан передавать ход другой задаче.
Оба утверждения здесь проверяются прогоном, а не пересказом документации.
ПОЧЕМУ ЖУРНАЛ СОБЫТИЙ, А НЕ ЗАМЕР ВРЕМЕНИ. Вопрос — порядок событий, а не
стоимость. Каждая корутина пишет строку в общий список; печатается он целиком.
От тулчейна вывод не зависит.
ЗАПУСК: python3.14 bench/async-await/when_it_runs.py
Вывод по версиям: runs/when_it_runs-3.14.txt и соседние.
"""
import asyncio
import sys
LOG: list[str] = []
def show(title: str) -> None:
print()
print(title)
print("-" * len(title))
def dump() -> None:
for line in LOG:
print(f" {line}")
LOG.clear()
async def body(name: str) -> str:
LOG.append(f"{name}: тело началось")
return name
async def neighbour() -> None:
LOG.append("сосед: получил ход")
async def no_suspend() -> int:
# Корутина без единой точки приостановки: await такой корутины
# выполняет её до конца, не возвращаясь в цикл событий.
return 42
# ---------------------------------------------------------------------------
# 1. Тело задачи исполняется до `await task`
# ---------------------------------------------------------------------------
async def task_before_await() -> None:
task = asyncio.create_task(body("задача"))
LOG.append(f"сразу после create_task: тело началось = {'задача: тело началось' in LOG}")
await asyncio.sleep(0) # приостановка на ДРУГОМ ожидании, не на task
LOG.append(f"после await asyncio.sleep(0): task.done() = {task.done()}")
LOG.append("теперь await task")
result = await task
LOG.append(f"await task вернул {result!r}")
# ---------------------------------------------------------------------------
# 2. С eager_task_factory (3.12+) тело идёт прямо внутри create_task
# ---------------------------------------------------------------------------
async def eager() -> None:
loop = asyncio.get_running_loop()
loop.set_task_factory(asyncio.eager_task_factory)
LOG.append("перед create_task")
task = asyncio.create_task(body("eager-задача"))
LOG.append(f"сразу после create_task: task.done() = {task.done()}")
await task
loop.set_task_factory(None)
# ---------------------------------------------------------------------------
# 3. await завершившегося Future не даёт хода соседу
# ---------------------------------------------------------------------------
async def done_future() -> None:
loop = asyncio.get_running_loop()
fut = loop.create_future()
fut.set_result("готово")
asyncio.create_task(neighbour()) # сосед готов к запуску
value = await fut
LOG.append(f"await завершившегося Future вернул {value!r}; сосед ходил = {'сосед: получил ход' in LOG}")
value = await no_suspend()
LOG.append(f"await корутины без приостановки вернул {value}; сосед ходил = {'сосед: получил ход' in LOG}")
await asyncio.sleep(0)
LOG.append(f"после await asyncio.sleep(0): сосед ходил = {'сосед: получил ход' in LOG}")
# ---------------------------------------------------------------------------
# 4. Корутину исполняет asyncio.run — без единого await над её объектом
# ---------------------------------------------------------------------------
async def top() -> str:
LOG.append("top: тело началось")
return "top готов"
def main() -> None:
print(f"Python {sys.version.split()[0]} ({sys.implementation.name})")
show("1. ТЕЛО ЗАДАЧИ ИСПОЛНЯЕТСЯ ДО AWAIT TASK")
asyncio.run(task_before_await())
dump()
show("2. С EAGER-ФАБРИКОЙ ЗАДАЧ ТЕЛО ИДЁТ ВНУТРИ CREATE TASK")
if hasattr(asyncio, "eager_task_factory"):
asyncio.run(eager())
dump()
else:
print(" eager_task_factory нет в этой версии (появилась в 3.12)")
show("3. AWAIT ГОТОВОГО РЕЗУЛЬТАТА НЕ УСТУПАЕТ ХОД")
asyncio.run(done_future())
dump()
show("4. КОРУТИНУ ИСПОЛНЯЕТ ASYNCIO.RUN")
result = asyncio.run(top())
LOG.append(f"asyncio.run вернул {result!r}")
dump()
if __name__ == "__main__":
main()