Learning Platform
Глоссарий Troubleshooting
Урок 06.02 · 28 мин
Продвинутый
Generator functionPyGenObjectCO_GENERATORgen_send_exYIELD_VALUEgi_frameStack frameLazy evaluation

Generators: PyGenObject и саспенднутый stack frame

«Что на самом деле происходит, когда я пишу def f(): yield 1; yield 2?» Простой ответ — «функция с yield становится генератором». Но это слишком поверхностно для D-07. Действительно глубокий ответ: Python compiler видит yield в теле функции, ставит флаг CO_GENERATOR на её code object. После этого вызов f() не исполняет тело функции — вместо этого CPython аллоцирует PyGenObject (C-структура), внутри которой живёт stack frame функции в саспенднутом состоянии. Первый next(gen) resumes этот frame, исполнение идёт до первого YIELD_VALUE opcode’а — frame suspends, передаёт значение caller’у. Следующий next(gen) resumes снова — frame продолжает с того же места, со всеми локальными переменными живыми.

Это самая важная machine в Python для lazy evaluation, async (async def использует ту же frame-suspension), iterator pipelines, и memory-efficient streaming. В этом уроке откроем Objects/genobject.c построчно: PyGenObject layout (5 ключевых полей), gen_send_ex функция (resume mechanism), YIELD_VALUE opcode (suspension protocol), и эмпирически померим memory profile (~176 байт PyGenObject vs ~8MB list для 10⁶ элементов).

Это ULTRA-DEEP урок — самый глубокий в M05 (сравним с M02 урок 03 dict-hashing). Если поняли его — yield from (урок 03), send/throw/close (урок 04) станут natural extensions.


Generator function: CO_GENERATOR flag в bytecode

Что отличает generator function от обычной? Только наличие yield (или yield from) в теле. Compiler делает остальное автоматически — добавляет флаг CO_GENERATOR (=0x0020) на co_flags поле code object’а:

import dis

def regular():
    return 42

def gen():
    yield 1
    yield 2


# co_flags — bit-field флагов, описывающих code object:
print(bin(regular.__code__.co_flags))   # 0b11 (или похожее — без CO_GENERATOR)
print(bin(gen.__code__.co_flags))       # ...0010_0011 — CO_GENERATOR (0x20) выставлен!

print(hex(gen.__code__.co_flags & 0x20))  # 0x20 — да, CO_GENERATOR

Cite: Include/cpython/code.hCO_GENERATOR = 0x0020 macro определён рядом с CO_OPTIMIZED, CO_NEWLOCALS, CO_VARARGS, и др.

Что делает CPython, когда видит CO_GENERATOR при вызове gen()? Не исполняет тело. Вместо этого:

  1. Аллоцирует stack frame (PyFrameObject) для функции — со всеми локальными переменными, argument bindings, fresh evaluation stack.
  2. Аллоцирует PyGenObject struct — обёртку, содержащую этот frame + metadata.
  3. Возвращает PyGenObject caller’у. Тело функции ещё не исполнялось.
def gen():
    print("inside gen")
    yield 1

g = gen()                # ничего не печатает! Тело не исполняется
print(type(g))           # <class 'generator'>
print(next(g))           # NOW печатает "inside gen", потом 1

Это критично для понимания. Вызов generator function — дешёвая операция: только PyGenObject allocation. Реальная работа — только при next() calls.


PyGenObject layout — 5 ключевых полей

Откроем Objects/genobject.c (или, точнее, Include/cpython/genobject.h — там определена struct):

/* Include/cpython/genobject.h (упрощено для ясности) */

typedef struct {
    PyObject_HEAD
    /* Frame is stored in gi_iframe (inline) */
    char gi_frame_state;             /* current suspend state */
    PyObject *gi_weakreflist;        /* weak references */
    PyObject *gi_name;               /* str — имя generator function (e.g., 'gen') */
    PyObject *gi_qualname;           /* str — qualified name (e.g., 'Module.gen') */
    PyObject *gi_exc_state;          /* exception state across yields */
    PyObject *gi_origin_or_finalizer;
    /* gi_code и gi_iframe inline далее в struct */
} PyGenObject;

Public Python view через inspect:

import inspect

def gen():
    x = 10
    yield x
    yield x + 1

