导语
本文将介绍几种内存泄漏检测工具,并通过实际例子介绍一种分析堆内存占用量的工具和方法,帮助定位内存膨胀问题。
背景
进程的内存管理是每一个开发者必须要考虑的问题,对于C++程序进程来说,出现问题很多情况下都与内存挂钩。进程崩溃问题通常可以使用gdb等调试工具轻松排查并解决。而对于进程内存膨胀这类问题,原因通常有三个:
1.内存泄漏。
2.分配器管理的空闲内存较多而造成的内存空洞。
3.有未统计使用的未知内存占用。
内存泄漏问题可以使用一些工具来检测。但是对于后两种问题,却一直没有比较通用的方法去确定。本文将介绍几种内存泄漏检测工具,并通过实际例子介绍一种分析堆内存占用量的工具和方法,帮助定位内存膨胀问题。
常见内存问题的分析方法
对于内存泄露问题,目前已经有较成熟的工具进行检测,这里简单介绍两个工具:AddressSanitizer和Valgrind。AddressSanitizer是google开源项目,可以用来检测内存泄漏和其他导致进程崩溃的内存问题。它的优势在于造成的额外CPU占用很小,但是需要重新编译项目,并且在编译的时候添加-fsanitize=address选项。在程序运行时如果有任何内存问题,就会终止进程并且打印出详细的错误信息。如果进程存在内存泄漏会在进程结束后,打印出所有泄漏的内存大小和申请这块内存的调用栈,如下图所示:
AddressSanitizer检测内存泄漏
另一个工具Valgrind的优势在于不需要重新编译,只需要在运行时加上valgrind --leak-check=yes即可。但是它的额外CPU开销会更大,大约是AddressSanitizer的十倍,功能上也不及AddressSanitizer完善。下表是两种工具功能和性能的比较:
AddressSanitizer与Valgrind对比这两种工具不光能够检测内存泄漏,对于堆栈溢出等问题也有比较好的效果。对于这两个工具更具体的介绍可以参照官方的使用文档:AddressSanitizer:https://github.com/google/sanitizers/wiki/AddressSanitizerValgrind:http://valgrind.org/而对于后两种原因,我们需要根据不同的分配器区别看待。常见的分配器有glibc默认的ptmalloc,google维护的tcmalloc以及facebook维护的jemalloc等。后两者都自带了内存分析工具(Heap Profiler),可以检测内存泄漏,也可以打印出详细的内存分配情况,对上述三个问题都有比较完善的排查方法,有兴趣可以查看官方文档,都讲得比较详细,这里不再介绍。而glibc默认的ptmalloc却不自带这样的工具,一种排查方法是去了解ptmalloc的实现和结构以后编写程序或者gdb脚本去分析进程的内存结构,我们接下来要介绍的一种内存分析工具就是以这种方法实现的。
针对ptmalloc的堆内存内用分析
1.环境
58自研的搜索引擎Esearch底层使用C++实现。Esearch在内存管理方面针对不同的场景会有不同的策略。对于对象生命周期有规律,高频分配的场景,Esearch实现了定制的内存池进行管理,并且这些内存都会在日志中统计占用量。而对于对象生命周期不确定,大小不确定的场景,内存池的代价可能高于通用分配器(new/malloc),所以直接使用通用内存分配器来分配。
对于通用分配器的选择,目前Esearch使用的是glibc2.12环境下默认的ptmalloc。之所以未使用tcmalloc或者jemalloc是因为经测试后发现后两者在常见场景下内存占用比ptmalloc要高,而且Esearch中对于内存分配热点已经使用了定制内存池,后两种分配器的优势其实并不明显。对于Ptmalloc完整结构的介绍可以阅读源码或者参阅华庭的《glibc内存管理ptmalloc2源代码分析》,这里只在用到时阐述一下原理,不做过多的介绍。
接下来我们通过一个例子来了解如何分析堆内存的用量。现在有一个realtime_searcher进程如下:
运行中的realtime_searcher进程 可见进程占用总物理内存27G,其中SHR内存占用18G,剩下的物理内存约9G。
2.工具介绍这里介绍一个非常强大的内存分析工具——core_analyzer。这是一个基于core文件的内存分析工具,由Michael Yan开发和维护并且开源在github上。利用它可以对glibc层的ptmalloc结构进行分析,还原进程真实的内存结构。目前core_analyzer支持的glibc2.3,2.4,2.5,2.12-2.23版本下的ptmalloc实现,这些版本对应的ptmalloc结构其实都大同小异。
Core_analyzer工具提供了以下功能:
Core_analyzer用户界面
[0] 打印core文件的基本信息,包括各个线程的信息,和内存段的信息等。[1] 水平搜索对象的引用[2] 垂直搜索对象的引用链,直到找到符号表中有调试信息的对象。[3] 打印线程共享对象[4] 打印给定地址段的内容[5] 通过地址查询所属chunk[6] 打印所给的地址周围的页(意义不明)[7] 打印整个堆的结构。该结构能够与ptmalloc的结构相对应[8]、[9]都是打印前N大的chunk,[9]还顺便打印出引用链[10] 根据它的名字为内存泄漏检测,但实际使用发现不仅耗时而且不正确[11] 退出原本的core_analyzer功能就上述这些,但是在实际使用过程中发现它对于线程较多(大于32)的进程支持并不好,所以在对其进行了改造后,又顺便添加了两个功能:[12] 以chunk大小分类,并按照占用总大小排序。[13] 打印出所有的chunk。由于结果可能较大,所以打印在out.txt文件中。项目原地址:https://github.com/yanqi27/core_analyzer.git改动过后的工具开源在igit上:http://igit.58corp.com/jichenxuan/core_analyzer_fixed.git 3.分析方法首先我们需要生成一个core文件。使用gcore命令可以不杀死进程同时生成core文件。执行命令:gcore 12763但是在生成core文件的过程中进程是暂停的,而且会将打开的mmap文件全部读取到内存中,因为它本身是通过gdb attach pid&& gcore实现的,所以对线上的服务需要酌情使用。现在我们拿到了一个core.pid文件。执行core_analyzerrealtime_searcher core.12763第一个参数为可执行程序。之后会看到core_analyzer询问core文件的main_arena和mp_地址。通过gdb命令print &main_arena 和 print &mp_可以拿到这两值
使用gdb打印main_arena和mp_的地址
如果安装了glibc对应版本的调试信息,就可以看出这两个变量分别为malloc_state和malloc_par,ptmalloc使用这两个结构体管理分配区(arena),其中每一个分配区都是malloc_state的一个实例,而malloc_par则是参数管理,为静态成员,全局有唯一的malloc_par实例。变量main_arena即为主分配区实例,ptmalloc中所有的分配区组织成一个环形链表的结构,如下图(图自http://core-analyzer.sourceforge.net/index_files/Page335.html),有了主分配区就可以遍历这个链表,拿到所有的分配区信息。
Ptmalloc结构
在当前环境下这两个结构体分别如下图所示,有兴趣可以对照ptmalloc的结构了解各个成员的含义这里不多介绍。不同版本的ptmalloc实现在这两个结构体的实现上有些许差异,但都是大同小异。Ptmalloc数据结构源码
将上述两个结构体的地址填入core_analyzer中,稍等一下加载过程,便可以看到它的功能界面。首先可以使用Print General Core Information可以看到每个线程的状态,收到的信号等,以及整个进程的内存地址从低到高的布局。可以对进程状态有个大致的了解。
各个线程状态
进程内存布局 执行Heap Walk便可将ptmalloc层的完整结构打印出来。由于打印内容较多,我们拆开来看。
打印ptmalloc堆结构 前边几行打印出记录在malloc_par结构中的内容,也就是一些参数。我们重点关注一下n_mmaps和mmaped_mem这两个值,就是说由ptmalloc申请的mmap chunk一共有70个,占用总空间为3213393920字节,记住这个值之后会分析到。下边从Main arena开始就是所有的分配区信息。Main arena:主分配区信息。主分配区从heap段分配空间,调用brk/sbrk增长,地址连续,所以只有一个堆块。Dynamic arena:非主分配区信息。从mmap段开始分配空间(注意这里的mmap含义为进程地址空间的mmap段,有别与前文中的mmap调用,后边还会有,注意区分),其下的每一行表示一个子堆(struct heap_info实例),每个子堆大小上限为64M,用完后需要调用mmap申请新的子堆,子堆以链表结构链在头节点后。每个子堆的信息从前到后依次为起止地址,共占用大小,使用中的chunk数量和占用大小,空闲的chunk数量和占用大小。每个线程都会有自己私有的分配区,在分配空间时会先检查该私有分配区是否被加锁,如果没有则加锁分配,否则遍历循环链表直到找到可用的分配区分配。极端情况可能遍历完整个环形链表也找不到一个可用分配区,这种情况下就分配一个新的分配区链到循环链表中。可以认为分配区数量等于线程数量。于是我们就可以大致猜测这些分配区都对应什么线程。例如上图中这些只有一个子堆的分配区可能就对应着空间占用较小的查询线程或者一些控制线程,而下图中的这个分配区有多个子堆,可能就对应着需要更多内存的文档线程。
含有多个子堆的分配区注意看这个线程的子堆大小,明显第一个子堆的总大小是小于64M的,而下边的子堆大小接近64M,这是因为每个分配区下的子堆虽然是链表结构,但是在逻辑上仍然看作是一块地址连续的堆空间,第一个子堆对应着堆顶,而堆的收缩就是通过模拟移动堆顶指针(实际上是由top_chunk管理)实现的。也就是说只有第一个子堆的堆顶释放了,下边的内存才可以释放,就像下图这样。当第一个子堆完全释放了,才可以释放第二个和下边的子堆。这就会造成如果下边的子堆上的内存已经释放,但是堆顶一直不释放,ptmalloc就无法将释放的内存归还给操作系统,也就是会造成内存空洞现象。
堆顶未释放而造成的内存空洞
就上边的例子来看,这个分配区大约有3M左右的内存空洞,比较正常,不是内存膨胀的主要来源。我们继续往后看,后边列出了所有的mmap段上申请的块
进程申请的mmap内存块
注意这里的块有些是调用malloc申请大于mmap_threashold的块时由ptmalloc分配的,而有些则是代码中直接使用mmap申请的空间。Core_analyzer无法对这两类空间进行区分,就比如继续看接下来打印的块:
含有较大的mmap块 还记得上文中提到的n_mmaps和mmaped_mem两个值吗?这两个值就对应了通过ptmalloc申请的mmap段空间,总大小为3G+,那我们至少我们能知道上图中大于3G的块都不是通过malloc接口申请的,而是通过mmap调用申请的。实际上这些较大的mmap块就是打开的索引文件。最后在打印的结尾处有一个汇总,说明了进程占用的总内存大小,以及使用中和空闲的内存大小,从这里也可以判断内存空洞现象是否严重。在这个例子中,空闲内存占用为775MB,占总内存的2%左右,因为空闲内存可以被复用,所以不算太大。
Ptmalloc层的内存统计 通过上边的方法,我们便可将进程的内存布局一览无余,并且可以判断是否存在大量的内存空洞。接下来就需要继续深究这些in-use内存都是由哪些对象在占用。我们可以先做个假设,所有大小相同的chunk极有可能是同一个类的实例,这个假设是接下来定位对象类型的前提,虽然这个假设不一定正确,但是大部分情况下是合理的。执行Sort by Type,这个功能按照chunk大小给所有的chunk分类,并且按照占用总大小排序。如果core文件较大可能需要较长的时间,需要耐心等待,但是会记录结果,也就是说只要跑出来一次,再次使用这个功能就直接输出结果而不用再跑一遍了。打印出如下图所示:
内存块分类排序 三列分别代表块大小,该大小的块个数和该大小块占用的总空间。按照占用总空间是升序排列。可以看到较大的几个块数量都很少,前边分析过这部分可能是索引文件,我们来加以验证。执行Get All Blocks,这个功能会打印出所有的chunk的大小和起始地址,注意会打印在当前目录下的out.txt中。
所有地址块的大小和起始地址 可以看到大小为4292967280的块共有三个,拿其中一个起始地址(十进制表示),使用vertical search,该功能会遍历所有的chunk,向上寻找引用到这个chunk的chunk,一直向上直到找到一个在符号表中有调试信息的对象。注意该过程可能耗费较长时间,需要耐心等待。
地址块引用链 上图中显示出多条调用链,这是由于对一个chunk的引用不只是有指针指向chunk的首部的情况,还可能指向chunk的内部。取上图中最后一条调用链举例,其意义为一块56大小的chunk在偏移量为40的位置指向了4292967280这个块中地址为0x7f8f802ec6a4的地方,而56大小块的虚指针为_vptr,这样,我们就可以通过gdb得到这个虚指针地址对应的符号为
使用gdb得到虚指针的符号表 从而得知这个56大小的块对应的类是某个ArrayReader,该类作用为读取正排索引值,内存结构如下图。可以得知偏移量为40的地址存放的是一个指针指向打开的索引文件。于是验证了前边的猜测。
ArrayReader数据结构 上边属于找到了直接引用该chunk的对象,而有些对象的引用可能是间接引用,中间通过好几层才引用到要查找的chunk,例如下图中就找到了一条间接引用链,但是定位方法跟直接引用是一样,通过gdb命令info symbol可见上层vptr指向LRUCache类,而所查询的chunk其实就是LRU缓存中双向链表的节点。
多层引用链以及顶层虚指针类型 如果还对结果存疑,可以使用Memory Pattern Analysis功能进一步验证,这个功能会打印出所给地址空间存储的内容,可以对照源码一一确认存储数据的值。例如确认一个HashNode的结构在内存中的布局:
内存存储值与代码中的结构一一对应 通过这种方式,对占用内存较大的几类chunk一一进行排查,便可知晓是那些对象在占用内存。但是不可避免有一些不同的类的大小却一样,前边的假设会将它们看做同一个类,这就需要对这种大小的chunk进行抽样排查,可以尝试写一些脚本辅助排查。
4.注意事项上文中介绍了使用core_analyzer分析堆内存占用的方法。虽然core_analyzer功能很强大,但是也存在一些缺陷,使用时有些注意事项需要了解。1)当前的原版core_analyzer最多支持到32个线程,而igit上改动过后的版本支持256个线程的进程,这部分以后可能会考虑优化,使其能够根据线程数自我扩展。2)引用搜索功能打印的结果只是潜在的引用链,因为对于例如某变量值刚好等于查询地址这些情况,core_analyzer并不能区分。所幸的是这样的情况极少。3)使用时候一定要有耐心,对于较大的core文件core_analyzer确实需要耗费较长的时间处理。4)[8]和[9]功能不要轻易尝试,很慢,而且跑不出来。5)内存泄漏检测功能形同虚设,结果难以理解。建议还是使用上文中介绍的两种工具来检测内存泄漏。6)Core_analyzer的使用前提是对应用的代码已经有比较详细的了解,至少要能分辨出代码中哪些内存的分配是调用了mmap而不是malloc。虽然core_analyzer的使用并不友好,但是目前为止,暂时还没有发现有其他工具有如此强大的功能。使用其他工具诸如libheap等来定位占用内存的对象无疑等同于大海捞针。所以可以说core_analyzer的功能之强大足以掩盖其用户体验差的问题。
内存优化建议
通过上述过程,我们已经能够分析堆内存的占用并且定位到内存膨胀的原因,接下来就可以针对这些原因对症下药。由于每个应用的复杂性和场景都不尽相同,这里只提一些简单的优化思路。对于内存空洞现象比较严重的情况,由于ptmalloc自身的策略原因,不适合分配长生命周期的内存,可以考虑尝试tcmalloc等其他分配器。如果非用不可,那一定要记得后分配的内存要先释放,这样才能够使堆收缩。如果分配区的堆顶不释放,那下边的内存就都不会释放。这在多线程进程中其实比较难实现,因为不能保证内存的申请和释放在同一个线程。另外可以通过调用malloc_trim()函数来手动收缩堆。官方文档上介绍只能够收缩主分配区的堆顶,但是经实践和源码分析发现,它其实可以遍历所有的分配区,并且对其中的空闲内存调用madvise建议操作系统回收该部分内存。虽然操作系统是否真的回收就是另一回事了,但是在实际的实践后发现,对内存空洞现象的改善效果明显,可以酌情使用。对于使用中的内存较多的情况,这类情况比较复杂,需要多加分析,寻找优化点。比如底层的数据结构是否有优化空间,尤其是对于实例比较多的类,例如哈希节点或者链表节点这些类,可能几个字节的优化就能带来较高的收益。还有在内存中尽量只保留有用的对象,无用的对象尽早释放掉,这样有利于内存空间的复用,一个例子就是过期的in-memory缓存要及时释放掉。如果使用定制内存池,一个细节就是内存池的分配器应该尽量避免再使用malloc/new分配空间,而是直接使用系统调用brk或者mmap申请空间。因为内存池通常会缓存一部分内存以提升分配效率,而如果保留的内存恰巧为分配区的堆顶,就会导致堆无法收缩从而造成比较严重的内存空洞问题。而且与底层分配器嵌套使用会使内存多次对齐,并且使内存块多加一层首部,造成不必要的浪费。
总结
本文介绍了Linux进程内存泄漏的检测方法,并且通过例子介绍了一种分析堆内存占用的方法。在面对内存膨胀问题时,如果排除了内存泄漏原因,可能就会觉得再往下排查有些无从下手。但是core_analyzer为我们提供了一种便捷的分析内存占用的方式,虽然这种便捷是相对的,也不妨碍它成为我们面对内存难题时一个很好的选择。内存管理是所有C++开发者需要面对的难题。C++语言在提供给开发者较高的内存支配自由度的同时,也将内存管理问题丢给开发者。优秀的内存管理策略可以充分利用内存支配的特性,使程序效率飞升。而复杂的内存管理一旦出现问题,定位起来也会非常困难。这就需要我们去深入到底层,了解内存的布局,一点一点分析内存管理的结构。使用一些工具协助排查也许会收获事半功倍的效果。当然除了本文介绍的方法外还有别的方法来分析内存问题,如果有更好的方法,也欢迎大家进行交流。
参考文献1.《glibc内存管理ptmalloc2源码分析》 庄明强2.http://core-analyzer.sourceforge.net/3.https://github.com/google/sanitizers/wiki/AddressSanitizerComparisonOfMemoryTools
作者简介姬晨烜,搜索技术部搜索平台团队后台开发工程师,负责搜索引擎的开发,参与搜索内核的内存和性能优化等工作。
END
相关推荐:独家| rocksdb compaction限速实践与源码分析
独家|一文了解58安全画像系统演进之路