AI 技术博客
返回首页
Python 基础 20 分钟阅读

协程雏形:生成器的双向通信

Python协程生成器send迭代器

引言

第十一章进行到第 7 篇。前 3 篇你已经掌握了迭代协议(第 1 篇)、生成器函数与惰性求值(第 2 篇)、send/close/throw 三个增强方法的初步用法(第 3 篇)。在那些篇章里,生成器承担的角色很单纯——一个数据源:for 循环单向地把产出取走,就像水龙头只管放水。本篇换一个视角:把生成器看作一个「可以对话的对象」。不仅数据从它里面流出来,调用方还可以把数据送回去;生成器收到新输入后,基于自己记住的历史状态,立刻给出下一次产出。把这一层想通,你就摸到了协程(coroutine,协作式子程序)的门槛——这是 Python 里最优雅的机制之一,也是理解后续并发编程(第十七章线程、第十八章 asyncio)的思想引子。本篇所有输出基于本机 CPython 3.12.10 实测。阅读前提:本章第 1–3 篇,以及第四章的异常处理语法(try/except/finally)。

概念与原理

从单向管道到双向对话

在纯「数据源」视角下,调用方与生成器之间只有一条单向路:for 循环每轮调用一次 next(),取走一个产出。生成器完全不知道调用方在想什么,也无法依据外部输入调整自己的行为。可真实任务往往是交互式的:统计服务器要根据每一条新日志更新数字;聊天程序要根据用户的每次提问选择回复;数据处理管道要根据前一步的结果决定下一步的分支。若用普通函数硬写,就得把「历史状态」当参数反复传递,或借助全局变量,代码很快被状态参数淹没。

协程正是为这类「有来有回」的任务设计的:一个可以在执行中途挂起(suspend)、之后从挂起点恢复(resume),并在挂起/恢复的边界上与调用方交换数据的子程序。Python 2.5 引入的 PEP 342「Coroutines via Enhanced Generators」(通过增强生成器实现协程),为生成器新增了 send()、throw()、close() 三个方法,从此生成器从「数据源」升级为「可对话的协程雏形」。提前说明:中文语境里的「协程」一词有歧义,本篇讲的是基于生成器的协程雏形;第十八章的 asyncio 是另一套 async/await 体系,本篇末尾会明确两者的边界。

yield 表达式的完整语义

最关键的一步转变是:yield 不是一个语句(statement),而是一个表达式(expression)。写法 value = yield 产出值 中,yield 表达式有两个「取值时刻」,恰好对应一次往返的两个阶段。

挂起阶段:生成器执行到 value = yield 产出值 这一行时停下来,把「产出值」交给调用方。此刻等式右边的表达式尚未求值,value 悬而未决。恢复阶段:调用方再次驱动生成器,解释器从同一行继续执行,yield 表达式这一次才真正求值——若用 next() 恢复,表达式值为 None;若用 send(数据) 恢复,表达式值为发送的数据;若用 throw(异常) 恢复,则直接在该行抛出传入的异常。

所以一次 received = yield "准备好了" 是一个完整的回合:产出「准备好了」→ 暂停 → 等待外界把值塞进 received → 带着新值继续走。在 Python 中 next(gen) 与 gen.send(None) 本质等价——这从侧面印证了「send 才是恢复的通用手段,next 只是不携带数据的特例」。

协程的本质:状态保持

为什么生成器能在多次驱动之间记得住东西?因为生成器对象从诞生起就持有自己的执行帧(frame):局部变量的值、下一条指令的位置、异常处理表,全部冻结在帧里。挂起就是把帧暂停,恢复就是把帧继续。普通函数则完全不同——每次调用都会创建新帧,return 时帧销毁、局部变量随之消亡,所以普通函数记不住跨调用的状态。你也可以把「执行帧」想象成生成器自带的属性集合:生成器里写的局部变量,天然就是它的记忆体,不需要类的 self.xxx 仪式。

这里必须补一句定位的话:生成器不是线程。线程由操作系统抢占式(preemptive)调度,随时可能被切换走;生成器之间是协作式(cooperative)协作——只有调用方用 next()/send() 驱动,它才往前推一步,自己从不主动让出。单线程里多个生成器交替推进,同一时刻只有一个在执行,因此天然没有数据竞争;代价是永远用不上多核并行。这是它「轻」的原因,也是它的边界。

与 asyncio 协程的边界(预告)