g = gen()

print(inspect.getmembers(g))
# Среди прочего:
#   gi_frame:    <frame at 0x...>           ← stack frame
#   gi_code:     <code object gen at ...>   ← code object с CO_GENERATOR
#   gi_running:  False                       ← True, когда генератор сейчас исполняется
#   gi_yieldfrom: None                       ← target yield from (если есть)

5 полей, которые нужно знать:

ПолеТипСодержит
gi_framePyFrameObject*Stack frame: локальные переменные, evaluation stack, instruction pointer (last_i). Это сердце генератора
gi_codePyCodeObject*Code object функции — bytecode + names + co_flags (включая CO_GENERATOR)
gi_runningbool (флаг в gi_frame_state)True во время next() execution; защита от reentrant calls
gi_namestrИмя generator function (для repr и tracebacks)
gi_qualnamestrQualified name (для tracebacks; Class.method.<locals>.gen форма)

Cite: Objects/genobject.c — функции gen_iternext, gen_send_ex, gen_close, gen_throw оперируют именно этими полями; Include/cpython/genobject.h — определение struct.

PyGenObject struct layout (5 полей + frame)
PyObject_HEADob_refcnt + ob_type16 bytes на 64-bit CPython. Стандартный PyObject header — counter ссылок и pointer на тип (в данном случае на PyTypeObject 'generator', cite Objects/genobject.c gen_type).
gi_frame_state + gi_iframesuspended frame inlinegi_iframe — сам PyFrameObject inline в PyGenObject (после оптимизации Python 3.11+; раньше был отдельный pointer). Содержит локальные переменные, evaluation stack, instruction pointer (last_i). last_i = 0 для fresh, increments как frame resumes/suspends.
gi_codePyCodeObject*Pointer на code object функции — содержит bytecode (co_code), constants (co_consts), names (co_names), и co_flags. CO_GENERATOR (0x20) обязательно выставлен — это и был сигнал создать PyGenObject вместо обычного call.
gi_runningbool flagTrue во время gen.send/next execution. Защищает от reentrant calls (попытка вызвать next из самого genrator-frame — TypeError 'generator already executing'). После Python 3.11 представлен через gi_frame_state enum (CREATED/RUNNING/SUSPENDED/CLOSED).
gi_name + gi_qualnamestr, strИмя для repr и tracebacks. Например, gen.__name__ = 'fib'; gen.__qualname__ = 'Module.fib'. Используется только для diagnostics, не affects execution.
gi_exc_state + gi_origin_or_finalizerexception machinerygi_exc_state хранит exception state, чтобы exceptions raised внутри generator корректно propagate через yields. gi_origin_or_finalizer — для async generator finalizers (PEP 525) и debugging.

Memory profile — empirically verified (cpython 3.12.7)

Самое яркое наблюдение D-07: PyGenObject — это compact struct, независимо от того, сколько элементов он будет производить:

import sys

def gen_ten_million():
    for i in range(10**7):
        yield i * i

g = gen_ten_million()
print(sys.getsizeof(g))                        # ~176 байт — одна PyGenObject struct

# Generator expression — то же самое:
genexp = (i * i for i in range(10**6))
print(sys.getsizeof(genexp))                   # ~200 байт

# Сравнение с list comprehension того же размера:
lst = [i * i for i in range(10**6)]
print(sys.getsizeof(lst))                      # ~8_448_728 байт = ~8 MB

Verified локально (cpython-3.12.7, 2026-04-28):

  • sys.getsizeof(generator)176 байт (на этой build; varies 176-200 в зависимости от build/version — Python 3.11 reorganized PyGenObject — gi_frame теперь inline).
  • sys.getsizeof(generator_expression)200 байт (gen-expr — тот же PyGenObject).
  • sys.getsizeof(list_of_10^6_ints)8 MB (8 байт на pointer × 10⁶ + small-int cache hits, но всё equivalent to 8MB per list overhead).

Соотношение — ~45 000× в пользу generator. Это причина, почему gen-expr рекомендуется для aggregations (sum(x for x in big), max(...), any(...)):

# Memory: O(1) — постоянные ~200 байт за весь pipeline:
total = sum(x*x for x in range(10**7))

