现在的位置: 首页 > 自动控制 > 工业·编程 > 正文

漫谈C++内存分配失败

2013-07-02 21:22 工业·编程 ⁄ 共 3701字 ⁄ 字号 暂无评论

没错,是“漫谈”,而且“漫”得有点乱。然而,抛砖尚可引玉,想到的事情,纵然脉络不是很畅,写下来也不是坏事。开卷有益,动笔也有益。
 
一切缘自一位C语言开发经验非常丰富的的朋友问我的一个问题。朋友问:“C++中的new在分配内存失败时会抛出异常(std::bad_alloc)而不返回0(一些老的编译器可能还在返回0,但这样的编译器实在“太老了”),这跟C程序员的做法很不一样。而且,许多C++程序在使用new创建对象时也根本不检查这种异常。这是一种什么哲学呢?”他还提到:“一般C程序员总会判断一下malloc失败的情况,就连Linux内核中都是如此。”
 
对于他的疑惑,我首先想到的是:一般用C++实现的应用层程序,内存管理方面自不能与内核程序相提并论。OS内核直接管理物理内存,所有应用程序的地址空间均由它映射而来,然后靠它建立机制进行翻译。内核如果在内存管理方面不保险,应用层还怎么过日子?内核中的内存分配还须考虑许多其它问题,比如不同区域的不同特性(像某些DMA使用的buffer要物理连续且位于特定位置)。同样重要的:对一个成熟的OS内核来说,即使在应用程序出现严重问题的时候也不能泄露物理内存及其它资源,且不能影响其它程序。
应用层程序则不同,它们一般拥有彼此独立的、flat的虚拟内存空间,数量上通常远大于物理内存。因此,一个应用程序如果能耗尽虚拟内存,那要么是对数据的规模估计不足,要么就是一个必需专门解决的严重bug。
耗尽虚拟内存跟其它许多严重的bug(再比如缓冲区溢出导致的堆栈破坏)一样,多数情况下即使能检测到也常常无计可施,如果“有计可施”,那何不早施此计?何苦等它发生再亡羊补牢呢?反过来想,该失败的时候痛痛快快的快速失败,这不算坏事。至少,比带着问题继续运行半小时,然后在某个完全不相干的地方发生莫名其妙又难以重现的bug要好得多。
这是我当时给朋友的回答,朋友勉强同意了,至少不再纠结C++程序员因何不在new的时候检查std::bad_alloc了。然而,顺着这个问题,我觉得可以联想到好多相关的话题。
 
(1)首先想到的是Java语言的做法。Java中的变量都是引用(基本类型的除外),而被引用的对象是用new在堆(heap)上创建的。在Java中new一个对象时,理论上也有可能引发java.lang.OutOfMemoryError。当然,这是个Error,不是从java.lang.Exception派生的“异常”,因此语言并没强制我们catch它。然而,语言是否要求并不重要,语言为什么不要求才是重要的。显然,如果问题足够严重,即使语言不要求,Java程序员也会在每一处new的周围包上try/catch。可Java程序员没有这么做。为什么?我想关键的原因跟上面是一样的:一个应用程序耗尽虚拟内存,要么是对数据的规模估计不足(是否应通过java命令的-Xm系列参数设置更大的heap呢?),要么就是一个必须专门解决的bug。
同时,相对C++来说,Java程序中采用这一决策还有更充分的理由:因为有GC机制,Java程序中因为粗心造成的内存泄露较少(可能会有因不良设计造成的内存伪泄露)。
 
(2)C++中的“new”还不只是分配内存那么简单。对于用户自定义的类型来说,“new T;”相当于operator new再加上对T的构造函数的调用。由于类的构造函数完全可能引发异常,于是,就算内存分配一切顺利,一条new语句还是可能产生异常。看来,需要catch的不止std::bad_alloc。
 
