14

每一盒香烟的包装上都会写『吸烟有害健康』。白酒瓶上也写了『过度饮酒,有害健康』。本文的外包装上写的则是『阅读有害健康』,特别是『甩掉强迫症』那一节,它适合我自己阅读,但不一定适合你。

黑暗的内存

很多人对 C 语言深恶痛绝,仅仅是因为 C 语言迫使他们在编程中必须手动分配与释放内存,然后通过指针去访问,稍有不慎可能就会导致程序运行运行时出现内存泄漏或内存越界访问。

C 程序的内存泄漏只会发生在程序所用的堆空间内,因为程序只能在堆空间内动态分配内存。NULL 指针、未初始化的指针以及引用的内存空间被释放了的指针,如果这些指针访问内存,很容易就让程序挂掉。

除了堆空间,程序还有个一般而言比较小的栈空间。这个空间是所有的函数共享的,每个函数在运行时会独占这个空间。栈空间的大小是固定的,它是留给函数的参数与局部变量用的。栈空间有点像宾馆,你下榻后,即使将房间搞的一团糟,也不需要你去收拾它,除非你把房间很严重的损坏了——用 C 的黑话来说,即缓冲区溢出。

虽然导致这些问题出现的原因很简单,但是却成为缺乏编程素养的人难以克服的障碍,被 C 语言吓哭很多次之后,他们叛逃到了 Java、C# 以及各种动态类型语言的阵营,因为这些语言将指针隐藏了起来,并提供内存垃圾回收(GC)功能。他们赢了,他们懒洋洋的躺在沙发上,拿着遥控器指挥内存,信号偶尔中断,内存偶尔紊乱。

C 内存的动态分配与回收

C 语言标准库(stdlib)中为堆空间中的内存分配与回收提供了 mallocfree 函数。例如,在下面的代码中,我们从堆空间中分配了 7 个字节大小的空间,然后又释放了:

#include <stdlib.h>

void *p = malloc(7);
free(p);

一点都不难!跟你去学校图书馆借了 7 本书,然后又还回去没什么两样。有借有还,再借不难,过期不还,就要罚款。有谁因为去图书馆借几本书就被吓哭了的?

我们也可以向堆空间借点地方存储某种类型的数据:

int *n = malloc(4);
*n = 7;
free(n);

如果你不知道 int 类型的数据需要多大的空间才能装下,那就用 sizeof,让 C 编译器去帮助你计算,即:

int *n = malloc(sizeof(int));
*n = 7;
free(n);

策略与机制分离

在 C 语言中有关内存管理的机制已经简单到了几乎无法再简单的程度了,那么为何那么多人都在嘲笑讥讽挖苦痛骂诅咒 C 的内存管理呢?

如果你略微懂得一些来自 Unix 的哲学,可能听说过这么一句话:策略与机制分离。如果没听说过这句话,建议阅读 Eric Raymond 写的《Unix 编程艺术》第一章中的 Unix 哲学部分。

mallocfree 是 C 提供的内存管理机制,至于你怎么去使用这个机制,那与 C 没有直接关系。例如,你可以手动使用 mallocfree 来管理内存——最简单的策略,你也可以实现一种略微复杂一点的基于引用计数的内存管理策略,还可以基于 Lisp 之父 John McCarthy 独创的 Mark&Sweep 算法实现一种保守的内存自动回收策略,还可以将引用计数与 Mark&Sweep 这两种策略结合起来实现内存自动回收。总之,这些策略都可以在 C 的内存管理机制上实现。

借助 Boehm GC 库,就可以在 C 程序中实现垃圾内存的自动回收:

#include <assert.h>
#include <stdio.h>
#include <gc.h>

