大型Python脚本长时间运行内存泄漏的排查与解决方案
Python常驻脚本内存泄漏排查与批量清理方案
一、易遗漏的泄漏场景与排查方法
首先纠正一个排查误区:sys.getsizeof()仅计算对象本身的浅内存占用,不会统计对象引用的关联内存,比如一个存了上百个numpy数组的列表,用这个方法测出来可能只有几十字节,完全无法反映真实内存占用,你之前的变量排查结果参考价值很低。
排除你已经查过的显式文件未关、大变量常驻问题后,以下是24小时循环类脚本最高发的泄漏场景:
- 绘图库全局缓存泄漏:以最常用的matplotlib为例,如果你用pyplot状态机接口绘图,哪怕手动调了
close(),只要没切换到非交互无GUI后端,全局FigureManager会持续留存轴对象、渲染位图缓存;如果循环中反复创建figure、叠加图层,缓存会线性增长。这类C实现的渲染缓存不会被Python层变量检测捕获,且GUI后端申请的内存必须等解释器退出才会释放,和你描述的“中断代码内存不释放、必须重启kernel”特征完全吻合。 - 隐式全局容器/缓存无限增长:最常见的包括三类:一是不小心在全局作用域定义了列表/字典,循环逻辑(尤其是异常分支、闭包回调)持续往里面追加处理结果、日志对象、异常栈帧;二是给处理函数加了
@lru_cache(maxsize=None)装饰器,无上限缓存所有入参和返回值;三是logging模块循环中重复addHandler却不主动移除,每个handler都会留存缓冲区和引用链。 - C扩展层内存泄漏:pandas、numpy、opencv、pyarrow这类带C实现的第三方库,其内部申请的内存不归Python GC管理,如果版本存在bug、或者你对数组/对象做了切片持有隐式引用,哪怕你删了Python层的变量名,C层申请的内存块也不会归还给操作系统,这类泄漏从Python层完全查不到变量异常。
- 带析构方法的循环引用泄漏:如果你自定义的类实现了
__del__方法,又存在循环引用(比如类实例绑定的回调持有实例本身、类之间互相引用),Python GC无法回收这类对象,会永久丢在gc.garbage列表中驻留。 - 未正确退出的线程/协程残留:如果循环中反复创建子线程、协程任务却没有正确join/回收,线程持有的栈内存、对象引用会一直留存。
针对性排查技巧按效率从高到低排序:
- 优先用标准库
tracemalloc定位内存增量,不需要装第三方包,定位精度到行:
import tracemalloc tracemalloc.start() # 执行1轮文件处理后打第一个快照 snap1 = tracemalloc.take_snapshot() # 执行10-20轮处理后打第二个快照 snap2 = tracemalloc.take_snapshot() # 按代码行统计内存增量,打印前10个最高增量的位置 for stat in snap2.compare_to(snap1, 'lineno')[:10]: print(stat)
- 如果怀疑是特定类型对象泄漏,用GC接口统计对象数量变化,每轮处理后打印计数,看哪类对象数量持续线性上涨:
import gc from collections import Counter def print_obj_count(): count = Counter(type(o).__name__ for o in gc.get_objects()) print(count.most_common(10))
- 先做快速验证:脚本开头强制加
matplotlib.use('Agg')切到无GUI非交互后端,每轮绘图结束加plt.close('all'),跑30分钟看内存是否还涨,30%以上的绘图类泄漏改完这两行直接解决。
二、低侵入批量资源释放方案
不需要手动逐行del变量,核心思路是断开引用链+触发GC+兜底隔离,几个可直接落地的方法:
- 每轮处理后的统一清理函数,放在单文件/单批次处理逻辑的最后执行即可:
import gc import matplotlib.pyplot as plt import logging import io def full_cleanup(keep_permanent_logger=True): # 清空所有matplotlib绘图对象和缓存 plt.cla() plt.clf() plt.close('all') # 触发三代GC回收,处理循环引用 gc.collect() # 清理logging重复的临时handler logger = logging.getLogger() for handler in logger.handlers[:]: if keep_permanent_logger and getattr(handler, "is_global", False): continue handler.close() logger.removeHandler(handler) # 可选:批量关闭残留的非标准文件句柄,注意过滤常驻句柄 # for obj in gc.get_objects(): # if isinstance(obj, io.IOBase) and not obj.closed: # if obj.name not in ('<stdin>', '<stdout>', '<stderr>'): # try: obj.close() # except: pass # 如果你用了lru_cache,在这里统一调用对应函数的cache_clear()方法
- 用函数作用域隔离单轮处理逻辑:不要把单文件处理的代码直接写在
while True的全局循环体里,封装成独立的process_single_file()函数,循环里只做函数调用。函数执行结束后所有局部变量会自动失去作用域引用,只要没有被全局对象持有,GC会自动回收,不需要手动删任何变量,能解决90%的Python层内存泄漏。 - C扩展泄漏兜底方案:如果定位到是第三方C扩展的泄漏、且没有版本修复方案,直接把单批次处理逻辑拆到独立子进程里用
multiprocessing执行,每处理完固定数量的文件就销毁子进程重新拉起,C层申请的内存会随着子进程退出被操作系统完全回收,主进程内存永远不会涨,是全年无休脚本最稳妥的工程化方案。
内容的提问来源于stack exchange,提问作者tgbnez
相关产品推荐
相关产品推荐