# Memory: O(N) — 80 MB list materialized в memory, потом sum:
total = sum([x*x for x in range(10**7)])
WARNING

Build dependency: Точное значение sys.getsizeof(gen) варьируется (176 / 192 / 200 байт в зависимости от Python build). Например, debug build (Py_DEBUG) добавляет дополнительные поля. Pyodide build тоже может отличаться. Главное: constant overhead, не пропорциональный yields. Это структурный invariant.

Cross-link M03 урок 03: generator expression (x*x for x in range(N)) — это то же самое, что generator function. Compiler синтезирует анонимную generator function, вызывает её, возвращает PyGenObject. Никакой магии за пределами обычной generator-machinery.


gen_send_ex — resume mechanism

Когда вы вызываете next(g) или g.send(value), CPython вызывает gen_send_ex в Objects/genobject.c. Это сердце генератора — функция, которая возобновляет suspended frame:

/* Objects/genobject.c (упрощено, сохраняя суть; ~150 LoC реального кода) */

static PyObject *
gen_send_ex(PyGenObject *gen, PyObject *arg, int exc, int closing)
{
    PyThreadState *tstate = _PyThreadState_GET();
    PyFrameObject *f = gen->gi_frame;

    /* 1. Reentrancy check — gi_running must be False */
    if (gen->gi_running) {
        PyErr_SetString(PyExc_ValueError, "generator already executing");
        return NULL;
    }

    /* 2. Already-finished generator — return StopIteration */
    if (f == NULL || f->f_stacktop == NULL) {
        PyErr_SetNone(PyExc_StopIteration);
        return NULL;
    }

    /* 3. First call (last_i == -1) and arg != None — error */
    if (f->f_lasti == -1 && arg != Py_None) {
        PyErr_SetString(PyExc_TypeError,
            "can't send non-None value to a just-started generator");
        return NULL;
    }

    /* 4. Push arg onto generator's evaluation stack (для yield-expression result) */
    *f->f_stacktop++ = arg;
    Py_INCREF(arg);

    /* 5. Mark running, link frame to current thread */
    gen->gi_running = 1;
    f->f_back = tstate->frame;
    tstate->frame = f;

    /* 6. Resume bytecode execution from f->f_lasti */
    PyObject *result = _PyEval_EvalFrameEx(f, exc);

    /* 7. Frame returned — suspend or stop */
    gen->gi_running = 0;
    tstate->frame = f->f_back;

    if (result == NULL) {                      /* error or StopIteration */
        f->f_stacktop = NULL;                  /* mark generator dead */
        return NULL;
    }
    if (f->f_stacktop == NULL) {               /* generator returned (no more yields) */
        /* Convert return value to StopIteration(value) */
        ...
    }

    /* Otherwise — yielded value */
    return result;
}

Главные шаги:

  1. Reentrancy checkgi_running должен быть False. Иначе ValueError “generator already executing” (защита от рекурсивного вызова next(g) из самого frame).
  2. Already-finished — если frame NULL (закрыт) или stack pointer NULL (нет более кода) → StopIteration.
  3. Primed-generator rule — на самом первом next/send вызове (f_lasti == -1), arg должен быть None. g.send(value) для fresh generator → TypeError. Урок 04 разберём детально.
  4. Push arg на стек — это значение, к которому resumes yield expression (для send-driven coroutines).
  5. Mark running, link framegi_running = 1, link к thread state (так что Python tracebacks показывают, что generator активен).
  6. Resume execution_PyEval_EvalFrameEx возобновляет bytecode interpreter с f_lasti (точка, где был последний yield).
  7. Frame returned → suspend or stop — если bytecode достиг YIELD_VALUE → suspend (frame сохраняется), вернёт yielded value. Если функция вернула return или достигла конца → StopIteration.

Cite: Objects/genobject.c функции gen_send_ex (~150 LoC), gen_iternext (вызывает gen_send_ex с arg=None); Python/ceval.c_PyEval_EvalFrameEx actual bytecode interpreter loop.


YIELD_VALUE opcode — suspension protocol

Что происходит, когда выполняется yield x? Compiler emits opcode YIELD_VALUE. В bytecode interpreter (Python/ceval.c) обработчик примерно такой:

/* Python/ceval.c — TARGET(YIELD_VALUE), упрощено */

