我们正在开发的类数据库系统有一个内存模块,出现了一个疑似”内存泄露”问题,现象如下:内存模块的内存释放以后没有归还操作系统,比如内存模块占用的内存为10GB,释放内存以后,通过TOP命令或者/proc/pid/status查看占用的内存有时仍然为10G,有时为5G,有时为3G, etc,内存释放的行为不确定。
首先说一下内存模块的内存管理机制。我们的内存管理很简单,使用全局的定长内存池,每一个内存块为64KB,如果申请的内存小于等于64KB时,直接从内存池的空闲链表中获取一个内存块,内存释放时归还空闲链表;如果申请的内存大于64KB,直接通过操作系统的malloc和free获取。某些数据结构涉及到很多小对象的管理,比如Hash表,B-Tree,这些数据结构从全局内存池获取内存后再根据数据结构的特点进行组织。为了提高内存申请/释放的效率,减少锁冲突,为每一个线程单独保留一个8MB的内存块,每个线程优先从线程专属的8MB内存块获取内存,专属内存不足时才从全局的内存池获取。
由于我们的所有内存申请/释放操作都需要通过全局的内存池进行,我们在全局的内存池中加入对每个子模块的内存统计功能:每个子模块申请内存时都将子模块编号传给全局的内存池,全局的内存池进行统计。复现问题后发现全局的内存池的统计结果符合预期,因此怀疑是操作系统或者glibc的行为。
Linux下Glibc的内存管理机制大致如下:
从操作系统的角度看,进程的内存分配由两个系统调用完成:brk和mmap。brk是将数据段(.data)的最高地址指针_edata往高地址推,mmap是在进程的虚拟地址空间中找一块空闲的。其中,mmap分配的内存由munmap释放,内存释放时将立即归还操作系统;而brk分配的内存需要等到高地址内存释放以后才能释放。也就是说,如果先后通过brk申请了A和B两块内存,在B释放之前,A是不可能释放的,仍然被进程占用,通过TOP查看疑似”内存泄露”。默认情况下,大于等于128KB的内存分配会调用mmap/mummap,小于128KB的内存请求调用sbrk(可以通过设置M_MMAP_THRESHOLD来调整)。详细的内存管理机制可以参考百度分享的文章。
我们的内存模块申请/释放内存都是以2MB为单位的,按理说应该是使用mmap和munmap进行内存分配和释放的,不会出现内存释放以后仍然被进程占用的情况。在内核同学的协助下,经过长时间的分析定位,发现了Glibc的新特性:M_MMAP_THRESHOLD可以动态调整。M_MMAP_THRESHOLD的值在128KB到32MB(32位机)或者64MB(64位机)之间动态调整,每次申请并释放一个大小为2MB的内存后,M_MMAP_THRESHOLD的值被调整为2M到2M + 4K之间的一个值(具体可以参考Glibc的patch说明)。例如:
char* no_used = new char[2 * 1024 * 1024];
memset(no_used, 0xfe, 2 * 1024 * 1024);
delete[] no_used;
// M_MMAP_THRESHOLD的值调整为2M到2M + 4K之间的一个值,后续申请 <= 2 * 1024 * 1024的内存块都会走sbrk而不是mmap
了解到这种现象后,我们找到了”内存泄露”的原因:M_MMAP_THRESHOLD的值动态调整,后续的2MB的内存申请通过sbrk实现,而sbrk需要等到高地址内存释放以后低地址内存才能释放。可以通过显式设置M_MMAP_THRESHOLD或者M_MMAP_MAX来关闭M_MMAP_THRESHOLD动态调整的特性,从而避免上述问题。
当然,mmap调用是会导致进程产生缺页中断的,为了提高性能,常见的做法如下:
1, 将动态内存改为静态,比如采用内存池技术或者启动的时候给每个线程分配一定大小,比如8MB的内存,以后直接使用;
2, 禁止mmap内存调用,禁止Glibc内存缩紧将内存归还系统,Glibc相当于实现了一个内存池功能。只需要在进程启动的时候加入两行代码:
mallopt(M_MMAP_MAX, 0); // 禁止malloc调用mmap分配内存
mallopt(M_TRIM_THRESHOLD, 0); // 禁止内存缩进,sbrk申请的内存释放后不会归还给操作系统
花絮:
追查”内存泄露”问题的过程中,尝试使用Glibc的钩子函数(Malloc Hook) 统计malloc和free的内存量:具体做法为malloc的时候多申请8个字节,其中4个字节记录长度,4个字节记录magic_num,malloc和free的时候统计进程申请和释放的内存量。实践表明无论自定义钩子函数是否加锁,malloc和free钩子函数在多线程的情况下运行都不正常,其它同学也发现了相同的问题(Malloc Hook多线程问题)。