第十八章将系统讲解 asyncio,核心语法是 async def 与 await。请先记住一个澄清:asyncio 协程与本篇的生成器协程共享「可挂起、可恢复、保持状态」的心智模型,但语法与驱动方式完全不同——生成器协程由你手动 next()/send() 驱动;asyncio 协程由事件循环(event loop)自动调度。本篇不展开 asyncio 的实现细节,只想把概念边界划清楚:看到 async/await 时不要用生成器的思维去套;等你学到第十八章,会发现本篇的思想早已打底。

操作与实现

最小双向通信:一个 relay 发生器

先看最精简的往返示例。relay 函数有两个 yield:第一个在场外接信,第二个把接到的信带进下一轮:

def relay():
    received = yield "准备好了"
    print(f"收到反馈: {received}")
    received2 = yield "继续工作"
    print(f"收到反馈2: {received2}")
    return "流程结束"


g = relay()
print("next() 得到:", next(g))
print("send() 得到:", g.send("你好"))
try:
    g.send("再见")
except StopIteration as e:
    print("生成器结束,返回值:", e.value)

实际运行输出:

next() 得到: 准备好了
收到反馈: 你好
send() 得到: 继续工作
收到反馈2: 再见
生成器结束,返回值: 流程结束

圈出三个要点。第一,时序:next() 取回第一条产出「准备好了」;send(“你好”) 把「你好」带入 received 并打印,接着走到第二个 yield,把「继续工作」作为返回值交出来。第二,send() 的返回值永远是「生成器下一次的产出」,而不是对你刚发送内容的回执——先收后发,方向不要搞反。第三,生成器走到 return “流程结束” 时不会真的返回普通值,而是抛出 StopIteration,返回值藏在异常的 value 属性里,必须靠 try/except 接住才能拿到。

状态保持的本质:普通函数 vs 生成器

用同一个「计数器」对比普通函数与生成器,直观感受帧的差别:

def plain_counter():
    n = 0
    n += 1
    return n


def gen_counter():
    n = 0
    while True:
        n += 1
        yield n


print("普通函数两次调用:", plain_counter(), plain_counter())
gc = gen_counter()
print("生成器连续产出:", next(gc), next(gc), next(gc))

实际运行输出:

普通函数两次调用: 1 1
生成器连续产出: 1 2 3

普通函数每次调用都新建帧,n 从 0 重新开始,两次调用都是 1;生成器把 n 留在自己的帧里,连续三次驱动分别得到 1、2、3。这就是协程「状态保持」的直观证据,也是第六篇流式处理里「巨大文件逐行读不占内存」的同一个原理——状态在帧里,数据在管道里。

经典例子:单词累积统计器

把 send 用在正经场景——「每次送进来一个词,立刻告诉我截至目前一共累加了多少字符」:

def word_totalizer():
    """每次被 send 一个词,产出累计的字符总数(经典协程例子)。"""
    total = 0
    while True:
        word = yield total
        total += len(word)


counter = word_totalizer()
print("启动后累计:", next(counter))
print("发送『Python』:", counter.send("Python"))
print("发送『生成器』:", counter.send("生成器"))
print("发送『协程』:", counter.send("协程"))

实际运行输出:

启动后累计: 0
发送『Python』: 6
发送『生成器』: 9
发送『协程』: 11

total 是协程自己的局部变量,保存了「所有来过的词」这个历史;每次 send 进入循环体,先取出 word,累加,然后回到 while 顶部触发下一次 yield,把最新累计数交出去。整个过程完全惰性:你不 send,它一分力气都不花。这个模式可以平移到日志统计、词频汇总等各种「增量聚合」(incremental aggregation)场景——把状态收进生成器后,主程序只需一行 counter.send(新数据) 就能推进统计,状态机被封装得干干净净。

启动仪式:第一次 send 必须是 None

刚创建、尚未启动的生成器直接 send 数据会怎样?实测:

def totalizer():
    total = 0
    while True:
        word = yield total
        total += len(word)


fresh = totalizer()
try:
    fresh.send("我还没启动")
except TypeError as e:
    print("TypeError:", e)
fresh.send(None)          # 等价于 next(fresh),完成启动仪式
print("启动成功,累计:", fresh.send("启动"))

实际运行输出:

TypeError: can't send non-None value to a just-started generator
启动成功,累计: 2

原因:生成器第一次运行时,执行流还在函数开头,尚未抵达任何 yield 表达式,此时没有「接收槽」能接住发来的数据,解释器直接拒绝。所以与协程打交道的第一条仪式是「启动仪式」(priming):先 next() 或 send(None) 把它推到第一个 yield,之后才可以自由 send 数据。

throw():从外部注入异常

