一、为什么需要"能暂停"的函数

普通函数一旦被调用,就一路执行到 return 或抛异常,然后整个调用栈帧销毁,中间状态全部丢失。这在两种场景下很别扭:

  1. 处理大序列不能一次性物化。读一个几十 GB 的日志文件,如果用 readlines() 一把读进内存,内存直接爆。你真正想要的是"读一行、处理一行、扔掉一行"的流式能力。
  2. 生产/消费节奏不对等。上游产得快、下游吃得慢,或者反过来,你希望双方各自按自己的节奏走,而不是一方阻塞另一方。

生成器(generator)就是 Python 给出的答案:一个可以暂停、也可以恢复执行的函数。它在 yield 处把值交出来并"冻结"整个调用栈帧,下次再被驱动时从冻结点接着跑。

本质一句话:生成器把"函数的执行"变成了一个可以逐步消费的数据流,暂停时连调用栈一起冻结在堆上。

C++ 对照:在 C++20 之前,语言层面没有协程,要么用回调、要么自己用状态机或栈模拟"可暂停";Python 从 PEP 255(2.2)起就有 yield,异步生态也比 C++ 早成熟十几年。

二、生成器协议底层:冻结的函数栈帧

调用一个带 yield 的函数不会立刻执行函数体,而是返回一个 generator 对象,它实现了迭代器协议(__iter__ 返回自身,__next__ 驱动执行)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def count_up(n):
i = 0
while i < n:
yield i # 在这里把 i 交出去,并冻结
i += 1

g = count_up(3)
print(next(g)) # 0
print(next(g)) # 1
print(next(g)) # 2
try:
print(next(g))
except StopIteration:
print("迭代结束")

输出:

1
2
3
4
0
1
2
迭代结束

底层发生了什么? 每次 next(g) 推进到下一个 yield,把值交出来,然后:

  • 解释器把当前字节码位置 frame.f_lasti 记下来;
  • 把局部变量保存在 frame.f_locals
  • 整个栈帧不销毁,留在堆上PyGenObject 持有 f_frame);
  • 下次 next() 时,从这个栈帧恢复,连 i 的值都还在。

对比普通函数——进去就跑到结束,栈帧随即销毁;生成器是"进去 → yield 冻结 → 恢复 → 再冻结 → … → 抛出 StopIteration 结束"。

1
2
3
4
5
6
7
普通函数:
call ──────────────────────► return
(栈帧存在 → 销毁)

生成器:
call ─► yield①(冻结栈帧) ─► 恢复 ─► yield②(冻结) ─► 恢复 ─► StopIteration
(栈帧留在堆上,反复复用)

PEP 479 的坑:在 Python 3.7+ 中,生成器内部如果显式 raise StopIteration,会被解释器替换成 RuntimeError。原因是为了防止生成器在内部循环中意外抛出 StopIteration 时被外层 for 误当成"迭代正常结束"。下面第四节会用一个匿名工程踩坑展开。

三、从生成器到协程:send / throw / close 与 yield from

光有 yield 只能"产出值"。当生成器还能"接收值",它就成了协程(coroutine)——双向通信。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def running_avg():
total = 0.0
count = 0
avg = None
while True:
x = yield avg # 把 avg 交出去,同时接收外部 send 进来的值
count += 1
total += x
avg = total / count

coro = running_avg()
next(coro) # 预激:跑到第一个 yield,停在等号右侧
print(coro.send(10)) # 10.0
print(coro.send(20)) # 15.0
print(coro.send(30)) # 20.0

输出:

1
2
3
10.0
15.0
20.0

send(value) 在恢复执行时,把 value 注入到 yield 表达式左侧;但必须先 next(coro)send(None) 预激,否则生成器还停在函数开头、没到任何 yield,无从注入。

yield from 则是更强大的语法(PEP 380):把迭代委托给子迭代器,并自动处理 StopIteration——子生成器 return 的值会通过 yield from 传出来

