Menu

C 语言内存泄漏:产生原因与排查方法

内存泄漏到底是什么,C 程序产生泄漏的三种方式,防止泄漏的所有权规范,以及如何用 valgrind 和 -fsanitize=address 找出剩下的泄漏,最后一步步修复一个有泄漏的程序。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

内存泄漏并不是内存消失了。这块内存仍然属于你,仍然被占用着,只是你再也没有办法把它归还,因为指向它的最后一个指针已经没了。什么都不会崩溃。程序继续运行,每次都变得更重一点,直到最终在某个不相关的地方出错。

本页介绍泄漏是如何产生的、能防止大多数泄漏的编程规范,以及找出剩余泄漏的两个工具。

泄漏是什么样子

每次迭代都会用一个新指针覆盖 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 内存管理中最有价值的一个习惯。

所有权规范

四条规则几乎涵盖了所有情况:

  1. 每次分配都恰好有一个所有者,也就是负责释放它的那一段代码。
  2. 每个分配函数都配一个释放函数。 vec_init / vec_free,config_load / config_free。这种对称让漏掉的调用一目了然。
  3. 在分配的同一层释放,除非函数的注释明确说明转移了所有权。
  4. 释放指针后把它设为 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) 失败的情况),函数在已经分配内存后提前返回,以及所有权不明确:两个函数都以为对方会释放,结果谁都没有释放。

如果程序反正会退出,内存泄漏还重要吗?

对于运行一次就退出的程序,操作系统会回收所有内存,所以实际影响几乎为零。但对于长时间运行的程序就很重要了,比如服务器、游戏循环、守护进程,每个请求泄漏一点,内存占用就会无限增长。无论如何都要坚持释放内存:泄漏报告中的噪音会掩盖那些真正重要的泄漏。

Coddy 编程语言插图

和 Coddy 一起学编程

开始