问题排查 - Python 内存占用 & 释放问题

15 11.5~14.7 min

一、背景

近期在做 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

讨论:

@stalkerg

 @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} 

geraldog

 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 程序申请了大量内存(比如用 listdict 存数据)

  • 然后释放这些对象(引用计数归零,垃圾回收触发)

  • 但用 top / ps 看,进程的 RES 内存几乎没降

这种情况在 Linux 上很常见,尤其是使用 glibc 的系统(大多数 Linux 发行版默认是 glibc)。

原因

Python 自己的内存管理(PyMem_MallocPyObject_Malloc 等)底层最终会调用 glibc 的 malloc/free

glibc 的 malloc 有个特点:

  • 释放内存时,并不一定立刻归还给操作系统

  • 它会把这些 “空闲内存” 保留在进程的堆(heap)中,以便下次申请时复用

  • 只有在某些特定条件下(比如释放了一个很大的连续块,或堆末尾有足够大的空闲区),glibc 才会调用 sbrkmmap 把内存归还给 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_=1024

2、Python 代码里调用 ctypesmallopt

 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.py

jemalloc/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