058、生成器即协程:yield、yield from、send、throw、close 的渐进理解

058、生成器即协程:yield、yield from、send、throw、close 的渐进理解

一个让我熬夜到凌晨三点的Bug

去年接手一个老项目,里面有一段爬虫代码,用yield做数据流处理。业务逻辑很简单:从API拉取分页数据,逐条处理,遇到特定条件就暂停等待外部信号。代码跑在Python 3.6上,看起来一切正常。

直到某天线上报警,说内存暴涨。我拉下日志一看,生成器对象堆积了上万个,每个都处于“挂起”状态,既不继续执行也不被回收。排查发现,上游调用方在某个分支路径里忘了调用next(),生成器永远卡在yield处,而下游的异常处理又没写close(),导致资源泄漏。

那天我盯着生成器对象的__next__、send、throw、close四个方法,突然意识到:生成器从来不只是“懒加载的迭代器”,它本质上就是一个被暂停的函数,一个可以双向通信的协程。只是很多人(包括当时的我)只把它当迭代器用。

yield:最基础的暂停与恢复

先看最朴素的用法。yield关键字让函数变成一个生成器函数,每次调用next(),函数执行到yield处暂停,把值吐出来。

defsimple_gen():print("准备第一次产出")yield1print("准备第二次产出")yield2print("结束了")g=simple_gen()print(next(g))# 输出:准备第一次产出 → 1print(next(g))# 输出:准备第二次产出 → 2print(next(g))# 抛出StopIteration

这里有个容易踩的坑:生成器函数被调用时并不执行任何代码,它只是返回一个生成器对象。第一次调用next()才真正开始执行函数体。如果你在生成器函数开头写了数据库连接或者文件打开操作,别指望它在创建生成器时就执行——我见过有人因为这个在初始化阶段没报错,跑起来才炸。

yield的另一个特性是“右值”属性。yield x这个表达式本身是有值的,默认是None。什么意思?看代码:

defdouble_yield():received=yield1print(f"收到了:{received}")yield2g=double_yield()print(next(g))# 输出1,此时received还没赋值print(next(g))# 输出:收到了:None → 2

第一次next()执行到yield 1就暂停了,received的赋值操作根本没执行。第二次next()才继续执行赋值,但因为没有send值,received拿到的是None。这个特性是理解send的基础。

send:给生成器“喂”数据

send方法让生成器从“单向产出”变成“双向通信”。它做的事情和next()类似——恢复生成器执行——但多了一个动作:把发送的值作为yield表达式的返回值。

defecho_gen():print("生成器启动")whileTrue:received=yieldprint(f"生成器收到:{received}")g=echo_gen()next(g)# 必须先启动一次,否则会抛TypeErrorg.send("hello")# 输出:生成器收到:hellog.send(42)# 输出:生成器收到:42

注意那个next(g)的调用。生成器在第一次yield之前,没有“暂停点”可以接收send的值,所以第一次必须用next()或者send(None)来启动。send(None)等价于next(),但如果你写成g.send(“hello”)作为第一次调用,Python会直接抛TypeError: can’t send non-None value to a just-started generator。这个错误信息我背得滚瓜烂熟,因为踩过不下十次。

send的真正威力在于“生产者-消费者”模式。比如一个数据管道,上游生成器产出数据,下游消费者通过send把处理结果反馈回来:

defdata_pipeline():total=0count=0whileTrue:data=yieldtotal/countifcount>0else0total+=data count+=1pipeline=data_pipeline()next(pipeline)# 启动print(pipeline.send(10))# 输出10.0print(pipeline.send(20))# 输出15.0print(pipeline.send(30))# 输出20.0

每次send进去一个数据,生成器计算当前平均值并yield回来。这种模式在实时流计算里很常见,比如计算滑动窗口的平均值,或者做简单的数据聚合。

throw:往生成器里扔异常

throw方法允许外部向生成器内部注入一个异常。这个异常会在生成器当前暂停的yield处被抛出,如果生成器内部有对应的except捕获,就可以继续执行;否则异常会传播到调用方。

defsafe_gen():try:whileTrue:data=yieldprint(f"处理数据:{data}")exceptValueErrorase:print(f"捕获到异常:{e}")yield"异常已处理"g=safe_gen()next(g)g.send(1)# 输出:处理数据:1g.throw(ValueError,"数据格式错误")# 输出:捕获到异常:数据格式错误 → 返回"异常已处理"

这里有个细节:throw方法可以返回值。如果生成器内部捕获了异常并执行了yield,throw的返回值就是那个yield的值。如果生成器没有捕获异常,throw会直接抛出异常,没有返回值。

实际开发中,throw常用于“取消”或“重置”正在运行的生成器。比如一个长时间运行的数据处理生成器,外部可以通过throw一个自定义的CancelException来让它优雅退出:

classCancelException(Exception):passdeflong_running_task():try:foriinrange(1000000):# 模拟耗时操作yieldiexceptCancelException:print("任务被取消,清理资源...")# 关闭文件、释放连接等finally:print("生成器结束")task=long_running_task()for_inrange(10):print(next(task))# 外部决定取消task.throw(CancelException)

别这样写:在生成器内部用raise重新抛出异常而不处理,会导致生成器直接终止,后续的yield都不会执行。如果你想让生成器在异常后继续工作,一定要在except块里写yield。

close:优雅地终止生成器

close方法做的事情很简单:在生成器当前暂停的yield处抛出一个GeneratorExit异常。如果生成器内部捕获了这个异常并试图yield,Python会抛RuntimeError,因为GeneratorExit不允许被“吞掉”并继续执行。