throw(异常) 的作用是在「生成器当前暂停的那一行」抛出异常,让生成器内部的 try/except 有机会处理。演示「能被处理」与「无人处理」两条路径:

def keeper():
    try:
        yield "启动"
        yield "运行中"
    except ValueError as e:
        print(f"生成器内捕获: {e}")
        yield "已恢复"


k = keeper()
print(next(k))
print(next(k))
print(k.throw(ValueError("注入的故障")))


def fragile():
    yield 1


frag = fragile()
next(frag)
try:
    frag.throw(KeyError("无人处理"))
except KeyError as e:
    print("未处理的异常回到调用方:", e)

实际运行输出:

启动
运行中
生成器内捕获: 注入的故障
已恢复
未处理的异常回到调用方: '无人处理'

前半段:生成器暂停在第二个 yield 上,throw(ValueError) 在那里炸开,被内部的 except ValueError 接住,打印日志后继续 yield 出「已恢复」——throw() 的返回值就是这条后续产出。后半段:fragile 内部没有对应处理,异常从生成器里原路弹出,回到调用方调用 throw() 的位置。throw 的典型用途是「取消任务、通知故障」:调用方判断某个输入有问题时,不需要小心翼翼地改造协程逻辑,直接把异常丢进去即可。

close():礼貌地关闭并清理

close() 在生成器暂停处注入 GeneratorExit,正常流程是让生成器跑完 try/finally 里的清理代码然后结束:

def worker():
    try:
        yield "开工"
        yield "干活"
    finally:
        print("清理:关闭数据库连接")


w = worker()
print(next(w))
print(next(w))
w.close()
print("close 之后 send:")
try:
    w.send("还有人吗")
except StopIteration:
    print("抛出 StopIteration,生成器已关闭")

实际运行输出:

开工
干活
清理:关闭数据库连接
close 之后 send:
抛出 StopIteration,生成器已关闭

close() 返回 None、不抛异常(前提是生成器顺从地结束);finally 里的清理语句一定执行——这正是给协程挂载「释放资源」收尾动作的标准位置。关闭之后再 send,生成器抛出 StopIteration,表示「对话已终止」。如果生成器在 except GeneratorExit 分支里拒绝关闭,反而试图 yield 新值,解释器会判定它「无视关闭信号」,向调用方抛出 RuntimeError:

def stubborn():
    try:
        yield 1
    except GeneratorExit:
        yield 2  # 错误示范:拒绝关闭时还试图产出值


g = stubborn()
print(next(g))
try:
    g.close()
except RuntimeError as e:
    print("RuntimeError:", e)

实际运行输出:

1
RuntimeError: generator ignored GeneratorExit

观察生命周期:inspect.getgeneratorstate

标准库 inspect 提供了观察生成器状态的后门,调试协程流程时非常有用:

import inspect


def demo():
    yield 1
    yield 2


g = demo()
print("刚创建:", inspect.getgeneratorstate(g))
next(g)
print("暂停中:", inspect.getgeneratorstate(g))
try:
    next(g)
    next(g)
except StopIteration:
    pass
print("结束后:", inspect.getgeneratorstate(g))

实际运行输出:

刚创建: GEN_CREATED
暂停中: GEN_SUSPENDED
结束后: GEN_CLOSED

生成器一生经历四个状态:GEN_CREATED(刚创建、未启动,此时不能 send 非 None 值)、GEN_RUNNING(正在执行,通常只能从生成器内部观察到,例如经由 yield from 链)、GEN_SUSPENDED(停在某个 yield 上等待驱动)、GEN_CLOSED(已结束或已被 close)。调试「为什么我的 send 不生效」时,先打印状态往往立刻破案——比如看到 GEN_CLOSED,说明生成器早已耗尽,问题根本不在 send 本身。

综合示例:一个双向往返的客服机器人

最后把 send、状态保持、条件分支组合成一个完整的「对话循环」。回复策略是生成器内部的局部逻辑,会话历史(当前的 reply)是生成器的局部状态,调用方只需要 send 一句、收一句:

def support_bot():
    reply = "你好,我是客服小助手"
    while True:
        question = yield reply
        if "再见" in question:
            yield "感谢咨询,再见"
            return
        if "价格" in question:
            reply = "我们提供免费版与专业版"
        else:
            reply = f"关于「{question}」的问题已记录,稍后人工回复"


bot = support_bot()
print("开场白:", next(bot))
print("问价格:", bot.send("请问价格?"))
print("问bug:", bot.send("程序崩了怎么办"))
print("道别:", bot.send("再见"))
try:
    bot.send(None)