int main(void)
{
    GC_INIT();
    for (int i = 0; i < 10000000; ++i)
    {
        int **p = GC_MALLOC(sizeof(int *));
        int *q = GC_MALLOC_ATOMIC(sizeof(int));

        assert(*p == 0);
        *p = GC_REALLOC(q, 2 * sizeof(int));
        if (i % 100000 == 0)
            printf("Heap size = %zu\n", GC_get_heap_size());
    }

    return 0;
}
在 C 程序中使用 Boehm GC 库,只需用 GC_MALLOCC_MALLOC_ATOMIC 替换 malloc,然后去掉所有的 free 语句。C_MALLOC_ATOMIC 用于分配不会用于存储指针数据的堆空间。

如果你的系统(Linux)中安装了 boehm-gc 库(很微型,刚 100 多 Kb),可以用 gcc 编译这个程序然后运行一次体验一下,编译命令如下:

$ gcc -lgc test-gc.c

GNU 的 Scheme 解释器 Guile 2.0 就是用的 boehm-gc 来实现内存回收的。有很多项目在用 boehm-gc,只不过很少有人听说过它们,见:http://www.hboehm.info/gc/#users

如果 C 语言直接提供了某种内存管理策略,无论是提供引用计数还是 Mark&Sweep 抑或这二者的结合体,那么都是在剥夺其他策略生存的机会。例如,在 Java、C# 以及动态类型语言中,你很难再实现一种新的内存管理策略了——例如手动分配与释放这种策略。

Eric Raymond 说,将策略与机制揉在一起会导致有两个问题,(1) 策略会变得死板,难以适应用户需求的改变;(2) 任何策略的改变都极有可能动摇机制。相反,如果将二者剥离,就可以在探索新策略的时候不会破坏机制,并且还检验了机制的稳定性与有效性。

Unix 的哲学与 C 有何相干?不仅是有何相干,而且是息息相关!因为 C 与 Unix 是鸡生蛋 & 蛋生鸡的关系——Unix 是用 C 语言开发的,而 C 语言在 Unix 的开发过程中逐渐成熟。C 语言只提供机制,不提供策略,也正因为如此才招致了那些贪心的人的鄙薄。

这么多年来,像 C 语言提供的这种 malloc + free 的内存管理机制一直都没有什么变化,而计算机科学家们提出的内存管理策略在数量上可能会非常惊人。像 C++ 11 的智能指针与 Java 的 GC 技术,如果从研究的角度来看,可能它们已经属于陈旧的内存回收策略了。因为它们的缺点早就暴露了出来,相应的改进方案肯定不止一种被提了出来,而且其中肯定会有一些策略是基于概率算法的……那些孜孜不倦到处寻找问题的计算机科学家们,怎能错过这种可以打怪升级赚经费的好机会?

总之,C 已经提供了健全的内存管理机制,它并没有限制你使用它实现一种新的内存管理策略。

手动管理内存的常见陷阱

在编写 C 程序时,手动管理内存只有一个基本原则是:谁需要,谁分配;谁最后使用,谁负责释放。这里的『谁』,指的是函数。也就是说,我们有义务全程跟踪某块被分配的堆空间的生命周期,稍有疏忽可能就会导致内存泄漏或内存被重复释放等问题。

那些在函数内部作为局部变量使用的堆空间比较容易管理,只要在函数结尾部分稍微留心将其释放即可。一个函数写完后,首先检查一下所分配的堆空间是否被正确释放,这个习惯很好养成。这种简单的事其实根本不用劳烦那些复杂的内存回收策略。

C 程序内存管理的复杂之处在于在某个函数中分配的堆空间可能会一路辗转穿过七八个函数,最后又忘记将其释放,或者本来是希望在第 7 个函数中访问这块堆空间的,结果却在第 3 个函数中将其释放了。尽管这样的场景一般不会出现(根据快递公司丢包的概率,这种堆空间传递失误的概率大概有 0.001),但是一旦出现,就够你抓狂一回的了。没什么好方法,惟有提高自身修养,例如对于在函数中走的太远的堆空间,一定要警惕,并且思考是不是设计思路有问题,寻找缩短堆空间传播路径的有效方法。

