18-生成器不只是省内存(上)-yield的状态机模型与帧暂停
文章目录
- 生成器不只是省内存(上):yield 的状态机模型——每一帧暂停与恢复的底层真相
- 导入语
- 1 ~> 生成器的本质——带暂停功能的帧对象
- 1.1 普通函数 vs 生成器函数
- 1.2 用 `gi_frame` 窥探内部状态
- 2 ~> 生成器的状态机——四种状态
- 3 ~> 生成器的 "一次性"——为什么不能重复迭代
- 3.1 现象
- 3.2 原因
- 4 ~> `yield` 暂停时局部变量怎么保存
- 5 ~> 实战:一个"可恢复的日志扫描器"
- 思考 && 总结
- 结尾
生成器不只是省内存(上):yield 的状态机模型——每一帧暂停与恢复的底层真相
📖文章简介:"生成器省内存"这个说法你肯定听过——不用一次性创建整个列表,惰性生成每个值。但本文重点不是省内存,而是深挖yield背后的状态机模型:生成器不是普通函数——它是一个可恢复的帧对象(Frame Object)。每次yield暂停时,CPython 保留下当前的局部变量状态和字节码指针,下一次next()从暂停点恢复执行。用gi_frame.f_lasti追踪字节码执行位置,解释生成器如何挂起和恢复、为什么return在生成器里不是结束而是抛StopIteration、以及不能重复迭代的"一次性"特性的底层原因。
🎬 个人主页:源码骑士
❄专栏传送门:《Android开发基础》《python基础课程》
⭐️热衷从源码视角拆解技术底层原理,将复杂架构讲得通俗易懂
🎬 源码骑士的简介:
5年Android Framework系统开发经验,曾主导多项系统级性能优化专项
技术栈覆盖Android系统全链路(Binder/Handler/AMS/WMS/启动流程)及Java后端全家桶(Spring + MyBatis + Redis + Oracle)
累计产出原创技术文章100+篇,文章以源码拆解为特色,被读者评价为"看一篇胜过啃一周文档"
导入语
你肯定写过for i in range(1000000),也肯定知道xrange或range在 Python 3 中是惰性生成器——不需要一次性创建一百万个 int 对象。这是生成器最常被提到的好处——省内存。
但生成器的真核不是"省内存"。它的本质是可恢复的栈帧——一个能在yield处暂停、状态全部保留、下次next()再从这个点恢复的函数。这个特性在很多高级用法里是关键——协程、异步迭代、数据管道——都基于它。
上篇从帧对象的角度讲清楚生成器到底是怎么暂停和恢复的。下篇进入send()和yield from——生成器的双向通信。
1 ~> 生成器的本质——带暂停功能的帧对象
1.1 普通函数 vs 生成器函数
# 普通函数:调用 → 执行 → 返回 → 结束defnormal():x=1x+=1returnx# 生成器函数:调用 → 创建生成器对象 → paused# → next() → 执行到 yield → 暂停# → next() → 从 yield 之后恢复 → 执行到下一个 yield → 暂停defgen():x=1yieldx x+=1yieldx普通函数的帧是一个一次性产物。函数返回后帧被销毁,局部变量不再存在。
生成器的帧是持久化的——yield暂停时,帧被挂起保存,所有局部变量、字节码工具指针、栈深度全部保留。下次next()恢复后继续跑。
1.2 用gi_frame窥探内部状态
defsimple_gen():foriinrange(3):yieldi g=simple_gen()# 查看生成器的内部帧对象print(g.gi_frame)# <frame at 0x...>print(g.gi_frame.f_lasti)# 当前执行到的字节码偏移print(next(g))# 0print(g.gi_frame.f_lasti)# 字节码偏移按 yield 行变了print(next(g))# 1print(next(g))# 2try:next(g)exceptStopIterationase:print(g.gi_frame)# None ← 帧已销毁gi_frame.f_lasti存储的是当前字节码指令的偏移量。每次yield暂停时,CPython 记住了这个偏移量,下一次next()从这个位置恢复。
2 ~> 生成器的状态机——四种状态
每个生成器内部有一个状态字段gi_frame:
| 状态 | 含义 | 检查方式 |
|---|---|---|
| CREATED | 刚创建,还没执行过 | g.gi_frame is not None and g.gi_frame.f_lasti == -1 |
| RUNNING | 正在执行中 | 没法从外部检查 |
| SUSPENDED | 在 yield 处暂停 | g.gi_frame is not None |
| CLOSED | 已关闭(正常结束或 .close()) | g.gi_frame is None |
defmy_gen():yield1yield2g=my_gen()print(g.gi_frame)# 帧存在 → CREATEDr=next(g)print(g.gi_frame)# 帧存在 → SUSPENDED(在 yield 1 处挂起)next(g)next(g)# StopIterationprint(g.gi_frame)# None → CLOSED3 ~> 生成器的 “一次性”——为什么不能重复迭代
3.1 现象
g=(xforxinrange(3))print(list(g))# [0, 1, 2]print(list(g))# [] ← 第二次是空的!3.2 原因
生成器走到底后帧即销毁——gi_frame变成None。它是一个状态机——一次性的。一旦走到了 StopIteration,状态从 SUSPENDED 进入 CLOSED,无法回退。
这和 Java 的Iterator一样——hasNext()走到 false 之后就结束了,不能重新开始。你需要重新创建一个迭代器对象。
正确做法:如果需要多次遍历,最外层用list()包一下缓存结果,或者直接用列表推导式而不是生成器。
4 ~>yield暂停时局部变量怎么保存
defkeep_track():x=0whileTrue:received=yieldx# 暂停在这里,x 的当前值被保留ifreceivedisnotNone:x=received x+=1g=keep_track()print(next(g))# 0print(next(g))# 1print(next(g))# 2# 局部变量 x 在三次暂停之间一直保持存在——没有重新初始化x在每次 yield 之间持续存在。这是因为帧对象在堆上分配——帧本身不会因为函数返回被销毁。所以生成器中的局部变量就像"住在堆里的全局变量"。
5 ~> 实战:一个"可恢复的日志扫描器"
deflog_scanner(filepath,keyword):"""扫描日志文件,遇到 keyword 就 yield 行号"""withopen(filepath,"r")asf:forlineno,lineinenumerate(f,1):ifkeywordinline:yieldlineno# 暂停,记住当前读到哪一行scanner=log_scanner("/var/log/app.log","ERROR")# 业务代码调用:lines_with_error=[]for_inrange(10):try:lines_with_error.append(next(scanner))# 逐次获取,每次只读文件的一行exceptStopIteration:break这个 generator 的优雅之处——文件只打开一次,但业务代码能按需"拉取"结果,而不用把整个文件读进来。如果你想获得前 10 个错误行号,它只在文件开头的必要行中读取,而非遍历全文件。
思考 && 总结
生成器的三个底层真相:
- 生成器是持久化帧对象。每
yield一次暂停,帧被保存进堆。帧内所有局部变量和字节码指针(f_lasti)完整保留。 next()触发的恢复从上次暂停点开始。CPython 恢复时跳转到保存的f_lasti指令处继续执行。- 生成器是一次性的。走到
StopIteration后帧被销毁,无法回退。需要多次遍历用list()缓存。
结尾
各位小伙伴,上篇完毕。下篇进入send()和yield from。感谢阅读!
源码骑士 — 源码级拆解,从底层看透技术
👀关注:跟博主一起从源码视角深耕底层原理
❤️点赞:让优质内容被更多人看见
⭐收藏:核心知识点存好,随用随查
💬评论:分享你的经验或疑问,一起交流
🔄一键四连:别忘了给博主一键四连!
🗡️寄语:生成器是 Python 对"暂停执行"这一需求的最优雅答案。
结语:生成器不是"省内存的替代方案",而是"可暂停的执行上下文"。下篇继续——send()双向通信和yield from原理。一键四连!
