拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Linux内存泄漏排查实战:perf、Valgrind与Heap Profiler的递进用法

Linux内存泄漏排查实战:perf、Valgrind与Heap Profiler的递进用法

内存泄漏这种问题,放到生产环境里基本都是先闹一段“越来越慢”“内存见底”“重启就好”的幺蛾子,然后大家才被迫认真查。查的时间往往比修的时间还长,就是因为好多人一上来就敲 valgrind,结果要么根本跑不起来,要么把 50 倍开销的锅全甩给工具。Linux 环境下定位内存泄漏,perf、Valgrind、Heap Profiler 这三类工具各管一段,它们不是替代关系,而是递进关系。这篇附录我尽量把每个工具的定位、原理、常用参数、坑点讲透,读完之后你至少能知道:眼下这个泄漏,到底该用哪一个,怎么用,什么时候换工具。

先说个结论:perf 适合在生产环境做粗筛,Valgrind 适合在开发环境拿证据,Heap Profiler(我默认指 gperftools 的 heap profiler)适合在压测环境看趋势。三者的代价、精度和适用阶段完全不同,选错工具基本等于白忙。下面按这个思路展开。

1. 先搞清楚你要定位的是哪一种“内存泄漏”

1.1 两种增长形态,决定完全不同的排查路线

常说的内存泄漏其实有两种。第一种是“真泄漏”,C/C++ 里最常见:malloc 出来的指针丢了,或者 new[] 配 delete 而不是 delete[],链表节点删了头忘了遍历,shared_ptr 互相引用导致引用计数永远到不了零,等等。这类问题 Valgrind 的 Memcheck 能直接判死刑,工具会把它归成 definitely lost。

第二种是“业务膨胀”,也叫应用内膨胀或逻辑泄漏:对象还有人引用着,引用计数也是正的,但业务上这些对象已经永远不会被用到了。典型例子是缓存不淘汰、日志队列不断堆积、任务队列只进不出、线程池里积累了废弃的上下文。这类问题 Valgrind 看到的结果往往是“still reachable”,你没法直接报错,必须结合业务判断。很多同学在这上面栽跟头,以为 still reachable 不是泄漏就放过了,结果 RSS 曲线照样一路往上走。

所以先想清楚:你的现象是“长时间空闲内存也不回落”,还是“并发峰值后内存居高不下”?前者多数是真泄漏,后者要重点怀疑逻辑膨胀。这一字之差,决定你应该首先上 Valgrind 还是先上 perf 加 Heap Profiler。

1.2 工具的本质分层:采样、插桩与插桩优化

把三个工具摆开看,它们的实现机制完全不同。

perf 是内核事件子系统,它从内核视角去采样内存分配事件,记录调用栈;它不干扰你的业务进程逻辑进程逻辑,只是在一个固定频次或者特定事件发生时“拍照”。所以 perf 的开销可以控制在极低水平,在生产环境挂几分钟是可行的。

Valgrind 是动态二进制翻译,整个程序被放到一个模拟 CPU 上执行,每条指令都会经过 Valgrind 的翻译层。Memcheck 在这个翻译层上维护“影子内存”,记录每个字节的合法性、初始化状态,还额外在分配块上下加 redzone 检查越界。代价就是 20 到 50 倍减速,程序大一点基本没法在日常环境跑,只能拿来跑回归用例或者复现现场。

Heap Profiler 走的是另一条路:用 LD_PRELOAD 把 glibc 的 malloc/free 替换成 tcmalloc,然后在 malloc/free 入口拦截,通过哈希统计每个调用栈的分配热度和内存占用,周期性地生成快照文件。开销大约 2 到 5 倍,压测环境完全扛得住,而且不用改业务代码。

一句话总结:perf 是流量监测,告诉你“谁在频繁分配”;Heap Profiler 是接口日志,告诉你“哪个调用栈的堆内存一直在涨”;Valgrind 是抓包取证,能精确告诉你“这个指针真的彻底丢了”。

1.3 为什么不能只依靠某一个工具

