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(结束了)gsimple_gen()print(next(g))# 输出准备第一次产出 → 1print(next(g))# 输出准备第二次产出 → 2print(next(g))# 抛出StopIteration这里有个容易踩的坑生成器函数被调用时并不执行任何代码它只是返回一个生成器对象。第一次调用next()才真正开始执行函数体。如果你在生成器函数开头写了数据库连接或者文件打开操作别指望它在创建生成器时就执行——我见过有人因为这个在初始化阶段没报错跑起来才炸。yield的另一个特性是“右值”属性。yield x这个表达式本身是有值的默认是None。什么意思看代码defdouble_yield():receivedyield1print(f收到了{received})yield2gdouble_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:receivedyieldprint(f生成器收到{received})gecho_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():total0count0whileTrue:datayieldtotal/countifcount0else0totaldata count1pipelinedata_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:datayieldprint(f处理数据{data})exceptValueErrorase:print(f捕获到异常{e})yield异常已处理gsafe_gen()next(g)g.send(1)# 输出处理数据1g.throw(ValueError,数据格式错误)# 输出捕获到异常数据格式错误 → 返回异常已处理这里有个细节throw方法可以返回值。如果生成器内部捕获了异常并执行了yieldthrow的返回值就是那个yield的值。如果生成器没有捕获异常throw会直接抛出异常没有返回值。实际开发中throw常用于“取消”或“重置”正在运行的生成器。比如一个长时间运行的数据处理生成器外部可以通过throw一个自定义的CancelException来让它优雅退出classCancelException(Exception):passdeflong_running_task():try:foriinrange(1000000):# 模拟耗时操作yieldiexceptCancelException:print(任务被取消清理资源...)# 关闭文件、释放连接等finally:print(生成器结束)tasklong_running_task()for_inrange(10):print(next(task))# 外部决定取消task.throw(CancelException)别这样写在生成器内部用raise重新抛出异常而不处理会导致生成器直接终止后续的yield都不会执行。如果你想让生成器在异常后继续工作一定要在except块里写yield。close优雅地终止生成器close方法做的事情很简单在生成器当前暂停的yield处抛出一个GeneratorExit异常。如果生成器内部捕获了这个异常并试图yieldPython会抛RuntimeError因为GeneratorExit不允许被“吞掉”并继续执行。defresource_gen():try:print(打开资源)yield1yield2yield3exceptGeneratorExit:print(收到关闭信号清理资源)# 这里不能yield否则会抛RuntimeErrorraise# 必须重新抛出否则Python也会报错finally:print(资源已释放)gresource_gen()print(next(g))# 输出打开资源 → 1g.close()# 输出收到关闭信号清理资源 → 资源已释放close的典型用途是确保生成器持有的资源被释放。比如一个生成器打开了文件句柄或网络连接调用方提前退出循环时应该调用close()来触发清理逻辑。但很多人会忘记这一步导致资源泄漏——这就是文章开头那个Bug的根源。Python的with语句和contextlib.closing可以自动处理close调用fromcontextlibimportclosingdeffile_reader():fopen(data.txt)try:forlineinf:yieldline.strip()finally:f.close()withclosing(file_reader())aslines:forlineinlines:ifstopinline:break# 自动调用close()yield from生成器委托的语法糖yield from是Python 3.3引入的语法用于在一个生成器中委托另一个生成器或任何可迭代对象。它的核心作用是“展开”子生成器让外部调用者直接与子生成器交互包括send、throw和close。defsub_gen():receivedyield子生成器启动print(f子生成器收到{received})yield子生成器结束defmain_gen():yield主生成器开始resultyieldfromsub_gen()print(f子生成器返回{result})yield主生成器结束gmain_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():resultyieldfromsub_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 fromasync/await的底层原理就一目了然。个人经验性建议生成器不是免费的。每次yield都有上下文切换的开销如果生成器内部逻辑很简单比如只是yield一个值用列表推导式可能更快。我做过基准测试百万级数据量下生成器的开销大约是列表的1.5倍。但生成器的优势在于内存数据量越大优势越明显。小心生成器的“粘性”状态。生成器对象是有状态的一旦被部分消费就不能重置。如果你需要多次遍历同一个数据集要么用列表缓存要么重新创建生成器。我见过有人试图“复用”一个已经StopIteration的生成器结果循环直接跳过。close()不是可选的。如果你的生成器持有文件、网络连接、锁等资源一定要确保调用close()。最稳妥的方式是用contextlib.closing或者with语句。别指望垃圾回收自动调用——GC的时机不可控而且如果生成器被循环引用可能永远不会被回收。yield from比手动迭代更安全。如果你需要在一个生成器里遍历另一个生成器用yield from而不是for x in sub_gen: yield x。前者会正确处理send、throw和close的传递后者会丢失这些控制信号。这个坑我踩过调试了整整一天才发现是for循环吞掉了throw异常。调试生成器协程时打印yield的值。生成器内部的执行流是跳跃的很难用断点跟踪。我习惯在每个yield前后加print打印当前状态和传递的值。等逻辑调通后再删掉这些调试代码。不要用生成器做复杂的协程调度。Python 3.5之后有原生的async/awaitasyncio库也成熟了。如果你需要写复杂的异步逻辑直接用async def。生成器协程适合做轻量级的、不需要事件循环的场景比如数据管道、状态机、简单的生产者-消费者模式。最后说一句生成器是Python里被严重低估的特性。很多人学了几年Python还在用列表推导式处理所有数据遇到大文件就内存爆炸。真正理解生成器的人写出来的代码不仅内存友好而且逻辑清晰——因为生成器天然地把“数据生产”和“数据消费”解耦了。花点时间把yield、send、throw、close、yield from这五个概念吃透你的Python水平会上一个台阶。