Python协程与生成器的底层实现(yield from解析)
引言
先讲一个我亲身经历的线上事故。
某电商大促期间,一个基于 Django 的商品详情接口突然响应时间从 80ms 飙到 3s。排查后发现,这个接口内部要串行调用 5 个下游服务:库存、价格、促销、评价、推荐。每个 RPC 平均 200ms,串起来就是 1s 起步。运维同学第一反应是"加机器",但 CPU 和内存都很闲——瓶颈在 IO 等待上。
架构师的直觉告诉我:这不是算力问题,是并发模型问题。线程池能解决,但 5000 QPS 下线程上下文切换和内存开销同样可怕。真正优雅的方案是用 async/await 把这些 IO 调用并发化,让单线程在等待网络时去处理别的请求。
但当我要求团队把代码改成异步时,很多人卡在了同一个地方:
result = await some_coroutine() # 为什么这里能"暂停"?暂停后栈去哪了?要真正理解 async/await,绕不开它的前身——生成器,以及那个经常被忽视却极其关键的关键字 yield from。今天这篇文章,我会从 CPython 源码层面把这条演进链路讲透:生成器 → yield from → 原生协程。看完之后,你不仅知道 await 怎么用,还会知道它在字节码层面到底发生了什么。
核心概念:餐厅里的"传菜员"类比
在讲源码之前,先用一个类比把三个概念串起来。
想象一家餐厅:
- 普通函数是"一次性上菜":厨师做完一道菜,端出去,任务结束,厨房台面清空。
- 生成器是"自助餐传菜员":他手里有一长串菜(
yield序列),每次你招手(next()),他就端出一道,然后停在原地等你下次招手,手里的托盘、位置、状态都保留着。
yield from是"传菜员转包":A 传菜员负责的区域里有个子档口 B,A 不用自己一道道端,而是直接把你的招手请求透传给 B,B 端出菜,A 原样转交给你。B 内部还能return一个"结算金额",A 收到后继续自己的流程。
- 原生协程(
async def) 是"专业传菜系统":它和生成器用的是同一套"暂停/恢复"机制,但被赋予了明确的语义——只用于异步 IO,还多了事件循环这个"总调度台"。
一句话总结:生成器是"能暂停的函数",yield from 是"暂停状态的委托协议",协程是"生成器机制在异步 IO 场景下的专用封装"。三者共享同一套底层实现,这正是理解整条链路的钥匙。
源码/原理深度分析
1. 生成器的本质:一个带状态的栈帧
很多人以为生成器是"特殊对象",其实在 CPython 里,它就是一个被挂起的栈帧(frame)。普通函数调用时,栈帧用完即销毁;生成器调用时,栈帧被"冻住"保存在对象里,下次 next() 时"解冻"继续跑。
我们用 dis 模块看看生成器函数的字节码:
import dis
def simple_gen():
x = yield 1
y = yield 2
return x + y
dis.dis(simple_gen)输出(CPython 3.11 简化版):
2 0 RESUME 0
3 2 LOAD_CONST 1 (1)
4 YIELD_VALUE 1
6 RESUME 1
8 STORE_FAST 0 (x)
...关键指令是 YIELD_VALUE 和 RESUME。YIELD_VALUE 做两件事:
- 把栈顶的值弹出,作为
next()的返回值交给调用方; - 保存当前栈帧的
f_lasti(最后执行的指令偏移)和求值栈,然后返回控制权。
下次 next() 进来时,解释器从 f_lasti 处 RESUME,把之前保存的栈恢复,继续往下跑。这就是"暂停/恢复"的全部秘密——它不是什么魔法,就是解释器帮你保存了一个 frame 对象。
在 Include/cpython/genobject.h 里,生成器对象的结构一目了然:
typedef struct {
PyObject_HEAD
PyCodeObject *gi_code; // 对应的代码对象
PyFrameObject *gi_frame; // 挂起的栈帧(3.11后是 _PyInterpreterFrame)
PyObject *gi_weakreflist;
...
} PyGenObject;gi_frame 就是那个被冻住的栈帧。所以生成器为什么"占内存"?因为它持有一个完整的栈帧。
2. yield from 到底做了什么
这是全文的核心。先看一个直觉版本:
def sub():
yield 1
yield 2
return "done"
def delegating():
result = yield from sub()
print("子生成器返回:", result)yield from sub() 的语义看似简单——"把 sub 里的 yield 一个个转发出去",但它其实是一套完整的委托协议,涉及三个方向的通信:
具体来说,yield from 等价于下面这段手写代码(CPython 文档给出的语义):
def delegating_manual():
it = iter(sub())
try:
value = None
while True:
try:
if value is None:
y = next(it) # 第一次,用 next
else:
y = it.send(value) # 之后用 send 把 caller 的值透传
except StopIteration as e:
result = e.value # 捕获子生成器的返回值
break
try:
value = yield y # 把子生成器 yield 的值转交给 caller
except GeneratorExit:
it.close()
raise
except BaseException as e:
value = it.throw(e) # 把 caller 抛的异常透传下去
finally:
pass
return result这段代码揭示了 yield from 的三个关键设计:
- 值的双向透传:caller 通过
send()发来的值,直接透传给子生成器;子生成器yield的值,直接透传给 caller。中间委托者不截获。 - 异常透传:caller 调
throw()或close(),委托者会原样转发给子生成器。 - 返回值捕获:子生成器
return的值会被包装进StopIteration.value,yield from自动捕获并作为表达式结果返回。
为什么这个设计重要? 因为它让"生成器套生成器"变成了零成本抽象。如果没有
yield from,你需要手写上面那几十行透传逻辑,还容易漏掉异常处理。有了它,你可以在协程里"await 一个子协程",底层就是这套委托机制在工作。
3. 从生成器到原生协程:await 就是 yield from 的语法糖
现在到了最关键的一步。在 CPython 3.5 之前,async/await 还不存在,人们用 @asyncio.coroutine + yield from 写异步代码:
@asyncio.coroutine
def old_style():
result = yield from fetch_data()
return result3.5 之后变成了:
async def new_style():
result = await fetch_data()
return result这两者本质上是同一个东西。 await coro 在字节码层面会生成 GET_AWAITABLE + SEND(3.11+)或 YIELD_FROM(3.5~3.10)指令,语义和 yield from 完全一致。区别在于:
| 维度 | 生成器 (yield from) |
原生协程 (await) |
|---|---|---|
| 关键字 | yield / yield from |
await |
能否被 for 迭代 |
能(本身就是迭代器) | 不能(显式禁止) |
能否 yield 给事件循环 |
能 | 只能 await |
| 语义约束 | 无 | 只能 await awaitable 对象 |
| 底层实现 | SEND/YIELD_VALUE |
SEND/YIELD_VALUE(相同) |
CPython 用 CO_COROUTINE、CO_GENERATOR、CO_ASYNC_GENERATOR 这几个 code object 标志位来区分它们,但执行引擎是同一套。await 之所以不能像 yield 那样随便 yield 任意值给事件循环,是因为它在 GET_AWAITABLE 阶段做了类型检查,保证语义纯粹。
看一段字节码对比会更清楚:
import dis
async def coro():
await something()
dis.dis(coro)3.11 输出:
2 LOAD_GLOBAL something
GET_AWAITABLE
LOAD_CONST None
SEND 3 (to 8)
...GET_AWAITABLE 会校验对象是否有 __await__ 方法(原生协程、asyncio.Future、带 __await__ 的对象都行),然后 SEND 指令执行和 YIELD_VALUE 相同的"挂起-恢复"逻辑。所以 await 从来不是新机制,它只是给 yield from 套了一层类型安全的壳。
实战代码
理论讲完,上代码。以下三个示例都可以直接运行(Python 3.10+)。
示例 1:手写 yield from 委托协议,理解透传机制
# demo1_yield_from.py
# 目的:手动实现 yield from 的等价逻辑,观察三个方向的通信
def sub_generator():
"""子生成器:接收值、产出值、最终返回一个结果"""
print(" [sub] 启动")
received = yield "sub-1" # 第一次 yield,等待 caller 发值
print(f" [sub] 收到: {received}")
received2 = yield "sub-2"
print(f" [sub] 收到: {received2}")
return f"sub-结束({received}+{received2})" # 返回值会被 StopIteration 携带
def delegating_manual():
"""手动实现 yield from sub_generator() 的完整语义"""
it = iter(sub_generator())
value = None
result = None
while True:
try:
# 关键:第一次用 next,之后用 send 透传 caller 的值
yielded = next(it) if value is None else it.send(value)
except StopIteration as e:
result = e.value # 捕获子生成器的 return 值
break
try:
value = yield yielded # 把子生成器 yield 的值转交给 caller
except BaseException as e:
# 异常透传:caller 抛的异常原样丢给子生成器
value = it.throw(e)
return result
def delegating_native():
"""用原生 yield from 实现同样的语义,作为对照"""
result = yield from sub_generator()
return result
def drive(gen, label):
print(f"\n=== {label} ===")
g = gen()
print("caller 收到:", next(g)) # 触发到第一个 yield
print("caller 收到:", g.send("A")) # 把 "A" 透传给子生成器
try:
g.send("B")
except StopIteration as e:
print("最终返回值:", e.value)
if __name__ == "__main__":
drive(delegating_manual, "手动委托")
drive(delegating_native, "原生 yield from")运行结果两者完全一致:
=== 手动委托 ===
[sub] 启动
caller 收到: sub-1
[sub] 收到: A
caller 收到: sub-2
[sub] 收到: B
最终返回值: sub-结束(A+B)这个例子想说明的是:yield from 不是语法糖那么简单,它是一套有明确契约的协议。理解了它,你就能理解为什么 await 一个子协程时,异常能正确冒泡、返回值能正确接收。
示例 2:用生成器实现一个迷你事件循环
这个例子最有价值——它把 asyncio 的核心思想用 30 行代码复刻出来,让你看清"事件循环 + 协程"到底怎么协作。
# demo2_mini_loop.py
# 目的:用生成器实现一个极简事件循环,理解 asyncio 的本质
import time
from collections import deque
class MiniLoop:
"""极简事件循环:维护一个就绪任务队列,轮询执行"""
def __init__(self):
self._ready = deque() # 就绪的协程
self._sleeping = [] # 睡着的协程 [(唤醒时间, 协程, 值)]
def call_soon(self, coro, value=None):
"""把协程放回就绪队列,value 是 send 进去的值"""
self._ready.append((coro, value))
def sleep(self, seconds):
"""模拟 IO 等待:yield 一个 (睡眠时长) 给事件循环"""
return _Sleep(seconds)
def run_until_complete(self, coro):
self.call_soon(coro)
while self._ready or self._sleeping:
# 1. 处理到期的睡眠任务
now = time.time()
still_sleeping = []
for wake_at, c, val in self._sleeping:
if wake_at <= now:
self._ready.append((c, val))
else:
still_sleeping.append((wake_at, c, val))
self._sleeping = still_sleeping
# 2. 处理就绪任务
if self._ready:
c, val = self._ready.popleft()
try:
# 驱动协程:第一次用 next,之后用 send
yielded = next(c) if val is None else c.send(val)
except StopIteration as e:
return e.value
# 3. 根据协程 yield 出的"指令"决定怎么调度
if isinstance(yielded, _Sleep):
wake_at = time.time() + yielded.seconds
self._sleeping.append((wake_at, c, None))
else:
time.sleep(0.001) # 没任务就稍微等一下,避免空转
class _Sleep:
def __init__(self, seconds):
self.seconds = seconds
loop = MiniLoop()
def async_sleep(seconds):
"""把 sleep 语义暴露给协程:yield 出去,让事件循环接管"""
yield loop.sleep(seconds)
def fetch(name, seconds):
"""模拟一个异步 IO 任务"""
print(f"[{name}] 开始请求")
yield from async_sleep(seconds) # 关键:yield from 委托给 sleep
print(f"[{name}] 完成,耗时 {seconds}s")
return f"{name}-result"
def main():
"""并发启动三个任务,观察总耗时接近最长的那个而非累加"""
start = time.time()
# 用生成器手动并发:把三个任务都注册进去
tasks = [fetch("A", 1), fetch("B", 0.5), fetch("C", 0.8)]
results = []
for t in tasks:
loop.call_soon(t)
# 简单驱动直到全部完成(生产环境用 asyncio.gather)
while loop._ready or loop._sleeping:
loop.run_until_complete(loop._ready[0][0]) if loop._ready else None
if not loop._ready and not loop._sleeping:
break
loop.run_until_complete(loop._ready[0][0] if loop._ready else next(iter([])))
break
print(f"总耗时: {time.time() - start:.2f}s")
if __name__ == "__main__":
# 更清晰的并发驱动写法
start = time.time()
tasks = [fetch("A", 1), fetch("B", 0.5), fetch("C", 0.8)]
pending = {id(t): t for t in tasks}
for t in tasks:
loop.call_soon(t)
while loop._ready or loop._sleeping:
if loop._ready:
c, val = loop._ready.popleft()
try:
yielded = next(c) if val is None else c.send(val)
except StopIteration as e:
pending.pop(id(c), None)
print(f" 任务返回: {e.value}")
continue
if isinstance(yielded, _Sleep):
loop._sleeping.append((time.time() + yielded.seconds, c, None))
else:
time.sleep(0.001)
print(f"总耗时: {time.time() - start:.2f}s(三个任务并发)")运行后你会看到总耗时约 1.0s,而不是 1+0.5+0.8=2.3s。这就是事件循环的威力:协程 yield 出的 _Sleep 就是它和事件循环之间的"契约"——协程说"我需要等 1 秒",事件循环把它挂起,转头去跑别的协程,1 秒后再 send() 唤醒它。
真实的 asyncio 里,这个"契约"是 Future 对象,await 本质上就是 yield from future.__await__()。看懂了这个迷你循环,你就看懂了 asyncio 的骨架。
示例 3:yield from 在异步 IO 中的实际应用——并发 HTTP 请求
# demo3_async_http.py
# 目的:对比同步串行 vs 异步并发的真实差距(用 asyncio + aiohttp 演示)
# 依赖: pip install aiohttp
import asyncio
import time
try:
import aiohttp
except ImportError:
aiohttp = None
async def fetch_url(session, url):
"""一个原生协程:await 让它能在等待网络时让出控制权"""
async with session.get(url) as resp:
# 这一行 await 背后就是 yield from resp 的 __await__ 协议
text = await resp.text()
return len(text)
async def concurrent_fetch(urls):
"""并发抓取:asyncio.gather 会把多个协程包装成 Future 并发调度"""
async with aiohttp.ClientSession() as session:
tasks = [fetch_url(session, u) for u in urls]
# gather 内部就是把这些协程 yield from 委托给事件循环
return await asyncio.gather(*tasks)
def sync_fetch_all(urls):
"""同步版本作为对照"""
import urllib.request
results = []
for u in urls:
with urllib.request.urlopen(u) as resp:
results.append(len(resp.read()))
return results
if __name__ == "__main__":
if aiohttp is None:
print("请先 pip install aiohttp")
else:
urls = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
]
# 同步串行
start = time.time()
sync_fetch_all(urls)
print(f"同步串行耗时: {time.time() - start:.2f}s")
# 异步并发
start = time.time()
asyncio.run(concurrent_fetch(urls))
print(f"异步并发耗时: {time.time() - start:.2f}s")典型输出:
同步串行耗时: 4.20s
异步并发耗时: 1.05s关键点:asyncio.gather 内部并不是魔法,它把每个协程的 __await__ 委托给事件循环,用 yield from 协议逐个驱动。四个 1 秒的请求并发执行,总耗时约等于最慢的那一个。
方案对比:生成器协程 vs 其他并发模型
作为架构师,选型时不能只看"哪个酷",要看场景。下面这张表是我多年踩坑总结的:
| 方案 | 底层机制 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 生成器/协程 | 单线程 + 事件循环 | 内存开销极小(每任务约几 KB),无锁竞争,切换成本低 | 只能跑同步代码阻塞整个循环,CPU 密集无用 | IO 密集、高并发网络服务(FastAPI、aiohttp) |
| 多线程 | OS 线程 + GIL | 编程模型简单,兼容同步库 | GIL 限制并行,线程切换开销大(MB 级栈),高并发易 OOM | IO 密集但必须用同步库(Django ORM、requests) |
| 多进程 | OS 进程 | 真并行,绕过 GIL | 进程创建/通信开销大,内存翻倍 | CPU 密集(图像处理、数据分析) |
| Go goroutine | M:N 调度 + 抢占式 | 真并行 + 轻量,语言级支持 | 需要 Go 生态,跨语言调用麻烦 | 高并发微服务 |
| 回调/Promise | 事件回调 | 无栈开销 | 回调地狱,异常难处理,调试困难 | 已基本被协程取代 |
我的经验法则:
- 单机 QPS 上不去且 CPU 空闲 → 用协程(
asyncio+async框架);
- 单个请求内有同步库不可替换(如老版本 ORM)→ 用线程池
run_in_executor兜底;
- 需要榨干多核 → 协程 + 多进程混合(
uvicorn --workers N就是这个思路)。
一个常见误区:很多团队把 Django 接口全改成 async def,结果里面还在调同步的 requests 和 ORM,导致事件循环被阻塞,性能反而更差。协程是"传染"的,从 IO 层到框架层必须全链路异步,改一半不如不改。
最佳实践与避坑指南
坑 1:在协程里调用阻塞函数
async def bad():
time.sleep(1) # ❌ 阻塞整个事件循环!所有协程都被卡住
requests.get(url) # ❌ 同样是阻塞的
async def good():
await asyncio.sleep(1) # ✅ 让出控制权
async with aiohttp.ClientSession() as s:
await s.get(url) # ✅ 真异步如果非要用同步库,用 await asyncio.to_thread(blocking_func)(3.9+)把它丢到线程池。
坑 2:忘记 await 导致协程没执行
async def main():
fetch_data() # ❌ 只是创建了协程对象,根本没执行,还报 RuntimeWarning
await fetch_data() # ✅Python 会给你一个 RuntimeWarning: coroutine was never awaited,不要忽略这个警告,它经常意味着逻辑 bug。
坑 3:生成器的 send(None) 第一次调用
def gen():
x = yield 1
g = gen()
g.send(10) # ❌ TypeError: can't send non-None value to a just-started generator
g.send(None) # ✅ 第一次必须 send(None),等价于 next(g)yield from 内部帮你处理了这个细节(第一次用 next),这也是它比手写透传更可靠的原因之一。
坑 4:yield from 的异常传播边界
yield from 会透传 throw() 和 close(),但子生成器如果没处理异常,异常会冒泡到委托者。写库时要注意:如果你在委托者里 try/except 包住 yield from,捕获的是子生成器未处理的异常,别误以为是自己的异常。
最佳实践清单
- 全链路异步:从 web 框架到 DB driver 到 HTTP client,全部选异步版本,别混搭。
- CPU 密集用
ProcessPoolExecutor:协程救不了 CPU 密集,别硬上。 - 善用
asyncio.TaskGroup(3.11+):比gather更安全的并发管理,异常能正确取消兄弟任务。 - 给协程加超时:
async with asyncio.timeout(5):(3.11+),避免下游慢拖垮整个服务。 - 调试用
PYTHONASYNCIODEBUG=1:能检测出阻塞事件循环的调用。
总结
回到开头那个线上事故。当我们把五个串行 RPC 改成 asyncio.gather 并发后,接口耗时从 1s 降到 220ms,机器一台没加。这不是玄学,而是理解了"暂停/恢复"机制后的自然结果。
这篇文章的核心,是希望你记住这条演进链:
栈帧用完即毁] --> B[生成器
栈帧被冻住 gi_frame] B --> C[yield from
委托协议:值/异常/返回值三向透传] C --> D[原生协程 async/await
GET_AWAITABLE + SEND 指令] D --> E[事件循环 asyncio
Future 作为协程与循环的契约]
三个关键认知:
- 生成器的本质是"被保存的栈帧",暂停/恢复靠的是
f_lasti和求值栈的保存与恢复,不是魔法。 yield from是一套完整的委托协议,负责值、异常、返回值三个方向的透传,是协程嵌套的基石。await就是yield from的语法糖,底层字节码几乎相同,只是加了类型检查保证语义纯粹。
延伸思考:理解了这套机制,你会更容易看懂 Go 的 goroutine 调度(都是"用户态栈切换")、JavaScript 的 async/await(V8 的 Promise + 微任务队列)、甚至 Kotlin 的 suspend 函数(编译期 CPS 变换)。"能暂停的函数"是现代高并发语言共同的抽象,Python 只是用生成器这个已有特性,巧妙地实现了它。
下次再有人问你"await 到底干了什么",你可以从 gi_frame 讲到 SEND 指令,从 yield from 讲到事件循环——这就是源码级理解带来的底气。