堆空间数据在多个函数中传递,这种情况往往出现于面向对象编程范式。例如在 C++ 程序中,对象会作为一种穿着隐行衣的数据——this 指针的方式穿过对象的所有方法(类的成员函数),像穿糖葫芦一样。不过,由于 C++ 类专门为对象生命终结专门设立了析构函数,只要这个析构函数没被触发,那么这个对象在穿过它的方法时,一般不会出问题。因为 this 指针是隐藏的,也没人会神经错乱在对象的某个方法中去 delete this。真正的陷阱往往出现在类的继承上。任何一个训练有素的 C++ 编程者都懂得什么时候动用虚析构函数,否则就会陷入用 delete 去释放引用了派生类对象的基类指针所导致的内存泄漏陷阱之中。

在面向对象编程范式中,还会出现对象之间彼此引用的现象。例如,如果对象 A 引用了对象 B,而对象 B 又引用了对象 A。如果这两个对象的析构函数都试图将各自所引用对象销毁,那么程序就会直接崩溃了。如果只是两个相邻的对象的相互引用,这也不难解决,但是如果 A 引用了 B,B 引用了 C, C 引用了 D, D 引用了 B 和 E,E 引用了 A……然后你可能就凌乱了。如果是基于引用计数来实现内存自动回收,遇到这种对象之间相互引用的情况,虽然那程序不会崩溃,但是会出现内存泄漏,除非借助弱引用来打破这种这种引用循环,本质上这只是变相的谁最后使用,谁负责释放

函数式编程范式中,内存泄漏问题依然很容易出现,特别是在递归函数中,通常需要借助一种很别扭的思维将递归函数弄成尾递归形式才能解决这种问题。另外,惰性计算也可能会导致内存泄漏。

似乎并没有任何一种编程语言能够真正完美的解决内存泄漏问题——有人说 Rust 能解决,我不是很相信,但是显而易见,程序在设计上越低劣,就越容易导致内存错误。似乎只有通过大量实践,亡羊补牢,塞翁失马,卧薪尝胆,破釜沉舟,久而久之,等你三观正常了,不焦不躁了,明心见性了,内存错误这种癌症就会自动从你的 C 代码中消失了——好的设计品味,自然就是内存友好的。当我们达到这种境界时,可能就不会再介意在 C 中手动管理内存。

让 Valgrind 帮你养成 C 内存管理的好习惯

Linux 环境中有一个专门用于 C 程序内存错误检测工具——valgrind,其他操作系统上应该也有类似的工具。valgrind 能够发现程序中大部分内存错误——程序中使用了未初始化的内存,使用了已释放的内存,内存越界访问、内存覆盖以及内存泄漏等错误。

看下面这个来自『The Valgrind Quick Start Guide』的小例子:

#include <stdlib.h>

void f(void)
{
        int* x = malloc(10 * sizeof(int));
        x[10] = 0;
}

int main(void)
{
        f();
        return 0;
}

不难发现,在 f 函数中即存在这内存泄漏,又存在着内存越界访问。假设这份代码保存在 valgrind-demo.c 文件中,然后使用 gcc 编译它:

$ gcc -g -O0 valgrind-demo.c -o valgrind-demo

为了让 valgrind 能够更准确的给出程序内存错误信息,建议打开编译器的调试选项 -g,并且禁止代码优化,即 -O0

然后用 valgrind 检查 valgrind-demo 程序:

$ valgrind --leak-check=yes ./valgrind-demo

结果 valgrind 输出以下信息:

