软件开发>杂文>1

共享软件开发的点滴心得

 [中文][English][日本語]

 [机器人制作] [软件开发] [豆知识] [Squeak] [关于]

资源管理的“变法”

刘新宇

2005年冬月

 

提起C++中的内存使用,许多人会立刻警惕起来,因为在获得了灵活使用内存的好处同时,使用者还必须小心谨慎以避免出现问题。

 

最近10多年来,随着竞争的加剧,在技术行业中,很少有人能够卓尔不群地成为创造产品的英雄了。社会更加强调团队、集体和协作,其本质是强调社会分工。如果每个成员成为一个质量还过得去的零件——例如螺丝钉之类的东西——那么整个集体就像一部大机器,得以安全的运转。

 

所 以,对于每个成员的要求,说得难听一点就是:“灭人欲,存天理”,也就是牺牲个性,要求纪律。当然也有别的说法,最常见的就是“团队协作”了。为什么说 “灭人欲”呢?根据心理学家马斯洛的理论:人的需求,也就是人欲,除了传统上的“饮食男女”之外,最重要的就是获得尊重的需求。换句话说就是个体既需要成 就感或自我价值的个人感觉,也需要他人对自己的认可与尊重。然而对于一部社会机器,也就是团队或者集体来说。一个价值连城、不可替代的部件所带来的风险, 远远低于一个便宜可靠,易于替换的螺丝钉能够带来的风险。因此所谓“存天理”就是顺应社会生产的规则,细化社会分工,分解风险。

 

所以,对于C++中的内存使用,不少团队分别建立了各自的“天理”,大体有这样种:

(1)    具有自动回收功能的公共堆基类;

(2)    分布构造和清除栈。

 

先说第一种方法。这种方法,或多或少受到了Java体系中GC的启发,总结下来问题出在释放上,而不是申请上。经常的问题是开发者忘记释放或者释放产生冲突,如下面的例子。

 

      MyClass* ptr=new MyClass();

     MyClass foo;

     ptr=&foo; //ptr指向的资源丢失了;

     

      MyClass* ptr1=new MyClass();

     MyClass* ptr2=ptr1;

    

     //

     // other process...

     //

     delete ptr1;

    

     //

     // other process...

     //

     delete ptr2;  //释放冲突.

 

于是聪明的“张居正”们,制定了“一条鞭法”,既然问题出在释放上,索性不让开发人员自己释放,一律由行政部门进行统一释放。为了推行这样的“一条鞭法”,行政部门提供了两个工具:

(1)    HeapZone,一块划定出来的土地,所有的开发人员必须在HeapZone这块内存上从事生产劳动,不得自己开垦私田。

(2)    HeapZoneObject,一个公共基类,开发人员创造的所有的对象都必须继承自这个类,从而“摊丁入亩”。

有了一条鞭法,“佃农”的程序写出来大致如下:

 

class MyClass : public HeapZoneObject

{

     //...

};

 

MyClass* ptr = new (HeapZone) MyClass;

 

这里用到了一个名叫:“placement new”的语法,具体可以参见Scott Meyer1998年所著的《Effective C++[1]

 

“佃农”所要做的,基本上只有两件事情:其一,是所有自定义的类都要从HeapZoneObject继承;其二,是当希望初始化自己的对象时,要将其放在HeapZone之上。此后“佃农”是否delete这个对象与否,悉听尊便。这个对象最后肯定会被回收。

 

那么为什么实行这样的“一条鞭法”,就能避免“佃农”有意或者无意的“逃租”,从而最终维护“地主阶级”的利益呢?首先我们来看看HeapZone的思想。按照普通对象初始化的本意,当采用new这样的操作时,对象被初始化在“堆(heap)”上,而堆是由系统维护的,系统为每个进程分配独立的进程地址空间,这个堆给与了开发人员极大的自由。但是任何人都不可能人为地控制这个堆。而HeapZone的设计本意,是事先从系统堆上划出一块地方,加以监控。只要有“佃农”在HeapZone上初始化对象,就算一笔“出账”。这样一笔一笔记录下来。如果有本分规矩、民风淳朴的人及时释放了申请的对象——自觉delete了。那么这就算一笔“入账”。到了程序结束,“秋后算账”的时候,对所有在HeapZone上的一笔一笔“出入账”进行结算,所有没有归还的一律“强制执行”。

 

