Python生成器与yield:从原理到内存优化实战指南
1. 从一段爆炸代码说起处理一亿个数时遇到的问题先讲一个我实际经历的场景。有一段时间我需要统计混合云平台数十台机器上的访问日志单日日志文件加起来大概有8GB出头。第一版脚本我写得很顺手把所有行读进来拆字段塞进一个列表然后再逐行处理。在测试环境跑一个小文件时一切正常放到真实数据上程序跑了不到两分钟就直接被系统 OOM Killer 干掉了。原因不难查8GB 的日志解析成 Python 对象放进列表内存占用会放大到原始文件大小的五六倍以上——这还没算中间产生的临时字符串和切片对象。最典型的错误写法是这样的# 直接把所有行读进内存小文件没问题大文件直接爆炸 lines open(access.log, encodingutf-8).readlines() parsed [] for line in lines: parts line.strip().split() parsed.append(process(parts))readlines() 会一次性把整个文件按行切分成列表每一行还是一个独立的字符串对象。对一个 8GB 的文件来说这一步就会吃进去远超物理内存的空间。我当时的机器是 16GB 内存按这个量级放大很快就顶不住了。这个问题本质上是计算需求和物理内存之间的矛盾。你其实并不需要同时拥有所有行的数据每一行处理完、统计完就可以丢掉。但传统写法让 Python 把所有数据先堆在内存里再处理数据规模一大内存就成了瓶颈。这时候真正该用的工具就是生成器Generator和它背后的 yield 关键字。生成器要解决的问题很直白让程序按需产出一个接一个的值而不是一次性把所有值全部造出来。这不是什么玄学它就是在迭代协议之上把求值这一步尽量往后拖。这篇内容我会从底层原理讲到实战写法再到我踩过的几个坑确保你看完能直接在自己的代码里用起来。我假设看这篇文章的读者对 Python 语法已经有基础了解知道函数、循环和列表推导式是什么。如果你连 def 和 for 都不熟建议先补一轮 Python 入门再回来看生成器体验会好很多。2. 迭代协议与 yield 的暂停机制生成器为什么能进能出2.1 一切 for 循环背后的约定迭代器协议要理解生成器必须先理解 Python 里一个最基本的约定——迭代协议。你可能天天写 for x in something但很少想过这个语法背后发生了什么。实际上for 循环被 Python 解释器翻译成了这样的执行逻辑调用内置函数 iter(obj)从对象上拿到一个迭代器。循环调用 next(iterator) 获取下一个元素。当迭代器抛出 StopIteration 异常时循环正常结束。也就是说任何能被 for 遍历的对象必须要么本身是迭代器要么能通过 iter() 返回一个迭代器。列表、元组、字典、字符串都满足这个条件所以它们都能被 for 循环遍历。这里有个很多人没注意到的细节列表这种容器是可迭代对象但本身不是迭代器。它每次被 iter() 调用时都会返回一个新的迭代器所以你可以反复遍历同一个列表。而迭代器自带状态遍历完就没了。你可以自己实现一个最简单的迭代器来加深理解class Counter: def __init__(self, limit): self.limit limit self.current 0 def __iter__(self): return self def __next__(self): if self.current self.limit: self.current 1 return self.current raise StopIteration用这个 Counter 去跑 for 循环效果和 range(1, limit1) 一致。但如果你让它配合 next(c) 手动取数就会注意到迭代器是一次性的取完就枯竭了。这个特性很重要后面我讲生产环境踩坑时会再次提到。2.2 yield 关键字既能出去又能回来的函数在弄清楚迭代器协议之后再去看生成器你会发现它其实是 Python 帮你封装好的一层语法糖。普通函数遇到 return 就彻底结束了函数栈帧被销毁局部变量全部清空。但一个包含 yield 的函数每次被调用时不会立即执行函数体而是返回一个生成器对象。真正牛的地方在 next(gen) 被调用时。函数开始执行一路跑到 yield 这一行然后把 yield 后面的值抛出来函数执行状态——包括所有局部变量、指令指针的位置——被完整地冻结保存。下一次 next(gen) 调用时函数不是从头开始而是从上次冻结的位置继续往下走。这个特性和 return 是完全不同的。我用一个简单例子展示这个状态保存机制def countdown(n): print(f开始倒计时初始值 {n}) while n 0: yield n n - 1 print(倒计时结束) gen countdown(3) # 注意这一行不会打印任何内容 print(生成器已经创建但函数体还没开始执行) next(gen) # 输出开始倒计时初始值 3 # 输出3 next(gen) # 输出2 next(gen) # 输出1 next(gen) # 输出倒计时结束 # 抛出 StopIteration注意第一点创建生成器对象的时候函数体一行都没执行。很多人第一次写生成器都会被这个行为惊到——以为调用函数就会执行函数体实际上要用 next() 或者 for 循环去驱动它。第二点每次 next() 回来循环里的 n - 1 都发生在yield 之后而 n 的值在上一次冻结时被保存了下来所以倒计时才能继续往下走。这就是既能出去又能回来的函数。如果还想再往深挖一层生成器的局部变量保存在一个叫 frame 对象的结构里Python 解释器在所有生成器被垃圾回收之前都会保留这些栈帧。这也是为什么生成器能记住自己的走到哪了。代价是一个生成器对象占用的内存比一个普通函数调用会多一些但和把整个数据集放进内存相比这点开销完全不值一提。2.3 惰性求值不是省内存是不急着算很多资料把生成器的优点概括成省内存这个说法不算错但容易让人误解让人觉得生成器很擅长压缩数据。实际上生成器对数据本身不进行任何压缩该算的还是一样算。它真正做的事是改变计算发生的时间点。拿我处理日志的例子来说。如果我用列表推导式parsed [process(line.strip().split()) for line in lines]这一行执行完内存里已经躺着几百万个处理结果对象。而如果改成生成器表达式parsed_gen (process(line.strip().split()) for line in lines)这行代码执行完没有任何一个具体的结果被算出来。它只是准备好了如何按顺序产出结果的流程。真正的计算发生在你 for 循环去消费它的时候而且每消费一个才计算一个。这就是惰性求值表达式被记录但不立即求知值到真正需要结果的时刻才去计算。生活化的类比是点外卖和做饭的区别。列表推导式相当于你提前做好几百道菜放着来一个人端一盘生成器相当于来了一个客人你才现场炒一道菜。如果一共只有三个客人后者省下的不只是冰箱空间还有你可能根本用不上的那些菜。但这个特性也是一把双刃剑。正因为生成器是惰性的它的计算结果不能像列表那样随意索引和重复遍历所有依赖先构造完整序列再做多次操作的算法在生成器上都不好使。这个取舍我在第 5 部分会详细对比。3. 真正能落地的三个场景大文件、无限序列与数据流水线3.1 逐行读取大文件把 8GB 日志吞下去的正确姿势回到我开头的实际问题。处理大文件时最简单的正确做法就是用生成器配合 with 语句逐行迭代因为文件对象本身就是一个迭代器而 for 循环天然按需读取def parse_log(path): with open(path, encodingutf-8) as f: for line in f: fields line.strip().split() if len(fields) 5: continue yield {time: fields[0], level: fields[1], msg: .join(fields[4:])} for record in parse_log(access.log): do_something(record)这里 open 返回的文件对象的迭代行为是按行读取底层是缓冲区机制在支撑——它不是一次性把内容全部读进内存而是每次读一块用完再读下一块。在此基础上再做 yield整条链路任何时刻内存里都只有当前这一行及其解析结果。如果你的日志行非常长比如包含一整段 JSON 或者大段堆栈按行读取仍然可能把单行的大字符串载入内存。这时可以用自定义的块读取生成器按固定字节数切块再按换行符还原def read_chunks(file_path, chunk_size8192): with open(file_path, rb) as f: tail b while True: chunk f.read(chunk_size) if not chunk: if tail: yield tail break data tail chunk lines data.split(b\n) tail lines.pop() # 最后一段可能是半行留到下次拼接 for line in lines: yield line这个写法的核心是半行拼接逻辑读取 8192 字节时最后一小段很可能只是某个完整行的前半部分所以要用一个 tail 变量把它留到下一次读取时拼上。我在实战中踩过这个坑最初没做拼接处理结果日志到了块边界就被拦腰切断解析出来全是残字段。这个函数虽然只有十几行但在处理超大单行的文件时比 for line in f 靠谱得多。3.2 无限序列生成器最不可替代的舞台列表永远存不下一个无限序列生成器可以。这个特性在一些数学计算和模拟场景里特别有用。比如斐波那契数列用生成器写出来既简单又自然def fibonacci(): a, b 0, 1 while True: yield a a, b b, a b注意这是一个无限循环的函数如果是普通函数写成 while True 且没有 return调用一次就卡死了。但因为里面是 yield它每产出一次就暂停驱动它的代码可以随时停止继续 next()所以无限循环在生成器里是安全的。你甚至可以配合 itertools 做节流。比如只取前 10 个斐波那契数from itertools import islice first_10 list(islice(fibonacci(), 10))除了斐波那契素数筛也是一个很经典的生成器写法。埃拉托斯特尼筛法Sieve of Eratosthenes通常需要维护一个不断增长的集合用生成器可以按需产出素数用到多少算多少def primes(): yield 2 known [] candidate 3 while True: is_prime True for prime in known: if prime * prime candidate: break if candidate % prime 0: is_prime False break if is_prime: known.append(candidate) yield candidate candidate 2这个生成器的性能其实一般但它体现出按需生成在无限序列上的决定性优势——你不需要先定义一个上限也不需要担心列表无限增长。如果你想拿它做更高效的素数生成可以改用分段筛法不过那不是本文重点。3.3 数据流水线把多个生成器串成处理链生成器另一个极其实用的玩法是流水线。每一个生成器只负责一个步骤数据像流水线一样从上一步流到下一步。如果以后想在中间插入新的处理环节不需要改动其他环节的代码。举个具体例子。假设我从日志里解析出访问时间想统计每个小时内的请求数。我可以分成三层import re from collections import Counter def extract_time(records): for record in records: match re.search(r\[(\d{2}/\w{3}/\d{4}:\d{2}):, record) if match: yield match.group(1) def hour_key(timestamp): return timestamp.split(:)[0] # 例如 12/May/2025:14 def count_by_hour(timestamps): counter Counter() for ts in timestamps: counter[hour_key(ts)] 1 return counter with open(access.log, encodingutf-8) as f: stats count_by_hour(extract_time(f))看见没有extract_time(f) 接收的是文件迭代器yield 出时间字符串count_by_hour 再逐条消费。中间没有出现任何中间列表。当处理链变长时你还可以做一个通用的管道组合工具。我比较常用的是把生产者-中间处理-消费者写成生成器嵌套这样整条链内存占用始终保持 O(1) 级别。有一点要提醒生成器流水线是惰性的整条链在最终被 for 消费之前任何一步都不会实际执行。我遇到过有人把流水线搭好后发现日志文件里的数据一条都没统计以为代码写错了其实只是因为没到最后一步聚合那里去真正消费生成器。惰性求值的特性在前面说过这里再强调一次生成器是流程定义不是流程本身。它需要外力驱动。4. 生成器进阶操作send、throw 和 yield from 到底是怎么回事4.1 yield from把嵌套生成器拉平写递归或嵌套生成器时最烦的就是 manually 一层层 yield。例如我想把两个列表的元素依次产出用普通写法def chain_manual(iterable1, iterable2): for item in iterable1: yield item for item in iterable2: yield itemPython 3.3 之后提供了 yield from它把把子生成器的每个值逐个 yield 出去这个行为包装成了语法def chain_elegant(iterable1, iterable2): yield from iterable1 yield from iterable2看起来只是省了几行代码但 yield from 的完整语义远不止转发值。它还负责把 send 进来的值直接传给子生成器把子生成器抛出的异常正确地传导回来并且当子生成器 return 一个值时这个返回值会成为 yield from 表达式的值。后面这点在处理复杂生成器委托时非常有用。一个使用 yield from 的经典场景是树的遍历def walk(node): if node is None: return yield from walk(node.left) yield node.value yield from walk(node.right)这样的中序遍历写起来几乎和递归的定义一模一样可读性比手动 yield 好太多了。我在处理目录树递归、嵌套 JSON 结构时经常用它来把递归产生的多层生成器变成单一生成器序列。4.2 send、throw 和 close生成器的双向通道很多人以为生成器只能往外吐值其实它还可以接收外部传入的数据。这就是 send 方法。看下面的例子def echo(): received yield 准备好接收数据了 while True: received yield f收到{received} gen echo() print(next(gen)) # 启动生成器输出准备好接收数据了 print(gen.send(你好)) # 输出收到你好 print(gen.send(再来一条)) # 输出收到再来一条send 的值会作为 yield 表达式的返回值赋给左值 received。第一次必须先调用 next(gen) 或 gen.send(None) 让生成器执行到第一个 yield否则 send 一个非 None 的值会抛 TypeError。这个机制让生成器从单向产出变成了半双工通信也就有了做简单协程的能力。最典型的例子是生成器配合装饰器实现一个极简协程框架。我可以写出一个每隔一段时间产出下一个日期的定时器import time def scheduler(): while True: wait_seconds yield print(f等待 {wait_seconds} 秒) time.sleep(wait_seconds) s scheduler() next(s) # 启动 s.send(2) # 让它睡 2 秒 s.send(5) # 再让它睡 5 秒实际上 asyncio 里的协程在 Python 3.5 之前就是靠生成器 send 实现的。后来 Python 3.5 专门引入了 async/await 语法但生成器协程的思想一直保留在语言里。如果你想深入理解 async def 的底层生成器和 send 就是绕不开的地基。throw 和 close 是另外两个方法。gen.throw(exc_type) 会在生成器暂停处抛一个异常如果生成器内没有处理它异常就传给外部调用者。gen.close() 则是让生成器退出之后对它的任何 next 或 send 都会抛 StopIteration。close 有一个重要用途最终确定资源。例如生成器打开了文件如果消费方中途 break文件句柄不会自动关闭这时可以用 try/finally 或 GeneratorExit 来清理。4.3 生成器与协程的分界线在哪很多初学者会把生成器和协程混为一谈这不奇怪因为它们的语法几乎一模一样。但从语义上讲生成器是产出序列强调数据的惰性生产协程是协作式多任务强调控制流的主动切换。生成器里的 yield 表达的是当前值协程里的 yield 表达的是让出控制权等对方发消息回来。Python 3.5 之后有了 async/await协程变成了语言的一等语法但生成器的进阶用法在编写特定类型的库时仍然没有过时。比如 requests 的流式响应处理、一些简易的状态机、后台任务调度器都可以用生成器协程来写。而且生成器协程不需要事件循环代码量小对于理解协作式调度非常有帮助。5. 生成器与列表推导式一个表格看清差别再回答何时用谁5.1 生成器表达式的括号陷阱90% 的初学者都会踩很多人第一次写生成器表达式是觉得它和列表推导式长得像顺手把方括号改成圆括号squares [x * x for x in range(10)] # 列表 squares_gen (x * x for x in range(10)) # 生成器第一行立刻生成一个包含 10 个数的列表第二行只是创建了一个生成器对象不会立即计算。这个区别很多人都知道但接下来这个坑就不一定人人踩过。当你在函数调用中写生成器表达式时其实只需要一组括号就够了total sum(x * x for x in range(10))这里的生成器表达式是不需要单独加一层括号的sum 函数的括号兼顾了语法。如果你写成 sum((x * x for x in range(10)))虽然也能跑但多了一层括号可读性反而变差。另一个坑是生成器表达式只能用一次你把它传给 sum 之后它已经枯竭再想复用就什么都拿不到了。5.2 性能实测列表和生成器在内存与速度上的真实差距纸上谈兵没有意义我直接用 timeit 和 memory_profiler 做过对比测试。场景是生成 1000 万个数的平方和import timeit # 列表推导式 def sum_with_list(): return sum([x * x for x in range(10_000_000)]) # 生成器表达式 def sum_with_gen(): return sum(x * x for x in range(10_000_000))运行结果大致如下在我本机的 Python 3.11 环境写法内存峰值运行耗时取多次平均列表推导式约 350MB约 0.58s生成器表达式约 5MB约 0.61s内存差距接近两个数量级速度差距反而很小。为什么会这样因为 1000 万个数并不是很大的计算量列表推导主要的时间也花在平方计算和求和上不全是分配内存的时间。但如果数据规模再放大 10 倍列表版本可能直接 OOM生成器版本仍然稳定运行。这是关键结论生成器省内存是确定的但速度并不一定更快。有时候因为逐次 yield 的开销生成器反而比列表更慢。我在一些优化严格的项目里同时试过两种写法纯计算场景下列表略快IO 场景下生成器优势明显因为逐行处理减少了 IO 等待的空隙。5.3 那些不适合用生成器的场景生成器不是万能的有些场景硬用反而是负优化。我说几个典型的第一需要随机访问的场景。生成器不支持索引你想取第 N 个元素只能从头迭代到第 N 个时间复杂度 O(N)。如果这类操作频繁还是老老实实转成列表。第二需要多次遍历同一份数据。生成器是一次性的想遍历两次就得重建生成器或者用 itertools.tee 把数据复制成多份但 tee 会用缓存兜底内存消耗并不低。第三数据量很小且计算逻辑十分简单。比如 range(5) 这种规模的序列生成器的 yield 开销反而让代码更绕直接用列表推导式更直白。任何优化都要先考虑是否真的到了瓶颈小数据上追求省内存其实是在给代码添乱。第四调试。生成器的惰性性质让调试变得更困难。你在一个生成器函数里打 print 看中间值得等它真正被消费时才打印而且一旦消费完再打印也打不出来。如果你在开发阶段频繁调试复杂算法先转成列表可能省心得多。一个实用的思路是业务层函数返回生成器但在测试和调试阶段写一个桥接函数把生成器转成列表保持两套走法并行。这样既享受生成器的惰性优势又不牺牲调试体验。6. 生产环境里我被生成器坑过的三个地方以及对应的诊断方法6.1 一次性生成的陷阱同一份数据被消费两次就没了我最早用生成器处理日志时犯过一个特别蠢的错误。我写了一个 parse_gen 生成器给它传给了两个统计函数一个统计请求级别分布一个统计 URL 访问量。我以为数据会像列表一样被两个函数各读取一遍结果第二个统计函数返回的全是 0。原因就是生成器被第一个函数消费完了第二个函数拿到的是一个已经枯竭的迭代器。这个问题解决起来很简单要么各自重新创建生成器要么把解析结果一次性物化成一个列表。我当时数据量并不大直接把 parse_gen 转成列表就解决了。后来我养成了一个习惯搞清楚自己的数据规模再决定用生成器还是列表不做一刀切。数据大才需要用生成器数据小就大胆用列表省心。另外如果你确实需要把生成器复制成多路消费可以用 itertools.tee。但 tee 的原理是用队列缓存已经产出的数据如果两路消费速度差距很大内存照样会涨。6.2 return 和 StopIteration 的微妙关系生成器里写 return 到底返回什么普通函数里写 return value调用方拿到这个值。生成器里写 return这个值去哪了答案是它被塞进了 StopIteration 异常的 value 属性里。看代码def gen_with_return(): yield 1 return finish g gen_with_return() print(next(g)) # 输出 1 try: next(g) except StopIteration as exc: print(exc.value) # 输出 finish这个行为在 for 循环里是完全看不见的因为 for 循环遇到 StopIteration 就直接结束了不会去读异常里的 value。只有当你手写 next() 驱动生成器时才能拿到 return 的值。而 yield from 会把子生成器 return 的值作为整个表达式的值传递出来这个我在 4.1 里提到过。这个特性在业务代码里用得不算多但如果你想写一个返回值的递归生成器这反而是最优雅的路径。比如一个递归搜索函数当找不到结果时 return None找到时 return 结果配合 yield from 就能把搜到与否的信号传出去。6.3 调试生成器的实用技巧next 打点、tee 观察和物化对比生成器调试确实比普通函数麻烦因为函数体是惰性执行的断点打进去也不一定按你期望的顺序触发。我自己常用的手段有这么几个第一个是手动 next 打点。写一小段驱动代码逐步调用 next()每调一次打印一次生成器当前产出的值观察它符不符合预期。这个方法适合生成器内部逻辑不复杂的场景能精确看到每一步的状态。第二个是用 itertools.tee 复制观察流。tee 可以让一个生成器变成两个独立的迭代器一个用于正式业务一个用于打印调试信息。注意 tee 有缓存机制调试结束后要确保观察流也走完否则内存可能被缓存撑起来。所以它更适合数据量可控的场景。第三个是物化对比。直接把生成器转成列表然后对比它和类似逻辑写出的列表推导式的输出。这个办法最笨但最可靠。当我怀疑生成器逻辑有 bug 时就分别用两种方式实现同一功能输出结果一致再二分定位问题。虽然不是高级技巧但在生产环境排查时效率很高。调试过程中还有一个很容易被忽略的点生成器内部如果在 yield 之间抛了异常生成器会立即关闭。这意味着你无法在一个已经抛过异常的生成器上继续 next() 恢复执行。如果你在 try/except 里捕获了异常然后尝试继续消费同一个生成器会发现它已经不再产出任何值。这个行为和普通循环体里捕获异常后继续循环是完全不同的我最初没意识到排查了很久才发现生成器已经死了。另外一个与内存相关的坑是生成器对象如果没有被完全消费完它持有的那份栈帧和局部变量就不会被释放。如果生成器内部引用了大文件对象或者大数组你只消费了一半就丢弃了生成器这些资源不会被及时回收。所以在大资源场景下要么确保消费完要么显式调用 gen.close()。close() 会触发 GeneratorExit 异常让生成器内部的 finally 块有机会清理资源。7. 最后补充两个小经验我在日常编码里养成了一个不算标准但很有用的习惯模块的数据供应层尽量返回生成器而把消费决策留给调用方。也就是说写一个 fetch_events() 时直接返回一个生成器调用方如果知道数据量小则可以 list(fetch_events())如果数据量大就保持生成器一路传递到最终聚合点。这样数据多大这个问题由最了解消费场景的人决定而不是在源头就焊死。第二个经验跟性能调优有关。当你发现生成器版本代码比列表版本慢很多时不要急着否定生成器。先检查是不是在生成器内部做了太多重复工作比如每一轮都重新 import、重复构造正则、或者反复打开同一个文件。把这些重活挪到生成器外部只做一次生成器的性能短板通常会大幅缩短。把 yield 本身的开销归咎于生成器性能差很多时候其实冤枉了它。从处理 8GB 日志到协程雏形生成器和 yield 在 Python 里是一个小到可以三行讲完、大到能撑起事件循环基础的功能。把你的数据延迟到需要的时候再算这个思维不只是省内存这么简单它能让你在设计接口时更自然地思考数据流而不是数据集合。希望这篇内容能帮你把生成器真正用顺手。