except StopIteration:
    print("会话完毕,生成器关闭")

实际运行输出:

开场白: 你好,我是客服小助手
问价格: 我们提供免费版与专业版
问bug: 关于「程序崩了怎么办」的问题已记录,稍后人工回复
道别: 感谢咨询,再见
会话完毕,生成器关闭

每轮循环:接收一个问题 → 依据问题内容改写 reply → 回到 yield 把 reply 交出去。注意「再见」分支里先 yield 告别语、再 return 的设计——如果不先 yield 就直接 return,告别语就永远送不出去。这就是协程雏形最有价值的地方:把「一问一答的流程」压缩成一个可恢复的函数,而不是散落在状态变量和回调函数里。

易错点与陷阱

陷阱一:忘记启动仪式就 send

刚创建的生成器不能 send 非 None 值,否则抛 TypeError: can’t send non-None value to a just-started generator。这个报错在真实代码里非常常见——尤其是把协程包进工厂函数时,第一个 send 悄悄变成了「首轮数据」。口诀:任何生成器在第一次 send 数据之前,先 next(gen) 或 gen.send(None) 完成启动仪式;也可以在创建后立刻启动一次,把「已经就绪的协程」再交付给别人用。

陷阱二:for 循环无法 send,且 send 返回的是「下一条产出」

for 循环内部只会调用 next(),不提供 send 的入口。想既用 for 遍历又给生成器反馈,两者无法在同一层共存——要么放弃 for,改成手动 next()/send() 循环并自行捕捉 StopIteration;要么把「需要外部输入才能决定走向」的部分重构为协程、把纯数据消费留在 for 里。另一个相关误解:send() 的返回值是「下一条产出」而不是「本条消息的回执」,刚发完消息就以为返回值是确认信息,会取到完全错误的语义。

陷阱三:close() 时不愿关闭

在 except GeneratorExit 分支中 yield 新值会导致 RuntimeError: generator ignored GeneratorExit(本文已实测)。生成器在关闭时必须顺从:清理完毕后直接 return,或什么都不做让 GeneratorExit 自然穿过;需要记录日志时只做无阻塞的清理动作(打印、写缓冲、关闭句柄),绝不要在清理过程中把控制权交回主程序。

陷阱四:throw 的异常在外部不接住

throw() 注入的异常如果生成器内没有对应 except,会直接抛回调用 throw() 的那一行代码。很多初学者以为「异常进到生成器里就消失了」,结果程序在 bot.throw(…) 处崩溃。设计协程接口时,要么保证生成器内部兜住所有预期异常,要么在调用处用 try/except 包住 throw,二者必居其一。

小结

yield 是表达式而不是语句:生成器暂停时它悬而未决,恢复时才真正求值——next() 求值为 None,send(数据) 求值为数据,throw(异常) 直接在该行炸开。send/throw/close 三个方法与生成器自带的执行帧,构成了协程雏形的全部:双向通信、状态保持、异常注入、优雅关闭。协作式的特性让它能在单线程内安全交替,也让它与 asyncio 的 async/await 体系划清了边界(第十八章再系统展开)。下一篇《实战:自定义可迭代对象》将把协议落成类,用 iter/next 亲手打造可迭代组件。

练习与思考题

  1. 实现「滑动平均」协程:函数 average() 内部维护总和与计数两个局部变量,每次被 send 一个数字,yield 当前全部数字的平均值;写好后用 next() 启动,依次 send 2、4、6,观察输出。(答案要点:total、count 更新后 yield total / count;启动会先交出「还没有任何数的平均值」0,第一次 send 的返回是第一个数的平均值。)

  2. 给 word_totalizer 增加「重置」命令:当 send 进来的字符串恰为 “reset” 时把累计数清零并且不累加,其余情况正常累加,保持「每次响应都 yield 当前累计数」。(答案要点:在累加前判断 if word == “reset”: total = 0 后用 continue 跳过累加、直接回到下一次 yield;验证 reset 之后累计数从 0 重新增长,生成器无需重新创建。)

  3. 思考题:为什么协作式调度下的多个生成器不需要加锁,而多线程共享数据必须加锁?(提示:协程的切换只发生在明确的 yield 暂停点,同一时刻只有一个执行流在运行,不存在「两个执行流同时改写同一变量」;线程随时可能被操作系统打断,任何两条指令之间都可能被插入其他线程的写操作。代价是协程无法利用多核并行,且任何一个协程里出现阻塞调用会卡住整条协作链。)