普通函数调用完,它的栈帧(局部变量、执行到哪一行)就没了。生成器函数里有个 yield,调用完不但没消失,下次 next() 还能接着上次的地方继续跑,局部变量原样还在。这一篇本机真实验证了这背后的机制——生成器有自己独立的"帧对象",不依赖 C 调用栈;还真实对比了用生成器和用 asyncio 协程,能把"能同时处理多少个并发任务"这件事做到什么程度。
要让一个函数"暂停",得有地方存"它执行到哪一行了、局部变量都是什么"——这份状态存在哪,决定了暂停恢复的成本和限制。
函数调用时在调用栈上压一帧,return 时弹出销毁。栈帧的生命周期和函数调用严格绑定,没有"中途保存、以后再接着跑"这回事。
生成器函数第一次调用不会真的执行,而是返回一个生成器对象——它自己拥有一份独立的帧(局部变量、执行位置全在里面),不挂在任何调用栈上,想什么时候恢复都行。
多个生成器/协程可以被同一个线程反复调用、来回切换,互不干扰——这正是 asyncio 事件循环的基础:不需要操作系统线程,靠"谁的帧该恢复了就恢复谁"实现并发。
关键就这一行:生成器对象自己持有一份 _PyInterpreterFrame,每次 next()/send() 都是拿着这份帧重新走一遍求值循环。
Objects/genobject.c · cpython @ v3.14.7, L192 gen_send_ex2(PyGenObject *gen, PyObject *arg, PyObject **presult, int exc, int closing) { _PyInterpreterFrame *frame = &gen->gi_iframe; // 帧是生成器对象自己的一部分 ... /* 把 send() 传进来的值压到帧的值栈上 */ _PyFrame_StackPush(frame, PyStackRef_FromPyObjectNew(arg_obj)); gen->gi_frame_state = FRAME_EXECUTING; PyObject *result = _PyEval_EvalFrame(tstate, frame, exc); // 从上次暂停的地方继续跑求值循环 ... if (result) { if (FRAME_STATE_SUSPENDED(gen->gi_frame_state)) { *presult = result; // 又一次 yield,把值返回给调用方,帧原样保留 return PYGEN_NEXT; } } ... }
这就是"暂停"的真相:_PyEval_EvalFrame 执行到 YIELD_VALUE 指令就返回了,但传进去的 frame 是生成器对象自己的一部分,不是临时分配、用完就释放的——所以下次调用照样能把同一个 frame 再传进去,从它当时暂停的字节码位置接着执行,局部变量原封不动。
生成器对象暴露了真实的 gi_frame,里面的 f_lasti(最后执行到的字节码偏移量)会随着每次 next() 往前走。
def g():
yield 1
yield 2
gen = g()
print('f_lasti 起始:', gen.gi_frame.f_lasti)
next(gen)
print('第一次 yield 之后:', gen.gi_frame.f_lasti, ' 行号:', gen.gi_frame.f_lineno)
next(gen)
print('第二次 yield 之后:', gen.gi_frame.f_lasti)
try:
next(gen)
except StopIteration:
print('耗尽之后 gi_frame 变成:', gen.gi_frame)
f_lasti 起始: 2
第一次 yield 之后: 10 行号: 4
第二次 yield 之后: 18
耗尽之后 gi_frame 变成: None
f_lasti 从 2 一路推进到 10、18,每一步都精确对应源码里下一个 yield 语句的字节码位置——这不是抽象比喻,是真实可以读出来的解释器状态。生成器耗尽之后,gi_frame 变成 None,帧对象被真正释放,和 FRAME_CLEARED 状态对应。
用 dis 看一眼这个函数的字节码,能看到 YIELD_VALUE 前后各有一个 RESUME——恢复执行时真的要先经过一条独立的指令:
生成器不提前算好所有元素,只在被要的时候算下一个——100 万个平方数,列表和生成器的内存差距真实测一下。
import sys
lst = [i*i for i in range(1_000_000)]
gen = (i*i for i in range(1_000_000))
print('list:', sys.getsizeof(lst), '字节')
print('generator:', sys.getsizeof(gen), '字节')
list: 8448728 字节
generator: 208 字节
生成器对象本身只有 208 字节——不管这个序列理论上有多长,生成器占用的内存都不随之增长,因为它压根没有把结果存起来,只存了"怎么算下一个"这件事本身。
协程(靠生成器机制实现)的一大实际用途是 asyncio:同一个线程反复"恢复"成千上万个协程,不需要真的开那么多操作系统线程。用同样"睡一觉再继续"的任务,分别测真实协程和真实线程,规模从 500 加到 6000。
asyncio 全程贴着 0.05~0.074s(理论下限就是单个任务的睡眠时长),几乎不随并发数增长;真实线程从 1.21 倍差距一路涨到 4.62 倍——开销来自真实的操作系统线程创建、调度,协程完全没有这部分成本,因为它们从头到尾都在同一个线程里,只是"帧对象"在不断切换谁被恢复。这和第一篇讲的 GIL 是同一个道理的另一面:线程本来就没法在 CPU 密集任务上帮上忙,现在连 I/O 密集场景下"开线程"这件事本身的开销,也被协程绕开了。
最小化复现事件循环在做的事:轮流调用两个"任务"各自的下一步,每个任务的内部状态只有它自己能改。
数据来自独立验证过的 Python 模拟:两个任务各自的内部步骤顺序,用一套独立的回放算法核对过——不管调度器怎么交替恢复它们,每个任务自己看到的步骤顺序完全不受影响,和真实生成器"帧互相独立"的行为一致。