处理大批量数据时,生成器链的优势在于“边产生、边消费”,但这种惰性并不会自动带来更高性能。一旦管道中出现重复遍历、隐式缓存、过早物化或不必要的数据结构转换,原本节省内存的代码可能变成重复计算严重、运行时间不可控的实现。定位问题时,先看每个阶段到底被执行了几次,再判断是否真的需要保留中间结果。

先判断:生成器链到底慢在哪里
生成器函数通常只在被迭代时执行代码。下面的 read_rows() 调用本身不会读取全部数据,真正的工作发生在 for 循环、list()、sum() 或其他消费操作中。
def read_rows():
for number in range(1_000_000):
yield number
rows = read_rows() # 此时几乎没有执行数据处理
first = next(rows) # 从这里开始产生第一个值
这种惰性求值适合串联多个逐条处理步骤,因为不需要一次性把全部数据放进内存:
def source():
for number in range(1_000_000):
yield number
def only_even(numbers):
for number in numbers:
if number % 2 == 0:
yield number
def square(numbers):
for number in numbers:
yield number * number
pipeline = square(only_even(source()))
for value in pipeline:
pass
但惰性只意味着“延后执行”,不意味着“执行一次后永久保存结果”。生成器的性能判断,通常要围绕三个问题展开:
- 这个阶段会被消费几次?
- 每次消费是否都会重新执行上游逻辑?
- 下游是否真的需要全部结果,还是只需要一部分?
如果答案没有厘清,就很容易把生成器当成既能流式读取、又能随时重复访问的容器。
陷阱一:生成器只能顺序消费,遍历后不会自动复用
生成器是有状态的迭代器。它被遍历到末尾后,再次遍历通常不会得到任何数据。
def numbers():
for number in range(5):
yield number
items = numbers()
print(list(items)) # [0, 1, 2, 3, 4]
print(list(items)) # []
这不仅是性能问题,也可能造成逻辑错误。例如,程序先用生成器统计记录数,再尝试用同一个生成器写入结果,第二次操作就会得到空输出。
def records():
for number in range(10):
yield {"id": number}
rows = records()
count = sum(1 for _ in rows)
saved = list(rows)
print(count) # 10
print(saved) # []
修复方式取决于数据规模和后续访问需求。如果数据量可接受,并且后续确实需要多次遍历,可以在明确的位置缓存:
rows = list(records())
count = len(rows)
saved = list(rows)
print(count)
print(saved)
这里的关键不是“所有生成器都应该转成列表”,而是把一次性流和可复用数据区分开。转成列表后,迭代器耗尽问题消失了,但内存占用会随数据量增长。对于无法安全放入内存的数据,应改为分批读取、分批处理,或者把中间结果写入适合重复读取的持久化介质,而不是无条件调用 list()。
陷阱二:为了多个结果重复执行昂贵管道
一个常见写法是先建立一个生成器管道,然后分别计算数量、最大值和结果列表:
def source():
for number in range(1_000_000):
yield number
def expensive_transform(numbers):
for number in numbers:
# 用计算步骤模拟成本较高的转换
yield number * number
pipeline = expensive_transform(source())
count = sum(1 for _ in pipeline)
maximum = max(pipeline, default=None)
items = list(pipeline)
print(count, maximum, items[:3])
这段代码的直接问题是生成器只能消费一次;如果把 pipeline 改成每次重新创建的管道,又会导致上游读取和转换过程重复执行。对于大数据集,重复计算往往比单次遍历本身更昂贵。
需要多次访问时缓存中间结果
如果后续操作确实需要多次遍历,可以只缓存已经完成昂贵转换的结果:
def source():
for number in range(1_000_000):
yield number
def expensive_transform(numbers):
for number in numbers:
yield number * number
transformed = list(expensive_transform(source()))
count = len(transformed)
maximum = max(transformed, default=None)
sample = transformed[:3]
print(count, maximum, sample)
这种方案适合转换成本高、结果规模可控、后续需要多次查询的场景。缓存位置也很重要:如果原始记录很大,但转换后只保留必要字段,可以在转换阶段直接缩减数据结构,避免把无用字段一并缓存。
def compact_rows(rows):
for row in rows:
yield {
"id": row["id"],
"score": row["score"],
}
缓存并不一定意味着保存完整对象。保留后续真正需要的数据,通常比机械复制整个记录更有效。
只需要一次结果时合并消费动作
如果统计信息和最终输出可以在一次遍历中完成,就不必为每个指标分别消费管道:
def source():
for number in range(1_000_000):
yield number
def transform(numbers):
for number in numbers:
yield number * number
count = 0
maximum = None
sample = []
for value in transform(source()):
count += 1
if maximum is None or value > maximum:
maximum = value
if len(sample) < 3:
sample.append(value)
print(count, maximum, sample)
这种重写牺牲了一点代码的简洁性,却把多个遍历合并为一次。适合数据只能顺序读取、处理成本较高,且统计逻辑比较固定的情况。
陷阱三:用 tee 复用迭代器,却忽略隐藏缓存
标准库中的 itertools.tee() 可以把一个迭代器拆成多个独立迭代器:
from itertools import tee
def source():
for number in range(5):
yield number
first, second = tee(source())
print(list(first))
print(list(second))
它解决了两个消费者需要读取同一数据流的问题,但并没有免费复制数据。两个分支消费速度不一致时,已经被较快分支读取、但还没有被较慢分支读取的数据需要暂存。分支之间的进度差距越大,隐藏缓存就可能越大。
因此,tee 更适合短小、分支消费节奏接近的迭代器。对于大规模数据或消费速度差异明显的管道,应该明确选择一种方案:
- 只需要一个结果:合并消费逻辑,保持单条管道。
- 需要多次访问且数据可控:显式缓存为列表或字典。
- 数据很大但需要复用:分批生成中间结果,按批次重复处理。
- 两个消费者职责不同:重新设计数据流,让上游一次产出所需的共享信息。
显式缓存虽然占用空间,但内存和生命周期都更容易观察;隐藏缓存则可能让性能问题出现在远离 tee() 的位置。
陷阱四:管道顺序不合理,过早处理了无效数据
生成器链中的步骤顺序会影响总工作量。下面的管道先执行转换,再筛选结果:
def transform(numbers):
for number in numbers:
yield number * number
def keep_small(numbers):
for number in numbers:
if number < 100:
yield number
result = keep_small(transform(range(1_000_000)))
如果筛选条件可以直接作用于原始数据,先筛选再转换通常更省计算:
def keep_small(numbers):
for number in numbers:
if number * number < 100:
yield number
def transform(numbers):
for number in numbers:
yield number * number
result = transform(keep_small(range(1_000_000)))
print(list(result))
不过,过滤条件是否应该提前,取决于它是否依赖转换结果。不能为了减少数据量而改变业务语义。例如,只有转换后才存在的字段,仍然必须先完成转换。优化管道顺序时,优先寻找“不改变结果、但能尽早减少数据量”的步骤。
根据访问模式调整数据结构
生成器适合顺序访问,却不适合随机查找、重复索引和按键聚合。数据结构选错后,即使每一层都写成生成器,整体仍可能很慢。
重复判断成员关系时使用集合
如果需要反复判断某个值是否存在,先把查找集合建立出来通常比每次扫描列表更合适:
allowed = {2, 5, 8, 13}
def filter_allowed(numbers):
for number in numbers:
if number in allowed:
yield number
print(list(filter_allowed(range(20))))
这里集合用于表达“是否属于某个集合”,生成器则负责保持输入和输出的流式处理。不要在循环中反复创建同一个集合,否则建立集合的成本也会被重复支付。
需要按键查找时建立映射
如果后续逻辑会根据标识查找记录,顺序遍历列表并不合适:
rows = [
{"id": 101, "name": "甲"},
{"id": 102, "name": "乙"},
{"id": 103, "name": "丙"},
]
by_id = {row["id"]: row for row in rows}
print(by_id.get(102))
如果数据只会被顺序消费一次,保持生成器即可;如果同一批数据要按多个键反复查询,就应在明确的边界处建立字典。这个转换会增加内存占用,但能避免后续每次查询都重新扫描全部记录。
避免无意义的中间元组和对象
下面的写法会先创建一批元组,再进行下一步处理:
result = ((row["id"], row["score"]) for row in rows)
如果下游只需要一个计算结果,可以把提取字段和计算逻辑合并到同一阶段:
result = (
row["score"] * 2
for row in rows
)
合并不是越多越好。若一个阶段承担了太多职责,测试和定位都会变困难。更合理的标准是:合并那些只做一次字段转移、没有独立复用价值的薄层;保留具有清晰业务含义、需要单独测试的处理阶段。

