← 中文资源

简体中文版本

Python — 处理大数据集后 RSS 居高不下

解释大对象释放后 Python RSS 仍居高的原因,用 tracemalloc 区分分配器竞技场驻留与真泄漏,并主动归还页面。

阅读英文原文

症状

  两个仪表的矛盾就是全部线索:tracemalloc 显示 current 回到 182 MiB、gc.get_objects 恢复基线,而容器 RSS 停在 4.1 GiB。当 Python 视角说对象已经释放、OS 视角不同意时,内存在解释器之下——分配器竞技场、glibc 堆驻留或原生扩展缓冲。先分层再定性:真泄漏会让对象计数居高不下,驻留只留住页面。泄漏测试全过而容器 OOM 告警照响,正是这个组合。

诊断思路

  先对比 Python 视角与 OS 视角(tracemalloc 对 ps),差距即分配器或原生层持有;再用 PYTHONMALLOC=debug 与对象计数确认“对象确实没了”;最后做一个分配-释放实验:建一个大 numpy 数组再删除、gc.collect 后看 RSS 是否回落,回答平台是否立即归还页面。CPython 竞技场只剩一个活对象时整页 256KB(3.10+ 为 1MB)都保持映射,glibc 把空闲块留在堆顶不 munmap,numpy BLAS 缓冲在 Python 堆外按自己的节奏释放——三者都不算泄漏。

根因

  CPython 与底层 C 分配器不必然把释放的内存还给操作系统:碎片化的竞技场保持映射、glibc 持有空闲块、原生扩展缓冲异步释放。进程没有任何存活 Python 对象,页面却仍以进程名义提交着,RSS 于是居高不下。

修复与验证

  结构性修复是让大阶段跑在会退出的子进程里(ProcessPoolExecutor 单 worker,退出即保证归还全部页面)。长驻进程则在阶段边界调 malloc_trim(glibc)归还空闲堆块,大缓冲改为一次分配反复复用而不是反复制造新竞技场,容器内存限额按“实测工作集 + 驻留带宽”设定而不是 Python 对象总量。验证时同一进程连续跑三遍完整负载并记录每轮后 RSS:第一轮冲高并部分驻留,后续轮次复用驻留页面而不再增长,最终 RSS 落在基线上方的固定带宽内;进程池变体则要求每批后 RSS 精确回到基线。两个数字都写进部署说明,容量规划用测量出的现实而不是解释器的乐观视角。

代码示例

tracemalloc current:  182 MiB   <- Python objects: freed
gc.get_objects:       back to baseline
container RSS:        4.1 GiB   <- pages still committed
from concurrent.futures import ProcessPoolExecutor

with ProcessPoolExecutor(max_workers=1) as pool:
    result = pool.submit(load_and_transform, path).result()
# worker exits: every page returns to the OS, guaranteed
import ctypes

libc = ctypes.CDLL("libc.so.6")
libc.malloc_trim(0)  # return free heap chunks to the OS
buf = bytearray(CHUNK)          # allocated once
for chunk in read_chunks(path):
    buf[:len(chunk)] = chunk     # reused, no new arena each time