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 做两件事:

  1. 把栈顶的值弹出,作为 next() 的返回值交给调用方;
  2. 保存当前栈帧的 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 一个个转发出去",但它其实是一套完整的委托协议,涉及三个方向的通信:

graph TD A[调用方 caller] -->|send/throw/close| B[委托生成器 delegating] B -->|yield from 转发| C[子生成器 sub] C -->|yield 出的值| B B -->|原样转交| A C -->|return 值 StopIteration.value| B B -->|赋给 result| B

具体来说,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 的三个关键设计:

  1. 值的双向透传:caller 通过 send() 发来的值,直接透传给子生成器;子生成器 yield 的值,直接透传给 caller。中间委托者不截获。
  2. 异常透传:caller 调 throw() 或 close(),委托者会原样转发给子生成器。
  3. 返回值捕获:子生成器 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 result

3.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,捕获的是子生成器未处理的异常,别误以为是自己的异常。

最佳实践清单

  1. 全链路异步:从 web 框架到 DB driver 到 HTTP client,全部选异步版本,别混搭。
  2. CPU 密集用 ProcessPoolExecutor:协程救不了 CPU 密集,别硬上。
  3. 善用 asyncio.TaskGroup(3.11+):比 gather 更安全的并发管理,异常能正确取消兄弟任务。
  4. 给协程加超时:async with asyncio.timeout(5): (3.11+),避免下游慢拖垮整个服务。
  5. 调试用 PYTHONASYNCIODEBUG=1:能检测出阻塞事件循环的调用。

总结

回到开头那个线上事故。当我们把五个串行 RPC 改成 asyncio.gather 并发后,接口耗时从 1s 降到 220ms,机器一台没加。这不是玄学,而是理解了"暂停/恢复"机制后的自然结果。

这篇文章的核心,是希望你记住这条演进链:

graph LR A[普通函数
栈帧用完即毁] --> B[生成器
栈帧被冻住 gi_frame] B --> C[yield from
委托协议:值/异常/返回值三向透传] C --> D[原生协程 async/await
GET_AWAITABLE + SEND 指令] D --> E[事件循环 asyncio
Future 作为协程与循环的契约]

三个关键认知:

  1. 生成器的本质是"被保存的栈帧",暂停/恢复靠的是 f_lasti 和求值栈的保存与恢复,不是魔法。
  2. yield from 是一套完整的委托协议,负责值、异常、返回值三个方向的透传,是协程嵌套的基石。
  3. await 就是 yield from 的语法糖,底层字节码几乎相同,只是加了类型检查保证语义纯粹。

延伸思考:理解了这套机制,你会更容易看懂 Go 的 goroutine 调度(都是"用户态栈切换")、JavaScript 的 async/await(V8 的 Promise + 微任务队列)、甚至 Kotlin 的 suspend 函数(编译期 CPS 变换)。"能暂停的函数"是现代高并发语言共同的抽象,Python 只是用生成器这个已有特性,巧妙地实现了它。

下次再有人问你"await 到底干了什么",你可以从 gi_frame 讲到 SEND 指令,从 yield from 讲到事件循环——这就是源码级理解带来的底气。