内存泄漏排查:为什么 valgrind 查不出内存池里的泄漏
3042 字 · 约 8 分钟
写 C 的人,大概没有谁愿意碰内存泄漏,可又躲不掉。内存和指针给了你在硬件地址之间纵横驰骋的自由,代价是它们像跗骨之蛆一样跟着你,稍不留神就给你来个下马威。高收益必然伴随着高风险,古人诚不欺我。
我写的采集器是 C 写的,常驻在客户的业务机上,内存靠 apr_pool 内存池管理。这种程序的内存问题有个特别让人难受的地方:退出的时候拿 valgrind 一查,干干净净;可它跑起来以后,RSS 却可能一路往上走。
排查内存泄漏的工具,valgrind、mtrace、bcc 的 memleak、自己写 hook,这几样我都用过,它们的用法网上一搜一大把,这里就不当教程写了。我更想说清楚的是:每种工具在结构上看不见什么,为什么「内存池 + 常驻进程」这个组合,恰好掉在大多数工具的盲区里。
(文中的示例都是最小复现程序,不是现场代码。)
先把「泄漏」说窄一点
教科书上的定义是:申请了、没释放、指针丢了。这个定义拿来读代码没问题,可拿来选工具就不够用了。选工具之前,得先问自己两件事:
| 问题 | |
|---|---|
| 什么时候看 | 进程退出那一刻看「还剩什么」,还是运行中看「什么在涨」 |
| 在哪一层看 | malloc/free 这一层,mmap/munmap 这一层,还是内存池自己那一层 |
后面所有工具的取舍,说白了都落在这两条轴上。
还有一个容易混的前提:RSS 在涨,不等于泄漏。glibc 的多 arena、碎片、free 之后不还给内核,都会让 RSS 只升不降,可这些内存在分配器眼里是空闲的(可以先调一下 malloc_trim(0),看能还回去多少)。反过来,内存池里没还给池子的块,在分配器眼里又是正在使用的。所以光盯着 RSS 看,两个方向都可能判断错。
所以我自己的判断标准是:数据量恒定的前提下,持续、线性、不回落的增长,才值得往泄漏上怀疑。
valgrind 和 mtrace:退出时对账
这两个工具原理不一样,判据却是同一个:进程结束时,还有哪些块没被释放。
valgrind
valgrind 在 Linux 下检测内存问题算得上神器,很多 C 语言入门教程都会提到它。它的 memcheck 把程序跑在一个合成的 CPU 上,给每个字节维护「能不能访问」「有没有初始化」两类影子状态,所以除了泄漏,越界、use-after-free、double free、读未初始化的内存,它都能报出来。
int main(void) {
void *p1 = malloc(10);
void *p2 = malloc(20); // 没有 free
free(p1);
return 0;
}$ valgrind --leak-check=full ./a.out
==229181== 20 bytes in 1 blocks are definitely lost in loss record 1 of 1
==229181== at 0x4C29F73: malloc (vg_replace_malloc.c:309)
==229181== by 0x40059C: main (leak.c:3)调用栈、行号一应俱全,定位相当准。可它的毛病也明摆着:要提前装,跑起来慢一个数量级以上,生产环境基本不可能挂着它跑。而且它只能在 Linux 上用,对一个还要跑在 AIX、HP-UX 上的程序来说,这算是最大的硬伤。
mtrace
mtrace 是 glibc 自带的功能,用法很简单:程序里调一次 mtrace(),之后每次 malloc/realloc/free 都往 $MALLOC_TRACE 指向的文件里记一行,程序退出后再用 mtrace 这个 perl 脚本对账:
$ mtrace ./main_mtrace mtrace.log
Memory not freed:
-----------------
Address Size Caller
0x00007f78508cb010 0x100000 at main_mtrace.c:24
0x00007f78509cc010 0x100000 at main_mtrace.c:24
...它比 valgrind 轻得多,可毛病也不少。它只记一层调用者,allocation() 同时被 func1 和 func2 调用的时候,日志里只有 allocation 那一行,到底是谁引起的分不出来,工程里封装一深,这条信息就几乎没用了。它挂在 malloc 上,mmap 出来的内存它看不见。跟 valgrind 一样,出了 Linux 也就没辙了。
还有个版本上的坑:它底层靠的是 __malloc_hook,这组 hook 在 glibc 2.34 里被删掉了。所以在新一点的系统上,光在代码里调 mtrace() 是没反应的,得用 LD_PRELOAD=libc_malloc_debug.so 把调试库预加载进来。
它们为什么对内存池无效
内存池的生命周期是这样的:启动时向系统要一大块,运行中从池子里切,退出时整池销毁。
启动 apr_pool_create() → 从系统拿内存
运行 apr_palloc(pool, n) × N → 只切不还(这就是泄漏)
退出 apr_pool_destroy() → 一次性全部还给系统在退出那一刻对账,每一个字节都被还回去了,valgrind 自然报 0 泄漏。就算不销毁池子,池子手里还攥着指针,最多也只会被归到 still reachable,算不上 definitely lost。
问题就出在这里。退出时对账的工具,判据是「指针丢没丢」;而内存池泄漏的样子是指针一直都在,只是该还的时候没还。所以这事怪不到工具头上,它们本来就不是查这个的。
还有一条更隐蔽:apr_pool 遇到大块申请会直接走 mmap。挂在 malloc 上的工具,连这部分内存的申请都看不到。
bcc memleak:盯着活进程看
既然要看的是「运行中什么在涨」,那就需要一个能挂到活进程上、按时间窗口出报告的工具。bcc 的 memleak 就是干这个的:用 uprobe 挂住分配和释放函数,每隔一段时间把还没释放的分配按调用栈汇总打印出来。
eBPF 这几年在网上炒得很热,不少人对它有点迷信。它确实能解决不少问题,但能不能真的放到生产环境上用,还得看场景。先看效果:
void func1() {
char *str = malloc(64 * 1024); // 每 100ms 漏 64K
usleep(100000);
}
int main() { while (1) func1(); }$ memleak.py -p $(pidof bcc_test) 10
[10:10:43] Top 10 stacks with outstanding allocations:
6225920 bytes in 95 allocations from stack
func1+0x1a [bcc_test]
main+0x19 [bcc_test]
[10:10:53] Top 10 stacks with outstanding allocations:
12779520 bytes in 195 allocations from stack
...
[10:11:03] Top 10 stacks with outstanding allocations:
19333120 bytes in 295 allocations from stack每 10 秒多出 100 次分配、6400 KB,栈一直是同一条,跟代码对得上。它看的不再是「指针丢没丢」,而是「同一个栈上没释放的量是不是一直在涨」,常驻进程要查的正是这个。
我之所以会去用 memleak,是因为我司同事给它提过一个 PR,加上了 mmap/munmap 跟踪(iovisor/bcc#3866)。apr_pool 走 mmap 的那部分大块分配因此也能看见了,对我们这种用内存池的代码来说,算是得天独厚。
可用起来比 mtrace 麻烦多了。eBPF 要较新的内核,我手上的 CentOS 7.9 是 3.10 内核,靠 RHEL 的 backport 才勉强能用,bcc 还得装 devtoolset 和 llvm-toolset 从源码编,配置繁琐,依赖一堆。它要挂到活进程上,得有 PID,跑完就退的程序用不了,好在那类程序本来也不是这篇的主角。打出来的栈只有符号加偏移,没有行号,封装一深还得自己拿 addr2line 去换算。
最要命的是开销。每次 malloc/free 都要进一次 uprobe,我实测挂上以后,目标进程的 CPU 占用直接翻了一倍不止。采集器跑在别人的业务机上,光这一条,它就只能短时间开一下,用完赶紧撤。所以我一般是在已经确定有泄漏、就是不知道漏在哪的时候才拿它出来定位,要不要上生产,得掂量掂量。
自己 hook:把检测做进程序里
不管是 mtrace 还是 bcc,其实都是 hook 的一种,只是一个场景受限,一个依赖太重,都是出了问题以后从外面挂上去的。对一个要部署到上百种客户环境里的采集器来说,更实际的做法是把检测做成程序自带、默认关闭的开关:怀疑有泄漏了就打开,不看目标机器上装了什么。
hook 本身没什么神秘的,说白了就是让 malloc/free 先经过自己的函数,记一笔账,再转给真正的实现。常见的有三种做法,能看到的范围各不相同:
| 做法 | 覆盖面 | 代价 |
|---|---|---|
宏替换:头文件里 #define malloc(n) malloc_hook(n, __FILE__, __LINE__) | 只有 include 了这个头文件的编译单元 | 第三方库完全看不见;只有一层调用者 |
符号劫持:LD_PRELOAD 一个 so,内部用 dlsym(RTLD_NEXT, "malloc") 拿到原函数 | 进程内所有动态链接的调用,含第三方库 | GNU 扩展,换到 AIX、HP-UX 要另写一套 |
C++ 重载 operator new/delete | 所有走 new 的分配,包括 std::string、容器、智能指针内部 | 只管 C++ 这一侧 |
dlsym 最大的毛病是只能在 Linux 上用。那有没有能在所有平台上跑的办法?有,就是宏替换。它是三种里唯一不依赖平台特性的,对一个要同时跑在 Linux、AIX、HP-UX 上的程序最省事,代价是只能看见自己的代码。
我当时写的示例图省事,记账用的是链表,C++ 那边防递归用的是一个全局标志位。当演示没问题,真要放进程序里,下面这几处都得改:
- 记账本身也要分配内存。用
std::map记new的账,map插入节点又会调operator new,一不小心就是无限递归。要么用标志位在记账期间旁路,要么给记账容器单独配一个不走 hook 的分配器。标志位要是全局的,多线程下就不成立了,得换成 thread-local。 - 记账结构要能 O(1) 删除。链表每次
free都要线性扫一遍,放到高频分配的程序上,hook 自己就成了性能问题。换成以地址为键的哈希表。 realloc要先删旧地址再记新地址,还得处理返回NULL时旧块依然有效的情况。- 只记文件和行号不够。跟 mtrace 一样,封装一深就分不出调用方。得在记账时用
backtrace()抓几层栈,按栈聚合,这其实就是在自己重做一个memleak。
不过就算这些都做好了,hook 到 malloc 这一层,对内存池泄漏照样无效,道理跟前面一样。要抓池子的泄漏,账得记在池子的接口上(apr_palloc / apr_pool_clear),按池子统计哪些只增不清。
怎么选
程序会退出吗?
├─ 会,且能在测试环境复现 → valgrind,信息最全
└─ 不会(常驻进程)
│
内存经过内存池吗?
├─ 不经过 → 运行时按栈看增长:memleak / tcmalloc heap profiler 做前后 diff
└─ 经过 → malloc 层的工具都会漏
├─ 池子走 mmap 的那部分 → memleak(带 mmap 跟踪)
└─ 根本办法 → 在池子接口上记账,看哪个池只增不清还是那两条轴。时间上,退出时对账的工具问的是「指针丢没丢」,常驻进程要问的是「同一个栈上没释放的量是不是一直在涨」。层次上,工具挂在哪一层,就只能看见哪一层:泄漏发生在池子里,挂在 malloc 上的工具看到的,只是池子在正常地向系统要内存。
回过头看,valgrind 在它自己的判据下一点没错,它准确地告诉了你「退出时没有丢失的指针」。是我们问错了问题。
工具说到底,核心思路就两条:要么像 valgrind 那样造一个虚拟环境,要么 hook 掉 malloc/free 记账。掌握多少种调试手段其实并不关键,不是会的方法越多就越牛。真正厉害的人,是尽量别写出会泄漏的代码。这一点说起来容易,做起来极难,只能靠平时写代码时养成好习惯,一点点把功底磨出来。