引言
第十一章进行到第 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 亲手打造可迭代组件。
练习与思考题
-
实现「滑动平均」协程:函数 average() 内部维护总和与计数两个局部变量,每次被 send 一个数字,yield 当前全部数字的平均值;写好后用 next() 启动,依次 send 2、4、6,观察输出。(答案要点:total、count 更新后 yield total / count;启动会先交出「还没有任何数的平均值」0,第一次 send 的返回是第一个数的平均值。)
-
给 word_totalizer 增加「重置」命令:当 send 进来的字符串恰为 “reset” 时把累计数清零并且不累加,其余情况正常累加,保持「每次响应都 yield 当前累计数」。(答案要点:在累加前判断 if word == “reset”: total = 0 后用 continue 跳过累加、直接回到下一次 yield;验证 reset 之后累计数从 0 重新增长,生成器无需重新创建。)
-
思考题:为什么协作式调度下的多个生成器不需要加锁,而多线程共享数据必须加锁?(提示:协程的切换只发生在明确的 yield 暂停点,同一时刻只有一个执行流在运行,不存在「两个执行流同时改写同一变量」;线程随时可能被操作系统打断,任何两条指令之间都可能被插入其他线程的写操作。代价是协程无法利用多核并行,且任何一个协程里出现阻塞调用会卡住整条协作链。)