症状
两个仪表的矛盾就是全部线索: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