强制执行时的情景大致如下:

 

//地主先划出一块1M大小的地

     void* HeapZone = std::malloc(1024);

 

     //佃农租借,出账一笔

     MyClass* ptr = new (HeapZone) MyClass;

     //...

 

     //秋后算账,强制执行

     free(HeapZone);

 

下面说说HeapZoneObject的作用。为什么要设置这个公共基类呢?这是因为各个“佃农”各自家里有各自的情况,没有办法统一按照“纳粮”来结算。这时候就要用到C++的继承和多态功能了。假设甲乙两个“佃农”各自情况如下:

 

//种植大米的佃农

class Rice{

public:

     Rice(){

          _tool.open(); //安装租借的碾子(打开文件)

     }

     ~Rice(){

          _tool.close(); //拆除租借的碾子(关闭文件)

     }

private:

     Tool _tool;   //租借了地主的石碾子(可能是文件)

};

 

//种植土豆的佃农

class Potato{

public:

     Potato(){

          _cow.birth(); //生小牛了(连接网络)

     }

     ~Potato(){

          _cow.kill();  //过年杀牛吃牛肉了(关闭网络)

     }

private:

     Cow _cow;     //家里有头牛(可能是网络)

};

 

如此琐屑、鸡毛蒜皮的情况怎么办?如果就是简单的认为HeapZone里所有的东西都是“粮食作物”——内存,按照上面的例子程序一执行的话,那么石碾子,牛不都流失了么?

 

这里实际上涉及到资源(Resource)的概念,内存(Memory)仅仅是资源的一种,打开的文件,连接的网络等等其实都是一种资源。所以如果资源回收仅仅回收内存的的话,那么其他类型的资源就有可能泄漏。

 

解决方案是提供一个HeapZongObject作为基类,这个基类有一个虚析构函数,而且必须是虚的,具体可以参考Scott Meyer的《Effective C++》条款14[1]HeapZone上所有内容不再是简单的内存字节,而是一个一个的HeapZoneObject

 

这样秋后“强制执行”时,一个一个释放HeapZoneObject,而实际上每一个HeapZongObject指针指向的是Up-cast上来的“佃农”子类。于是各个子类的析构函数得以执行,牲畜和农具就都得以最终归还了。

 

到这里,“一条鞭法”的原理基本已经说清楚了。下面就可以给出一个“实施细则及其内幕”,为了简化,暂时不考虑多线程的问题。

 

首先看HeapZoneObject的定义:

 

class gc_obj{

public:

     gc_obj():_info("noname"){ cout<<_info<<endl;}

     gc_obj(string objName):_info(objName){ cout<<_info<<endl;}

     virtual ~gc_obj(){}

 

     static void* operator new(size_t size);

     static void* operator new[](size_t size);

     static void  operator delete(void* p);

     static void  operator delete[](void*p);

private:

     string _info;

};

 

这里,把类的名字从HeapZoneObject改为gc_obj,揭示了其本意为“自动垃圾回收对象”。相应地,在后面HeapZone的名字也改为了gc。这里还作了一个小小改进,就是重载了new运算符,这样就不需要使用多少有些怪的placement new,“佃农”只要确保自定义的类从gc_obj继承来,就可以使用普通的new运算符了。

 

这里,又引出了一个新的问题:对象数组。因为“佃农”可能会批量申请“生产资料”:

Foo* p3 = new Foo[5];

 

“秋后算账”时,地主仅仅知道gc里面是一个一个的gc_obj的指针,但是不知道究竟是单个对象的指针,还是数组的指针,所以也就不知道是应该调用delete还是delete[]来释放。

 