==10000== Memcheck, a memory error detector
==10000== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==10000== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==10000== Command: ./valgrind-demo
==10000== 
==10000== Invalid write of size 4
==10000==    at 0x400574: f (valgrind-demo.c:6)
==10000==    by 0x400585: main (valgrind-demo.c:11)
==10000==  Address 0x51d3068 is 0 bytes after a block of size 40 alloc'd
==10000==    at 0x4C29FE0: malloc (in /usr/lib64/valgrind/vgpreload_memcheck-amd64-linux.so)
==10000==    by 0x400567: f (valgrind-demo.c:5)
==10000==    by 0x400585: main (valgrind-demo.c:11)
==10000== 
==10000== 
==10000== HEAP SUMMARY:
==10000==     in use at exit: 40 bytes in 1 blocks
==10000==   total heap usage: 1 allocs, 0 frees, 40 bytes allocated
==10000== 
==10000== 40 bytes in 1 blocks are definitely lost in loss record 1 of 1
==10000==    at 0x4C29FE0: malloc (in /usr/lib64/valgrind/vgpreload_memcheck-amd64-linux.so)
==10000==    by 0x400567: f (valgrind-demo.c:5)
==10000==    by 0x400585: main (valgrind-demo.c:11)
==10000== 
==10000== LEAK SUMMARY:
==10000==    definitely lost: 40 bytes in 1 blocks
==10000==    indirectly lost: 0 bytes in 0 blocks
==10000==      possibly lost: 0 bytes in 0 blocks
==10000==    still reachable: 0 bytes in 0 blocks
==10000==         suppressed: 0 bytes in 0 blocks
==10000== 
==10000== For counts of detected and suppressed errors, rerun with: -v
==10000== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 0 from 0)

valgrind 首先检测出在 valgrind-demo 程序中存在一处内存越界访问错误,即:

==10000== Invalid write of size 4
==10000==    at 0x400574: f (valgrind-demo.c:6)
==10000==    by 0x400585: main (valgrind-demo.c:11)
==10000==  Address 0x51d3068 is 0 bytes after a block of size 40 alloc'd
==10000==    at 0x4C29FE0: malloc (in /usr/lib64/valgrind/vgpreload_memcheck-amd64-linux.so)
==10000==    by 0x400567: f (valgrind-demo.c:5)
==10000==    by 0x400585: main (valgrind-demo.c:11)

然后 valgrind 又发现在 valgrind-demo 程序中存在 40 字节的内存泄漏,即:

10000== 40 bytes in 1 blocks are definitely lost in loss record 1 of 1
==10000==    at 0x4C29FE0: malloc (in /usr/lib64/valgrind/vgpreload_memcheck-amd64-linux.so)
==10000==    by 0x400567: f (valgrind-demo.c:5)
==10000==    by 0x400585: main (valgrind-demo.c:11)

由于我们在编译时开启了调试选项,所以 valgrind 也能告诉我们内存错误发生在具体哪一行源代码中。

除了可用于程序内存错误的检测之外,valgrind 也具有函数调用关系跟踪、程序缓冲区检查、多线程竞争检测等功能,但是无论 valgrind 有多么强大,你要做的是逐渐的摆脱它,永远也不要将自己的代码建立在『反正 valgrind 能帮我检查错误』这样的基础上。

甩掉强迫症

选择用 C 语言来写程序,这已经让我们牺牲了很多东西——项目进度、漂亮的桌面程序、炙手可热的网站前端……如果你再为 C 语言的一些『脆弱』之处患上强迫症,这样的人生太过于悲催。用 C 语言,就要对自己好一点。

负责分配内存的 malloc 函数可能会遇到内存分配失败的情况,这时它会返回 NULL。于是,问题就来了,是否需要在程序中检测 malloc 的返回值是否为 NULL?我觉得没必要检测,只需记住 malloc 可能会返回 NULL 这一事实即可。如果一个程序连内存空间都无法分配了,那么它还有什么再继续运行的必要?有时,可能会因为系统中进程存在内存泄漏,导致你的程序无法分配内存,这时你使用 malloc 返回的 NULL 指针来访问内存,会出现地址越界错误,这种错误很容易定位,并且由于你知道 malloc 可能会返回 NULL 这一事实,也很容易确定错误的原因,实在不济,还有 valgrind。

如果确实有对 malloc 返回值进行检查的必要,例如本文评论中 @依云 所说的那些情况,可以考虑这样做:

#include <stdio.h>
#include <stdlib.h>

#define SAFE_MALLOC(n) safe_malloc(n)