defresource_gen():try:print("打开资源")yield1yield2yield3exceptGeneratorExit:print("收到关闭信号,清理资源")# 这里不能yield,否则会抛RuntimeErrorraise# 必须重新抛出,否则Python也会报错finally:print("资源已释放")g=resource_gen()print(next(g))# 输出:打开资源 → 1g.close()# 输出:收到关闭信号,清理资源 → 资源已释放

close的典型用途是确保生成器持有的资源被释放。比如一个生成器打开了文件句柄或网络连接,调用方提前退出循环时,应该调用close()来触发清理逻辑。但很多人会忘记这一步,导致资源泄漏——这就是文章开头那个Bug的根源。

Python的with语句和contextlib.closing可以自动处理close调用:

fromcontextlibimportclosingdeffile_reader():f=open("data.txt")try:forlineinf:yieldline.strip()finally:f.close()withclosing(file_reader())aslines:forlineinlines:if"stop"inline:break# 自动调用close()

yield from:生成器委托的语法糖

yield from是Python 3.3引入的语法,用于在一个生成器中委托另一个生成器(或任何可迭代对象)。它的核心作用是“展开”子生成器,让外部调用者直接与子生成器交互,包括send、throw和close。

defsub_gen():received=yield"子生成器启动"print(f"子生成器收到:{received}")yield"子生成器结束"defmain_gen():yield"主生成器开始"result=yieldfromsub_gen()print(f"子生成器返回:{result}")yield"主生成器结束"g=main_gen()print(next(g))# 输出:主生成器开始print(next(g))# 输出:子生成器启动print(g.send("hello"))# 输出:子生成器收到:hello → 子生成器结束 → 子生成器返回:None → 主生成器结束

注意看,send(“hello”)直接穿透了main_gen,到达了sub_gen内部的yield处。这就是yield from的魔力——它建立了一条双向通道,外部调用者可以直接控制最内层的生成器。

yield from的返回值是子生成器结束时通过return语句返回的值(不是yield的值)。在Python 3.3之前,生成器不能使用return返回值,但之后可以了,return的值会通过StopIteration的value属性传递:

defsub_gen():yield1yield2return"完成"defmain_gen():result=yieldfromsub_gen()print(f"子生成器返回:{result}")list(main_gen())# 输出:子生成器返回:完成

这个特性在实现协程框架时非常关键。asyncio库的底层就是靠yield from(以及后来的async/await)来传递控制权的。

渐进理解:从迭代器到协程

把上面这些概念串起来,生成器的进化路径就很清晰了:

第一阶段:yield作为迭代器。你只需要记住“生成器是懒加载的列表”,用for循环遍历就行。这是90%的人对生成器的认知。

第二阶段:yield作为暂停点。你开始用send和throw做双向通信,生成器变成了一个“可暂停的函数”,可以在执行过程中接收外部输入。这时候你已经在写简单的协程了。

第三阶段:yield from作为委托。你发现可以用yield from把多个生成器组合成管道,数据和控制流可以穿透多层。这时候你实际上在实现一个轻量级的协程调度器。

第四阶段:async/await作为语法糖。Python 3.5引入的async/await本质上就是yield from的语法糖,把生成器协程包装成了更直观的异步编程模型。理解了yield from,async/await的底层原理就一目了然。

个人经验性建议

  1. 生成器不是免费的。每次yield都有上下文切换的开销,如果生成器内部逻辑很简单(比如只是yield一个值),用列表推导式可能更快。我做过基准测试,百万级数据量下,生成器的开销大约是列表的1.5倍。但生成器的优势在于内存,数据量越大优势越明显。

  2. 小心生成器的“粘性”状态。生成器对象是有状态的,一旦被部分消费,就不能重置。如果你需要多次遍历同一个数据集,要么用列表缓存,要么重新创建生成器。我见过有人试图“复用”一个已经StopIteration的生成器,结果循环直接跳过。

  3. close()不是可选的。如果你的生成器持有文件、网络连接、锁等资源,一定要确保调用close()。最稳妥的方式是用contextlib.closing或者with语句。别指望垃圾回收自动调用——GC的时机不可控,而且如果生成器被循环引用,可能永远不会被回收。

  4. yield from比手动迭代更安全。如果你需要在一个生成器里遍历另一个生成器,用yield from而不是for x in sub_gen: yield x。前者会正确处理send、throw和close的传递,后者会丢失这些控制信号。这个坑我踩过,调试了整整一天才发现是for循环吞掉了throw异常。

  5. 调试生成器协程时,打印yield的值。生成器内部的执行流是跳跃的,很难用断点跟踪。我习惯在每个yield前后加print,打印当前状态和传递的值。等逻辑调通后再删掉这些调试代码。

  6. 不要用生成器做复杂的协程调度。Python 3.5之后有原生的async/await,asyncio库也成熟了。如果你需要写复杂的异步逻辑,直接用async def。生成器协程适合做轻量级的、不需要事件循环的场景,比如数据管道、状态机、简单的生产者-消费者模式。

最后说一句:生成器是Python里被严重低估的特性。很多人学了几年Python,还在用列表推导式处理所有数据,遇到大文件就内存爆炸。真正理解生成器的人,写出来的代码不仅内存友好,而且逻辑清晰——因为生成器天然地把“数据生产”和“数据消费”解耦了。花点时间把yield、send、throw、close、yield from这五个概念吃透,你的Python水平会上一个台阶。