有人觉得 Valgrind 最牛,能报 definitely lost,直接用它不就行了吗?问题是生产服务器进程动不动几十个线程、几十 G 内存,Valgrind 跑起来慢到业务会直接超时,而且日志量大到没人敢完整看。反过来,perf 虽然能在生产上跑,但它只负责统计分配事件,根本不会去追踪指针是否还被引用,你没法从 perf 报告里看到“这个块到底有没有被释放”。Heap Profiler 则有大量的快照文件,能看出增长曲线,但如果你连业务代码都不熟,pprof 输出的函数栈也只是个名字,判断最后还是得人来做。

所以现实场景里,一次完整的泄漏排查往往是先用 perf 把嫌疑模块圈出来,再切到 Valgrind 去拿精确证据,修复之后再挂 Heap Profiler 做整夜验证。三种工具是排查链路的不同环节,不是三选一。

2. perf:生产环境先行的轻量粗筛

2.1 perf 能做什么:内核事件采样与调用栈

perf 的完整能力远超“内存泄漏定位”,它本身是 Linux 自带的性能剖析框架,但这里我们关心的内核事件子系统和调用栈采样能力。你可以让内核在某个事件发生的瞬间,记录当前的进程、函数地址和用户态调用栈,之后用 perf report 把数据聚合出来,就知道哪个模块是分配的大头。

针对内存分配,内核相关的 tracepoint 主要有几个:kmem:kmalloc、kmem:kfree、kmem:kmem_cache_alloc、kmem:kmem_cache_free,还有伙伴系统的 mm_page_alloc。我们关注的是分配和释放的不平衡。如果你在 60 秒内看到 kmem:kmalloc 数量巨大,而对应 kfree 几乎没有,至少说明这部分分配路径没有释放,或者释放发生在用户态的不透明层面,值得往下查。

需要明白的一点是,perf 里看到的是内核态分配事件,跟用户态的 malloc 并不是一一对应的。glibc 的 malloc 收到小块请求时,往往直接复用 tcmalloc 之前分配好的 arena 空间,不一定会触发内核的 kmalloc。所以别指望用 perf 直接算出“泄漏了多少字节”,它只能给你“谁在频繁走内核分配路径”这样一个相对粗的方向。

2.2 常用的内存类 tracepoint 与命令组合

我平时在松一台生产机器做粗筛时的标准姿势是这样:

# 全系统采样分配事件,按调用栈聚合,持续60秒 perf record -a -e kmem:kmalloc -F 99 -g -o /tmp/perf.data -- sleep 60

-F 99 是采样频率,99Hz 对生产环境压力不大。如果怀疑事件太多导致 overhead 高,可以降到 49 或者只采样特定进程:

# 只看指定进程(pid)的分配事件 perf record -e kmem:kmalloc -p 12345 -F 99 -g -o /tmp/perf.data -- sleep 60

分析数据用:

perf report -i /tmp/perf.data --stdio --no-children

--no-children 的意思是只看实际命中的叶子函数,不要把所有累加都堆到根上,否则你会看到一个很宽的调用栈列表,反而抓不住“到底谁分配了最多”。

如果内核事件跑不了,说明系统里的 perf_event_paranoid 限制太严,手动调一下:

# 临时放开,建议只在测试阶段设置 sudo sysctl kernel.perf_event_paranoid=1

perf 报告里调用栈能不能还原成函数名,取决于你有没有符号表和栈帧。编译程序时建议至少加 -g 和 -fno-omit-frame-pointer,否则抓出来全是地址,还得花时间 addr2line 反解。

2.3 perf 的三类盲区(实操体会)

用 perf 定位内存问题,有三个坑我是踩过的。

第一个坑是用户态 malloc 和内核态 kmalloc 之间的映射问题。前面说过,glibc malloc 的 fastbin 会缓存小块内存,大量 malloc/free 完全不会触发内核分配。所以如果进程 RSS 只涨不快,分配次数也不多,perf 的记录里可能干干净净,啥也看不出来。这时候你得配合 /proc/ /smaps 或者 VmRSS 的监控,先确认“到底有没有人真的在向内核要内存”,再决定下一步。

第二个坑是快路径分配根本不在 tracepoint 里。比如 per-CPU 分配、页表分配、内核里某些栈路径上的 fastpath,这些事件没有暴露成稳定的 tracepoint,你采样采不到。遇到这种情况只能接受现实:perf 是方向指引,不是定案工具。

