Python tee 探空后只消费一路,内存随输入增长
保留落后的 tee 分支会缓存另一分支已读取的值;只需探空时用 next 和 chain 接回首项,并保留一次性输入的副作用边界。
为了判断生成器是否为空,先用 itertools.tee() 分成两路,只从其中一路取一项,然后让另一路持续处理大批数据。代码看起来仍然是逐项读取,内存却可能不断增长:那条仍被保留、又没有继续消费的分支,需要以后还能读到其他分支已经读过的值。
这是 Python 3 中 tee() 的缓存成本,不应只凭“用了生成器”就判断额外存储始终固定。这里讨论单线程、一次性迭代输入,只需要探空后完整处理一次的场景。
检查是否还保留着落后的分支
下面是容易出现问题的结构示例,不要用巨大输入直接验证:
from itertools import tee
work, probe = tee(source)
try:
next(probe)
except StopIteration:
pass
for item in work:
handle(item)
source 和 handle 代表原有输入及处理函数。若 probe 一直留在作用域中,却停在第一项之后,work 领先读取的后续值需要为它保留。领先距离越大,需要暂存的数据可能越多;数据对象大小也会影响占用。
tee() 不是把每个元素深复制成两份业务对象,而是让两个迭代位置能够访问同一来源的值。它也不能让底层数据源重来一次。重点应检查分支的生命周期和消费进度,以及下游是否另行收集了列表或保留对象引用,不能把所有内存增长都归给 tee()。
只判断一次空输入时,取出首项再接回去
不需要两路独立遍历时,可以避免创建第二条长期保留的分支。下面的函数返回一个可继续消费的迭代器,None 专门表示输入为空:
from itertools import chain
def peek_nonempty(source):
iterator = iter(source)
try:
first = next(iterator)
except StopIteration:
return None
return chain((first,), iterator)
items = peek_nonempty(source)
if items is None:
handle_empty()
else:
for item in items:
handle(item)
source、handle_empty() 和 handle() 是调用方提供的对象与业务函数。非空时,chain 先交出已取到的首项,再继续原迭代器;首项本身即使是 None,也不会被误判为空,因为返回的是迭代器对象。
这段只为探空保留一项,不会为一条闲置的第二分支持续积累队列。但它不限制数据源、单个元素或 handle() 自身的内存使用,也不保证立即降低进程已经占用的内存。
探测已经执行了一次 next()。若数据源会读文件、推进数据库游标或产生其他副作用,这一步同样会发生;除 StopIteration 外的异常应继续暴露,不能统一当成空输入。后续处理应使用返回的 items,不要又从原来的 source 开始读,以免丢掉已经取出的首项。
真正需要两路时,要管理进度和存储
如果两条分支都需要完整数据,不能用上面的单路方案替换业务。应确认是否能交错消费,使进度差保持在可接受范围;若一条必须全部完成后另一条才开始,tee() 可能需要保存大部分甚至全部数据。
有限且规模已知的数据可以明确物化为列表,但这仍然需要容纳全部数据,并非降低内存的通用办法。很大的输入应采用业务认可的外部暂存、重新读取或受控批次设计。不要为了“释放缓存”把另一条有副作用的分支盲目消费完。
不再使用的分支应结束其生命周期,并检查是否还有其他引用。即使对象已可回收,Python 实现、分配器和其他引用仍会影响回收时机与进程常驻内存;不要以为一次 del 或 gc.collect() 必然立即降低操作系统显示的数值。
同一次 tee() 产生的迭代器也不是线程安全的数据分发队列。将两路交给线程并不能自动解决进度差,反而可能产生运行时错误。
用小输入核对是否漏掉首项
先覆盖空输入、单项输入、第一项为 None 和多项输入,检查业务处理次数与顺序。还应覆盖数据源抛出真实异常的情况,确认它没有被当作空结果吞掉。
保留原处理代码,按已确认的数据规模观察新的消费过程。如果此前已经部分处理一次性输入,替换函数不会撤销副作用,也不能把已耗尽的生成器恢复到起点。重试前按业务检查点重新取得输入,避免重复处理前缀;回退时同样保留这个约束。


