内存暴增问题剖析与解决

一、问题背景

我们的智能驾驶系统,遇到了 Glibc 的内存暴增问题。系统中实现了一个简单的内存管理模块,在高压力环境下长时间运行(加载感知模型),当内存管理模块的内存释放给 C 运行时库之后,C 运行时库并没有立即把内存归还给操作系统。比如占用的内存为 10GB,释放内存后,通过 TOP 命令查看,有时是 10GB、有时是 5GB,有时是 2GB,内存释放行为非常不确定。

我们系统中的内存管理方式比较简单,使用全局的定长内存池,内存管理模块每次分配/释放 2MB 内存,然后分成 64KB 为单位的一个个小内存块用 hash 加链表的方式进行管理。如果申请的内存小于等于 64KB 时,直接从内存池的空闲链表中获取一个内存块,内存释放时归还空闲链表;如果申请的内存大于 64KB,直接通过C运行时库的 malloc 和 free 获取。某些数据结构涉及到很多小对象的管理,这些数据结构从全局内存池获取内存后再根据数据结构的特点进行组织。为了提高内存申请/释放的效率,减少锁冲突,为每一个线程单独保留 8MB 的内存块,每个线程优先从线程专属的 8MB 内存块获取内存,专属内存不足时才从全局的内存池获取。

系统在高压力、高并发环境下长时间运行会发生内存暴增的现象,最终进程被 OOM 掉。

二、分析解决过程

为了便于跟踪分析问题,在全局的内存池中加入对每个子模块的内存统计功能:每个子模块申请内存时都将子模块编号传给全局的内存池,全局的内存池进行统计。复现问题后发现全局的内存池的统计结果符合预期。

然后我们动用 ASAN 、review 代码 确认没有发现内存泄漏。

**由于内存管理不外乎三个层面,用户管理层,C运行时库层,操作系统层,在操作系统层发现进程的内存暴增,同时又确认了用户管理层没有内存泄露,因此怀疑是C运行时库的问题,也就是 Glibc 的内存管理方式导致了进程的内存暴增。 **

经过我们分析 ptmalloc 的源码之后,针对我们的问题,我们大概得出了如下几点原因:

  1. 在 64 位系统上使用默认的系统配置,也就是说 ptmalloc 的 mmap 分配阈值动态调整机制是开启的。我们的系统经常分配内存为 2MB,并且这 2MB 的内存很快会被释放,在 ptmalloc 回收 2MB 内存时,ptmalloc 的动态调整机制会认为 2MB 对我们的系统来说是一个临时的内存分配,每次都用系统调用 mmap 向操作系统分配内存,ptmalloc 认为这么做太低效了,于是把 mmap 的阈值设置为 2MB+4KB,当再次在分配 2MB 的内存时,尽量从 ptmalloc 缓存的 chunk 中分配,缓存的 chunk 不能满足要求,才考虑调用 mmap 进行分配,提高分配的效率。

  2. 系统中全局的内存 cache 每次分配 2MB,但不确定什么时候释放,也不确定下次会什么时候再分配 2MB 内存,但有一点可以确认,就是每次分配的 2MB 内存要经过比较长的的一段时间才会释放,所以其实可以看成是长生命周期的内存块。ptmalloc 不擅长管理长生命周期的内存块,ptmalloc 的设计中就明确假设缓存的内存块都用于短生命周期的内存分配,因为 ptmalloc 的内存收缩是从 top chunk 开始,如果与 top chunk 相邻的那个 chunk 在我们系统中没有释放,top chunk 以下的空闲内存都无法返回给系统,即使这些空闲内存有几十G 也不行。

  3. 我们的系统中全局内存池会不定期的分配内存,可能下次分配的内存是在 top chunk 分配的,分配以后又短时间不释放,导致 top chunk 升到了一个更高的虚拟地址空间,从而使 ptmalloc 中缓存的内存块更多,但无法返回给操作系统。

  4. 进程的线程数越多,在高压力高并发环境下,频繁分配和释放内存,由于分配内存时锁竞争更激烈,ptmalloc 会为进程创建更多的分配区,由于我们系统的全局内存池长时间不释放内存的缘故,会导致 ptmalloc 缓存的 chunk 数量增长的更快,更容易出现内存暴增

  5. 我们系统的内存池管理内存的方式导致了 ptmalloc 大量的内存碎片。我们的内存池对于小于等于 64KB 的内存分配,则从内存池中分配 64KB 的内存块,如果内存池中没有,则调用 malloc 分配 64KB 的内存块,释放时,将该 64KB 的内存块加入内存池中,不还给 ptmalloc。对于大于 64KB 的内存分配,调用 malloc 分配,释放时调用 free 函数还给 ptmalloc。这些大量的 64KB 的内存块长时间存在于内存池中,导致了 ptmalloc 中缓存了大量的内存碎片不能释放给操作系统。

    举个例子,假如应用层分配内存的顺序是 64KB、100KB、64KB,然后释放 100KB 的内存块,ptmalloc 会缓存这个 100KB 的内存块,其中的两个 64KB 内存块都在我们的系统内存池中,一直不释放,如果下次再分配 64KB 的内存,就会将 100KB 的内存块拆分成 64KB 和 36KB 的两个内存块,64KB 的内存块返回给应用层,并被我们的系统内存池缓存,但剩下的 36KB 被 ptmalloc 缓存了,再也不能被应用层分配了,因为应用层分配的最小内存为 64KB,这个 36KB 的内存块就是内存碎片,这也是内存暴增的原因之一。

三、解决方案

问题找到之后,我们提出了几点解决方案

  1. 关闭 ptmalloc 的 mmap 分配阈值动态调整机制,同时将 mmap 分配阈值设置为 64KB。也就是说,大于 64KB 的内存分配都使用 mmap 向系统分配,释放大于 64KB 的内存将调用 munmap 释放回系统。但是因为 mmap 是串行的,分配内存时会加锁,而且操作系统对 mmap 的物理页强制清 0 非常慢。我们经过测试,关掉 mmap 的动态阈值调整机制后,我们的系统占用的 cpu 升高超过 10%,性能大大降低,于是我们否定了这一方案。
  2. 改写我们系统的内存池实现,使用空闲链表方式管理所分配的内存块。使用预分配的方式,优化已有的代码逻辑。并使用线程局部缓存 TLS 来存放线程所使用的私有内存。不过这种方式也不够好,相当于我们又干了一次内存管理器的事情。
  3. 从长远的设计来看,我们的系统是分阶段执行的,每次会分配 2MB 为单位的内存,使用完之后进行释放。业界有关于 jemalloc、tcmalloc 的开源实现,无锁设计,使用线程局部缓存的形式尽量减少分配时线程对锁的竞争。经测试,jemalloc 的内存使用曲线较为平稳,最后选择使用 jemalloc。

对比 jemalloc、tcmalloc、ptmalloc:

总的来看,作为基础库的 ptmalloc 是最为稳定的内存管理器,无论在什么环境下都能适应,但是分配效率相对较低。而 tcmalloc 针对多核情况有所优化,性能有所提高,但是内存占用稍高,大内存分配容易出现 CPU 飙升。jemalloc 的内存占用更高,但是在多核多线程下的表现最为优异。