void * safe_malloc(size_t n) {
        void *p = malloc(n);
        if (p) {
                return p;
        } else {
                printf("你的内存不够用,我先撤了!\n");
                exit(-1);
        }
}
int main(void) {
        int *p = SAFE_MALLOC(sizeof(int));
        ... ... ...
        
        return 0;
}

如果你被我说服了,决定不去检查 malloc 的返回值是否为 NULL,那么又一个问题随之而来。我们是否需要在程序中检测一个指针是否为 NULLNULL 指针实在是太恐怖了,直接决定了程序的生死。为了安全起见,在用一个指针时检测一下它的值是否为 NULL 似乎非常有必要。特别是向一个函数传递指针,很多编程专家都建议在函数内部首先要检测指针参数是否为 NULL,并将这种行为取名为『契约式编程』。之所以是契约式的,是因为这种检测已经假设了函数的调用者可能会传入 NULL……事实上这种契约非常容易被破坏,例如:

void foo(int *p)
{
        if(!p) {
                printf("You passed a NULL pointer!\n"); 
                exit(-1);
        }
        ... ... ...        
}

int main(void)
{
        int *p;
        foo(p);
        
        return 0;
}

当我将一个未初始化的指针传给 foo 函数时,foo 函数对参数的检测不会起到任何作用。

可能你会辩解,说调用 foo 函数的人,应该先将 p 指针初始化为 NULL,但这有些自欺欺人。契约应当是双方彼此达成一致意见之后才能签署,而不是你单方面起草一个契约,然后要求他人必须遵守这个契约。foo 不应该为用户传入无效的指针而买单,何况它也根本无法买这个单——你能检测的了 NULL,但是无法检测未初始化的指针或者被释放了的指针。也许你认为只要坚持将指针初始化为 NULL,并坚持消除野指针,那么 foo 中的 NULL 检测就是有效的。但是很可惜,野指针不是那么容易消除,下面就会讨论此事。凡是不能彻底消除的问题,就不应该再浪费心机,否则只是将一个问题演变了另一个问题而已。那些被重重遮掩的问题,一旦被触发,你会更看难看清真相。

指针不应该受到不公正待遇。如果你处处纠结程序中用到的整型数或浮点数是否会溢出,或者你走在人家楼下也不是时时仰望上方有没有高空坠物,那么也就不应该对指针是否为 NULL 那么重视,甚至不惜代价为其修建万里长城。在 C 语言中,不需要指针的 NULL 契约,只需要遵守指针法律:你要传给我指针,就必须保证你的指针是有效的,否则我就用程序崩溃来惩罚你。

第三个问题依然与 NULL 有关,那就是一个指针所引用的内存空间被释放后,是否要将这个指针赋值为 NULL?对于这个问题,大家一致认为应该为之赋以 NULL,否则这个指针就成为『野指针』——野指针是有害的。一开始我也这么认为,但是久而久之就觉得消除野指针,是一种很无聊的行为。程序中之所以会出现野指针引发的内存错误,往往意味着你的代码出现了拙劣的设计!如果消除野指针,再配合指针是否为 NULL 的检测,这样做固然可以很快的定位出错点,但是换来的经常是一个很脏的补丁式修正,而坏的设计可能会继续得到纵容。

如果你真的害怕野指针,可以像下面这样做:

#include <stdio.h>
#include <stdlib.h>

#define SAFE_FREE(p) safe_free((void **)(&(p)))

void safe_free(void **p) {
        if(*p) {
                free(*p);
                *p = NULL;
        } else {
                printf("哎呀,我怕死野指针了!\n");
        }
}

int main(void) {
        int *p = malloc(sizeof(int));
        for(int i = 0; i < 10; i++) {
                SAFE_FREE(p);
        }
        return 0;
}

