Deep Engineering

MEASUREMENT

bench/async-await/forgotten_await.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

111 lines
"""Забытый `await`: почему это не падает и почему предупреждение не спасает.

Сценарий взят не с потолка: проверка прав возвращает bool, её результат
подставляют в `if`. Стоит потерять `await` — и условие перестаёт зависеть от
данных, потому что объект корутины истинен всегда.

От тулчейна ничего не зависит: смотрим значения и поведение сборщика мусора.
"""
import asyncio
import gc
import subprocess
import sys
import tempfile
import textwrap
import warnings
from pathlib import Path

ALLOWED = {"anna", "boris"}


async def is_allowed(user: str) -> bool:
    """«Ходит в базу», а на деле просто уступает управление один раз."""
    await asyncio.sleep(0)
    return user in ALLOWED


async def main() -> None:
    print("PY", sys.version.split()[0])

    print("  с await — так, как задумано:")
    for user in ("anna", "chuck"):
        print(f"    {user:<6} -> {await is_allowed(user)}")

    print("  без await — условие больше не зависит от данных:")
    for user in ("anna", "chuck"):
        coro = is_allowed(user)
        # `if coro:` — ровно это и написали бы в реальном коде.
        print(f"    {user:<6} -> {bool(coro)}")
        coro.close()

    # КОГДА ПОЯВЛЯЕТСЯ ПРЕДУПРЕЖДЕНИЕ. Не в момент ошибки, а в момент
    # уничтожения объекта корутины. Пока ссылка жива, интерпретатор молчит.
    print("  когда печатается RuntimeWarning:")
    seen: list[str] = []

    def collect(message, category, filename, lineno, file=None, line=None):
        seen.append(category.__name__)

    old = warnings.showwarning
    warnings.showwarning = collect
    warnings.simplefilter("always")
    try:
        held = [is_allowed("anna") for _ in range(3)]
        gc.collect()
        print(f"    ссылки живы, после gc.collect(): предупреждений {len(seen)}")
        for c in held:
            c.close()
        held.clear()
        gc.collect()
        print(f"    ссылок не осталось:              предупреждений {len(seen)}")

        # А теперь то же самое, но корутину никто не закрывал.
        [is_allowed("anna") for _ in range(3)]
        gc.collect()
        print(f"    результат выброшен, не закрыт:   предупреждений {len(seen)}")
        print(f"    и это: {sorted(set(seen)) or 'ничего'}")
    finally:
        warnings.showwarning = old

    strictest()


# САМОЕ ВАЖНОЕ ЗДЕСЬ. Предупреждение печатается из финализатора объекта
# корутины, а исключения оттуда интерпретатор проглатывает. Поэтому даже
# `-W error::RuntimeWarning` — самая строгая настройка, какая есть, — не
# останавливает выполнение, не ловится через `except RuntimeWarning` и не
# меняет код возврата. Проверяется отдельным процессом, иначе настройку
# пришлось бы менять у самого замера.
SOURCE = textwrap.dedent(
    """
    import asyncio
    async def f():
        return 1
    try:
        f()
    except RuntimeWarning as exc:
        print("ПОЙМАЛИ:", exc)
    print("после try/except выполнение продолжается")
    """
)


def strictest() -> None:
    with tempfile.TemporaryDirectory() as tmp:
        script = Path(tmp) / "never_awaited.py"
        script.write_text(SOURCE, encoding="utf-8")
        done = subprocess.run(
            [sys.executable, "-W", "error::RuntimeWarning", str(script)],
            capture_output=True,
            text=True,
        )
    first = (done.stderr.splitlines() or [""])[0]
    print("  при самой строгой настройке (-W error::RuntimeWarning):")
    print(f"    в stderr:      {first}")
    print(f"    поймано except: {'ПОЙМАЛИ' in done.stdout}")
    print(f"    дошло до конца: {'после try/except' in done.stdout}")
    print(f"    код возврата:   {done.returncode}")


asyncio.run(main())