Python生成器与yield:从惰性求值到数据管道的内存优化指南
1. 先搞清楚yield到底干了什么1.1 一个把内存吃光的例子写Python写久了你会发现一个很有意思的规律凡是能用列表拿走的东西迟早会在内存上还回来。我最早接触yield就是因为一段处理日志的脚本把服务器内存直接打满了。当时的需求很简单统计一个2GB的访问日志文件里每个接口被调用了多少次。第一版代码特别“直觉”lines open(access.log, encodingutf-8).readlines()就这一行服务器内存蹭蹭往上涨然后进程被系统杀掉。原因不难理解readlines()会把整个文件的所有行塞进一个列表2GB的日志文件变成内存里的列表之后内存占用远远超过2GB。后来改成用for line in open(...)循环逐行读问题立刻消失了。文件对象本身就是一个迭代器它会按行惰性地吐数据。但如果你需要在这条数据流上做更复杂的处理比如过滤、转换、汇总、多段组合yield就派上用场了。1.2 yield其实是在“存档”很多人第一次看生成器代码会觉得别扭因为函数执行顺序看起来“断断续续”。拿一个最简单的例子来说def count_up_to(n): i 0 while i n: yield i i 1调用count_up_to(3)的时候函数体其实一行都没执行它只是返回了一个生成器对象。直到你调用next()函数才开始跑。第一次next(gen)函数执行到yield i把0交出来然后整个函数停在那一行状态全部被保存——包括i等于几、下一步该执行哪条指令。第二次next(gen)函数从刚才停下的位置继续跑完i 1回到循环头部又一次执行到yield i把1交出来。这个过程可以一直重复下去。可以把它理解为游戏存档打到某个关卡保存当前进度交一关给外部下次接着打的时候从存档点继续而不是从头再来。普通函数用return是“打完收工进程结束”生成器用yield是“暂停一下我先把当前结果给你你用完叫我我再接着算”。这两种语义的差别是所有生成器应用的基础。用next()手动驱动比较啰嗦所以日常更常见的做法是直接for i in count_up_to(3)。for循环内部会自动不断调用next()直到捕获到StopIteration异常才知道“数据吐完了到此为止”。1.3 惰性求值不到最后不真正算真正理解生成器绕不开“惰性求值”这四个字。什么概念就是表达式不会在创建的时候立刻计算而是等到你真正需要结果的那一刻才去算。生活化一点说自助餐厅不会一次性把一年后的菜都做好囤着而是你拿一个盘子它炒一个菜给你。对比一下squares_list [x * x for x in range(1000000)] # 立刻算出100万个平方数放入内存 squares_gen (x * x for x in range(1000000)) # 先记着怎么算一个数都还没算第一行会先把100万个整数全部算出来存进列表。第二行只是创建了一个生成器表达式它保存了“算法公式”但还没有执行。真正触发计算的是后面遍历它的时候。一次只算一个算完丢给你自己不留任何历史包袱。这种“算得慢、用得快”的模式在处理大文件、无限序列、数据流、管道式处理时几乎是不可替代的。你不需要为了一个暂时用不上的中间结果提前付出时间和内存成本。2. 惰性求值的三个实战场景2.1 大文件逐行处理10GB日志不用读进内存回到日志处理的场景。改用生成器之后代码变成了这样def read_log_lines(path): with open(path, r, encodingutf-8) as f: for line in f: yield line def parse_access_log(rows): for row in rows: parts row.split( ) yield { ip: parts[0], path: parts[6], status: parts[8], }外层可以这样组合for entry in parse_access_log(read_log_lines(access.log)): print(entry[path], entry[status])注意整个链条里任何时候内存里只存在一行日志。read_log_lines从文件里吐出一行parse_access_log接住这一行解析成一个字典交给外层打印然后继续下一行。就算文件有10GB内存占用也基本恒定在几MB的量级不会随着文件变大而暴涨。这也是生成器特别适合做ETL的原因之一。数据从源头到出口是一条流动的管道而不是一堆拷来拷去的快照。如果你用列表理解来做同样的事中间会生成一个10GB的解析结果列表那还不如暴力readlines()。2.2 无限序列与实时数据流生成器天生适合“永不停歇”普通列表没法表达“无限”的概念因为它的长度必须是有限的。但生成器可以——它只在每次next()的时候算出一个值只要你不主动停它就能一直吐。经典的斐波那契例子def fibonacci(): a, b 0, 1 while True: yield a a, b b, a b注意里面是一个while True无限循环。如果这段逻辑写在一个普通函数里函数会永远跑不完直接死循环。但放在生成器里它每执行到yield a就暂停把当前斐波那契数交出来等下一次召唤。你可以用islice切出前20个也可以用于实时行情、传感器数据、股价变化流等无限推送的场景。有些同学会在量化策略里碰到类似需求实时行情是一条永不停止的数据流策略需要每来一个新价格就算一次指标。如果用列表保存所有历史价格内存迟早撑不住而且每次算指标都要把全量数据重新扫一遍。换成生成器思路你可以维护一个滑动窗口数据一边进来一边算窗口外的东西直接被丢弃。2.3 数据处理流水线让每一条数据“游”过全程生成器最常见的组合玩法是把多个生成器串成一条流水线。每个生成器只做一件事数据经过它的时候被过滤、被变形然后继续往下游走。比如我们要从一堆日志里找出所有404请求对应的IPdef filter_status(rows, target): for row in rows: if row[status] target: yield row def extract_ip(rows): for row in rows: yield row[ip]串起来写ips extract_ip(filter_status(parse_access_log(read_log_lines(access.log)), 404))数据在管道里是一行一行走的第一行日志从文件里读出解析成字典被过滤提取IP返回给调用方处理完第一行再处理第二行。这非常像Unix的管道命令cat xxx | grep 404 | awk {print $1}前一个命令的输出直接喂给后一个命令没有任何中间整表落盘。如果一切正常这段代码可以跑很长时间都不卡死。但有一类隐藏问题如果管道中某个环节偷懒从生成器里只取了一部分数据就半途停止后续处理上游数据没人继续追问也就不会继续生成。这时候如果上游是文件句柄文件就不会正常读到结尾。我在第五节会专门讲这个问题操作不当会导致文件没关、资源泄漏。3. 深入生成器协议send、close、yield from3.1 生成器不只是“一只单向的管子”很多人以为生成器就是“只能往外吐值”其实它的协议远不止单向。Python的生成器对象自带四个方法next()、send()、throw()、close()。其中send()是逆向通道允许调用方向生成器内部传数据。生成器可以在yield的位置接收这个数据并根据它改变后续行为。看一个例子这个生成器会累加每次传入的值def accumulator(): total 0 while True: value yield total total value gen accumulator() print(next(gen)) # 启动生成器执行到第一个yield返回0 print(gen.send(10)) # 把10传给yieldtotal变成10下一次循环yield返回10 print(gen.send(5)) # 把5传给yieldtotal变成15下一次循环yield返回15第一次调用不能用send()必须先用next()让生成器运行到第一个yield挂起。如果你新手直接上gen.send(10)Python会报TypeError: cant send non-None value to a just-started generator。send()最常见的用途是协程雏形。在async/await成熟之前Python有一段时期就是靠生成器和send()实现协作式多任务的。现在写异步代码虽然几乎不用手写生成器了但很多第三方库内部依然依赖这套协议。理解它对阅读源码和排查“为什么我这个生成器没法接收外部输入”这类问题很有帮助。3.2 用throw和close做资源清理生成器挂起的时候你可以从外部向它抛一个异常进去gen.throw(ValueError(数据异常))如果生成器内部没有捕获这个异常它会在yield挂起的位置把这个异常抛出来然后生成器直接停止。这用于中断正在进行的生成流程比较有效比如管道中游发现上游数据格式不对不想再继续处理了可以主动throw进去让生成器退出。close()则是温和一点的关闭方式它在生成器内部相当于在yield挂起点抛出一个GeneratorExit异常。如果生成器里有finally块此时会执行到。这个特性可以用来保证资源释放def worker(): try: while True: yield finally: print(生成器被关闭释放资源) gen worker() next(gen) gen.close() # 输出生成器被关闭释放资源很多线上问题都出在“生成器被部分消费后没人再继续迭代它”导致finally迟迟不执行、文件句柄一直开着。合理调用close()或者在with块里包裹生成器生命周期能让资源管理干净很多。3.3 yield from优雅地委托给子生成器当你在一个生成器里希望“把另一个生成器或可迭代对象的所有值都吐出去”时朴素写法是def chain(*iters): for it in iters: for v in it: yield vyield from能把这个写法压缩得更直观def chain(*iters): for it in iters: yield from it它做的事情不止是“遍历子生成器”它还会把子生成器内部的yield直接对接给外层调用方。双向通道都能走通。甚至当子生成器用return返回一个值时这个值会被放进StopIteration异常里外层可以用value yield from sub_gen捕获。yield from让生成器有了“组合子生成器”的能力。你可以把一个大任务拆成多个子生成器再用一个父生成器把它们串起来代码可读性提升一个台阶。管道式的写法在第三章就展示过了实际项目里把chain和groupby配合使用能处理不少棘手的嵌套数据。4. 生成器表达式与高频踩坑4.1 一行代码的取舍生成器表达式 vs 列表推导式生成器表达式长这样(x * 2 for x in range(100))和列表推导式只差一个中括号vs圆括号。这一个小括号的差异背后是完全不同的内存策略。我做过一个粗测[x * 2 for x in range(1000000)]内存占用大约80MB左右不同系统和Python版本有浮动而等价生成器表达式(x * 2 for x in range(1000000))内存占用只有一两百字节。空间省了几个数量级代价是你要for循环去逐个取值无法像列表那样按下标随机访问或反复遍历。所以取舍标准很简单如果只需要一次性遍历完用生成器表达式如果需要多次使用、随机访问、求长度、切片这类“名单式”操作老老实实用列表。生成器里没有len()的概念因为它根本没把全部数据存起来也回答不了“你一共有多长”这个问题。4.2 坑一生成器只能遍历一次这是我见过最多人踩的坑也是最容易踩的。有人写出了这样的代码gen (x * x for x in range(5)) print(list(gen)) # [0, 1, 4, 9, 16] print(list(gen)) # []第二次变成空列表了。因为生成器是“一次性消耗品”它已经从头到尾吐完了内部指针走到末尾再次遍历什么都不会给。这不是bug这就是惰性求值的自然结果它不保存历史值吐一个丢一个。要绕过这个问题要么用列表把结果固定住要么重新创建生成器。很多业务代码里因为没注意这一点第二次遍历时得到空白数据查半天才发现生成器早已“燃烧殆尽”。调试方法也简单看到某个变量明明是生成器但第二次用变成空先怀疑是不是已经被遍历过。4.3 坑二外部变量在迭代时才算数生成器表达式和嵌套函数一样有个“晚绑定”特性。它捕获的变量不是在创建那一刻取当前值而是在真正迭代那一刻才去取值。nums [1, 2, 3] multiplier 2 gen (n * multiplier for n in nums) multiplier 10 print(list(gen)) # [10, 20, 30]不是 [2, 4, 6]看到没multiplier在迭代前被改成10了生成器就按10来算。这对“配置后生效”这类场景是友好的但对不熟悉的人就是个隐形炸弹。如果你希望它在创建那一刻就固化某个常量值可以把它作为默认参数传进去gen (n * multiplier for n in nums, multiplier) # 实际上要这样写 gen (n * multiplier for n in nums) # 无法直接固化更可靠的做法是把逻辑包进函数其实想固化最干净的方式是定义一个接收参数的函数再返回生成器这样参数变成局部变量不再受外部后续修改影响。这点在写实时数据处理代码时尤其重要外部变量被多次赋值生成器却只认迭代那一刻的值容易产生“我明明改了结果没变”的错觉。4.4 坑三盲目追求生成器的“快”聊性能前得先声明一点生成器的优势主要是省内存不是跑得快。每轮迭代生成器都要经历一次“状态保存→恢复执行→再挂起”的协议开销这比直接遍历列表要慢一些。如果数据量不大几十万个元素列表推导式可能比生成器表达式还快因为省去了yield/resume的调度成本。所以我的习惯是数据量小、一次性用完后还要继续用名单的情况下放心用列表数据量大、只遍历一次、对内存敏感的场景果断用生成器实时流、无限序列只能是生成器。不要听信“生成器永远更快”这种话性能问题要拿数据说话而不是靠感觉。5. 实战用生成器重写一条数据处理管道5.1 场景设定与需求分析假设有这样一个场景手头有一份结构化的订单日志每一行是CSV格式包含订单号、用户ID、商品ID、支付金额、下单时间。需要从中筛选出金额大于100元的订单然后按用户ID汇总总金额最后输出消费最高的5个用户。如果文件只有几千行用列表怎么折腾都行。但把文件换成几十GB的离线订单数据一次性读入内存就不可能了。这是典型的生成器管道场景。我们需要的不是一个把全量数据加载进来的大函数而是层层过滤、逐条传递的数据流。5.2 完整代码与逐段解读def read_csv(path): with open(path, r, encodingutf-8) as f: for line in f: yield line.rstrip(\n) def parse_orders(lines): for line in lines: fields line.split(,) if len(fields) 5: continue yield { order_id: fields[0], user_id: fields[1], product_id: fields[2], amount: float(fields[3]), time: fields[4], } def filter_large_orders(orders, threshold100): for order in orders: if order[amount] threshold: yield order def aggregate_by_user(orders): summary {} for order in orders: uid order[user_id] summary[uid] summary.get(uid, 0) order[amount] return summary主流程orders filter_large_orders(parse_orders(read_csv(orders.csv))) summary aggregate_by_user(orders) top5 sorted(summary.items(), keylambda x: x[1], reverseTrue)[:5] for uid, total in top5: print(uid, total)虽然aggregate_by_user最终还是要维护一个全量字典但注意它只是汇总每个用户的总金额用户数量远远小于订单数量。几十GB的订单可能只有几万个用户这个字典完全能放进内存。真正会爆内存的是“所有订单对象先全部存入列表”而不是“按用户聚合”。read_csv、parse_orders、filter_large_orders三个函数全部是生成器数据从文件流出来后解析一条、过滤一条、累加一条全程不会出现一个装着全部订单的列表。文件读取也使用了缓冲流式读取磁盘IO一批一批地读速度并不慢。5.3 运行效果与调优备注我自己跑过一份800MB的模拟订单数据生成器管道版本内存占用稳定在50MB以内耗时大约十几秒如果用readlines()加列表推导式内存峰值直接到几个GB时间也没快多少因为GC开销反而拖了后腿。对于几十GB的离线数据前者能跑完后者大概率直接OOM被杀。有一个细节值得提当管道中任何一环被中断——比如外层只取前1000个结果就退出——上游生成器不会自动感知。文件句柄可能一直开着直到进程退出才释放。如果这个管道运行在长驻服务里文件被反复打开不关很快就会把文件描述符耗尽。解决办法是用contextlib.closing或者把整个处理逻辑放进with结构化上下文里保证异常和早退时资源能被清理。另一种常见优化是把parse_orders直接改成返回元组而不是字典能省不少对象创建开销。数据量大的时候几百万个小字典的创建和GC成本也是实打实的。这个取舍得评估可读性和性能一般项目里保持字典足够用但压测发现瓶颈在对象分配时再考虑优化。6. 生成器的更多玩法与我的实战体会6.1 异步生成器惰性在异步世界里同样好用Python 3.6之后引入了异步生成器配合async for可以在异步代码里同样实现惰性求值。它的语法就是把yield放到async def函数里async def fetch_pages(urls): for url in urls: page await fetch_page(url) yield page调用方用async for page in fetch_pages(urls)就能逐页消费不需要一次性把全部页面都放到内存。这在爬虫、批量抓取API、逐帧处理视频流的场景里非常实用。注意异步生成器必须在事件循环里运行不能像普通生成器那样直接next()日常开发中别把它和普通生成器混用。异步生成器和普通生成器的底层机制是相通的——都是通过保存执行状态来实现“暂停/恢复”只不过异步版的多了一个 await 挂起点。理解好普通生成器的yield再看async for就会觉得很自然。6.2 itertools生成器的最佳拍档Python标准库itertools是专门为生成器和迭代器设计的工具包里面不少函数可以直接和yield组合。islice可以从无限生成器里切出前N项takewhile按条件截断数据流chain把多个迭代器首尾相连groupby对排序后的数据做连续分组。写量化策略或者处理时序数据时itertools基本可以代替一大票手写循环。from itertools import islice for idx, val in enumerate(islice(fibonacci(), 10)): print(idx, val)无限斐波那契数列只拿前10个就这么简单。如果没有islice你就得自己写计数器然后手动break代码冗长且容易出错。组合itertools和生成器表达式常常能写出教科书里没有但实际特别好用的简洁代码。比如计算滑动窗口均值用一个生成器不断产出窗口内元素外层用sum()加除法即可。这种组合方式也会显著提升你对Python数据流的掌控感。6.3 个人习惯与最后建议我写Python这么多年遇到“要不要用生成器”的抉择时基本遵循三条经验第一函数返回的是一个“大集合”而不是“单个值”的时候先评估这个集合是否会被一次性完整消费第二如果只是取前几个、做流式加工、给下游逐个处理默认用生成器永远不会错第三如果集合需要反复遍历、随机索引、计算长度再考虑转成列表。还有一个小技巧生成器函数里的调试打印往往让人头大因为打印会在迭代时才执行。想确认生成器内部执行到哪一步了直接list(generator)是不错的办法但不建议拿它处理大文件。小范围调试时很好用大文件调试请用islice截取一部分再list不然内存又爆了。惰性求值是我觉得Python里最被低估的特性之一。它不只是语法糖更是一种思考方式先定义“是什么”把“什么时候算”交给真正需要结果的时刻。写复杂数据处理逻辑的时候用生成器把步骤拆开、串起来代码往往比堆砌中间变量清晰得多。下次你一看到for row in read_log_lines()这种写法就能明白背后“随用随取、流水作业”的优雅了。