case TARGET(YIELD_VALUE): {
    PyObject *retval = POP();     /* x — то, что справа от yield */
    f->f_stacktop = stack_pointer;
    f->f_lasti = INSTR_OFFSET();  /* save current instruction position */
    /* frame "frozen" — все локальные переменные сохранены, IP сохранён */
    return retval;                /* return из _PyEval_EvalFrameEx */
}

Магия в трёх строках:

  1. f->f_stacktop = stack_pointer — сохранить evaluation stack pointer (так чтобы resume знал, где продолжить).
  2. f->f_lasti = INSTR_OFFSET() — сохранить instruction pointer (offset в bytecode, куда вернуться).
  3. return retval — выход из bytecode loop. Frame жив (не deallocated), просто suspended.

Когда gen_send_ex следующий раз вызовет _PyEval_EvalFrameEx(f, ...) — interpreter увидит f->f_lasti != -1, переключится на этот offset, и продолжит. Локальные переменные (f->f_localsplus[]) всё это время сохранены в heap-allocated frame.

def gen():
    x = 100              # f_lasti после этой строки = некий N
    y = 200
    yield x              # YIELD_VALUE — suspend, x возвращается
    print(x, y)          # после next(): x=100, y=200 — оба ЖИВЫ
    yield x + y          # YIELD_VALUE снова

g = gen()
print(next(g))           # 100 — yield x
print(next(g))           # 300 — print(100, 200), потом yield x+y

Frame persists между yield’ами. Это отличает generator от обычной функции, где после return все локальные переменные dealloc’ируются.

Generator lifecycle: caller ↔ PyGenObject ↔ frame
caller
PyGenObject
frame (suspended)
g = gen()next(g)resume frameYIELD_VALUE → xxnext(g) againreturn → StopIteration

Generator vs def f(): return 42 — Pitfall (не generator)

Ключевое: только yield в теле делает функцию generator. return alone — нет:

def not_a_gen():
    return 42

print(type(not_a_gen()))   # <class 'int'> — обычный return value
print(bin(not_a_gen.__code__.co_flags & 0x20))   # 0 — CO_GENERATOR НЕ выставлен

Так как нет yield в теле, compiler не ставит CO_GENERATOR flag. Функция исполняется как обычная — тело runs, return value возвращается напрямую.

def is_a_gen():
    return 42      # этот return не отменяет yield
    yield 1        # дед-код, но compiler видит yield → CO_GENERATOR

g = is_a_gen()
print(type(g))                 # <class 'generator'> — даже если yield unreachable!
print(bin(is_a_gen.__code__.co_flags & 0x20))   # 0x20 — флаг выставлен

Compiler — это lexical проверка наличия yield в теле, не runtime path. Один yield где угодно (даже после return, даже в недостижимом коде) делает функцию generator.

WARNING

Pitfall (M05 урок 04 раскроет глубже): внутри generator function return 42 это не обычный return — это raise StopIteration(42). for x in gen() это значение выбросит (PEP 479). Только yield from gen() или gen.send(...) могут captured это значение. Python 3.13 also gen.close() returns final value.


Big-O: lazy O(1) per element

Generator next() — это O(1) per resume + O(работы тела) between yields. Frame allocation — O(1) one-time. Storage — O(1) PyGenObject + O(стек переменных). Это делает generator идеальным для:

  • Streaming pipelines: sum(filter(pred, map(f, gen()))) — O(1) memory regardless of source size.
  • Lazy infinite sequences: def naturals(): n=0; while True: yield n; n += 1 — never materialized.
  • State machines: каждый yield — точка suspension, frame несёт state между resumes.

Сравнение:

OperationGeneratorListMap/Filter (lazy)
Initial allocationO(1) PyGenObjectO(N) all elementsO(1) wrapper
Per-element memoryO(1) (frame только)O(1) array slot perO(1) (lazy)
Total memory for N~200 байтO(N) × element size~200 байт
Random access❌ нет✓ O(1)❌ нет
Multi-pass❌ exhausted✓ multi❌ exhausted

Per D-07 carrying decision: lazy O(1) per element — это reason Python предлагает обе формы (list comprehension и generator expression). Choose by use case: streaming → gen; multi-pass / random → list.