第三个坑是采样可能丢失调用栈。生产进程里经常是深调用栈加高并发,-F 调太高反而会因为 perf 自身的事件排队导致丢失,栈也经常被截断。所以我一般会先看分配热点排行前 20 的函数,再手动 grep 相关的源码路径,而不是直接沉到所有调用栈里。这能省很多时间。

3. Valgrind:慢,但能拿到底稿

3.1 Memcheck 的原理与四种泄漏归因

Valgrind 的 Memcheck 不是传统的调试器,它把整个进程放到自己定制的 CPU 模拟层里跑,每条指令都要先被 Valgrind 翻译成中间表示,再交给宿主 CPU 执行。需要检测内存错误时,Memcheck 会维护一份影子内存,记录每个地址的已定义/未定义状态,并在每次 malloc 返回时记录地址和大小,释放时就抹掉记录。程序退出后,它扫描全局变量区和栈区,找出“没有任何指针可达的已分配内存块”,据此判断泄漏类型。

Memcheck 输出的泄漏类型有四种,很多新手只看 definitely lost,这是不够的:

  • definitely lost:分配后没有任何指针指向,100% 泄漏,直接判死刑。
  • indirectly lost:本身没丢,但它被一个 definitely lost 的父块所持有,比如结构体里嵌套的指针。
  • possibly lost:存在某个指针指向内存块内部,但无法确定它是否真的是“所有权指针”,常见于指针被转换成整数、再转回来。
  • still reachable:全局变量或栈上仍有指针指向该块,按规范不算泄漏,但如果业务上认定这些块“不该活着”,它依然是问题。

修复 definitely lost 和 indirectly lost 是硬功夫,而 still reachable 需要结合业务去看。比如启动时加载一次的全局配置对象,进程跑完后还在理论上“可达”,但严格说并没泄漏;但如果它是每个请求都 new 出来挂到全局缓存队列里,那就算 still reachable 也一样要处理。

3.2 让 Memcheck 跑起来:参数要点

一个可复现泄漏的有罪判定通常这样起:

valgrind --tool=memcheck \ --leak-check=full \ --show-leak-kinds=all \ --track-origins=yes \ --error-exitcode=1 \ --log-file=valgrind_%p.log \ ./your_program arg1 arg2

--leak-check=full 是必须的,只有 full 才会输出每个泄漏点的完整调用栈。--show-leak-kinds=all 会把这四种类型都列出来,避免你漏看 possibly lost。--track-origins=yes 能追出未初始化值的来源,但这开销非常大,如果不是怀疑用了未初始化内存,我建议关掉以缩短执行时间。--error-exitcode=1 是为了让它能作为 CI 的一个判断关卡。--log-file=valgrind_%p.log 会把输出按进程号拆开,多进程场景特别好用。

如果程序太大,别直接全量跑。可以先编一个纯内存分配验证的小用例,只请求那几个你认为泄漏的函数路径,把数据量调小到可控范围。很多大项目不是不能用 Valgrind,是没人愿意拿全量数据去跑,一跑一个晚上都不一定完。

3.3 Massif:从内存曲线到分配热点

Memcheck 负责找“是否泄漏”,如果你还想看“整个程序的内存使用随业务变化怎么波动”,就得用 Massif。

Massif 的职责是快照式堆分析。它定时记录当前堆内存占用和各个分配点的大小,生成一个 .out 文件,再通过 ms_print 或可视化工具还原成曲线。定位思路是:跑一段正常的业务操作,然后在某个对业务关键的时刻做一次高负载请求,看曲线是否突然抬升而且不再回落。

valgrind --tool=massif --time-unit=B --stacks=no \ --massif-out-file=massif.out ./your_program

time-unit=B 意思是按分配字节数采样,而不是按指令条数采样,这对内存问题更直观。跑完用:

ms_print massif.out | less

输出里最上面的曲线和摘要表会标“峰值点”,下面每个 snapshot 都会列出那一刻最占内存的调用栈列表。如果峰值点对应的调用栈是一个缓存结构,那基本可以判定是业务膨胀,而不是指针丢失。

3.4 Valgrind 的代价和工程化管理

