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.h — CO_GENERATOR = 0x0020 macro определён рядом с CO_OPTIMIZED, CO_NEWLOCALS, CO_VARARGS, и др.
Что делает CPython, когда видит CO_GENERATOR при вызове gen()? Не исполняет тело. Вместо этого:
- Аллоцирует stack frame (PyFrameObject) для функции — со всеми локальными переменными, argument bindings, fresh evaluation stack.
- Аллоцирует PyGenObject struct — обёртку, содержащую этот frame + metadata.
- Возвращает 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_frame | PyFrameObject* | Stack frame: локальные переменные, evaluation stack, instruction pointer (last_i). Это сердце генератора |
gi_code | PyCodeObject* | Code object функции — bytecode + names + co_flags (включая CO_GENERATOR) |
gi_running | bool (флаг в gi_frame_state) | True во время next() execution; защита от reentrant calls |
gi_name | str | Имя generator function (для repr и tracebacks) |
gi_qualname | str | Qualified name (для tracebacks; Class.method.<locals>.gen форма) |
Cite: Objects/genobject.c — функции gen_iternext, gen_send_ex, gen_close, gen_throw оперируют именно этими полями; Include/cpython/genobject.h — определение struct.
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)])
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;
}
Главные шаги:
- Reentrancy check —
gi_runningдолжен быть False. Иначе ValueError “generator already executing” (защита от рекурсивного вызоваnext(g)из самого frame). - Already-finished — если frame
NULL(закрыт) или stack pointerNULL(нет более кода) → StopIteration. - Primed-generator rule — на самом первом
next/sendвызове (f_lasti == -1), arg должен бытьNone.g.send(value)для fresh generator → TypeError. Урок 04 разберём детально. - Push arg на стек — это значение, к которому resumes
yieldexpression (для send-driven coroutines). - Mark running, link frame —
gi_running = 1, link к thread state (так что Python tracebacks показывают, что generator активен). - Resume execution —
_PyEval_EvalFrameExвозобновляет bytecode interpreter сf_lasti(точка, где был последний yield). - 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 */
}
Магия в трёх строках:
f->f_stacktop = stack_pointer— сохранить evaluation stack pointer (так чтобы resume знал, где продолжить).f->f_lasti = INSTR_OFFSET()— сохранить instruction pointer (offset в bytecode, куда вернуться).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 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.
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.
Сравнение:
| Operation | Generator | List | Map/Filter (lazy) |
|---|---|---|---|
| Initial allocation | O(1) PyGenObject | O(N) all elements | O(1) wrapper |
| Per-element memory | O(1) (frame только) | O(1) array slot per | O(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.
Cross-link M03 урок 03 — generator expression IS generator function
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 и инкрементальная обработка в DataFusionCross-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
Streamtrait в DataFusion’s PhysicalPlan parallels CPython generator:poll_next()≈__next__(); saspended state в Future ≈gi_frame. Memory invariant идентичен — O(1) per stage независимо от размера source — что делает streaming pipeline’ы возможными для миллиарда строк.
Ключевые выводы
- Generator function — это
def f(): ... yield .... Compiler ставит флагCO_GENERATOR(0x20) вco_flags. Одинyieldгде угодно в теле — функция автоматически становится generator. - Вызов
gen()не исполняет тело — создаётPyGenObject(структура с suspended frame), возвращает caller’у. Тело первый раз runs только при первомnext()/send(). - 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. gen_send_ex— функция вObjects/genobject.c, которая resumes frame: reentrancy check, primed-rule check, push arg, link к thread state, вызов_PyEval_EvalFrameEx. Frame продолжает с сохранённогоf_lasti.YIELD_VALUEopcode — suspension primitive: сохраняет stack pointer + instruction pointer, возвращает value из interpreter loop. Frame остаётся живым вgi_frame. Локальные переменные между yields persistent.- 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. - Generator expression IS generator function (M03 урок 03 cross-link):
(expr for x in it)компилируется в anonymous generator function, returning the samePyGenObjectчерез те же mechanisms. - Pitfall:
def f(): return 42(безyield) — НЕ generator (noCO_GENERATORflag). Внутри generatorreturn value— этоraise StopIteration(value)(PEP 479). - 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.