解决办法是增加定义了一个结构chunk,里面存了两个东西,一个是一个void*指针,用来指向gc_obj,另外一个用来标记这个指针究竟当初是单个gc_obj还是一个数组:

 

struct chunk{

    chunk(void* p, bool isArray=false): ptr(p), isArray(isArray){}

    chunk(const chunk& v):ptr(v.ptr), isArray(v.isArray){}

    chunk& operator = (const chunk& v){

          ptr = v.ptr;  isArray = v.isArray;

          return *this;

     }

 

     const bool operator == (const chunk& v){ return ptr == v.ptr; }

 

     void* ptr;

     bool  isArray;

};

 

增下了上述定义后,凡是gc_obj的子类在初始化时,就会调用下面这2个实现:

 

void* gc_obj::operator new(size_t size){

     return gc::instance().alloc(size);

}

 

void* gc_obj::operator new[](size_t size){

     return gc::instance().alloc(size, true);

}

 

其中,第一个实现是针对单个对象,第二个实现是对象数组。每个实现都会调用一个gcsingleton,有关singleton的概念请参考[1][2]。这样gc的唯一实例会把这个资源申请“登记入账”,同时对于用new[]申请的数组资源,会把相应的chunk数组标志设置为true

 

当“佃农”主动提前归还生产资料“入账”时,仍然是调用gc的唯一实例,并告之gc归还的是否是数组:

 

void  gc_obj::operator delete(void* p) {

     gc::instance().free(p);

}

 

void  gc_obj::operator delete[](void* p) {

     gc::instance().free(p, true);

}

 

最后,也就是最核心的东西就是gc的实现了。实现的主要手法是维护一个“出入账账本”——一个chunk的链表。每当有gc_obj的资源申请时,其首先判断是否是数组申请,然后把数组标志和申请的资源打包到一个chunk里,最后再把chunk增加进入链表从而“入账”。而当“佃农”主动提前释放资源时,先把这个chunk的记录在“账本”中找到,清掉这笔记录从而“出账”,然后再依据是否是数组的标志决定调用delete还是delete[]

 

class gc{

private:

     gc(){}

     gc(const gc&);

     gc& operator = (const gc&);

 

     list<chunk> mem_list;

public:

     static gc& instance(){

          static gc inst;

          return inst;

     }

 

     ~gc();

 

     void* alloc(size_t size, bool isArray = false){

          gc_obj* p = (gc_obj*) (isArray?

                               ::operator new[](size) :

                               ::operator new(size));

          mem_list.push_back(chunk(p, isArray));

          cout<<"\talloc by gc at: "<<hex<<p<<dec<<" for ";

          return p;

     }

 

     void free(void* p, bool isArray = false){

          cout<<"\tfree by gc\n";

          mem_list.remove(chunk(p, isArray));

          if(isArray){

              ::operator delete[](p);

          }else{

              ::operator delete(p);

          }

     }

};

 

现在解释如何“秋后算账”。经过了一年间众多“佃农”的反复“借贷”、“归还”,有的有借有还;有的借了不还,最后程序结束终于到了“丰收的季节”。程序结束时gcsingleton对象走到了它的析构函数:

 

gc::~gc(){

     list<chunk>::iterator it;

     list<chunk> leak_list(mem_list);

     int i;

     cout<<"===============memory leak list==============\n";

     for(i=1, it=leak_list.begin(); it!=leak_list.end(); ++it, ++i){

          cout<<i<<": obj["<<hex<<it->ptr<<dec

              <<"]\tfinal release...";

     if(it->isArray)

          delete[] (gc_obj*)((char*)it->ptr+4);  //this is a trick, C++ add 4 bytes to record demension.

     else

          delete ((gc_obj*)it->ptr);

     }

}

 

