Deep Engineering

MEASUREMENT

bench/iterators/states.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 protocol.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

Замеры для урока «Итераторы и генераторы»

скрипт что показывает
protocol.py iter(x) is x для списка и итератора, исчерпание, состояния генератора
silent.py ошибка, которая не падает: два прохода по аргументу дают 0 вместо 19
memory_time.py пик памяти (tracemalloc) и время для списка против генератора на 1 000 000 и на 10 элементах
traps.py PEP 479 (RuntimeError и __cause__), yield from против ручного цикла, in и len на генераторе
versions.py запись __future__.generator_stop и возврат значения из close()
inlining.py PEP 709 структурно: <listcomp>, MAKE_FUNCTION и глубина стека — без секундомера

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

for v in 3.11 3.12 3.13 3.14; do echo "== $v"; python$v protocol.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 и 3.14 — Clang 20.1.4. Сравнивать время между этими парами нельзя: разница сборок перекрывает разницу версий; это общее правило замеров, а не оговорка одного этого каталога.

Поэтому эффект PEP 709 в уроке показан не секундомером, а inlining.py: наличием объекта кода <listcomp>, инструкции MAKE_FUNCTION и глубиной стека внутри включения. Эти признаки от тулчейна не зависят вовсе.

Сравнение «генератор против списка» при этом остаётся честным всегда: оно делается внутри одного запуска одного интерпретатора.

Script

149 lines
"""Состояния генератора и корутины — по опросу самого интерпретатора.

ЗАЧЕМ ЭТОТ ФАЙЛ. Про генератор в уроке сказано всё: вызов создаёт объект,
`next` доводит до `yield`, исчерпание даёт `StopIteration`, `close` завершает.
Но сказано это словами и по кускам, а машина состояний в голове читателя при
этом не собирается: где объект находится МЕЖДУ вызовами и чем «исчерпан»
отличается от «закрыт», из отдельных абзацев не видно.

Состояние — не метафора: `inspect.getgeneratorstate` и
`inspect.getcoroutinestate` возвращают его строкой. Значит, картинку можно не
рисовать по памяти, а снять прогоном: каждый переход здесь печатается вместе с
состоянием до и после.

ЧТО ЗДЕСЬ ЕСТЬ. Блок 1 — жизнь генератора целиком, включая `send`, `throw` и
`close`. Блок 2 — то же для корутины. Блок 3 — разница между объектом-корутиной
и задачей: у них разные состояния и разные владельцы, и путают их чаще всего.

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

import asyncio
import inspect
import sys


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


def row(event: str, state: str, extra: str = "") -> None:
    print(f"  {event:<34} {state:<14} {extra}")


# ------------------------------------------------------------------ 1
def block1() -> None:
    show("1. Генератор: состояние после каждого действия")

    log: list[str] = []

    def counter():
        log.append("тело начало выполняться")
        received = yield 1
        log.append(f"после первого yield получено: {received!r}")
        yield 2
        log.append("тело дошло до конца")

    gen = counter()
    row("вызов counter()", inspect.getgeneratorstate(gen), "тело ещё не выполнялось")
    row("next(gen) -> " + str(next(gen)), inspect.getgeneratorstate(gen), "приостановлен на yield")
    row("gen.send('привет') -> " + str(gen.send("привет")), inspect.getgeneratorstate(gen))

    try:
        next(gen)
    except StopIteration:
        row("next(gen) -> StopIteration", inspect.getgeneratorstate(gen), "тело кончилось")

    # Второй генератор — чтобы показать закрытие, а не исчерпание: это разные
    # пути в одно и то же состояние, и различить их можно только так.
    other = counter()
    next(other)
    other.close()
    row("gen2.close()", inspect.getgeneratorstate(other), "закрыт, а не исчерпан")

    # Третий — состояние ВНУТРИ работы. Снаружи его не увидеть: пока генератор
    # выполняется, управление у него, и спросить может только он сам.
    def introspect():
        yield inspect.getgeneratorstate(inner)

    inner = introspect()
    row("состояние изнутри тела", next(inner), "единственный способ увидеть RUNNING")

    print()
    for line in log:
        print("   журнал:", line)


# ------------------------------------------------------------------ 2
def block2() -> None:
    show("2. Корутина: те же четыре состояния, другие имена")

    async def work():
        await asyncio.sleep(0)
        return "готово"

    coro = work()
    row("вызов work()", inspect.getcoroutinestate(coro), "тело не выполнялось")

    async def drive():
        task = asyncio.ensure_future(coro)
        await asyncio.sleep(0)
        row("после первого шага", inspect.getcoroutinestate(coro), "приостановлена на await")
        result = await task
        row("после завершения", inspect.getcoroutinestate(coro), f"результат: {result!r}")

    asyncio.run(drive())

    # Корутина, которую никто не запускал: закрыть её можно, и состояние
    # отличается от завершённой — та же пара «исчерпан / закрыт», что у
    # генератора.
    never = work()
    never.close()
    row("coro2.close() без запуска", inspect.getcoroutinestate(never), "закрыта, не завершена")


# ------------------------------------------------------------------ 3
def block3() -> None:
    show("3. Объект-корутина и задача — разные вещи")

    async def work():
        await asyncio.sleep(0.01)
        return 42

    async def main():
        coro = work()
        row("тип объекта из вызова", type(coro).__name__, "тело не выполняется")
        row("кто его выполняет", "никто", "пока его не отдали циклу событий")

        task = asyncio.create_task(coro)
        row("тип после create_task", type(task).__name__, "теперь у него есть исполнитель")
        row("состояние корутины", inspect.getcoroutinestate(coro))
        row("задача завершена?", str(task.done()), "цикл ещё не доходил до неё")

        result = await task
        row("после await задачи", str(task.done()), f"результат: {result}")
        row("состояние корутины", inspect.getcoroutinestate(coro))

        # Задачу можно спросить второй раз — корутину нельзя.
        row("task.result() ещё раз", str(task.result()), "результат хранится в задаче")
        try:
            await coro
        except RuntimeError as exc:
            row("await той же корутины", type(exc).__name__, "корутину нельзя ждать дважды")

    asyncio.run(main())


def main() -> None:
    print(f"Python {sys.version.split()[0]} ({sys.implementation.name})")
    block1()
    block2()
    block3()


if __name__ == "__main__":
    main()