Python 系列 · 第四篇

普通函数一 return 就消失了,generator 是怎么"暂停"了还能接着跑的?

普通函数调用完,它的栈帧(局部变量、执行到哪一行)就没了。生成器函数里有个 yield,调用完不但没消失,下次 next() 还能接着上次的地方继续跑,局部变量原样还在。这一篇本机真实验证了这背后的机制——生成器有自己独立的"帧对象",不依赖 C 调用栈;还真实对比了用生成器和用 asyncio 协程,能把"能同时处理多少个并发任务"这件事做到什么程度。

40,619x
本机真实测得:100万个数的列表 vs 生成器对象本身的内存占比
4.62x
本机真实实测:6000 个并发任务,真实 OS 线程比 asyncio 协程慢多少

问题:暂停的状态存在哪

要让一个函数"暂停",得有地方存"它执行到哪一行了、局部变量都是什么"——这份状态存在哪,决定了暂停恢复的成本和限制。

普通函数

状态活在 C 调用栈上

函数调用时在调用栈上压一帧,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——恢复执行时真的要先经过一条独立的指令:

真实实测 $ python3 -c "import dis; dis.dis(lambda: (yield 1))" 2>&1 | grep -E "RETURN_GENERATOR|YIELD_VALUE|RESUME" RETURN_GENERATOR RESUME 0 YIELD_VALUE 0 RESUME 5

真实实验:生成器的内存优势有多大

生成器不提前算好所有元素,只在被要的时候算下一个——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 协程 vs 真实 OS 线程,并发规模一测便知

协程(靠生成器机制实现)的一大实际用途是 asyncio:同一个线程反复"恢复"成千上万个协程,不需要真的开那么多操作系统线程。用同样"睡一觉再继续"的任务,分别测真实协程和真实线程,规模从 500 加到 6000。

真实实测 $ python3 exp_asyncio_scale.py # 每个任务 sleep 0.05s N= 500 asyncio=0.053s threads=0.064s threads/asyncio ratio=1.21x N= 2000 asyncio=0.059s threads=0.099s threads/asyncio ratio=1.68x N= 6000 asyncio=0.074s threads=0.340s threads/asyncio ratio=4.62x

asyncio 全程贴着 0.05~0.074s(理论下限就是单个任务的睡眠时长),几乎不随并发数增长;真实线程从 1.21 倍差距一路涨到 4.62 倍——开销来自真实的操作系统线程创建、调度,协程完全没有这部分成本,因为它们从头到尾都在同一个线程里,只是"帧对象"在不断切换谁被恢复。这和第一篇讲的 GIL 是同一个道理的另一面:线程本来就没法在 CPU 密集任务上帮上忙,现在连 I/O 密集场景下"开线程"这件事本身的开销,也被协程绕开了。

演示:两个任务轮流被恢复,帧互不干扰

最小化复现事件循环在做的事:轮流调用两个"任务"各自的下一步,每个任务的内部状态只有它自己能改。

协作式调度演示

数据来自独立验证过的 Python 模拟:两个任务各自的内部步骤顺序,用一套独立的回放算法核对过——不管调度器怎么交替恢复它们,每个任务自己看到的步骤顺序完全不受影响,和真实生成器"帧互相独立"的行为一致。

参考与说明

  • 本文全部真实实验(gi_frame.f_lasti 观察、dis.dis() 反汇编、生成器与列表的内存对比、asyncio 与真实线程的并发规模对比)均在本机真实运行的 Python 3.14.7 上完成,数据未做删改。
  • 源码引用(Objects/genobject.cgen_send_ex2()gi_iframe、帧状态常量 FRAME_CREATED/FRAME_EXECUTING/FRAME_SUSPENDED/FRAME_CLEARED)取自 python/cpython 仓库 v3.14.7 标签,与本机安装的 Python 3.14.7 完全一致。
  • asyncio vs 线程的对比实验里,线程数量在本机最高测到 6000 个仍能成功创建和运行——这台机器当时的负载和资源余量足以支撑这个规模,不同机器/不同系统负载下具体倍数会不一样,但"线程开销随数量增长、协程几乎不变"这个趋势是稳定的。
  • 演示动画的两任务调度模型由 Python 独立实现并交叉验证过(确认交替调度不会打乱任一任务自己的内部步骤顺序),不是这份 C 源码的逐行翻译,而是复现了它对外可观察的行为规则。
  • 没有涉及:yield from 和子生成器委托的具体机制、async for/异步生成器与普通生成器在帧状态机上的差异、asyncio 事件循环本身怎么决定"下一个该恢复谁"(真实调度基于 select/epoll 之类的 I/O 多路复用,和本文第三篇讲过的机制是同一类思路,但 asyncio 内部的具体实现细节本文没有展开)。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电