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():x1x1returnx# 生成器函数调用 → 创建生成器对象 → paused# → next() → 执行到 yield → 暂停# → next() → 从 yield 之后恢复 → 执行到下一个 yield → 暂停defgen():x1yieldx x1yieldx普通函数的帧是一个一次性产物。函数返回后帧被销毁局部变量不再存在。生成器的帧是持久化的——yield暂停时帧被挂起保存所有局部变量、字节码工具指针、栈深度全部保留。下次next()恢复后继续跑。1.2 用gi_frame窥探内部状态defsimple_gen():foriinrange(3):yieldi gsimple_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 -1RUNNING正在执行中没法从外部检查SUSPENDED在 yield 处暂停g.gi_frame is not NoneCLOSED已关闭正常结束或 .close()g.gi_frame is Nonedefmy_gen():yield1yield2gmy_gen()print(g.gi_frame)# 帧存在 → CREATEDrnext(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():x0whileTrue:receivedyieldx# 暂停在这里x 的当前值被保留ifreceivedisnotNone:xreceived x1gkeep_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# 暂停记住当前读到哪一行scannerlog_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原理。一键四连