Python生成器与协程底层深度解析
一、为什么需要"能暂停"的函数
普通函数一旦被调用,就一路执行到 return 或抛异常,然后整个调用栈帧销毁,中间状态全部丢失。这在两种场景下很别扭:
- 处理大序列不能一次性物化。读一个几十 GB 的日志文件,如果用
readlines()一把读进内存,内存直接爆。你真正想要的是"读一行、处理一行、扔掉一行"的流式能力。 - 生产/消费节奏不对等。上游产得快、下游吃得慢,或者反过来,你希望双方各自按自己的节奏走,而不是一方阻塞另一方。
生成器(generator)就是 Python 给出的答案:一个可以暂停、也可以恢复执行的函数。它在 yield 处把值交出来并"冻结"整个调用栈帧,下次再被驱动时从冻结点接着跑。
本质一句话:生成器把"函数的执行"变成了一个可以逐步消费的数据流,暂停时连调用栈一起冻结在堆上。
C++ 对照:在 C++20 之前,语言层面没有协程,要么用回调、要么自己用状态机或栈模拟"可暂停";Python 从 PEP 255(2.2)起就有 yield,异步生态也比 C++ 早成熟十几年。
二、生成器协议底层:冻结的函数栈帧
调用一个带 yield 的函数不会立刻执行函数体,而是返回一个 generator 对象,它实现了迭代器协议(__iter__ 返回自身,__next__ 驱动执行)。
1 | def count_up(n): |
输出:
1 | 0 |
底层发生了什么? 每次 next(g) 推进到下一个 yield,把值交出来,然后:
- 解释器把当前字节码位置
frame.f_lasti记下来; - 把局部变量保存在
frame.f_locals; - 整个栈帧不销毁,留在堆上(
PyGenObject持有f_frame); - 下次
next()时,从这个栈帧恢复,连i的值都还在。
对比普通函数——进去就跑到结束,栈帧随即销毁;生成器是"进去 → yield 冻结 → 恢复 → 再冻结 → … → 抛出 StopIteration 结束"。
1 | 普通函数: |
PEP 479 的坑:在 Python 3.7+ 中,生成器内部如果显式 raise StopIteration,会被解释器替换成 RuntimeError。原因是为了防止生成器在内部循环中意外抛出 StopIteration 时被外层 for 误当成"迭代正常结束"。下面第四节会用一个匿名工程踩坑展开。
三、从生成器到协程:send / throw / close 与 yield from
光有 yield 只能"产出值"。当生成器还能"接收值",它就成了协程(coroutine)——双向通信。
1 | def running_avg(): |
输出:
1 | 10.0 |
send(value) 在恢复执行时,把 value 注入到 yield 表达式左侧;但必须先 next(coro) 或 send(None) 预激,否则生成器还停在函数开头、没到任何 yield,无从注入。
yield from 则是更强大的语法(PEP 380):把迭代委托给子迭代器,并自动处理 StopIteration——子生成器 return 的值会通过 yield from 传出来。
1 | def gen_a(): |
输出:
1 | [0, 1, 2, 'x', 'y'] |
1 | 调用方 ──yield from──► 委托生成器 ──yield from──► 子生成器 |
yield from 是后来 async/await 和 asyncio 管道的语法地基——它让"把一个大协程拆成多个小协程串联"变得自然。
四、async/await 与事件循环:协程如何驱动并发 I/O
async def 定义的是原生协程函数(底层机制与生成器协程同源,但语义更纯粹)。await 是一个"主动让出点":当协程遇到 I/O 等待,它在 await 处让出控制权给事件循环,循环转去跑别的就绪协程;等 I/O 完成再回来继续。
1 | import asyncio |
输出(A、B 几乎同时开始,体现并发):
1 | A 开始 |
三种并发模型的对比:
1 | 回调(Callback): A完成→调回调→B完成→调回调 问题:嵌套地狱,错误处理散落 |
1 | 事件循环时间线: |
五、C++20 协程对照与实战踩坑
C++20 终于引入语言级协程,关键字是 co_await / co_yield / co_return,核心是 promise_type 与 coroutine_handle,同样是 stackless(编译器把协程体切成状态机,不单独占系统栈)。
1 |
|
Python 生成器/协程 vs C++20 协程
| 维度 | Python 生成器/协程 | C++20 协程 |
|---|---|---|
| 关键字 | yield / yield from / async / await |
co_yield / co_await / co_return |
| 底层实现 | 字节码 + 堆上冻结 frame(stackless 解释器级) | 编译器把协程体切成状态机(stackless,无独立系统栈) |
| 调度 | 事件循环(asyncio)或手动 next/send |
由 promise_type / awaiter 决定挂起与恢复 |
| 异常 | PEP 479:生成器内 StopIteration 转 RuntimeError |
通过 promise_type::unhandled_exception 捕获 |
| 互操作 | 与同步代码混用容易 | 需显式定义 promise/handle,门槛高 |
| 典型用途 | 流式数据、管道、async I/O | 异步 I/O 库、游戏逻辑、高性能服务 |
匿名化实战踩坑(三条)
坑 1:生成器只能遍历一次
1 | g = (x * 2 for x in range(3)) |
某数据同步脚本把"生成器当列表"反复用,第一次迭代有数据、第二次全空,排查半天才发现生成器是一次性消费品。想复用就转成 list,或重新构造生成器。
坑 2:PEP 479 让生成器内的 StopIteration 变 RuntimeError
某匿名服务用生成器做分页拉取,循环里写着 if not page: raise StopIteration 来"优雅结束"。Python 3.6 及之前这没问题;升级到 3.7 后,整条流水线在翻到空页时直接抛 RuntimeError 崩掉。修复方式:用 return 自然结束,或让 for 循环自己处理耗尽,而不是手动 raise StopIteration。
坑 3:惰性求值导致异常"延迟"抛出
生成器里的逻辑(类型转换、字段校验)只有在被消费时才执行,不是在定义时。某数据清洗管道在生成器里做类型转换,单测只构造生成器、从不消费,上线后第一次遍历才炸——一度误以为是上游数据脏,其实是生成器把执行推迟到了消费点。这个特性叠上 async/await 后更隐蔽:协程不 await 就永远不执行,调试时容易以为是调度问题而非逻辑问题。
小结
- 生成器 = 可暂停的函数,暂停时连调用栈帧一起冻结在堆上,用
yield产出、StopIteration收尾。 - 协程 = 双向的生成器,
send注入值、yield from做管道委托,是async/await的语法前身。 async/await+ 事件循环用单线程协作式多任务实现 I/O 并发,写起来像同步、跑起来像并发,避免了回调地狱与线程切换成本。- C++20 协程与 Python 殊途同归(都是 stackless 状态机),但 C++ 要手写
promise_type/handle,门槛更高。 - 实战三坑:生成器一次性、PEP 479 的 StopIteration→RuntimeError、惰性求值让异常延迟到消费时才抛——都源于"执行被推迟"这一核心特性 🐾