Valgrind 慢到什么程度?我实测过一个 8 线程的收单服务,正常跑 QPS 上千,挂上 Memcheck 之后 QPS 掉到几十,一个回归用例从 2 分钟变成 40 分钟。这还是在它内部串行调度线程的情况下,多线程时间片切得更碎,额外损耗更大。

所以把 Valgrind 塞进日常开发 CI 里,一定要控制跑测范围和超时指标。我见过很多团队在单测阶段跑一遍全量 Valgrind,结果构建卡死,最终直接把这条流水线禁掉了。合理的方式是:只保留几个核心内存敏感用例,每个用例设定最大执行时间 10 分钟,超过就视为失败;同时开启 --error-exitcode=1,保证真正的问题能阻塞发布。你要是连这个都跑不动,就压缩数据规模,用代表性小样本复用同一条代码路径,这既能触发问题,又能控制在可接受的时间窗口内。

4. Heap Profiler:压测环境里的折中方案

4.1 gperftools 的实现逻辑

gperftools 的 heap profiler 是很多人容易忽略的利器。它不靠内核事件,也不做二进制翻译,而是直接通过 LD_PRELOAD 把 libtcmalloc.so 塞进进程,让所有 malloc/free 调用都走 tcmalloc 的代码路径。tcmalloc 内部在分配/释放时,会把这些动作记到调用栈哈希表里,统计每个栈的累计分配字节数、存活字节数和调用次数,然后在达到设定的分配间隔或时间间隔时,把当前快照写成一个 heap 文件。

这样做的好处很直接:不用改代码,不用重新编译,开销只有 2 到 5 倍,压测环境完全扛得住。它跟 Valgrind 最大的不同是它只做统计,不做引用分析和边界检查,所以它更适合回答“当前内存主要堆在哪些调用栈”,而不是“谁泄漏了某个地址”。

4.2 接入、启动与文件生成

第一步是确认环境里有 libtcmalloc。Debian/Ubuntu 下可以:

sudo apt install libgoogle-perftools-dev google-perftools

或者直接找编译好的文件也行,重点是要确认进程真的加载到了 libtcmalloc:

LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc.so.4 \ HEAPPROFILE=/tmp/app_heap \ ./your_program

启动之后,程序会按默认的 1GB 分配阈值生成快照文件,命名类似:

/tmp/app_heap.0001.heap /tmp/app_heap.0002.heap ...

如果程序整体分配不大,想尽快拿到快照,可以调小分配间隔:

export HEAP_PROFILE_ALLOCATION_INTERVAL=268435456 # 256MB export HEAP_PROFILE_TIME_INTERVAL=60 # 或每隔60秒快照一次

快照文件会越来越多,跑长时间压测时建议定期清理旧文件,避免把磁盘塞爆。观察时重点对比最早的快照和最新的快照,如果某个函数在最后一轮里仍然占据大量存活字节,而它在早期快照里同样存在,那你大概率已经锁定了嫌疑模块。

4.3 pprof 报告解析与增长对比

gperftools 自带的 pprof 脚本可以直接把 heap 快照渲染成文本、图形或 PDF,也可以按函数名搜索。

pprof --text ./your_program /tmp/app_heap.0010.heap pprof --pdf ./your_program /tmp/app_heap.0010.heap > report.pdf pprof --list=SomeFunction ./your_program /tmp/app_heap.0010.heap

--text 会输出一个调用栈和字节占比的列表,--pdf 则生成带调用关系的图。图上每个节点都有几个数值:当前栈自身占用的存活字节、包含所有子调用后的总字节,以及样本数。通常我们更关心“总字节”那一列,因为它体现的是整棵子树的内存占用。

我更推荐直接对比两个快照:

pprof --text --inuse_objects ./your_program /tmp/app_heap.0001.heap pprof --text --inuse_objects ./your_program /tmp/app_heap.0020.heap

如果 0020 比 0001 的某个函数多出几倍,那就说明这个模块是增长主场。用 --inuse_objects 还能看到对象个数,避免出现“单对象很小,但对象数量巨大”的隐蔽问题。

4.4 和 Valgrind、perf 的差异

