周五晚上十点,线上一个C++后台服务的内存曲线又开始抬头,RSS已经爬到6GB左右,离cgroup上限只剩一截。群里同事的第一反应分成了三派:有人喊“用Valgrind跑一下”,有人建议“perf record抓一把调用栈”,还有人直接说“挂个Heap Profiler,看看哪个调用栈一直在涨”。这三句话听着都专业,但那天晚上我们实际做下来会发现,这三类工具根本不是同一个层面的东西,perf是采样器,Valgrind是精确记账器,Heap Profiler是快照对比器,不同的问题形态要选不同的工具,选错直接浪费一个晚上。
这篇文章就是系列实战里的工具对比附录,专门把Perf、Valgrind、Heap Profiler三套内存泄漏定位体系放在同一页比较。会讲清楚它们各自的原理、能回答什么问题、会漏掉什么,最后用一次真实排查过程把它们串起来。读完你会得到一个明确结论:什么场景用哪个,怎么搭配能最快把泄漏点钉死。
1. 先给三套工具定性:它们看到的“内存”根本不是一回事
1.1 为什么我在那次排查里没直接上Valgrind
Valgrind是绝大多数人提到内存泄漏时第一个想到的工具,因为它确实精准。但精准是有代价的,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::Allocate
- ProtoSerialize
- HashTable::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-origins=yes,它甚至能追溯一块未初始化内存的来源,这对查“偶发脏数据”特别有用。
那为什么它慢?因为每一条内存读写指令都要经过额外的记账逻辑,翻译后的代码膨胀好几倍,命中率也下降,业界普遍说性能降低10-50倍并不夸张。这也是它只适合离线复现的原因。
3.2 一条命令看懂memcheck输出
实际使用我基本固定用这组参数:
valgrind --leak-check=full \ --show-leak-kinds=all \ --track-origins=yes \ --log-file=vg.log \ ./your_app arg1 arg2跑完以后,日志的末尾会有类似这样的LEAK SUMMARY:
==30129== 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出来的匿名内存不经过malloc,memcheck默认不追踪。很多自研内存池、jemalloc、tcmalloc直接调用mmap的就不在账本里。
- 静态分配和线程局部存储不经过堆接口,memcheck只能通过“still reachable”偶尔扫到,无法判断是不是“预期存活”。
- 内核态、GPU、DMA等内存彻底在它的视野之外。
还有一个实践坑:如果程序使用了自建内存池,需要在代码里用Valgrind客户端请求(比如VALGRIND_MALLOCLIKE_BLOCK)告诉它哪些块是“等价于malloc”的,否则Memcheck会把内存池统一算成still reachable甚至possibly lost,结果会误导你。这属于高级用法,但遇到大型框架时几乎绕不开。
4. Heap Profiler(gperftools):快照对比,适合看“慢性增长”
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_INTERVAL=7200 \ ./server其中HEAP_PROFILE_TIME_INTERVAL=7200表示每2小时自动写一个dump,适合做大跨度观察。如果想按分配量触发,可以设置HEAP_PROFILE_ALLOCATION_INTERVAL,默认大约每1GB累计分配写一次。如果程序里大量使用mmap直接映射,还要加:
HEAP_PROFILE_MMAP=true否则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 从原理到结果:一张对比表
把前面的分析压缩成一张表,适合直接保存备查:
| 维度 | perf | Valgrind memcheck | gperftools Heap Profiler |
|---|---|---|---|
| 底层机制 | 内核事件采样 | 动态二进制插桩 | 替换malloc并定期快照 |
| 能否直接证明泄漏 | 不能,只能给代理指标 | 能,精确到调用栈和字节 | 能,通过增长趋势证明“只增不减” |
| 定位粒度 | 热点函数级别 | 文件行号级别 | 调用栈级别 |
| 性能开销 | 几个百分点 | 10-50倍变慢 | 20%-80%左右 |
| 是否可上生产 | 可以 | 不建议 | 谨慎,可灰度 |
| 是否需改程序 | 不需要 | 不需要 | 链接或LD_PRELOAD注入 |
| 追踪mmap | 间接看缺页 | 默认不追踪 | 需HEAP_PROFILE_MMAP=true |
| 适合场景 | 线上粗筛、定方向 | 离线小用例、精确定位 | 长周期慢性增长、增量对比 |
| 典型误报/盲区 | 热点不等于泄漏 | 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-pointer,strip掉符号的线上二进制会把perf和pprof的调用栈变成一串地址,排查效率断崖式下降。我见过太多团队线上二进制全strip,最后只能靠一堆十六进制地址反向对照addr2line,非常痛苦。
6. 复盘一次真实的内存泄漏定位:Valgrind的“过关”和漏洞
6.1 第一轮:Valgrind在测试环境抓到“一小块”泄漏
去年我们处理过的一个网关服务,就是文章开头说的那个场景:48小时内RSS从2.5GB涨到6.8GB,cgroup告警器基本每两小时响一次。值班同事先在测试环境用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::Make
- SessionManager::GetOrCreate
- HashTable::Resize
前两个是请求处理的主路径,有大量新对象创建,第三个则是哈希表扩容时的大块内存分配。这三个路径看起来都比较“正常”:请求来了当然要创建context,若表占用率高当然要扩容。但这个结果把怀疑范围从整个服务压缩到了SessionManager这一层。我们确认了一个关键现象:内存越涨越慢,涨幅曲线接近对数形态,说明不是每来一个请求都泄漏,而是某个会“累积沉淀数据”的容器在缓慢膨胀。
6.3 第三轮:Heap Profiler的dump雪球撬出真凶
确定SessionManager有嫌疑后,我们在预发环境挂上gperftools Heap Profiler,用HEAP_PROFILE_TIME_INTERVAL=3600每小时生成一个dump,连续收集了8个。用pprof做base对比:
pprof --text --base=server.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_MMAP
gperftools的Heap Profiler如果只替换malloc,对直接使用mmap分配内存的路径是瞎的。现代C++服务里,mmap要么来自高并发内存池(jemalloc、我们自己写的HOOK),要么来自文件映射和大块共享内存。一旦这些路径占大头,默认配置下的dump会严重失真,增长率低得吓人。
解决办法就是打开HEAP_PROFILE_MMAP=true,然后确认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热点。内存泄漏定位的难点从来不是工具不够多,而是手里的工具与问题的形态不匹配。先想清楚你的泄漏是“指针丢失型”还是“只增不减型”,再决定让谁上场,效率差距是数量级的。