首页 / 资讯中心 / 文章详情

深入理解Python生成器:yield与惰性求值解决大文件内存实战

深入理解Python生成器:yield与惰性求值解决大文件内存实战 ★ FEATURED ARTICLE
先交代一个让我对生成器Generator和 yield 关键字彻底改观的背景。前几年我处理线上服务的访问日志文件大概 2.3GB图省事写的是lines open(access.log).readlines()然后逐行解析。代码逻辑非常简单但进程跑起来不到半分钟服务器内存红光告警最后直接 OOM 被内核杀了。我第一反应是这机器配置不行冷静下来才发现真正的问题是 readlines() 会把 2.3GB 内容一次性塞进内存加上解析过程产生的中间对象内存直接失控。换成逐行读取之后同样的逻辑哪怕换到 20GB 的日志也能稳稳跑完。从那天起我养成了一个习惯凡是处理大文件、无限序列、流式数据的场景优先考虑生成器和 yield。这篇博客把我这些年对惰性求值的理解和实战经验完整梳理了一遍适合两类人一类是刚接触 Python、想搞清楚生成器和普通列表到底差在哪的新手另一类是写过不少 Python 但停留在能用就行层面、想真正理解 yield 底层原理和协程演进的中级开发者。1. 从内存爆掉的深夜说起生成器是如何把 2.3GB 变成几十 MB 的1.1 事故现场还原先看那段让我在凌晨三点被报警电话吵醒的代码问题出得极其典型with open(access.log, r, encodingutf-8) as f: lines f.readlines() # 2.3GB 全部进内存 for line in lines: parse_line(line) # 解析、提取字段、写结果问题的根源不在解析逻辑而在readlines()这个动作本身。我等于先把整份数据完整复制了一份放在内存里然后才开始处理。更隐蔽的是parse_line过程中产生的中间字符串、切片、临时对象还会继续叠加内存压力GC 根本来不及回收。而正确的姿势其实 Python 早就给好了文件对象本身就是一个惰性迭代器直接 for 循环即可。with open(access.log, r, encodingutf-8) as f: for line in f: # 每次只从磁盘读一行 parse_line(line)同样一份日志后者在任意时刻内存里只有当前这一行加上解析产生的临时对象内存占用从 GB 级降到几十 MB 级。这不是什么黑魔法靠的就是惰性求值Lazy Evaluation数据不是提前全部算好放在那里等你取而是你取一次它算一次。我当时还试过另一个方案用f.read().split(\n)把文件按行切成长列表。结果也一样因为 split 同样要先把整个文件读进内存。所以关键不在于按行处理这个表象而在于你有没有把数据和处理数据的动作解耦。1.2 列表推导式与生成器表达式的本质差异等到对文件场景熟悉之后你会发现这种惰性无处不在它不限于 IO纯计算场景同样适用。列表推导式大家都写过squares [x * x for x in range(10_000_000)] # 立即生成 1000 万个元素这一行跑完内存里多了一个包含一千万个整数的列表每个整数又是独立对象占用轻松超过 300MB。而如果把中括号换成圆括号squares_gen (x * x for x in range(10_000_000))这一行跑完内存几乎没有任何变化因为它只是创建了一个生成器对象一个数都还没算。只有当你对它进行迭代比如for s in squares_gen它才会一个接一个地吐出平方数。我经常用图书馆这个类比给团队新人解释列表方案等于把图书馆整座楼的书全部搬回家再看生成器方案等于办了一张借书证一次借一本看完再换下一本。同样读完 1000 本书前者需要一个巨型仓库后者只需要一个书包。对这个差异的理解直接决定了你后面能不能正确使用生成器。别急着背 API先把什么时候算、算多少、存多少这个问题想清楚。2. 惰性求值的底层逻辑生成器到底懒在哪2.1 迭代协议for 循环背后的两个魔法方法要说清楚生成器先要说清楚 Python 里 for 循环的本质。很多人以为for x in obj是在遍历一个容器其实它做的是三件事调用iter(obj)拿到一个迭代器循环调用迭代器的__next__()捕获StopIteration异常后结束循环。所以可以被 for 循环的东西其实分成两类可迭代对象Iterable它实现了__iter__()负责返回迭代器迭代器Iterator它实现了__next__()负责每次吐出一个值。很多新手在这两个概念上栽跟头我直接把最小的迭代器实现写出来你就懂了class Counter: def __init__(self, n): self.n n self.i 0 def __iter__(self): # 返回迭代器自身 return self def __next__(self): # 每次被 for 调用一次 if self.i self.n: raise StopIteration # 没值了就抛这个异常 value self.i self.i 1 return value生成器函数本质上就是 Python 替你写好了这个类的语法糖。你不需要手动维护self.i、不需要手动抛StopIteration只要在函数里写上yieldPython 就自动帮你实现了__iter__和__next__。这里还有个容易混淆的点列表、元组、字典这些容器是可迭代对象但它们本身不是迭代器。你去iter([1, 2, 3])会得到一个新的列表迭代器而不是列表自身。真正既是可迭代对象又是迭代器的除了我们自己写的 Counter就是生成器对象。这个区别在你判断一段代码能不能复用同一份数据时特别重要。2.2 函数体根本没有执行yield 让栈帧挂起这是理解生成器最关键的一点。普通函数被调用时函数体会立即从头执行到最后return 时整个调用栈销毁。而包含 yield 的函数被调用时你得到的不是返回值而是一个生成器对象函数体一行都不会执行。def countdown(n): print(fcountdown 开始, 初始值 {n}) while n 0: yield n n - 1 print(countdown 结束) gen countdown(3) # 什么都不会打印 next(gen) # 打印 countdown 开始, 初始值 3, 返回 3 next(gen) # 返回 2不打印第一次调用next(gen)时Python 会从函数体第一行开始执行一直执行到yield n然后把n的值返回给调用方同时把当前函数的所有局部变量、执行位置、指令指针完整地冻结在当前这个栈帧里。下次再next(gen)不是从头跑而是从上次yield的那一行继续往下走。这种代码执行到一半被暂停下次继续的机制才是惰性求值的真正来源。你可以把 yield 理解成函数里的暂停键 出口把 next() 理解成继续键。我当初自己验证的时候会刻意在 yield 前后各放一行 print观察输出顺序。建议你也这么做——只有亲眼看见先打印、再返回、下次接着打印的诡异顺序你才会摆脱yield 就是 return的错误印象。2.3 为什么内存占用能低这么多搞清楚了挂起机制内存问题就很好解释了。列表存的是全部结果的副本而生成器存的是产生下一个结果所需的最小状态。拿斐波那契举例列表方案要存下从 F(0) 到 F(n) 的全部数字生成器方案只需要存 a、b 两个整数以及当前执行到哪一行。前者内存是 O(n)后者是 O(1)。所以生成器解决的本质问题是把存储全部结果的负担转换成一点点计算的开销。用空间换时间是一般优化思路生成器反其道而行——用时间一点点算换空间不存全部在某些场景下这笔买卖非常划算。这里补充一个细节生成器对象虽然小但它也有开销每一个生成器对象都包含一个独立的栈帧对象。你在一个函数里同时创建几十万个生成器又不迭代内存照样会涨。惰性不是零内存而是按需内存。3. yield 的真正威力不只是暂停而是双向通道3.1 yield 表达式能接收外部传来的值很多初学者以为 yield 只是return 了一下直到他们看到生成器还能接收数据时才会被震撼到。实际上yield是一个表达式它本身是有值的。当外部通过send(value)把值传进生成器时这个值就会成为上一次yield表达式的求值结果。def echo_box(): while True: received yield print(f收到外部消息: {received}) gen echo_box() next(gen) # 启动生成器执行到 yield暂停 gen.send(你好) # 打印 收到外部消息: 你好 gen.send(42) # 打印 收到外部消息: 42注意第一次启动时不能直接 send 非 None 的值因为生成器还没有执行到 yield没有任何上一次 yield可以接收。惯例是先next(gen)或gen.send(None)完成启动。3.2 send、throw、close 是协程的三板斧在 PEP 342 之后生成器补全了三个方法这也让生成器具备了完整的协程能力send(value)把值送进去同时让生成器继续执行到下一个 yieldthrow(exc_type, ...)在生成器暂停的位置抛一个异常进去主要用于让生成器内部的 try/except 处理外部传入的错误close()在生成器暂停的位置抛GeneratorExit让生成器进行清理后退出。这三个方法配合起来生成器就不再是单向的数据生产线而是一段可以和外部互相通信、可以被外部打断的独立执行流。这也是后来async/await协程的重要前身。我印象最深的一点是close()的语义它不是为了销毁对象而存在而是为了给生成器一个打扫战场的机会。你可以在生成器里写 finally 块当外部调用 close() 时finally 里的资源释放逻辑会正常执行。这在管理文件、连接等资源时非常有用。3.3 yield from把子生成器的交接工作交给解释器Python 3.3 引入的 PEP 380 又往前走了一大步。以前你想在一个生成器里委托给另一个生成器只能手写循环def outer_before(): for x in inner(): yield x有了yield from之后直接写def inner(): yield 1 yield 2 def outer(): yield 0 yield from inner() # 把内层生成器的每个值逐一遍历转交 yield 3 print(list(outer())) # [0, 1, 2, 3]yield from不只是语法糖它还会把 send、throw、close 这些操作自动转发给子生成器让内外两层生成器之间的双向通信变得透明。这在实现状态机、嵌套数据遍历、责任链式处理流程时特别有用。我写数据清洗脚本时经常把读文件做清洗做格式化分别实现成独立的生成器函数然后用 yield from 把它们串起来每个函数职责单一debug 起来极其舒服。另外一个容易被忽略的细节是yield from的返回值。子生成器 return 的值会成为整个yield from表达式的值这在实现委托子生成器并获取最终结果的模式时能省掉很多手写通信代码。3.4 一个可暂停的累加器实例为了把双向通道讲透我上一个工作中真写过的例子——一个可以随时查询当前总和的累加器def running_total(): total 0 while True: value yield total # 第一次启动时返回 0 if value is not None: total value acc running_total() next(acc) # 启动得到 0 print(acc.send(10)) # 返回 10 print(acc.send(5)) # 返回 15 print(acc.send(3)) # 返回 18你看同一个函数既产出当前总和又接收新加的数这在纯列表推导式的思维里是做不到的。这就是为什么我强调yield 是一个通道不是 return 的变体。4. 实战用生成器搭建数据处理流水线4.1 生产-转换-消费的管道思维如果只能从这篇文章里带走一个实战模式我建议是生成器流水线把数据处理拆成三段——生产原始数据、逐条转换、批量消费每一段都写成生成器数据像水流一样经过管道。我用一个最典型的例子从多个日志文件里提取所有包含 ERROR 的行的 IP 字段。如果写成单层 for 循环会越写越嵌套用生成器拆开后每一层都清晰独立def read_lines(file_paths): for path in file_paths: with open(path, r, encodingutf-8) as f: yield from f # 逐行转交 def filter_error(lines): for line in lines: if ERROR in line: yield line def extract_ip(lines): for line in lines: # 假设日志格式为: 2024-06-01 10:00:00 ERROR 192.168.1.10 timeout parts line.split() if len(parts) 4: yield parts[3] def consume(ips): from collections import Counter counter Counter() for ip in ips: counter[ip] 1 return counter.most_common(10) # 组装管道 pipeline extract_ip(filter_error(read_lines([a.log, b.log]))) print(consume(pipeline))这个模式好在哪第一任何一段都可以单独测试比如你可以直接把filter_error(read_lines([...]))转成 list 来做单元测试第二内存占用恒定管道最长的那一段只保存一个元素第三想在中间加采样去重计数等环节只需在管道里插入一个新的生成器函数完全不动其他代码。我后来处理几 TB 级的数据集基本都是这个套路。这种写法的精髓是延迟组装extract_ip(filter_error(read_lines(...)))这一行执行时所有函数体都还没跑直到consume开始 for 循环数据才一级一级地流动起来。理解这一点你才算是真正从逐步执行的思维切换到了流水线的思维。4.2 无限序列生成器最舒服的主场有些序列在物理上根本不可能用列表表达比如斐波那契数列、素数的无穷序列、周期信号。生成器因为边算边吐天生适合这种场景def fibonacci(): a, b 0, 1 while True: yield a a, b b, a b fib fibonacci() for _ in range(10): print(next(fib), end ) # 0 1 1 2 3 5 8 13 21 34配合 itertools.islice你可以取任意前 N 项而不用关心这个序列到底有没有结尾from itertools import islice first_50 list(islice(fibonacci(), 50))这里有个值得注意的细节islice返回的也是迭代器如果你写islice(fibonacci(), 50)而不转成 list它同样不会一次性算出 50 个。惰性是会传染的——这也是为什么 Python 标准库里大量工具都在跟迭代器打交道。需要提醒的是无限序列生成器最怕的就是被扔进一个没有边界的 for 循环里。我见过有人把for x in fibonacci():写成了死循环进程 CPU 飙到 100% 还停不下来。处理无限序列时永远记得用 islice、takewhile 这类工具给它加一个闸门。4.3 与 itertools 搭配的几个高频姿势itertools 是生成器的官方健身房我日常用到最多的组合itertools.chain(*iterables)把多个迭代器首尾相连相当于把多个 for 拼接成一个itertools.takewhile(pred, seq)从头开始拿直到条件不满足为止适合处理有序无限序列itertools.groupby(iterable, keyNone)对已排序的数据按 key 分组但它是惰性分组每组返回的也是迭代器别急着存列表itertools.product、permutations做组合枚举时配合生成器使用避免一次性生成全部组合导致内存爆炸。随手写一个例子从斐波那契数列里取出所有小于 1000 的项。from itertools import takewhile small_fibs list(takewhile(lambda x: x 1000, fibonacci()))注意 takewhile 的顺序它先判断再取遇到第一个不满足的立即停止非常适合找一个有界的无限序列前缀。如果你用 filter 就很难停得干净因为 filter 会傻傻地遍历完整个序列——而对无限序列来说遍历完是不可能的。还有groupby这个函数它本身是惰性的所以当你对一个大文件按某个字段分组时内存不会因为分组而暴涨。但你要记住它只对连续相同的元素分组必须先排序。这个坑我踩过一次后来养成习惯用 groupby 之前先确认数据是否有序或者干脆 line 按 key 排好序再进管道。4.4 生成器表达式与内置函数的默契生成器表达式除了省内存还有一个隐藏优势很多内置函数能直接消费它每次消费一个不会产生中间大列表。最典型的total sum(x * x for x in range(1_000_000))假设写成列表推导式再 sum内存里会先出现一个一百万元素的列表再累加写成生成器表达式sum 边取边加全程只有累加器这个变量。类似地下、max、min、any、all 都能这样搭配。我见过不少代码写sum([x for x in data])把小括号换成圆括号的生成器表达式内存立刻下来代码一字未改。不过有个小坑如果你要对同一个生成器表达式做两遍以上遍历比如先求 sum 再求 max那就必须转成列表否则第二遍遍历会拿到空结果。这个坑在下面第 6 章展开说。5. 从生成器到协程yield 的另一半江湖5.1 生成器协程异步编程的史前时代聊到协程很多人第一时间想的是 async/await但真正追根溯源Python 的异步协程就是从生成器长出来的。PEP 342 给生成器加了 send/throw/close让生成器能双向通信PEP 380 加入 yield from让生成器能透明嵌套正是在这两个能力的基础上Python 3.4 推出了asyncio而 async/await 语法PEP 492本质上是基于生成器协程的语法封装。在 Python 3.5 之前写异步代码就是直接写生成器协程。你如果翻旧代码会看到类似这样的写法asyncio.coroutine def fetch_page(url): data yield from http_get(url) return data这里的yield from就是在暂停当前协程等 IO 完成后再回来。理解了 yield 的挂起机制你就理解了 async/await 最核心的执行模型——事件循环不停调度那些被挂起的协程谁的数据准备好了谁被唤醒。5.2 什么时候你仍然需要手写生成器协程虽然现在正式写异步代码应该用 async/await但生成器即协程的思想并没有过时。有两个场景我至今还在手写一是在单线程里实现简单的协作式多任务比如游戏里多个角色的动作循环不想引入线程锁就可以用生成器各自 yield由主循环统一调度二是做测试替身用一个生成器去模拟一个需要交互的协议对象能精确控制每次交互的返回值非常灵活。def player_action(): while True: command yield if command attack: yield slash elif command defend: yield block这种代码放到事件循环里跑每个玩家都是一个独立的状态机靠 send 指令驱动靠 yield 让出控制权多角色切换毫无阻塞。5.3 生成器与 async/await 的选择建议我自己给团队定的简单规则是数据生产用生成器IO 等待用 async/await两者不要混着写。如果你的目标是产出一个序列不管这个序列有没有尽头都应该用 yield如果你的目标是多个网络/磁盘操作并发跑应该用 async/await。用 async def 写一个纯计算型迭代器会白白背上事件循环的开销用 yield 去写阻塞 IO也会把整个事件循环卡死。分清这两个场景你就能避开 90% 的协程误用。有个历史知识可以补充一下Python 3.7 里asyncio.coroutine装饰器被移出标准库正式过渡到 async/await但生成器协程的思想仍然大量存在于 asyncio 的实现中读 asyncio 源码时你会频繁看到 generator 的身影。6. 我踩过的生成器坑与性能真相6.1 生成器是一次性的单程票生成器没有回头路。迭代完之后它就耗尽了再迭代结果是空。gen (x * 2 for x in range(5)) print(list(gen)) # [0, 2, 4, 6, 8] print(list(gen)) # []这个问题在把同一个生成器传给两段处理逻辑时尤其致命。我之前写数据校验脚本先sum(gen)判断总量再for item in gen做明细检查第二段永远跑不到。后来排查了半天才意识到不是逻辑错了是生成器已经空了。解决办法很简单要么在需要多次消费时转成 list/tuple要么重新创建生成器。这是新手最容易踩、也最隐蔽的坑。6.2 生成器包装着文件句柄时记得及时 close如果生成器在内部持有文件对象而你又只消费了前几行就停止这个文件句柄不会自动关闭直到生成器对象被 GC 回收。在循环里反复创建这种半截生成器文件描述符可能被耗尽。def read_first_lines(path, n): with open(path) as f: for i, line in enumerate(f): if i n: break yield line别小看这个 break它退出 with 块时文件会正常关闭逻辑没问题。但如果你把文件打开放在生成器外面又在中途停止消费那文件句柄会一直占着。稳妥做法是要么用 with 把文件生命周期包在生成器内部要么在不需要继续消费时显式调用gen.close()触发 GeneratorExit 让其中的清理逻辑执行。6.3 yield from 递归时容易写出栈溢出yield from 是可以递归委托的但很多人忘了它依然是函数调用链层数太深会栈溢出。我曾经写一个嵌套 JSON 的扁平化生成器结构嵌套一千层直接 RecursionError。遇到这种情况要么把递归改成显式栈的迭代式写法要么用collections.deque手动维护待遍历节点。def flatten(obj): stack [obj] while stack: item stack.pop() if isinstance(item, list): stack.extend(reversed(item)) else: yield item把递归换成显式栈之后深度再大也只是堆内存的问题不会栈溢出。这类看起来是惰性、其实还是会爆的场景值得心里有数。6.4 性能真相生成器不一定更快快在内存很多人以为用了生成器程序就会跑得更快这是个误会。生成器每个元素都要经过暂停、恢复、检查 StopIteration这几步逐元素开销比纯列表迭代要高。我做过一个简单基准对一个 1000 万元的 range 做平方求和列表推导式大约 0.35 秒生成器表达式大约 0.5 秒——生成器慢了三到五成。但如果把数据量放大到十几亿或者数据来自文件、网络列表方案直接 OOM那讨论速度就没有意义了。我一般这样权衡场景推荐方案原因数据量小几百上千列表推导式代码直观、速度快、可以反复遍历数据量大但内存够列表推导式 分批处理平衡速度和可读性数据量大到内存吃紧生成器/生成器表达式内存恒定宁可慢一点无限序列、流式数据、IO 逐行处理生成器没有其他方案可选这张表不是绝对的但它能帮你在省内存和赶速度之间做决定。我的个人经验是先写生成器让程序能跑起来再 profile 出热点如果瓶颈确实是逐元素处理开销再把最内层的一段换成列表或 numpy 向量化这种混合打法在数据管道里非常实用。6.5 什么时候不要用生成器最后说几个我建议干脆别用生成器的场景避免为了高级而高级需要随机访问gen[5]是做不到的如果代码里频繁按下标取值list/tuple 才是正道需要多次完整遍历除非你愿意反复重建生成器否则一次性的特性会让你措手不及数据量很小且逻辑复杂一个生成器函数可能比一个简单 for 循环难读得多小数据场景收益为零甚至为负调试时想看到完整数据PyCharm 的 debugger 对生成器的支持已经不错但直接打印一个 list 比逐步 next 快得多。工具永远是服务于可读性和可维护性的。生成器很强但它不是银弹该用列表的时候别犹豫。最后再分享一个我个人的习惯每次写完一个生成器函数我都会问自己三个问题——它会不会被多次遍历它持有的资源能及时释放吗如果中途 break会不会留下悬挂状态这三个问题陪我排掉了不少线上隐患。另外如果你刚开始接触 yield建议你在本地把 countdown 那个例子用 print 一行一行跑一遍亲眼看看暂停和恢复到底发生在哪一行比看十篇文章都有用。惰性求值不是 Python 特有的概念但 Python 把这种思想做进了语言底层让它成为每个普通开发者都能随手使用的日常工具——这本身就是一种美。
阅读完成 · 觉得有帮助?
咨询建站