1
2
3
4
5
6
7
8
def gen_a():
yield from range(3)

def gen_b():
yield from gen_a()
yield from ('x', 'y')

print(list(gen_b()))

输出:

1
[0, 1, 2, 'x', 'y']
1
2
3
调用方 ──yield from──► 委托生成器 ──yield from──► 子生成器
▲ │
└────────── 值双向流动(产出 & 异常 & return 值)┘

yield from 是后来 async/awaitasyncio 管道的语法地基——它让"把一个大协程拆成多个小协程串联"变得自然。

四、async/await 与事件循环:协程如何驱动并发 I/O

async def 定义的是原生协程函数(底层机制与生成器协程同源,但语义更纯粹)。await 是一个"主动让出点":当协程遇到 I/O 等待,它在 await 处让出控制权给事件循环,循环转去跑别的就绪协程;等 I/O 完成再回来继续。

1
2
3
4
5
6
7
8
9
10
11
12
13
import asyncio

async def fetch(name, delay):
print(f'{name} 开始')
await asyncio.sleep(delay) # 在等待点让出,事件循环去跑别的协程
print(f'{name} 完成')
return name

async def main():
results = await asyncio.gather(fetch('A', 1), fetch('B', 1))
print('结果:', results)

asyncio.run(main())

输出(AB 几乎同时开始,体现并发):

1
2
3
4
5
A 开始
B 开始
A 完成
B 完成
结果: ['A', 'B']

三种并发模型的对比:

1
2
3
回调(Callback):  A完成→调回调→B完成→调回调   问题:嵌套地狱,错误处理散落
线程(Thread): A、B 各占一线程,内核调度 问题:切换贵、GIL 下 CPU 并行受限
协程(Coroutine): 单线程内协作式多任务,await 让出 优势:零抢占、无锁、写起来像同步
1
2
3
4
5
6
事件循环时间线:
t0 协程A 运行 ──await 网络I/O──► 挂起
t1 循环切到 协程B 运行 ──await 网络I/O──► 挂起
t2 A 的 I/O 就绪 ─► A 恢复 ─► A 完成
t3 B 的 I/O 就绪 ─► B 恢复 ─► B 完成
(全程单线程,靠"主动让出"实现并发,不靠抢占)

五、C++20 协程对照与实战踩坑

C++20 终于引入语言级协程,关键字是 co_await / co_yield / co_return,核心是 promise_typecoroutine_handle,同样是 stackless(编译器把协程体切成状态机,不单独占系统栈)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <coroutine>
struct Task {
struct promise_type {
Task get_return_object() { return {}; }
std::suspend_always initial_suspend() { return {}; } // 开始时先挂起
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
using handle = std::coroutine_handle<promise_type>;
handle h;
~Task() { if (h) h.destroy(); }
};

Task demo() {
co_await std::suspend_always{}; // 在 await 点挂起,控制权交还调用方
}

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:生成器内 StopIterationRuntimeError 通过 promise_type::unhandled_exception 捕获
互操作 与同步代码混用容易 需显式定义 promise/handle,门槛高
典型用途 流式数据、管道、async I/O 异步 I/O 库、游戏逻辑、高性能服务

匿名化实战踩坑(三条)

坑 1:生成器只能遍历一次

1
2
3
g = (x * 2 for x in range(3))
print(list(g)) # [0, 1, 2] 的 2 倍 → [0, 2, 4]
print(list(g)) # [] —— 已经耗尽,第二次是空的

某数据同步脚本把"生成器当列表"反复用,第一次迭代有数据、第二次全空,排查半天才发现生成器是一次性消费品。想复用就转成 list,或重新构造生成器。

坑 2:PEP 479 让生成器内的 StopIterationRuntimeError

某匿名服务用生成器做分页拉取,循环里写着 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惰性求值让异常延迟到消费时才抛——都源于"执行被推迟"这一核心特性 🐾