在选型上,Heap Profiler 介于 perf 和 Valgrind 之间。它比 perf 更贴近用户态的分配行为,因为它 hook 的就是 malloc/free,你能看到完整的业务调用栈;它又比 Valgrind 便宜得多,能撑起长时间压测。

但它的短板也很明显:它没有引用追踪,不会告诉你某块内存是不是还“可达”,也不会分析越界行为。它只能帮你画出一条内存趋势曲线和分配热点雷达图,最后判断还是人工补上。所以在整套流程里,它适合在压测和发布前阶段确认修复效果,也适合线上发生内存增长后先由运维挂一晚上,捞出热点函数,再让开发拿着函数名去 Valgrind 阶段做精细化排查。

5. 三工具横向对比与组合策略

5.1 系统对比表

维度perfValgrindgperftools Heap Profiler
实现原理内核事件采样动态二进制翻译malloc/free hook
定位粒度分配热点调用栈精确到每条泄漏路径分配热点+堆缓存趋势
性能开销5%~20% 左右20~50 倍减速2~5 倍左右
适用阶段生产/预发粗筛开发/CI 取证压测/发布前验证
输出内容perf report 火焰图leak summary、调用栈heap 快照、pprof 图
是否需要改代码不需要不需要不需要
能否追踪指针可达性不能能不能
典型误用场景指望它算泄漏量生产环境全量跑想找精确泄漏地址

这张表基本就能回答“我该选谁”。如果非要给一条硬性规则:线上先用 perf,复现后在开发环境用 Valgrind,发布前压测时挂 Heap Profiler 一周观察趋势。顺序别倒过来。

5.2 三种实战组合打法

组合 A:线上先定位方向。方法:生产机器上停掉一部分流量,或者挑一个业务低峰窗口,perf record 采样分配事件 10 分钟,拿到报告后看前 10 个分配热点。如果热点集中在某个模块,直接把这个模块拉下来做本地复现;如果热点分散,说明全局分配压力大,未必是单一泄漏,优先检查缓存和对象池。

组合 B:开发环境精确取证。方法:本地写一个能触发该模块的复现用例,挂 Valgrind --leak-check=full,拿到 definitely lost 和 possibly lost 的详细栈。修复代码后再跑一遍,确认 leak summary 归零。这是最“标准”的流程,效率最高,因为不用在生产上冒险。

组合 C:压测环境持续观察。方法:把 Heap Profiler 挂到压测机上的被测进程,压测模型保持稳定,持续跑 1 到 2 小时,期间每 30 分钟收回一个快照文件。最后做成快照对比,看曲线斜率是趋缓还是持续上扬。如果持续上扬,就把对应调用栈拉出来交给开发修;修完再挂一遍,对比修复前后的曲线是否变平。

这三种组合可以独立存在,也能串起来。最常见的完整链路就是 A -> B -> C,即线上粗筛、本地取证、回归验证。

5.3 参数选择背后的逻辑

很多同学会纠结为什么 perf 的 -F 不直接拉满。原因是 perf record 的采样本身也会消耗 CPU,在内存分配非常频繁的场景下,-F 99 已经能采集到足够多的样本。提到 999 反而可能让 perf 自身的事件队列溢出,导致关键调用栈丢失,最后报告里的反而不准确。你在压测环境想更精确可以提到 499,但生产环境别这么玩。

Heap Profiler 的 HEAP_PROFILE_ALLOCATION_INTERVAL 设置同理。设得太小(比如 16MB)会疯狂生成快照,干扰业务;设得太大又可能整个压测周期只有一个文件,没法对比趋势。我默认是 1GB,如果压测总分配量在 10GB 量级,那差不多能生成 10 份快照,正好够画出增长曲线。

Valgrind 的 --track-origins=yes 也不是无脑开的。它有额外性能损耗且影响最终判断,如果不怀疑未初始化内存,建议关闭;但如果 valgrind 出现 Conditional jump depends on uninitialised value,就重新开一次,拿到源头再关掉跑回归。

6. 常见问题与排错实录

6.1 常见问题速查表