Generator expression (expr for x in iter)синтаксический сахар для anonymous generator function. Compiler буквально создаёт generator function с тем же body и вызывает её:

# (x*x for x in range(10))
# эквивалентно:

def _gen_inner(it):
    for x in it:
        yield x * x

g = _gen_inner(iter(range(10)))   # PyGenObject

Та же co_flags с CO_GENERATOR. Тот же PyGenObject. То же ~200 байт sys.getsizeof. То же lazy semantics. Единственное различие — scope leak: generator expression имеет dedicated scope (как list comprehension в Python 3), но иначе всё одинаково.

Проверка:

import sys

def gen_func():
    yield from (i * i for i in range(10**6))

g1 = gen_func()                              # generator function
g2 = (i * i for i in range(10**6))           # generator expression

print(sys.getsizeof(g1))    # ~176 байт
print(sys.getsizeof(g2))    # ~200 байт — same order, slightly different overhead

Это объясняет M03 урок 03 наблюдение «gen-expr ~120 байт vs list 8MB» — за этим стоит та же PyGenObject machinery, что мы разбирали здесь.


Cross-course context

Streaming и инкрементальная обработка в DataFusion

Cross-course → Spark: 03/01 dataframe-creation-schema — Spark DataFrame lazy в том же духе, что Python generator: df.filter(...).select(...) строит plan, но не материализует данные до .collect() / .write(). PyGenObject хранит suspended frame; Spark DataFrame хранит logical plan tree. Resume mechanism у обоих — pull-based: caller просит next batch, lazy chain исполняется ровно настолько, чтобы выдать одну порцию.

Cross-course → DataFusion: 02/03 physical-plan — Rust Stream trait в DataFusion’s PhysicalPlan parallels CPython generator: poll_next()__next__(); saspended state в Future ≈ gi_frame. Memory invariant идентичен — O(1) per stage независимо от размера source — что делает streaming pipeline’ы возможными для миллиарда строк.


Ключевые выводы

  1. Generator function — это def f(): ... yield .... Compiler ставит флаг CO_GENERATOR (0x20) в co_flags. Один yield где угодно в теле — функция автоматически становится generator.
  2. Вызов gen() не исполняет тело — создаёт PyGenObject (структура с suspended frame), возвращает caller’у. Тело первый раз runs только при первом next()/send().
  3. PyGenObject layout (5 ключевых полей): gi_frame (saspended PyFrameObject — сердце), gi_code (code object с CO_GENERATOR), gi_running (reentrancy flag), gi_name/gi_qualname (metadata). См. Include/cpython/genobject.h + Objects/genobject.c.
  4. gen_send_ex — функция в Objects/genobject.c, которая resumes frame: reentrancy check, primed-rule check, push arg, link к thread state, вызов _PyEval_EvalFrameEx. Frame продолжает с сохранённого f_lasti.
  5. YIELD_VALUE opcode — suspension primitive: сохраняет stack pointer + instruction pointer, возвращает value из interpreter loop. Frame остаётся живым в gi_frame. Локальные переменные между yields persistent.
  6. Memory profile (verified empirically cpython-3.12.7): sys.getsizeof(generator) ≈ 176 байт (varies 176-200 в зависимости от build), sys.getsizeof(genexp) ≈ 200 байт, sys.getsizeof([i*i for i in range(10**6)]) ≈ 8 MB. Соотношение ~45 000× — главная причина выбирать generator для streaming/aggregations.
  7. Generator expression IS generator function (M03 урок 03 cross-link): (expr for x in it) компилируется в anonymous generator function, returning the same PyGenObject через те же mechanisms.
  8. Pitfall: def f(): return 42 (без yield) — НЕ generator (no CO_GENERATOR flag). Внутри generator return value — это raise StopIteration(value) (PEP 479).
  9. Big-O: O(1) memory regardless of N elements; O(1) resume + O(work between yields) per next(); ideal for streaming pipelines.

В уроке M05 урок 03 раскроем yield from — делегирование к подгенератору через gen_throw/gen_close proxy semantics (PEP 380). Это позволит композировать generators и реализовать chain / flatten / tree-walker без cumbersome inner loops.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Какой механизм отличает generator function от обычной function на уровне CPython compilation?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5