(3)暂不考虑“哲学”因素,如果有人仍然觉得应该像C程序那样严格检查内存分配,可不可以呢?当然可以,毕竟它还能抛出异常么,它能抛出我们就能捕捉。于是人们自然会想:C++或Java程序员用驼鸟策略对付内存分配的失败,异常在使用上比较麻烦的事实会不会是原因之一呢?表面看是显然的:每分配一次内存都要包上一层try/catch,跟C中的针对返回值的if/else风格比起来凌乱多了。
实际上,那不是使用异常的正确方法。如果异常只是if/else的简单语法替代物,那它根本就没有存在的必要。异常的好处之一(真的只是“之一”)是:一个异常只需一个地方处理就足够了。比如下面这样:
[cpp] view plaincopy
void f1() { 
    try { 
        // ... 
        f2(); 
    } catch (const some_exception& e) { 
        // ... 
    } 

 
void f2() { 
    // ... 
    f3(); 

 
void f3() { 
    // ... 
    f4(); 

 
void f4() { 
    // ... 
    throw some_exception(); 

f4惹祸,f1收场,中间f2和f3只是一脸无辜地把异常“透过去”了(在Java中可能要声明一下)——原因很可能是它们不具备足够的上下文来处理这个异常。于是,我们不用像使用返回值那样,从发生问题的地方开始,到处理问题的地方“之下”,中间每一层都要判断一下,从而写下一层又一层的诸如:
[cpp] view plaincopy
x = f(); 
if(x < 0) 
    return x; 
之类的语句。你不觉得这样可以使大多数函数更加干净吗?在异常或错误处理的问题上,这也使得不同逻辑层次的责任更加清晰。
值得一提的是,在异常回滚的过程中,栈上已经构造好的对象都会正常析构。当然,这要求程序员在设计类的时候要考虑“异常安全”的因素。
关于异常处理的思想和异常的使用,完全可以讲一本书。更有兴趣的朋友不妨看看Herb Sutter写的“Exceptional三卷本”:《Exceptional C++》、《More Exceptinal C++》和《Exceptional C++ Style》。
 
(4)事实上,C++中并非只有抛出异常的new,也有不抛异常的new,即通常所说的“nothrow new”。可以这样使用它:
[cpp] view plaincopy
#include <new> 
// ... 
T* p = new (std::nothrow) T(/* ... */); 
其中,nothrow是头文件<new>中定义的一个类型为std::nothrow_t的常量,我们可以直接使用它。这时,如果内存分配失败,p的值将为空(0),且不会有异常抛出,跟C的malloc很像了。
nothrow new实际是标准库中实现的operator new和operator new[]的重载。我们也可以根据自己的需要重载operator new/operator new[],可以有全局的,也可以针对某个类重载。但实践中用的不多。
注意,使用nothrow new创建对象时,只能保证不会因为operator new或operator new[]的失败而抛出std::bad_alloc,但难保对象的构造函数不会抛出其它异常,甚至就抛出std::bad_alloc。
 
(5)说到C++的内存分配,还有必要提一下set_new_handler。它允许你设置一个在operator new和operator new[]分配内存失败时可以回调的函数。如果你觉得在std::bad_alloc发生时还有什么办法能改善一下内存使用的情况,这个回调函数也算得一根救命稻草,详细的用法和说明可以看这里:http://new.cplusplus.com/reference/std/new/set_new_handler/
 
(6)虽然当std::bad_alloc发生时我们常常已无计可施,但并非所有的异常都如此,有些异常是可以处理从而挽回损失的。因此,在主函数最后,或者在多线程程序,尤其是所谓的worker thread的线程函数退出之前,用“catch(...)”捕捉一下所有异常还是有好处的。即使不指望恢复什么,至少不要因为一个线程而挂掉整个程序,同时尽量确保数据的完整性。
但别指望catch(...)能捕获一切“问题”或“bug”,没有那么好的事情。它只能捕获C++的异常,其它的问题,比如前面提到的堆栈破坏,再比如野指针访问,哪有那么容易检测得到。(话说我最近被野指针搞得烦死了,不是我写的。)
通常一个线程crash会导致整个进程crash,有人因为这个原因而更倾向于使用多进程,尤其是在类Unix的环境中。我个人对此虽不反对也不是特别赞同,因为欠债总是要还的,这也包括“技术债务”:有bug迟早还是要找到根源并真正解决,哪怕“真正绕开”也行。
不过,使用多进程还有别的好处,因为进程间共享数据比同一个进程的线程之间要麻烦得多,这会迫使开发者做出减少共享,从而既能减少并发问题又能提高并发效率的设计。步入多核时代之后,让并发实体尽可能地独立,从而充分发挥硬件的并行性能,比什么都重要。
 
(7)我的另一个好朋友兼同事(新浪微博 @不许说话)认为:程序crash没有那么可怕。它可能是多数客户最难以忍受的bug,但那只是源于社会心理,不见得是真正最严重的bug。

给我留言

留言无头像?