MEASUREMENT
bench/async-await/cost.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
for v in 3.11 3.12 3.13 3.14; do echo "== $v"; python$v cost.py; done
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
Замеры для урока «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 |
Запускать на всех версиях, которые есть:
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 и параллелизм, а не в уроке про синтаксис.
Script
109 lines"""Во сколько обходится `await` и во сколько — выход в цикл событий.
ЗАЧЕМ ЭТО МЕРИТЬ. Про async обычно говорят «быстрее» или «медленнее», не
уточняя, что именно. А цен здесь три, и они отличаются на порядки:
1. дождаться корутины, которая ничего не ждёт, — это чуть дороже вызова
обычной функции и остаётся внутри интерпретатора;
2. `await asyncio.sleep(0)` — полный оборот через цикл событий;
3. `create_task` — заведение объекта задачи и постановка её в очередь.
Из первого следует, что дробить async-код на мелкие корутины не бесплатно.
Из второго и третьего — что выигрыш async берётся не из скорости вызова, а
только из того, что на время настоящего ожидания процессор занят другим.
ОБЩЕЕ ПРАВИЛО ЗАМЕРОВ: время между версиями не сравнивается вообще.
Сравнивается только измеренное внутри одного запуска одного интерпретатора —
все сравнения ниже такие. Числа 3.13.7 и 3.14.0rc2 приводятся рядом как два
независимых результата, а не как сравнение.
"""
import asyncio
import sys
import time
N = 200_000
REPEAT = 7
def plain(x):
return x + 1
async def coro(x):
return x + 1
async def bench_plain(n):
for i in range(n):
plain(i)
async def bench_await(n):
for i in range(n):
await coro(i)
async def bench_sleep0(n):
for _ in range(n):
await asyncio.sleep(0)
async def bench_task(n):
for i in range(n):
await asyncio.create_task(coro(i))
async def best(body, n, repeat=REPEAT):
"""Лучшее из `repeat` прогонов, наносекунд на итерацию."""
times = []
for _ in range(repeat):
start = time.perf_counter()
await body(n)
times.append(time.perf_counter() - start)
return min(times) / n * 1e9
async def main():
print("PY", sys.version.split()[0], f"| лучшее из {REPEAT} прогонов")
rows = [
("обычный вызов", bench_plain, N),
("await корутины", bench_await, N),
("await asyncio.sleep(0)", bench_sleep0, N // 4),
("await create_task(...)", bench_task, N // 10),
]
# ЗАЧЕМ ВЕСЬ ЗАМЕР ПОВТОРЯЕТСЯ ЦЕЛИКОМ.
#
# Урок сравнивает две сборки между собой и обязан сказать, больше ли
# разница между ними, чем шатание самого прибора. Одиночный прогон этого
# сказать не может: он печатает четыре числа и молчит о том, повторимы ли
# они. Поэтому весь замер прогоняется OUTER раз, и под таблицей печатается
# размах каждой строки — в процентах от её же лучшего результата.
OUTER = 3
series = {label: [] for label, _, _ in rows}
for _ in range(OUTER):
for label, body, n in rows:
series[label].append(await best(body, n))
results = {label: min(vals) for label, vals in series.items()}
for label, _, n in rows:
ns = results[label]
# Микросекунды печатаются рядом не для красоты: две нижние строки в
# уроке стоят в таблице именно в них, а таблица обязана цитировать
# запись прогона дословно (scripts/validate-measured-tables.mjs).
us = f" = {ns / 1000:5.2f} мкс" if ns >= 1000 else " " * 12
print(f" {label:<24} {ns:8.1f} нс{us} (по {n:,} итераций)".replace(",", " "))
base = results["обычный вызов"]
print(" во сколько раз дороже обычного вызова:")
for label in ("await корутины", "await asyncio.sleep(0)", "await create_task(...)"):
print(f" {label:<24} x{results[label] / base:6.1f}")
print(f" размах по {OUTER} повторам всего замера, в % от лучшего:")
for label, _, _ in rows:
lo, hi = min(series[label]), max(series[label])
print(f" {label:<24} {(hi - lo) / lo * 100:5.1f} %")
asyncio.run(main())