用可观察的方式定位瓶颈
优化前不要只看生成器表达式是否简短。可以先为每个阶段加入简单计数,确认它实际产生了多少条数据、执行了几次:
def counted_transform(numbers, counter):
for number in numbers:
counter["transform"] += 1
yield number * number
counter = {"transform": 0}
pipeline = counted_transform(range(100), counter)
result = list(pipeline)
print("转换次数:", counter["transform"])
print("结果数量:", len(result))
如果一个阶段的处理次数远大于预期,优先检查它是否被重新创建并遍历了多次;如果处理次数正常但单条记录耗时较高,再查看转换函数本身。不要把“生成器写得很长”直接等同于“生成器就是瓶颈”。
还可以用 time.perf_counter() 对完整管道和关键阶段做对比测试:
from time import perf_counter
def run():
data = (
number * number
for number in range(1_000_000)
if number % 2 == 0
)
return sum(data)
start = perf_counter()
value = run()
elapsed = perf_counter() - start
print("结果:", value)
print("耗时:", elapsed)
测试时应保持输入规模和数据分布稳定,并分别比较“单次流式处理”“缓存后重复读取”“多次重新计算”等方案。单看某一次运行时间,容易把输入差异、缓存状态或其他干扰误判为优化效果。
一套更稳妥的重写顺序
面对一个已经变慢的生成器链,可以按下面的顺序处理:
- 确认消费次数。 找出
list()、sum()、max()、循环和其他终端操作,判断同一批数据是否被要求多次遍历。 - 区分一次性流和可复用数据。 一次性流保持生成器;需要重复访问的数据,在边界处显式缓存。
- 把过滤尽量前置。 在不改变语义的前提下,先排除无效数据,再执行昂贵转换。
- 按访问模式选择结构。 顺序处理使用生成器,成员判断考虑集合,按键查找考虑字典。
- 合并重复遍历。 多个统计指标可以在一次循环中完成,就不要分别消费同一条管道。
- 重新测量。 比较处理次数、内存占用和总耗时,而不是只看代码长度。
最终的目标不是让所有代码都“生成器化”,而是让数据生命周期与访问模式匹配:数据只看一次,就让它流过去;数据需要反复看,就在明确位置保存;数据要快速查找,就使用适合查找的数据结构。生成器链真正的性能陷阱,往往不在 yield 本身,而在程序没有明确决定数据应该被计算一次、保存一次,还是重复计算。
发布者:jacky,转转请注明出处:https://kubiyun.com/archives/4559
评论列表(5条)
原来 tee 的缓存是隐形的,确实容易忽略
过滤条件能前置的话,省下来的才是真性能
显式缓存虽然占内存,但比隐形消耗好排查多了
统计一次再写出,第二遍就空了
合并多次遍历成一次循环,这招最实用。