Python 的 yield 到底暂停了什么?拆开 generator 的栈帧,看 192 字节怎么装下整个现场
结论先行:yield 不是「函数返回」,而是「带着现场离开」——生成器把局部变量、执行位置、甚至待接收的返回值,全部塞进一个 192 字节的 generator 对象加一块 184 字节的栈帧里;恢复执行时,栈帧原地满血复活。实测这份「现场」的性价比高得惊人:产出 10 万个斐波那契数,生成器全程只占 36 KB 峰值内存,而返回 list 的版本要 445 MB,差了一万两千倍;迭代速度上,生成器 142.8 ms 反而比 list 版本 150.7 ms 还快。这篇用 6 组实验拆开发电机的内部结构。
1. 先看字节码:yield 前后是两套世界
把一个带 yield 的函数丢进 dis,能看到生成器的「出厂结构」:
26 RETURN_GENERATOR # 调用函数 → 先造 generator 对象,函数体一行没跑
POP_TOP
L1: RESUME 0
27 LOAD_CONST 1 (1)
STORE_FAST 0 (x)
28 LOAD_FAST 0 (x)
YIELD_VALUE 0 # ← 产出一个值,现场冻结在这里
RESUME 5 # ← 恢复后从这里继续
STORE_FAST 1 (y)
29 LOAD_FAST 1 (y)
RETURN_VALUE
三个关键指令说明了一切:RETURN_GENERATOR 让「调用生成器函数」这件事只造对象、不执行函数体;YIELD_VALUE 是唯一的暂停点,产出一个值并把控制权交还调用方;RESUME 是唤醒后的第一件事。所谓「函数停在 yield 那一行」,底层就是 f_lasti(最后执行的指令偏移)死死指向 YIELD_VALUE。
2. 暂停之后,局部变量还活着
最反直觉的事实:生成器停在 yield 上时,函数的所有局部变量都还在,而且可以读:
>>> def stash():
... a = [1, 2, 3]
... big = 'x' * 1000
... yield 'first'
... yield 'second'
... return a
>>> g = stash()
>>> next(g)
'first'
>>> g.gi_frame.f_locals
{'a': [1, 2, 3], 'big': 'xxxxxxx...'}
实测确认:f_locals 里 a 和 big 都原样躺着,a 甚至可以直接取出来用。yield 暂停的不是「计算」,而是「整个栈帧」——局部变量、名字查找环境、异常表,全都封存在 gi_frame 里。这就是为什么生成器函数里的循环能「记住」自己走到第几圈。
3. 这份现场的体积:192 + 184 字节
用 getsizeof 直接量:
| 对象 | 大小 |
|---|---|
| generator 对象本身 | 192 字节 |
| 它持有的栈帧(frame) | 184 字节 |
一个刚 next 过一次的生成器,全部家当不到 400 字节——和它「装着一整个函数现场」的观感完全不匹配。对比一下:同样「记住进度」,用 list 存 10 万个中间结果要 800 KB 起步。这就是生成器敢叫「惰性」的物理基础:它不存结果,存的是「怎么算出下一个结果」的现场。
4. 恢复一次多少钱?179.6 纳秒
单步 next() 的实测成本:
1 000 000 次 next():179.6 ms → 179.6 ns/次
这不到 200 纳秒里装着:交还控制权、解冻栈帧、跳到 RESUME、跑到下一个 YIELD_VALUE、再冻结、再返回。作为参照,一次普通函数调用约 60–80 ns,一次纯 Python 层的方法调用约 100 ns——****生成器的「暂停—恢复」比多数人预期的便宜,因为它不重建任何东西,只是激活一块冻存的现场。
5. 速度反常识:生成器比 list 还快一点
多数教程说「生成器省内存但慢一点」。实测 100 万个整数的迭代汇总:
| 数据源 | 100 万次迭代耗时 |
|---|---|
生成器 for x in g(n) |
142.8 ms |
list for x in list(range(n)) |
150.7 ms |
range 直接迭代 |
126.9 ms |
生成器比 list 版本还快 8 ms。原因不复杂:list 版本要先花时间和内存把 100 万个元素造出来(含一次过度分配扩容),而生成器边产边耗,制造和消费在同一个循环里重叠了。生成器真正的代价在「每个元素都要过一次 yield 协议」,但这点开销早就被「免造 list」抵掉了。
6. 内存差距:36 KB 对 445 MB
这是生成器的杀手锏。用 tracemalloc 实测产出 10 万个斐波那契数:
| 方式 | 当前内存 | 峰值内存 |
|---|---|---|
| 生成器边产边累加 | 18 KB | 36 KB |
| list 全部收集再处理 | 445 681 KB(约 435 MB) | 445 699 KB |
一万两千倍的差距。斐波那契数越往后越大(第 10 万个已有 2 万位),list 版本必须同时抱住所有大数;生成器只要保住当前两个。流式处理、无限序列(想想 itertools.count())、大文件逐行,靠的都是这 36 KB 的峰值。
顺带一提三件套协议:send(x) 让 yield 变成一个接收器(x = yield v 的右侧能接住调用方塞的值);throw() 往暂停点里扔异常;close() 在暂停点注入 GeneratorExit 触发收尾。实测一个 echo 生成器,send('hello') 后函数内确实收到了 'hello',close() 之后再 next 立刻抛 StopIteration。
一句话收尾:yield 暂停的是整个栈帧——192 字节的 generator 对象加 184 字节的 frame,把局部变量、执行位置、待收值全部封存;恢复只要 179.6 纳秒。省内存(10 万项差一万两千倍)还不赔速度(100 万次迭代反快 8 ms),这才是「惰性求值」四个字在 CPython 里的真实售价。

浙公网安备 33010602011771号