ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Perf、Valgrind与Heap Profiler:C++内存泄漏定位工具对比

Perf、Valgrind与Heap Profiler:C++内存泄漏定位工具对比 周五晚上十点线上一个C后台服务的内存曲线又开始抬头RSS已经爬到6GB左右离cgroup上限只剩一截。群里同事的第一反应分成了三派有人喊“用Valgrind跑一下”有人建议“perf record抓一把调用栈”还有人直接说“挂个Heap Profiler看看哪个调用栈一直在涨”。这三句话听着都专业但那天晚上我们实际做下来会发现这三类工具根本不是同一个层面的东西perf是采样器Valgrind是精确记账器Heap Profiler是快照对比器不同的问题形态要选不同的工具选错直接浪费一个晚上。这篇文章就是系列实战里的工具对比附录专门把Perf、Valgrind、Heap Profiler三套内存泄漏定位体系放在同一页比较。会讲清楚它们各自的原理、能回答什么问题、会漏掉什么最后用一次真实排查过程把它们串起来。读完你会得到一个明确结论什么场景用哪个怎么搭配能最快把泄漏点钉死。1. 先给三套工具定性它们看到的“内存”根本不是一回事1.1 为什么我在那次排查里没直接上ValgrindValgrind是绝大多数人提到内存泄漏时第一个想到的工具因为它确实精准。但精准是有代价的Valgrind内部采用动态二进制翻译程序每条内存操作都会被插桩运行速度普遍要慢10到50倍。我们的服务要承接真实流量生产环境根本撑不住这种开销拉回测试环境压测又很难完整模拟线上那种几十个模块同时跑的复杂交互。Valgrind在测试环境里跑了一个小时压测最后报告“definitely lost”只有1.2MB而线上内存涨了几个GB这明显不是同一个问题。后来复盘我意识到Valgrind擅长的是“可短时间复现、进程能干净退出”的泄漏而线上最常见的反而是“运行几天、内存缓慢爬坡”的慢性增长这类问题Valgrind既不经济也不一定有结论。1.2 一句话总结三者的分工先把我这些年用下来的结论放在最前面后面再展开论证perf是采样器它通过内核事件采样找出“哪个代码路径正在频繁地做某件事”比如频繁触发缺页、频繁访问内存。它不追踪内存块生命周期所以它不能直接证明泄漏只能缩小怀疑范围。Valgrind memcheck是记账器它精确记录每一次malloc、free以及内存读写进程退出时能给出哪些块确实没释放、哪些可能越界。它是三者里定位最精确的但开销最大只适合离线小规模复现。Heap Profiler我主要指gperftools的heapprof是快照对比器它定期记录每个调用栈的存活分配量通过对比多个时间点的dump告诉你哪个调用栈的内存“一直在净增长”。这是定位慢性内存泄漏最直接的手段。认清这三个定位后面所有命令和参数才不容易用错。2. perf采样器不是内存泄漏检测器2.1 perf的内存事件本质上是在采样“正在发生的事”很多人把perf当成万金油一上来就敲perf record -g以为抓到的调用栈就是泄漏点。这是最大的误解。perf的工作方式是采样。它会按照指定的事件频率或周期抓取当前运行现场、调用栈、相关寄存器等信息。它擅长回答“CPU时间都花在哪儿了”“哪些内存访问最活跃”这类问题但不擅长回答“哪块内存分配后一直没释放”。原因是perf没有“内存块生命周期”的模型。Leak的本质是“某块内存存活时间超过预期”它是一个跨时间的状态而perf采到的每一帧都是瞬时快照无法知道一个地址是第一次出现还是已经存活了很久。所以如果你指望perf直接告诉你“这里泄漏了”它做不到。但它可以做好一件事粗筛。比如进程的匿名内存持续上涨上涨总是伴随着新的内存页被分配而这些新页首次被访问时会触发缺页异常。用perf去采集page-faults的调用栈就能看到“谁频繁地触碰新内存”。如果某些栈长期霸榜且和RSS增长时间吻合那这基本就是分配路径接下来只需要沿这个路径去追释放逻辑。2.2 用缺页异常间接勾出“持续分配路径”我在线上定位慢性内存增长时第一把武器经常是page-fault采样命令很简单# 追踪30秒内的缺页事件带调用栈 perf record -e page-faults -g -p $(pgrep gateway) -- sleep 30 # 看聚合结果 perf report -g graph输出里通常会出现一串热点比如PoolAllocator::AllocateProtoSerializeHashTable::Resize看到这类调用栈先别急着下结论。PoolAllocator::Allocate有可能只是在复用空闲块ProtoSerialize可能只是在做序列化时的临时分配。缺页热点只能告诉你“这些路径在持续申请并触碰新内存”至于这些内存是不是泄漏还要结合后面的存活量对比来看。perf里还有一个命令叫perf mem它采样的是CPU访存指令比如load和store。它能告诉我们哪些数据结构是“真热点”但注意perf mem采样的是访存活跃度不是分配行为。一块被频繁读写的泄漏内存会在perf mem里显形一块“分配完就再也无人访问”的死内存则完全隐身。那类泄漏只有靠快照对比工具才能看出来。2.3 什么时候该用perf什么时候别抱希望我的经验是perf适合线上、长周期、低侵入的三个场景。它几乎不要求程序改动开销通常在几个百分点以内可以安全地在生产机抓几分钟到几十分钟数据。反过来如果问题已经被压缩成一个很小的复现用例比如几百行代码、几十毫秒就能执行完那就没必要用perf扫调用栈直接上Valgrind一分钟内就能给出精确到行的答案。3. Valgrind memcheck精确但昂贵的“内存账本”3.1 memcheck的记账模型为什么能精确到文件行号Valgrind memcheck的底层是动态二进制插桩。它先把程序的机器码翻译成中间表示在每条会读写内存的指令前后插入检查逻辑同时替换掉libc中的malloc、free、realloc、memcpy等函数。这样一来程序里每一次堆内存分配、释放、访问都相当于被记进了一本账。所以Memcheck能在程序退出时告诉你三件事哪些堆块确实没释放definitely lost、哪些可能丢指针导致无法释放possibly lost、哪些虽然没释放但指针还在still reachable。如果开启了--track-originsyes它甚至能追溯一块未初始化内存的来源这对查“偶发脏数据”特别有用。那为什么它慢因为每一条内存读写指令都要经过额外的记账逻辑翻译后的代码膨胀好几倍命中率也下降业界普遍说性能降低10-50倍并不夸张。这也是它只适合离线复现的原因。3.2 一条命令看懂memcheck输出实际使用我基本固定用这组参数valgrind --leak-checkfull \ --show-leak-kindsall \ --track-originsyes \ --log-filevg.log \ ./your_app arg1 arg2跑完以后日志的末尾会有类似这样的LEAK SUMMARY30129 LEAK SUMMARY: 30129 definitely lost: 1,204 bytes in 7 blocks 30129 indirectly lost: 0 bytes in 0 blocks 30129 possibly lost: 8,196 bytes in 54 blocks 30129 still reachable: 118,208 bytes in 3,092 blocks 30129 suppressed: 0 bytes in 0 blocks这几类的含义和处理思路我在实战里总结成一张表类型含义处理思路definitely lost指针丢失无法释放基本是铁证优先修indirectly lost结构体丢失导致内部指针也无法释放跟着definitely lost一起修possibly lost指针可能被藏在某处或已部分丢失需要结合代码看可能误报still reachable指针还在但退出前没释放不一定是问题但要结合生命周期看suppressed被压制规则过滤掉一般是第三方库可忽略3.3 不归memcheck管的三类内存Valgrind虽然精确但边界非常明确以下三类它基本管不到mmap出来的匿名内存不经过mallocmemcheck默认不追踪。很多自研内存池、jemalloc、tcmalloc直接调用mmap的就不在账本里。静态分配和线程局部存储不经过堆接口memcheck只能通过“still reachable”偶尔扫到无法判断是不是“预期存活”。内核态、GPU、DMA等内存彻底在它的视野之外。还有一个实践坑如果程序使用了自建内存池需要在代码里用Valgrind客户端请求比如VALGRIND_MALLOCLIKE_BLOCK告诉它哪些块是“等价于malloc”的否则Memcheck会把内存池统一算成still reachable甚至possibly lost结果会误导你。这属于高级用法但遇到大型框架时几乎绕不开。4. Heap Profilergperftools快照对比适合看“慢性增长”4.1 为什么它能看“增长”快照对比而非事件审计gperftools的Heap Profiler是另一套思路。它在链接阶段或运行前通过替换malloc/new把程序的堆分配记录接管过来然后定期把“当前所有仍存活堆分配”的调用栈分布写成一个dump文件。注意这个机制和Valgrind的关键区别它不逐条记录malloc/free事件它只关心“此刻还有多少字节活着”。正因为是快照你可以把第1小时、第4小时、第20小时的dump放在一起对比看哪个调用栈的存活量一直在增加。这个“增长量”正是慢性内存泄漏最核心的特征。它不能告诉你“哪一行代码忘了释放”但能告诉你“哪个业务路径的分配在持续净增长”。在长周期进程里这种定位往往比Valgrind的精确记账更实用因为运行速度和开销都小得多甚至在某些服务里可以直接短时间挂上生产环境。4.2 实战链接与运行参数接入方式有三种我列一个最常用的组合源码接入在代码里手动控制启动和dump#include gperftools/heap-profiler.h HeapProfilerStart(/data/heap_logs/server); // 业务逻辑... HeapProfilerDump(checkpoint_after_peak); // ... HeapProfilerStop();不改代码运行时用环境变量注入LD_PRELOAD/usr/lib/x86_64-linux-gnu/libprofiler.so.0 \ HEAPPROFILE/data/heap_logs/server \ HEAP_PROFILE_TIME_INTERVAL7200 \ ./server其中HEAP_PROFILE_TIME_INTERVAL7200表示每2小时自动写一个dump适合做大跨度观察。如果想按分配量触发可以设置HEAP_PROFILE_ALLOCATION_INTERVAL默认大约每1GB累计分配写一次。如果程序里大量使用mmap直接映射还要加HEAP_PROFILE_MMAPtrue否则mmap分配的部分会从dump里漏掉。这个工具的运行时开销虽然比Valgrind小很多但也不是零。我见过的大部分C服务开启后负载大约增加20%到80%取决于分配频率和栈回溯成本。上线前最好先在灰度环境观察一轮确认没有明显拖慢再全量。4.3 用pprof对两个dump做diff定位泄漏栈生成一堆dump文件不是目的关键是对比增量。我一般在服务启动后先手工触发一个基准dump然后隔几小时再取一个后续dump用pprof做差值分析# 查看某个dump的完整分布 pprof --text ./server /data/heap_logs/server.0024.heap # 以第一个dump为基准看后续dump的相对增长 pprof --text --base/data/heap_logs/server.0001.heap \ ./server /data/heap_logs/server.0024.heap第二命令输出里会有一个“xxMB”或“Growth”列重点看哪些调用栈的存活分配净增长最大。如果某个调用栈在前几个dump里几乎不增长从某个时间点开始一路向上且只增不减基本就是泄漏路径。然后再结合代码review去查这个路径的释放逻辑定位速度会非常快。需要提一句现在市面上的pprof有两套一套是gperftools自带的Perl版pprof另一套是github.com/google/pprof的Go实现两者参数略有差别。上面示例是gperftools系老pprof习惯在CentOS和Ubuntu的libgoogle-perftools-dev包里都能找到。5. 三张工具对比表与一套选型流程5.1 从原理到结果一张对比表把前面的分析压缩成一张表适合直接保存备查维度perfValgrind memcheckgperftools Heap Profiler底层机制内核事件采样动态二进制插桩替换malloc并定期快照能否直接证明泄漏不能只能给代理指标能精确到调用栈和字节能通过增长趋势证明“只增不减”定位粒度热点函数级别文件行号级别调用栈级别性能开销几个百分点10-50倍变慢20%-80%左右是否可上生产可以不建议谨慎可灰度是否需改程序不需要不需要链接或LD_PRELOAD注入追踪mmap间接看缺页默认不追踪需HEAP_PROFILE_MMAPtrue适合场景线上粗筛、定方向离线小用例、精确定位长周期慢性增长、增量对比典型误报/盲区热点不等于泄漏glibc缓存、内存池误报静态分配、部分mmap漏记5.2 选型决策流程先定性再定位我常年用这套判断顺序几乎没跑偏过先确认泄漏形态。用pidstat、/proc/PID/status里的VmRSS、以及cgroup的memory.current看内存是几十分钟内猛涨还是几天内缓慢爬坡。如果是几分钟内可复现且进程能退出优先Valgrind直接拿到行号级结论。如果是长周期增长、线上不能停机先用perf抓缺页采样看哪些调用路径在持续触碰新内存确定嫌疑模块。确定嫌疑模块后挂gperftools Heap Profiler收集几个dump用pprof做增长diff锁定具体调用栈。最后如果需要“代码行号级”证据再把这个调用栈对应的模块摘出来在离线环境写一个小用例用Valgrind复现。整个流程可以简单理解成perf负责缩小战场Heap Profiler负责固定罪证Valgrind负责给出最终代码定位。三个工具不是竞争者而是上下游关系。5.3 组合拳与“线上实时跑”的取舍这里有个和直觉相反的教训越精确的工具越难上生产越能上生产的工具越不精确。Valgrind精确到行但它让程序慢几十倍perf几乎不打扰程序但它给不了“泄漏”两个字的结论。所以线上问题的标准答案不是一个工具跑到底而是按上面的三级组合拳逐步收网。另外一个细节所有依赖调用栈的工具都需要靠谱的符号表。编译时建议保留-g和-fno-omit-frame-pointerstrip掉符号的线上二进制会把perf和pprof的调用栈变成一串地址排查效率断崖式下降。我见过太多团队线上二进制全strip最后只能靠一堆十六进制地址反向对照addr2line非常痛苦。6. 复盘一次真实的内存泄漏定位Valgrind的“过关”和漏洞6.1 第一轮Valgrind在测试环境抓到“一小块”泄漏去年我们处理过的一个网关服务就是文章开头说的那个场景48小时内RSS从2.5GB涨到6.8GBcgroup告警器基本每两小时响一次。值班同事先在测试环境用Valgrind跑了20分钟压测结果是definitely lost: 1.2MB in 8 blocks still reachable: 210MB in 906 blocks那个definitely lost很快被修复了无非是一个临时对象忘删。但所有人都没想到修完以后线上内存照样涨而且涨速没怎么变。现在回头看那1.2MB就是Valgrind能确认的“真相”但它只是整个泄漏中的九牛一毛。真正的大头以“still reachable”的形式躺在日志里被大家忽略了。这个案例给我最大的教训是Valgrind报告的“definitely lost”是铁证但铁证不等于全部真相。一个运行数天的进程里大量“存活但无价值”的对象会以still reachable形式存在它们不是经典意义的“指针丢失”但它们同样蚕食着内存。Valgrind的记账模型天然倾向于发现“指针彻底丢失”的泄漏而生产环境里最常见的是“该淘汰的缓存不淘汰”“该清空的map不清理”这类逻辑泄漏。6.2 第二轮perf缺页采样锁定向三个热点路径Valgrind这条路走不通后我们转回生产机做低开销采样。这次带着更明确的问题到底是哪个模块在持续“要新内存”。perf record -e page-faults -g -p $(pgrep gateway) -- sleep 30 perf report -g graph结果前三位热点路径非常清晰RequestContext::MakeSessionManager::GetOrCreateHashTable::Resize前两个是请求处理的主路径有大量新对象创建第三个则是哈希表扩容时的大块内存分配。这三个路径看起来都比较“正常”请求来了当然要创建context若表占用率高当然要扩容。但这个结果把怀疑范围从整个服务压缩到了SessionManager这一层。我们确认了一个关键现象内存越涨越慢涨幅曲线接近对数形态说明不是每来一个请求都泄漏而是某个会“累积沉淀数据”的容器在缓慢膨胀。6.3 第三轮Heap Profiler的dump雪球撬出真凶确定SessionManager有嫌疑后我们在预发环境挂上gperftools Heap Profiler用HEAP_PROFILE_TIME_INTERVAL3600每小时生成一个dump连续收集了8个。用pprof做base对比pprof --text --baseserver.0001.heap ./server server.0008.heapGrowth列几乎是一面倒的答案PC-48 2.9GB SessionManager::GetOrCreate PC-102 210MB RequestContext::Make 其他 几十MB ...SessionManager::GetOrCreate累计净增长接近3GB继续往下追代码发现问题出在一个以session_id为key的成员map里。每次请求到这个节点都走GetOrCreate命中以后把ref_count刷新但某些异常路径只调用了GetOrCreate没有调用配套的Release。也就是说session对象创建了引用计数却永远不归零清理线程判断“还在使用中”就永远不淘汰。这条路径在平时流量下每天只积累几百兆但连续跑一天多就撞上了内存上限。修法很简单在异常分支补上Release调用另外给清理线程增加一个“按最后访问时间淘汰”的兜底逻辑避免任何单点路径漏掉归还。改动不到20行上线后观察48小时RSS稳定在2.7GB附近增长曲线基本拉平。7. 踩坑记录三个很容易被忽略的细节7.1 Valgrind的still reachable到底算不算泄漏很多人看到still reachable就认定是泄漏实际上要分情况。glibc的线程本地缓存、内存池的头节点、被静态指针长期持有的全局对象都可能以still reachable形式存在。判断标准只有一个这块内存是否会随进程生命周期无限增长如果它是固定大小、启动后不再变那就不算泄漏如果它每次业务触发都在膨胀即使指针还存在也该按泄漏对待。Valgrind的缺陷在于它只能在“退出瞬间”打一张总表无法告诉你这些still reachable的内存在几十个小时里是怎么变化的。想看变化必须换Heap Profiler。7.2 Heap Profiler漏掉的mmap与HEAP_PROFILE_MMAPgperftools的Heap Profiler如果只替换malloc对直接使用mmap分配内存的路径是瞎的。现代C服务里mmap要么来自高并发内存池jemalloc、我们自己写的HOOK要么来自文件映射和大块共享内存。一旦这些路径占大头默认配置下的dump会严重失真增长率低得吓人。解决办法就是打开HEAP_PROFILE_MMAPtrue然后确认LD_PRELOAD真的生效。验证方法很土但很有效grep -m1 libtcmalloc\|libprofiler /proc/$(pgrep server)/maps如果加载了libtcmalloc或libprofiler再继续分析。否则后面所有的dump都可能是假的。7.3 符号与框架指针三个工具共同依赖的地基最后说一个横跨所有工具的坑编译优化的影响。perf和gperftools的栈回溯高度依赖frame pointer如果编译时用了-O2且没加-fno-omit-frame-pointer内联函数会消失栈回溯可能直接断掉Valgrind虽然不依赖frame pointer但它需要DWARF调试信息来匹配行号strip太多符号同样会失去精确位置。我的标准做法是线上二进制保留-g调试信息单独存一份部署时strip成精简版但保留符号映射文件编译统一加-fno-omit-frame-pointer这会让二进制变大一点但对所有排查工具都友好如果要分析线上问题先从cgroup的memory.current和/proc/PID/smaps维度确认增长类型再决定上哪个工具。到现在我已经习惯了把这三件套当作一条流水线perf粗筛、heapprof拿到增长调用栈、Valgrind做最终定位。顺序反过来的那些夜班基本都在等一个永远不会结束的Valgrind进程或者在翻一堆看不出趋势的perf热点。内存泄漏定位的难点从来不是工具不够多而是手里的工具与问题的形态不匹配。先想清楚你的泄漏是“指针丢失型”还是“只增不减型”再决定让谁上场效率差距是数量级的。
返回列表