问题排查 - Python 内存占用 & 释放问题
一、背景
近期在做 RAG 工程,任务调度 Server 是 Temporal,Worker 是 Python 写的,在基于 Temporal Client 的基础上设计了一套 DSL,方便业务编写异步任务。
导入文档异步任务是我们的业务最复杂的其中一个环节。在性能压测下,一次性发起 6000 个任务不同类型的文件导入任务,最后都能处理完成,但是有一个问题,我们的 Worker 进程内存迟迟不回落,这会导致我们在 k8s 上的 HPA 缩容异常,在请求高峰后,副本数无法降下来。
二、解决过程
2.1 内存 profile
先用 memray 看看情况,启动命令改为:
memray run --aggregate -m app.worker开启压测,可以 profile 到内存 allocate 的细节,这个过程针对性地优化了一些内存使用,比如在 Python ,以下行为都可能导致内存升高 80MB、90MB :
一次 import
一次实例化
一次 log.debug()
在这里的优化方案是适当考虑使用单例,以及不要对大对象做 str 打印
最后这一步减少了内存占用 200MB 左右
2.2 gc leak
对于内存分配后老是无法回落,进程占用物理内存高这个问题,尝试过在后台手动挂一个线程,手动 gc,但是并没有效果:
while self._running:
try:
collected = gc.collect()
logger.debug(f'Manual GC completed, collected objects: {collected}')
time.sleep(self.interval)
except Exception as e:
logger.error(f'GC thread error: {e}')
break但是内存 profile 工具大部分只能看到 allocate 内存的情况,无法看到实时的内存堆栈,或者像 JDK 那样看到对象实时的堆栈。所以不能将希望寄托在工具上了。
QA 压测的结果来看,这个还不是内存泄漏,内存只是不回落,但是不会无止境变大。那说明应该还是 python 的 gc 机制,内存的问题。比如占用了一块大的堆内存,但是不用。
我们需要往 leak memory 方面的关键字找更多资料。
最后找到 cpython 的 ISSUE:https://github.com/python/cpython/issues/109534
讨论:
@graingert @rojamit seems like any numbers for MALLOC_TRIM_THRESHOLD_ env var can mitigate the situation. It should be MALLOC_TRIM_THRESHOLD_=131072 by default (128KB) but for some reason even that is change behavior a little.
UPDATE: Okey, it's happened because such env var disable dynamic threshold. https://github.com/lattera/glibc/blob/master/malloc/malloc.c#L5031
UPDATE: this is actual values during our test: {trim_threshold = 532480, top_pad = 131072, mmap_threshold = 266240, arena_test = 8, arena_max = 0, thp_pagesize = 0, hp_pagesize = 0, hp_flags = 0, n_mmaps = 3, n_mmaps_max = 65536, max_n_mmaps = 5, no_dyn_threshold = 0, mmapped_mem = 610304, max_mmapped_mem = 1142784, sbrk_base = 0x555555558000 "", tcache_bins = 64, tcache_max_bytes = 1032, tcache_count = 7, tcache_unsorted_limit = 0} Confirming that setting MALLOC_TRIM_THRESHOLD_=131072 or any other number will release memory back to the system. Thank you very much @stalkerg that was impressive!最后的解决方案:在 Worker 镜像设置一个 ENV
ENV MALLOC_TRIM_THRESHOLD_=131072经过几波大规模的几万次压测,内存监控看起来很正常,可以正常及时回落
下面附上 AI 对此解决方案的解释:
现象:
Python 程序申请了大量内存(比如用
list、dict存数据)然后释放这些对象(引用计数归零,垃圾回收触发)
但用
top/ps看,进程的 RES 内存几乎没降
这种情况在 Linux 上很常见,尤其是使用 glibc 的系统(大多数 Linux 发行版默认是 glibc)。
原因
Python 自己的内存管理(PyMem_Malloc、PyObject_Malloc 等)底层最终会调用 glibc 的 malloc/free。
glibc 的 malloc 有个特点:
释放内存时,并不一定立刻归还给操作系统
它会把这些 “空闲内存” 保留在进程的堆(heap)中,以便下次申请时复用
只有在某些特定条件下(比如释放了一个很大的连续块,或堆末尾有足够大的空闲区),glibc 才会调用
sbrk或mmap把内存归还给 OS
所以即使 Python 已经释放了对象,glibc 可能只是把这些内存标记为 “可用”,并没有真正还给内核。
什么时候会归还给 OS?
glibc 会在以下情况考虑归还给系统:
释放的内存块位于 堆的最顶端(top of the heap)
连续的空闲内存达到一定阈值(
trim threshold)对于通过
mmap分配的大块内存(通常 > 128KB,可由M_MMAP_THRESHOLD控制),free 时会直接munmap归还给 OS
因此:
小内存对象释放后,通常不会立即减少 RES
大对象(超过 mmap 阈值)释放后,内存会直接还给 OS
影响:
好处:下次申请内存更快,避免频繁的系统调用
坏处:看起来进程 “占着内存不放”,可能引起误解
在容器环境(Docker、K8s)下,如果容器有内存限制,可能导致 OOM 被 kill
4 种调整方法:
1、过环境变量或 mallopt 调优:
# 让 glibc 更积极地把空闲内存还给系统
export MALLOC_TRIM_THRESHOLD_=10242、Python 代码里调用 ctypes 调 mallopt:
import ctypes
libc = ctypes.CDLL("libc.so.6")
# M_TRIM_THRESHOLD = -1 表示总是尝试归还给OS
libc.mallopt(1, -1)3、 使用 jemalloc /tcmalloc 替换 malloc 实现,例如:
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so python your_script.pyjemalloc/tcmalloc 在内存归还策略上和 glibc 不同,通常更 “愿意” 把内存还给 OS。
4、手动触发 malloc_trim
glibc 提供 malloc_trim(0) 可以强制尝试释放堆顶空闲内存:
import ctypes
libc = ctypes.CDLL("libc.so.6")
libc.malloc_trim(0)三、总结
本次问题主要解决内存占用性能优化 & 内存不回落问题,这个过程发现:
python 的 profile 工具还是不够好,大部分只能看到 allocate,而看不到实时内存的情况,memray run --live 其实看到也不是实时,而是采样的 allocate
cpython 的默认实现 glibc 居然会存在占用 RES 不归还给 OS 的情况,这个看起来在 ASGI 程序会出现,解决方案最简单就是恢复 mallopt 的默认行为,即,env MALLOC_TRIM_THRESHOLD_=131072
0