析构函数为了保险起见,先复制了一份“账本”,并命名为“应收款项”leak_list。然后遍历所有的项目,打印出明细,最后根据当初申请时是否是数组的标志,分别调用deletedelete[]。这里有一个小小的trick,也就是上面的代码中注释的地方,需要详细解释一下。首先思考这样一个问题,看下面的代码:

 

void* raw_ptr;

 

class Foo{

public:

     Foo(){}

     ~Foo(){}

    

     static void* operator new[](size_t size){

          raw_ptr=::operator new[](size);

          cout<<"raw addr:"<<hex<<raw_ptr<<endl;

          return raw_ptr;

     }

};

 

int main(int argc, char* argv[]){

     Foo* ptr = new Foo[5];

     cout<<"ptr addr:"<<hex<<p<<dec<<endl;

     return 0;

}

 

猜猜结果会是什么?非常令人吃惊:

raw addr:0x3d3e40

ptr addr:0x3d3e44

 

为什么会这样呢?为什么new返回前和返回后的地址相差了4个字节呢?再深入思考一下下面的代码:

Foo* p1 = new Foo[3];

Foo* p2 = new Foo[5];

delete[] p1;

delete[] p2;

 

问题出来了:C++如何知道delete[]时,是应该释放3个对象,还是应该释放5个对象呢?是不是下面的代码看起来更加容易理解一些呢?

delete[](3) p1;

delete[](5) p2;

 

这个问题的答案在于编译器。编译器在遇到new Foo[3]这样的代码时,首先申请内存,在32位版本的情况下,这块内存大小是3Foo再加上4个字节。这个额外的4个字节放在最前面,用来保存数组的大小,也就是3,然后编译器把第5个字节的地址返回给p1。但是当第5个字节的地址存入chunk时,被强转成了void*,这样如果将来delete[]时,直接对着由void*强转回来的指针,也就是第5个字节的地址,则前面保存着贵重信息“3”的4个字节就丢弃了。最终结果就是Crash。因此,这里必须这样正确释放内存:

delete[] (Foo*)((char*)raw_ptr+4);

 

故事到这里并没有结束,如果删除掉Foo中定义的析构函数,程序的运行结果更加有趣了:

raw addr:0x3d3e40

ptr addr:0x3d3e40

 

说明了什么呢?编译器对待有无析构函数的对象是不同的,本质上释放一组n个对象需要做两件事:第一件事是首先是依次对每个对象调用析构函数,共n次;第二件事是统一回收这一组内存。对于没有析构函数的对象,由于第一件事不用做了,所以编译器会偷懒进行优化,不需要记录数组个数,而直接把这块土地“依法回收”。反之,编译器不得不记录数组个数,以决定要调用多少次析构函数。好在“一条鞭法”提供的gc_obj是基类,并且定义了虚析构函数,所以只要“佃农”老老实实从gc_obj继承,那么向前移动4个字节后再delete[]就不会出问题。

 

现在可以看看“一条鞭法”的效果了:

 

int main(int argc, char* argv[]){

     class Foo: public gc_obj{

     public:

          Foo(string name):gc_obj(name){}

          Foo():gc_obj("noname_Foo"){}

          ~Foo(){}

     };

 

     Foo* p1 = new Foo("obj1");

     Foo* p2 = new Foo("obj leak");

     Foo* p3 = new Foo[5];

     delete p1;

     p2 =0;  //mem leak

     p3 =0;  //array leak

 

     return 0;

}

程序输出:

        alloc by gc at: 003A3578 for obj1

        alloc by gc at: 003A3270 for obj leak

        alloc by gc at: 003A6028 for noname_Foo

noname_Foo

noname_Foo

noname_Foo

noname_Foo

        free by gc

===============memory leak list==============

1: obj[003A3270]        final release...        free by gc

2: obj[003A6028]        final release...        free by gc

 

下面再说说第二种“天理”:两步构造和清除栈。如果说前面所讲的是“张居正”,那么接下来说的可以算是“王安石”了[4][5]

 

