内存泄漏并不是内存消失了。这块内存仍然属于你,仍然被占用着,只是你再也没有办法把它归还,因为指向它的最后一个指针已经没了。什么都不会崩溃。程序继续运行,每次都变得更重一点,直到最终在某个不相关的地方出错。
本页介绍泄漏是如何产生的、能防止大多数泄漏的编程规范,以及找出剩余泄漏的两个工具。
泄漏是什么样子
每次迭代都会用一个新指针覆盖 block。之前的内存块仍然处于分配状态,却没有任何变量保存它的地址,所以永远无法释放。三次迭代丢失了 12 KB。如果服务器每处理一个请求就这样做一次,这些内存就会永久丢失,丢失的速度与请求到达的速度相同。
修复只需要一行:在循环体末尾加上 free(block);。但真正的技能在于判断它应该放在哪里。
泄漏是怎么产生的
1. 丢失的指针
如果一个指针保存着某个仍在使用的内存块的唯一引用,那么对它的任何赋值都会造成泄漏。
char *name = malloc(32);
name = malloc(64); /* the first 32 bytes are now unreachable */
上面的循环也是同一个 bug,只是套上了循环。给结构体字段重新赋值也是如此,calloc 和 realloc 一节中提到的 realloc 简写也一样:
p = realloc(p, n); /* on failure: p becomes NULL, old block orphaned */
2. 提前返回
函数的每一条退出路径都必须释放函数已经申请的资源。被遗忘的那条路径总是错误处理路径。
正常路径是正确的,错误路径却会泄漏,所以这种 bug 能逃过测试:在开发过程中,失败的分支几乎从不执行。修复方法是写一个统一的清理部分,让每条路径都跳转到那里:
这是经验丰富的 C 程序员唯一会主动推荐的 goto 用法。它之所以可行,是因为每个指针都从 NULL 开始,而 free(NULL) 什么也不做,所以无论函数执行到哪一步,单一的退出块都是正确的。
3. 所有权不明确
最隐蔽的泄漏根本不是编码错误,而是两个函数对“这是谁的职责”有不同的理解。
char *build_message(void); /* does the caller free this? */
void store(char *text); /* does store take ownership? */
如果 build_message 返回分配的内存,而 store 复制了它,那么调用者必须释放。如果 store 保留了这个指针,调用者就不能释放。代码中没有任何地方说明是哪一种,所以总有一个假设被重复做了两次,结果不是内存泄漏,就是重复释放。
解决办法是一个约定:在每个分配内存的函数旁边,用注释写清楚:
/* Returns a newly allocated string; the caller must free it. */
char *build_message(void);
/* Takes ownership of 'text'; it will be freed by store_free(). */
void store(char *text);
把规则写在函数旁边,而不是写在设计文档里。这是 C 内存管理中最有价值的一个习惯。
所有权规范
四条规则几乎涵盖了所有情况:
- 每次分配都恰好有一个所有者,也就是负责释放它的那一段代码。
- 每个分配函数都配一个释放函数。
vec_init/vec_free,config_load/config_free。这种对称让漏掉的调用一目了然。 - 在分配的同一层释放,除非函数的注释明确说明转移了所有权。
- 释放指针后把它设为
NULL,这样之后不小心使用它时,程序会在出错的地方崩溃,而不是悄悄破坏堆。
查找泄漏:valgrind
在 Linux 上,valgrind 不需要重新编译,不过有调试符号时报告会更容易读:
gcc -g -O0 program.c -o program
valgrind --leak-check=full --show-leak-kinds=all ./program
对于本页开头那个有泄漏的循环,报告的结尾大致如下:
==12345== HEAP SUMMARY:
==12345== in use at exit: 12,000 bytes in 3 blocks
==12345== total heap usage: 3 allocs, 0 frees, 12,000 bytes allocated
==12345==
==12345== 12,000 bytes in 3 blocks are definitely lost in loss record 1 of 1
==12345== at 0x4C2FB0F: malloc (vg_replace_malloc.c:299)
==12345== by 0x108671: main (program.c:6)
==12345==
==12345== LEAK SUMMARY:
==12345== definitely lost: 12,000 bytes in 3 blocks
从下往上读。"definitely lost" 表示程序退出时已经没有任何指针指向这个内存块,这是真正的泄漏。调用栈指出的是创建这个内存块的 malloc 所在的行,而不是丢失它的那一行,这通常足以找到缺失的 free。
报告中还会出现另外两类:
- indirectly lost:只能通过一个本身已经丢失的内存块才能访问到的内存块,比如一个泄漏的链表中的元素。修复 "definitely lost" 的那个,这些就会消失。
- still reachable:程序退出时仍处于分配状态,但还有指针指向它,通常是全局缓存。它不算危险意义上的泄漏,但也值得释放,好让报告保持为空。
valgrind 还能发现对未初始化内存的读取,以及越过内存块末尾的写入,你常常正是这样发现泄漏背后的 bug 的。
查找泄漏:AddressSanitizer
AddressSanitizer 内置于 GCC 和 Clang,运行速度比 valgrind 快得多,在 valgrind 无法使用的地方(包括当前的 macOS)也能工作:
gcc -g -fsanitize=address -fno-omit-frame-pointer program.c -o program
./program
程序退出时会自动打印泄漏报告:
=================================================================
==12345==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 12000 byte(s) in 3 object(s) allocated from:
#0 0x7f... in malloc
#1 0x1086... in main program.c:6
SUMMARY: AddressSanitizer: 12000 byte(s) leaked in 3 allocation(s).
ASan 还会把释放后使用(use-after-free)和堆缓冲区溢出变成立即发生、标注清晰的中止,而不是在之后造成莫名其妙的内存损坏。测试运行时开启它;发布版本中关闭它,因为它会消耗内存并降低速度。
如果在你的平台上泄漏检测没有触发,运行前在环境变量中设置 ASAN_OPTIONS=detect_leaks=1。
一步步修复一个有泄漏的程序
下面这个小程序有三处独立的泄漏:
valgrind 会报告三条 "definitely lost" 记录,分别对应三个不同的行号。逐个修复:
修复 1 把所有权注释落到了实处:shout 负责分配,main 负责释放。修复 2 直接去掉了重复的分配,而不是去释放第一块内存,更简单的代码也是正确的代码。修复 3 在提前返回处补上了缺失的 free;如果涉及更多的内存分配,前面介绍的单一 cleanup: 标签比重复写释放语句更容易扩展。
防止泄漏的习惯
- 写完
malloc后立即写上free,再补全中间的代码。 - 给每个分配函数配一个对应的释放函数。
- 对于任何返回或接收由它分配的指针的函数,用注释写明所有权。
- 在持有多处分配的函数中,使用一个
cleanup:退出块。 - 把在
-fsanitize=address下运行测试当作常规操作,而不是等到发现异常时才用。 - 把 "definitely lost: 0 bytes" 当作测试通过的一部分。
常见问题
C 语言中的内存泄漏是什么?
用 malloc 分配后再也无法释放的内存,因为程序中已经没有任何东西指向它。这块内存在进程的整个生命周期内都会保持占用。它不会导致崩溃,程序照常运行,只是每运行一轮就多占用一些内存,直到最终耗尽。
如何查找 C 语言中的内存泄漏?
在 valgrind 下运行程序:valgrind --leak-check=full ./program。它会报告程序退出时仍未释放的每个内存块,并附上创建它的 malloc 的调用栈。在 macOS 上或无法使用 valgrind 的地方,用 -fsanitize=address 编译,程序退出时会输出同样的报告。
C 语言中内存泄漏是怎么产生的?
几乎所有泄漏都可以归为三种模式:覆盖了指向某个内存块的唯一指针(包括 p = realloc(p, n) 失败的情况),函数在已经分配内存后提前返回,以及所有权不明确:两个函数都以为对方会释放,结果谁都没有释放。
如果程序反正会退出,内存泄漏还重要吗?
对于运行一次就退出的程序,操作系统会回收所有内存,所以实际影响几乎为零。但对于长时间运行的程序就很重要了,比如服务器、游戏循环、守护进程,每个请求泄漏一点,内存占用就会无限增长。无论如何都要坚持释放内存:泄漏报告中的噪音会掩盖那些真正重要的泄漏。