对于所引用的内存被释放了的指针,即使赋之以 NULL,也只能解决那些你原本一眼就能看出来的问题。更糟糕的是,当你对消除野指针非常上心时,每当消除一个野指针,可能会让你觉得你的程序更加健壮了,这种错觉反而消除了你的警惕之心。如果一块内存空间被多个指针引用,当你通过其中一个指针释放这块内存空间之后,并赋该指针以 NULL,那么其他几个指针该怎么处理?也许你会说,那应该用引用计数技术来解决这样的问题。引用计数的确可以解决一些问题,但是它又带来一个新的问题,对于指针所引用的空间,在引用计数为 0 时,它被释放了,这时另外一个地方依然有代码在试图 unref,这时该怎么处理?

绝对的不去检测指针是否为 NULL 肯定也不科学。因为有时 NULL 是作为状态来用的。例如在树结构中,可以根据任一结点中的子结点指针是否为 NULL 来判断这个结点是否为叶结点。有些函数通过返回 NULL 告诉调用者:『我可耻的失败了』。我觉得这才是 NULL 真正的用武之地。

王垠在『编程的智慧』一文中告诫大家,尽量不要让函数返回 NULL,他认为如果你的函数要返回『没有』或『出错了』之类的结果,尽量使用 Java 的异常机制。这种观点也许是对的,但是鉴于 C 没有异常机制(在 C 中可以用 setjmp/longjmp 勉强模拟异常),只有 NULL 可用。有些人形而上学强加附会的将这种观点解读为让函数返回 NULL 是有害的,甚至将这种行为视为『低级错误』,甚至认为 C 指针的存在本身就是错误,认为这样做是整个软件行业 40 多年的耻辱,这是小题大作,或者说他只有能力将罪责推给 NULL,而没有能力限制 NULL 的副作用。如果我们只将 NULL 用于表示『没有』或『出错了』的状态,这非但无害,而且会让代码更加简洁清晰。

如果你期望一个函数能够返回一个有效的指针,那么你就有义务检查它是不是真的返回了有效的指针,否则就没必要检查。这种检查其实与这个函数是否有可能返回 NULL 无关。类似的 NULL 检查,在生活中很常见。即使银行的 ATM 机已经在安全性上做了重重防御,但是你取钱时,也经常会检查一下 ATM 吐出来钱在数目上对不对。你过马路时,虽然有红绿灯,而且你也都是通过驾照考试的,但你依然会下意识的环顾左右,看有没有正在过往的车辆。如果你坚持 NULL 的意义不明确而导致歧义,然后得出推论『返回 NULL 的函数是有害的』,这只不过是在说「这个人又像好人,又像坏蛋,所以他是有害的」。

当你打算检测一个指针的值是否为 NULL 时,问题又来了……我们是应该

if(p == NULL) {
        ... ... ...
}

还是应该

if(!p) {
        ... ... ...
}

?

很多人害怕出错,他们往往会选择第一种判断方式,他们的理由是:在某些 C 的实现(编译器与标准库)中,NULL 的值可能不是 0。这个理由,也许对于 C99 之前的 C 是成立的,但是至少从 C99 就不再是这样了。C99 标准的 6.3.2.3 节,明确将空指针定义为常量 0。在现代一些的 C 编译器上,完全可以放心使用更为简洁且直观的第二种判断方式。

用 C 语言,就不要想太多。想的太多,你可能就不会或者不敢编程了。用 C 语言,你又必须想太多,因为不安全的因素到处都有,但是也只有不安全的东西才真正是有威力的工具,刀枪剑戟,车铣刨磨,布鲁弗莱学院传授的挖掘机技术,哪样不能要人命!不要想太多,指的是不要在一些细枝末节之处去考虑安全性,甚至对于野指针这种东西都诚惶诚恐。必须想太多,指的是多从程序的逻辑层面来考虑安全性。出错不可怕,可怕的是你努力用一些小技俩来规避错误,这种行为只会导致错误向后延迟,延迟到基于引用计数的内存回收,延迟到 Java 式的 GC,延迟到你认为可以高枕无忧然而错误却像癌症般的出现的时候。


garfileo
6k 声望1.9k 粉丝

这里可能不会再更新了。