首先“王安石”们提出“青苗法”问题时,不仅着眼于普通的资源管理问题,还注意到了异常这样的“天灾”造成的问题。在“宋朝”这样的环境里,“自然灾害响应机制”——try-catch这样的C++异常处理不能使用,原因有两个:一个是“大宋”的嵌入式环境不同于“大明”的PC环境,资源非常受限制,OS实现是只适合于小型设备的轻量级稳定系统;第二个原因是一个历史问题,“大宋”所处的时代,GNU的编译器还不支持标准C++异常处理。

 

于是“大宋”的问题可以描述如下:

(1)    “农民”都是良民,大家好借好还;

(2)    环境非常恶劣,“大宋政府”经常无地可以“放贷”,更有甚者,会有在办理“借贷”手续中突然发现所有“土地”已经耗尽了;

(3)    没有应付“天灾”的编译器级异常处理机制。

 

对于第三点,“大宋国朝”的解决方案是设计了一套程序级别的Trap-Leave机制,简单说来,就是在可能出现异常的地方进行trap;在异常出现时,调用leave。具体的用法可能如下:

TRAPD(error, MyFunc());

 

if(error){

     // 错误处理的代码.

}

else{

     // OK

}

 

void MyFunc(){

     // process here...

     Foo* ptr= new Foo; // try-catch没有进入标准前,new失败返回Null

     if(!ptr)

          User::Leave();

}

 

由于TRAPD不是语言级别由编译器支持的。所以只好把所有可能发生异常的部分,归入一个函数内,当然这个函数也可以调用其它可能引发“自然灾害”的函数,一旦发生“自然灾害”——异常,程序会显式地调用Leave进行“灾害报警”。然后“大宋朝廷”就会动用“国有资产”——清除栈进行善后。

 

清除栈 这种“灾害善后”体系,可以理解为大宋的一种“社会保险”,在“丰年”大宋的农民在生产劳动中,把所有资源的申请都加以登记——推入清除栈,秋收后,再从 清除栈注销这笔记录——从清除栈弹出。一旦“自然灾害”发生,进入“荒年”。大宋的就把清除栈拿出来,根据里面的记录,进行“善后”——释放所有资源。

 

这个清除栈基本上和“大明朝”的gc看起来差不多,可以这样实施:

class CleanupStack{

public:

     static void _push(Base* ptr){

          instance()._cleanStack.push(ptr);

     }

 

     static void pop(Base* ptr){

          instance()._cleanStack.pop();

     }

 

     static void pop_and_destroy(){

          delete (instance()._cleanStack.top());

          pop();

     }

 

     static CleanupStack& instance(){

          static CleanupStack inst;

          return inst;

     }

 

     ~CleanupStack(){

          while(!_cleanStack.empty()){

              delete (_cleanStack.top());

              _cleanStack.pop();

          }

     }

private:

     CleanupStack(){}

     CleanupStack(const CleanupStack&);

     CleanupStack& operator = (const CleanupStack&);

 

     stack<Base*> _cleanStack;

};

 

虽然看起来简陋一些,但是基本能用了。其前提条件是“民为良民”,大家平时“风险意识”都很好,凡是可能Leave的地方,都是先入栈,如果幸运没有赶上“荒年”,就安安稳稳的出栈。另外一个重要的前提是必须注意顺序,由于stack这种数据结构是后进先出(LIFO),所以顺序稍一搞错,就会满盘皆输。清除栈配合trap-leave异常机制使用后,“农民”生产劳动的代码大致如下:

 

Foo* ptr= new Foo;

     CleanupStack::_push(ptr);

 

     // do something with ptr...

 

     CleanupStack::pop_and_destroy();

 

“青苗法”的故事到此并未结束,清除栈的“社会保险”机制带来了新的问题,例如,考虑下面这种情况:

 

class Tool{};

class Cow{};

 

class MyClass{

public:

