Deep Engineering

ЗАМЕР

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

Запускать на всех версиях, которые есть:

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 и параллелизм, а не в уроке про синтаксис.

Скрипт

186 строк
"""Что именно делает `gather` со вторым исключением.

ЗАЧЕМ ЭТОТ ФАЙЛ. Урок говорил: «gather второе исключение теряет насовсем».
Аудит линии возразил: документация обещает другое — при
`return_exceptions=False` вызывающий немедленно получает ПЕРВОЕ исключение, а
остальные awaitables не отменяются и продолжают работу. То есть «теряется» оно
не в gather, а в вызывающем коде, который никуда больше не смотрит. Разница не
словесная: если задачи сохранены, их ошибки наблюдаемы — и это ровно та
поправка, ради которой пишется скрипт.

ЧТО ЗДЕСЬ ПРОВЕРЯЕТСЯ ПРОГОНОМ, А НЕ ПЕРЕСКАЗОМ:

  1. отменяются ли соседи после первого исключения;
  2. доходят ли они до конца и виден ли их результат;
  3. наблюдаемо ли ВТОРОЕ исключение, если задачи сохранены;
  4. что печатает Python, если их не сохранить и не посмотреть;
  5. чем от этого отличается TaskGroup.

ПОЧЕМУ ВЕЗДЕ ЖУРНАЛ СОБЫТИЙ, А НЕ ЗАМЕР ВРЕМЕНИ. Вопрос урока — семантика, а
не стоимость: важно, ЧТО произошло и в каком порядке, а не за сколько
наносекунд. Поэтому каждый корутин пишет строку в общий список, и печатается
он целиком.

ЗАПУСК: python3.13 bench/async-await/gather_semantics.py
Вывод по версиям: runs/gather_semantics-3.11.txt и соседние.
"""

import asyncio
import gc
import sys

LOG: list[str] = []


def show(title: str) -> None:
    print()
    print(title)
    print("-" * len(title))


async def fails_fast(name: str) -> str:
    LOG.append(f"{name}: начал")
    await asyncio.sleep(0.01)
    LOG.append(f"{name}: возбуждает ошибку")
    raise ValueError(f"ошибка из {name}")


async def fails_late(name: str) -> str:
    LOG.append(f"{name}: начал")
    await asyncio.sleep(0.05)
    LOG.append(f"{name}: возбуждает ошибку")
    raise RuntimeError(f"ошибка из {name}")


async def finishes_late(name: str) -> str:
    LOG.append(f"{name}: начал")
    await asyncio.sleep(0.08)
    LOG.append(f"{name}: дошёл до конца")
    return f"{name} готов"


# ---------------------------------------------------------------- 1
async def block1() -> None:
    """Первое исключение прилетает сразу; что происходит с соседями."""
    LOG.clear()
    slow = asyncio.create_task(finishes_late("медленный"))
    late = asyncio.create_task(fails_late("падающий позже"))
    fast = asyncio.create_task(fails_fast("падающий первым"))

    try:
        await asyncio.gather(fast, late, slow)
    except BaseException as exc:
        LOG.append(f"вызывающий получил: {type(exc).__name__}: {exc}")

    LOG.append(f"сразу после gather: медленный done={slow.done()}, поздний done={late.done()}")

    # Ждём столько, чтобы соседи успели завершиться, и смотрим на них.
    await asyncio.sleep(0.15)
    LOG.append(f"медленный: cancelled={slow.cancelled()}, результат={slow.result()!r}")
    LOG.append(f"поздний: cancelled={late.cancelled()}, исключение={late.exception()!r}")

    for line in LOG:
        print(" ", line)


# ---------------------------------------------------------------- 2
async def block2() -> None:
    """Те же задачи, но ссылок на них никто не держит и никто не смотрит.

    «Ошибка потерялась» — утверждение проверяемое, и проверять его нужно не
    глазами. Незабранное исключение задачи цикл событий отдаёт своему
    обработчику ошибок (loop.call_exception_handler, «Task exception was never
    retrieved»). Поэтому обработчик здесь подменяется и считает вызовы: ноль
    означает, что об ошибке не сообщил никто и нигде, а не что её «не было
    видно на экране».
    """
    LOG.clear()
    reported: list[str] = []
    loop = asyncio.get_running_loop()
    previous = loop.get_exception_handler()
    loop.set_exception_handler(lambda _loop, ctx: reported.append(str(ctx.get("message"))))

    try:
        try:
            await asyncio.gather(
                fails_fast("падающий первым"),
                fails_late("падающий позже"),
            )
        except BaseException as exc:
            LOG.append(f"вызывающий получил: {type(exc).__name__}: {exc}")

        # Ждём и заставляем сборщик мусора добраться до брошенных задач:
        # именно при уничтожении задачи цикл сообщает о незабранной ошибке.
        await asyncio.sleep(0.15)
        gc.collect()
        await asyncio.sleep(0)
    finally:
        loop.set_exception_handler(previous)

    LOG.append(f"сообщений от цикла событий о незабранных ошибках: {len(reported)}")
    for message in reported:
        LOG.append(f"  сообщение: {message}")
    for line in LOG:
        print(" ", line)


# ---------------------------------------------------------------- 3
async def block3() -> None:
    """return_exceptions=True: исключения возвращаются как значения."""
    LOG.clear()
    got = await asyncio.gather(
        fails_fast("падающий первым"),
        fails_late("падающий позже"),
        finishes_late("медленный"),
        return_exceptions=True,
    )
    for item in got:
        LOG.append(f"вернулось: {item!r}")
    for line in LOG:
        print(" ", line)


# ---------------------------------------------------------------- 4
async def block4() -> None:
    """TaskGroup: другая семантика — отменяет соседей и собирает группу.

    Ссылки на задачи сохраняются намеренно: «отменяет соседей» — утверждение о
    состоянии соседей, и проверяется оно у них самих, а не по тексту ошибки.
    """
    LOG.clear()
    slow = late = None
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(fails_fast("падающий первым"))
            late = tg.create_task(fails_late("падающий позже"))
            slow = tg.create_task(finishes_late("медленный"))
    except BaseException as exc:
        LOG.append(f"вызывающий получил: {type(exc).__name__}: {exc}")
        subs = getattr(exc, "exceptions", ())
        LOG.append(f"в группе исключений: {[type(s).__name__ for s in subs]}")

    await asyncio.sleep(0.15)
    LOG.append(f"медленный: cancelled={slow.cancelled()}")
    LOG.append(f"поздний: cancelled={late.cancelled()}")
    for line in LOG:
        print(" ", line)


async def main() -> None:
    show("1. gather(return_exceptions=False): что стало с соседями")
    await block1()

    show("2. те же задачи без ссылок: вторая ошибка не наблюдается")
    await block2()

    show("3. gather(return_exceptions=True): исключение как значение")
    await block3()

    show("4. TaskGroup: отмена соседей и ExceptionGroup")
    await block4()


if __name__ == "__main__":
    print(f"Python {sys.version.split()[0]} ({sys.implementation.name})")
    asyncio.run(main())