现象可能原因处理办法
perf record 提示 permission deniedperf_event_paranoid 限制用 root 或调 sysctl kernel.perf_event_paranoid=1
perf report 看不到用户态函数程序没有符号、帧指针被优化编译加 -g -fno-omit-frame-pointer
Valgrind 跑到一半被杀超时或 OOM缩小数据规模、拆用例、限制单用例时间
Valgrind 报 still reachable未必是真泄漏,但需要人工判定结合 RSS 走势确认是否业务膨胀
LD_PRELOAD 后 pprof 没数据libtcmalloc 没加载成功ldd 确认,或检查是否被沙箱清除了 LD_PRELOAD
heap 文件生成太多撑爆磁盘分配间隔太小调大 HEAP_PROFILE_ALLOCATION_INTERVAL
pprof 输出全是十六进制地址代码被 strip 过保留带符号二进制或 debug 包

6.2 排查技巧与避坑经验

第一个技巧:排查前先给进程拍个基线照。记录 /proc/ /status 里的 VmRSS 和 heap 段峰值,然后周期性采样。没有基线,你很难判断 memcheck 的 “still reachable” 是不是问题。

第二个技巧:别在已经用着其他内存分析库的环境里挂 gperftools。LD_PRELOAD 会被覆盖,或者 hook 链冲突,malloc 计数彻底乱掉。一次线上排查时我遇到过业务程序自带了 jemalloc,我再挂 libtcmalloc 就互相打架,快照文件就没意义了。先查 ldd 输出有没有别的 malloc 替代库,再决定要不要用 LD_PRELOAD。

第三个技巧:编译时保留 debug 符号。分布式发布环境常常会把二进制 strip 掉以减小体积,但这对三种工具都是灾难。perf 没符号只能出地址,Valgrind 没符号栈会断在汇编层,pprof 没符号输出全是 hex。稳妥的做法是发布时同时产出保留符号的 debug 包,单独存放,排查时直接用同一 commit 的 debug 版对齐。

第四个技巧:区分并发增长与泄漏。如果一个进程单线程独占内存持续增长,而多线程并发时内存反而回落,大概率是线程私有数据没清理,Valgrind 不一定能查出来,优先看线程栈和 Thread Local Storage。

第五个技巧:修复完一定要回到原始环境做回归。我曾经修掉一个 definitely lost 之后,内存下降了一点,但整体 RSS 曲线还是向上涨,结果发现根因是另一个 still reachable 的缓存队列。Valgrind 清零不代表业务曲线变平,必须再用 Heap Profiler 做一轮对比验证。

6.3 一个真实场景的排查顺序

去年我处理过一个订阅推送服务的案例,现象是运行两个小时后内存从 1.2G 涨到 9G,重启即好。第一阶段我先在低峰窗口用 perf record 采集了十分钟 kmem:kmalloc,报告里有大量调用栈落在消息解码模块和连接管理模块,方向锁定到连接管理。第二阶段本地用 Valgrind 跑一个模拟连接断开的小用例,memcheck 直接给出 definitely lost,栈都在连接对象的引用计数上,根因是连接关闭时异步回调持有了已销毁对象的裸指针。第三阶段修复代码后,在压测环境挂 gperftools 两小时,读取首尾两个 heap 快照,确认连接管理模块的存活字节不再增长。整个过程一天完成,如果一开始就让整个服务挂 Valgrind,大概率一个晚上都跑不完一轮。

需要强调的细节是,在每个阶段看清工具的适配边界再行动。perf 出了方向,就不要执着于让它算出精确字节数;Valgrind 出了 definitively lost,就不要拿它去评估整个服务性能;Heap Profiler 画出曲线,就老老实实做前后对比,别拿它的热点列表去冒充泄漏证据。

我自己实际排查时的习惯是:任何内存问题,第一件事永远先看 RSS 曲线和 /proc 里的 heap 段变化,确认问题真实存在于什么时间窗口;然后根据窗口性质决定上 perf 还是 Valgrind;最后用 Heap Profiler 做回归。工具选型这件事,本质上是在“可观测性、精确性、开销”三个维度里做权衡,没有一把刀能切所有菜,但只要你弄清楚每个工具管的是哪一段,排查效率至少能提高一个量级。最后再分享一个小经验:真正难处理的其实不是 definitely lost,而是那些“看起来还活着”的对象,业务上已经永远不会再访问了。工具只能告诉你对象活着,判断它该不该活,永远是你的工作。

返回列表