     MyClass(){

          _tool= new Tool;

          _cow = new Cow;    //如果在这一行申请失败将会如何?

     }

 

     ~MyClass(){

          delete _cow;

          delete _tool;

     }

private:

     Tool* _tool;

     Cow*  _cow;

}

 

MyClass* ptr= new MyClass;

 

如这个例子中所示,某个“农民”在申请“土地”后,必须继续申请必要的“农具”和“耕牛”,假设其成功申请了“农具”,但是在申请“耕牛”的时候,发现出现了严重资源短缺,那么将会发生什么后果呢?

 

由于实际上new做了两件事情:首先分配内存,然后调用构造函数进行初始化。现在为Ptr分配内存已经成功了,但是在执行构造函数时遇到了“天灾”——异常,而ptr对部分资源申请成功,部分却失败的细节却一无所知。结果是指向“农具”的指针和ptr本身都发生了“国有资产流失”。

 

在构造函数内初始化而导致失败的情况非常令“大宋官员”头疼。于是他们不得不下“狠手”。“青苗法”规定,凡是拥有数据成员的类,不得提供有任何构造函数!必须采用如下的两步构造法:

 

class Foo:public Base{

public:

     static Foo* _new(int data){

          Foo* self = Foo::_new_and_construct(data);

          CleanupStack::pop(self);

          return self;

     }

    

     static Foo* _new_and_construct(int data){

          Foo* self = new Foo;

          CleanupStack::_push(self);

          self->_construct(data);

          return self;

     }

    

     ~Foo(){}

    

private:

     Foo(){}   //disable init.

 

     void _construct(int data){

          _data=data;

     }

 

     int _data;

};

 

Foo* ptr = Foo::_new(5);

 

所谓两步构造法,就是第一步仅仅分配内存,由于构造函数是空的,所以不会做任何初始化操作,不会由于构造函数造成任何异常。然后在获得这块内存的指针self后,立刻将其推入清除栈,然后进入“风险”很大的第二步——初始化,初始化函数_construct在内部对所有的成员进行构造。“青苗法”规定,所有存在引发“天灾”的函数,必须冠以“_”作为标记。如果某个类含有指针成员,那么在他的_construct内,也要采取类似的“两步构造法”。

 

“青苗法”还带来了另外一项强制措施:所有这样的类,都不能在栈上实例化,因为他们不提供公有的构造函数。至此“王安石”们的“天理”也就讲完了。

 

中国 古代讲“尽人事,知天命”,说得就是人与自然和谐相处的道理。张居正和王安石不管后世评价如何分歧,不可否认的是他们都是非常聪明的人。张居正为内阁大学 士,做到明朝“首辅”。而王安石“宋史”说他“属文动笔如飞”,又“议论高奇,能以辨博济其说”。然而中国历史上越是这样的聪明人,越是给后世带来唇枪舌 剑的争论。我之所以斗胆拿他们以及变法的历史“戏说”,是非常想表现同样的历史矛盾——后世观“青苗法”觉得当时制定得非常完善细致,本心也是好的,即为 农民着想,使之免受“高利贷”的痛苦,又可增加国家财政收入,从而富国强兵。然而这样聪明人制定的聪明法,被历史开了无情的玩笑。宋朝引发了“元佑党 争”,明朝则逐渐发生了“党祸”和“魏忠贤专权”,庞大的帝国逐渐走向衰弱。这些都值得读书很好的“聪明人”深思。

 

参考书目:

[1] Scott Meyer, Effective C++.

[2] Andrei Alexandrescu, Modern C++ Design: Generic Programming and Design Pattern Applied. Addison Wesley, Feb. 2001

[3] Leigh Edwards, Richard Barker, etc. Developing series 60 Applications: A guide for symbian OS C++ developers. Addison Wesly.

[4] 黄仁宇《万历十五年》

[5] 林语堂《苏东坡转》


[English][下一篇][目录][主页][联系我们: liuxinyu95@gmail.com]