【转】标准C++类std::string的内存共享和Copy-On-Write技术

一、             概念
 

Scott Meyers在《More Effective C++》中举了个例子,不知你是否还记得?在你还在上学的时候,你的父母要你不要看电视,而去复习功课,因而你把本身关在房间里,作出一副正在复习功课的样子,其实你在干着别的诸如给班上的某位女生写情书之类的事,而一旦你的父母出来在你房间要检查你是否在复习时,你才真正捡起课本看书。这就是“拖延战术”,直到你非要作的时候才去作。

 

固然,这种事情在现实生活中时每每会出事,但其在编程世界中摇身一变,就成为了最有用的技术,正如C++中的能够随处声明变量的特色同样,Scott Meyers推荐咱们,在真正须要一个存储空间时才去声明变量(分配内存),这样会获得程序在运行时最小的内存花销。执行到那才会去作分配内存这种比较耗时的工做,这会给咱们的程序在运行时有比较好的性能。必竟,20%的程序运行了80%的时间。

 

固然,拖延战术还并不仅是这样一种类型,这种技术被咱们普遍地应用着,特别是在操做系统当中,当一个程序运行结束时,操做系统并不会急着把其清除出内存,缘由是有可能程序还会立刻再运行一次(从磁盘把程序装入到内存是个很慢的过程),而只有当内存不够用了,才会把这些还驻留内存的程序清出。

 

写时才拷贝(Copy-On-Write)技术,就是编程界“懒惰行为”——拖延战术的产物。举个例子,好比咱们有个程序要写文件,不断地根据网络传来的数据写,若是每一次fwrite或是fprintf都要进行一个磁盘的I/O操做的话,都简直就是性能上巨大的损失,所以一般的作法是,每次写文件操做都写在特定大小的一块内存中(磁盘缓存),只有当咱们关闭文件时,才写到磁盘上(这就是为何若是文件不关闭,所写的东西会丢失的缘由)。更有甚者是文件关闭时都不写磁盘,而一直等到关机或是内存不够时才写磁盘,Unix就是这样一个系统,若是非正常退出,那么数据就会丢失,文件就会损坏。

 

呵呵,为了性能咱们须要冒这样大的风险,还好咱们的程序是不会忙得忘了还有一块数据须要写到磁盘上的,因此这种作法,仍是颇有必要的。

 

 

二、             标准C++类std::string的Copy-On-Write
 

在咱们常用的STL标准模板库中的string类,也是一个具备写时才拷贝技术的类。C++曾在性能问题上被普遍地质疑和指责过,为了提升性能,STL中的许多类都采用了Copy-On-Write技术。这种偷懒的行为的确使使用STL的程序有着比较高要性能。

 

这里,我想从C++类或是设计模式的角度为各位揭开Copy-On-Write技术在string中实现的面纱,以供各位在用C++进行类库设计时作一点参考。

 

在讲述这项技术以前,我想简单地说明一下string类内存分配的概念。经过常,string类中必有一个私有成员,其是一个char*,用户记录从堆上分配内存的地址,其在构造时分配内存,在析构时释放内存。由于是从堆上分配内存,因此string类在维护这块内存上是格外当心的,string类在返回这块内存地址时,只返回const char*,也就是只读的,若是你要写,你只能经过string提供的方法进行数据的改写。

2.一、         特性
 

由表及里,由感性到理性,咱们先来看一看string类的Copy-On-Write的表面特征。让咱们写下下面的一段程序:

 


#include

#include

using namespace std;

 

main()

{

       string str1 = "hello world";

       string str2 = str1;

       

       printf ("Sharing the memory:"n");

       printf (""tstr1's address: %x"n", str1.c_str() );

       printf (""tstr2's address: %x"n", str2.c_str() );

       

    str1[1]='q';

       str2[1]='w';

 

       printf ("After Copy-On-Write:"n");

       printf (""tstr1's address: %x"n", str1.c_str() );

       printf (""tstr2's address: %x"n", str2.c_str() );

 

       return 0;

}

 

 

这个程序的意图就是让第二个string经过第一个string构造,而后打印出其存放数据的内存地址,而后分别修改str1和str2的内容,再查一下其存放内存的地址。程序的输出是这样的(我在VC6.0和g++ 2.95都获得了一样的结果):

 


> g++ -o stringTest stringTest.cpp

> ./stringTest

Sharing the memory:

        str1's address: 343be9

        str2's address: 343be9

After Copy-On-Write:

        str1's address: 3407a9

        str2's address: 343be9

 

 

从结果中咱们能够看到,在开始的两个语句后,str1和str2存放数据的地址是同样的,而在修改内容后,str1的地址发生了变化,而str2的地址仍是原来的。从这个例子,咱们能够看到string类的Copy-On-Write技术。

 

 


2.二、         深刻
在深刻这前,经过上述的演示,咱们应该知道在string类中,要实现写时才拷贝,须要解决两个问题,一个是内存共享,一个是Copy-On-Wirte,这两个主题会让咱们产生许多疑问,仍是让咱们带着这样几个问题来学习吧:

一、  Copy-On-Write的原理是什么?

二、  string类在什么状况下才共享内存的?

三、  string类在什么状况下触发写时才拷贝(Copy-On-Write)?

四、  Copy-On-Write时,发生了什么?

五、  Copy-On-Write的具体实现是怎么样的?

 

喔,你说只要看一看STL中stirng的源码你就能够找到答案了。固然,固然,我也是参考了string的父模板类basic_string的源码。可是,若是你感到看STL的源码就好像看机器码,并严重打击你对C++自信心,乃至产生了本身是否懂C++的疑问,若是你有这样的感受,那么仍是继续往下看个人这篇文章吧。

 

OK,让咱们一个问题一个问题地探讨吧,慢慢地全部的技术细节都会浮出水面的。

 

2.三、         Copy-On-Write的原理是什么?
 

有必定经验的程序员必定知道,Copy-On-Write必定使用了“引用计数”,是的,必然有一个变量相似于RefCnt。当第一个类构造时,string的构造函数会根据传入的参数从堆上分配内存,当有其它类须要这块内存时,这个计数为自动累加,当有类析构时,这个计数会减一,直到最后一个类析构时,此时的RefCnt为1或是0,此时,程序才会真正的Free这块从堆上分配的内存。

 

是的,引用计数就是string类中写时才拷贝的原理!

 

不过,问题又来了,这个RefCnt该存在在哪里呢?若是存放在string类中,那么每一个string的实例都有各自的一套,根本不能共有一个RefCnt,若是是声明成全局变量,或是静态成员,那就是全部的string类共享一个了,这也不行,咱们须要的是一个“民主和集中”的一个解决方法。这是如何作到的呢?呵呵,人生就是一个糊涂后去探知,知道后和又糊涂的循环过程。别急别急,在后面我会给你一一道来的。

 

2.3.一、      string类在什么状况下才共享内存的?
 

这个问题的答案应该是明显的,根据常理和逻辑,若是一个类要用另外一个类的数据,那就能够共享被使用类的内存了。这是很合理的,若是你不用个人,那就不用共享,只有你使用个人,才发生共享。

 

使用别的类的数据时,无非有两种状况,1)以别的类构造本身,2)以别的类赋值。第一种状况时会触发拷贝构造函数,第二种状况会触发赋值操做符。这两种状况咱们均可以在类中实现其对应的方法。对于第一种状况,只须要在string类的拷贝构造函数中作点处理,让其引用计数累加;一样,对于第二种状况,只须要重载string类的赋值操做符,一样在其中加上一点处理。

 

 

唠叨几句:

 

1)构造和赋值的差异

对于前面那个例程中的这两句:

       string str1 = "hello world";

       string str2 = str1;

不要觉得有“=”就是赋值操做,其实,这两条语句等价于:

       string str1 ("hello world");   //调用的是构造函数

       string str2 (str1);            //调用的是拷贝构造函数

 

若是str2是下面的这样状况:

    string str2;      //调用参数默认为空串的构造函数:string str2(“”);

str2 = str1;     //调用str2的赋值操做:str2.operator=(str1);

 

2) 另外一种状况

       char tmp[]=”hello world”;

     string str1 = tmp;

       string str2 = tmp;

    这种状况下会触发内存的共享吗?想固然的,应该要共享。但是根据咱们前面所说的共享内存的状况,两个string类的声明和初始语句并不符合我前述的两种状况,因此其并不发生内存共享。并且,C++现有特性也没法让咱们作到对这种状况进行类的内存共享。

 
 


 

2.3.二、      string类在什么状况下触发写时才拷贝(Copy-On-Write)?
 

哦,何时会发现写时才拷贝?很显然,固然是在共享同一块内存的类发生内容改变时,才会发生Copy-On-Write。好比string类的[]、=、+=、+、操做符赋值,还有一些string类中诸如insert、replace、append等成员函数。

 

修改数据才会触发Copy-On-Write,不修改固然就不会改啦。这就是托延战术的真谛,非到要作的时候才去作。

 

2.3.三、      Copy-On-Write时,发生了什么?
 

咱们可能根据那个访问计数来决定是否须要拷贝,参看下面的代码:

 

If  ( RefCnt>0 ) {

    char* tmp =  (char*) malloc(strlen(_Ptr)+1);

    strcpy(tmp, _Ptr);

    _Ptr = tmp;

}

 
 


上面的代码是一个假想的拷贝方法,若是有别的类在引用(检查引用计数来获知)这块内存,那么就须要把更改类进行“拷贝”这个动做。

 

咱们能够把这个拷的运行封装成一个函数,供那些改变内容的成员函数使用。

2.3.四、      Copy-On-Write的具体实现是怎么样的?
 

最后的这个问题,咱们主要解决的是那个“民主集中”的难题。请先看下面的代码:

 

string h1 = “hello”;

string h2= h1;

string h3;

h3 = h2;

 

string w1 = “world”;

string w2(“”);

w2=w1;
 


 

很明显,咱们要让h一、h二、h3共享同一块内存,让w一、w2共享同一块内存。由于,在h一、h二、h3中,咱们要维护一个引用计数,在w一、w2中咱们又要维护一个引用计数。

 

如何使用一个巧妙的方法产生这两个引用计数呢?咱们想到了string类的内存是在堆上动态分配的,既然共享内存的各个类指向的是同一个内存区,咱们为何不在这块区上多分配一点空间来存放这个引用计数呢?这样一来,全部共享一块内存区的类都有一样的一个引用计数,而这个变量的地址既然是在共享区上的,那么全部共享这块内存的类均可以访问到,也就知道这块内存的引用者有多少了。

 

请看下图:






因而,有了这样一个机制,每当咱们为string分配内存时,咱们老是要多分配一个空间用来存放这个引用计数的值,只要发生拷贝构造但是赋值时,这个内存的值就会加一。而在内容修改时,string类为查看这个引用计数是否为0,若是不为零,表示有人在共享这块内存,那么本身须要先作一份拷贝,而后把引用计数减去一,再把数据拷贝过来。下面的几个程序片断说明了这两个动做:

 

 

   //构造函数(分存内存)

    string::string(const char* tmp)

{

    _Len = strlen(tmp);

    _Ptr = new char[_Len+1+1];

    strcpy( _Ptr, tmp );

    _Ptr[_Len+1]=0;  // 设置引用计数   

}

 

//拷贝构造(共享内存)

    string::string(const string& str)

    {

         if (*this != str){

              this->_Ptr = str.c_str();   //共享内存

              this->_Len = str.szie();

              this->_Ptr[_Len+1] ++;  //引用计数加一

         }

}

 

//写时才拷贝Copy-On-Write

char& string::operator[](unsigned int idx)

{

    if (idx > _Len || _Ptr == 0 ) {

         static char nullchar = 0;

return nullchar;

          }

    

_Ptr[_Len+1]--;   //引用计数减一

    char* tmp = new char[_Len+1+1];

    strncpy( tmp, _Ptr, _Len+1);

    _Ptr = tmp;

    _Ptr[_Len+1]=0; // 设置新的共享内存的引用计数

    

    return _Ptr[idx];

}



 


 

//析构函数的一些处理

~string()

{  

_Ptr[_Len+1]--;   //引用计数减一

   
         // 引用计数为0时,释放内存

    if (_Ptr[_Len+1]==0) {

        delete[] _Ptr;
         }
 

}

 


 

哈哈,整个技术细节彻底浮出水面。

 

不过,这和STL中basic_string的实现细节还有一点点差异,在你打开STL的源码时,你会发现其取引用计数是经过这样的访问:_Ptr[-1],标准库中,把这个引用计数的内存分配在了前面(我给出来的代码是把引用计数分配以了后面,这很很差),分配在前的好处是当string的长度扩展时,只须要在后面扩展其内存,而不须要移动引用计数的内存存放位置,这又节省了一点时间。

 

STL中的string的内存结构就像我前面画的那个图同样,_Ptr指着是数据区,而RefCnt则在_Ptr-1 或是 _Ptr[-1]处。
2.四、         臭虫Bug
 

是谁说的“有太阳的地方就会有黑暗”?或许咱们中的许多人都很迷信标准的东西,认为其是久经考验,不可能出错的。呵呵,千万不要有这种迷信,由于任何设计再好,编码再好的代码在某一特定的状况下都会有Bug,STL一样如此,string类的这个共享内存/写时才拷贝技术也不例外,并且这个Bug或许还会让你的整个程序crash掉!

 

不信?!那么让咱们来看一个测试案例:

 

假设有一个动态连接库(叫myNet.dll或myNet.so)中有这样一个函数返回的是string类:

 

string GetIPAddress(string hostname)

{

    static string ip;

    ……

    ……

    return ip;

}

 
 


 

而你的主程序中动态地载入这个动态连接库,并调用其中的这个函数:

 

 

main()

{

//载入动态连接库中的函数

hDll = LoadLibraray(…..);

pFun =  GetModule(hDll, “GetIPAddress”);

 

//调用动态连接库中的函数

string ip = (*pFun)(“host1”);

……

……

//释放动态连接库

FreeLibrary(hDll);

……

cout << ip << endl;

}

 
 


 

 

让咱们来看看这段代码,程序以动态方式载入动态连接库中的函数,而后以函数指针的方式调用动态连接库中的函数,并把返回值放在一个string类中,而后释放了这个动态连接库。释放后,输入ip的内容。

 

根据函数的定义,咱们知道函数是“值返回”的,因此,函数返回时,必定会调用拷贝构造函数,又根据string类的内存共享机制,在主程序中变量ip是和函数内部的那个静态string变量共享内存(这块内存区是在动态连接库的地址空间的)。而咱们假设在整个主程序中都没有对ip的值进行修改过。那么在当主程序释放了动态连接库后,那个共享的内存区也随之释放。因此,之后对ip的访问,必然作形成内存地址访问非法,形成程序crash。即便你在之后没有使用到ip这个变量,那么在主程序退出时也会发生内存访问异常,由于程序退出时,ip会析构,在析构时就会发生内存访问异常。

 

内存访问异常,意味着两件事:1)不管你的程序再漂亮,都会由于这个错误变得暗淡无光,你的声誉也会由于这个错误受到损失。2)将来的一段时间,你会被这个系统级错误所煎熬(在C++世界中,找到并排除这种内存错误并非一件容易的事情)。这是C/C++程序员永远的心头之痛,千里之堤,溃于蚁穴。而若是你不清楚string类的这种特征,在成千上万行代码中找这样一个内存异常,简直就是一场噩梦。

 

备注:要改正上述的Bug,有不少种方法,这里提供一种仅供参考:

string ip = (*pFun)(“host1”).cstr();

 

 

三、             后记
 

文章到这里也应该结束了,这篇文章的主要有如下几个目的:

 

1)    向你们介绍一下写时才拷贝/内存共享这种技术。

2)    以STL中的string类为例,向你们介绍了一种设计模式。

3)    在C++世界中,不管你的设计怎么精巧,代码怎么稳固,都难以照顾到全部的状况。智能指针更是一个典型的例子,不管你怎么设计,都会有很是严重的BUG。

4)    C++是一把双刃剑,只有了解了原理,你才能更好的使用C++。不然,必将引火烧身。若是你在设计和使用类库时有一种“玩C++就像玩火,必须千万当心”的感受,那么你就入门了,等你能把这股“火”控制的驾轻就熟时,那才是学成了。

 

最后,仍是利用这个后序,介绍一下本身。我目前从事于全部Unix平台下的软件研发,主要是作系统级的产品软件研发,对于下一代的计算机革命——网格计算很是地感兴趣,同于对于分布式计算、P2P、Web Service、J2EE技术方向也很感兴趣,另外,对于项目实施、团队管理、项目管理也小有心得,但愿一样和我战斗在“技术和管理并重”的阵线上的年轻一代,可以和我多多地交流。个人MSN和邮件是:haoel@hotmail.com。

 

个人专栏是:
html

http://blog.csdn.net/haoel/c++

 


补充1:std::string的另外一个bug--非线程安全的
程序员

线程A里的string strA赋值线程B里的string strB经过某个方法(void setstr(string& strA){ strB=strA;})。这样,当strA,strB到析构时都还共享内存,当它们同时析构时,就可能出现异常.编程

好比是这样的流程:strA 对引用计数-1,strB对引用计数-1,这样本来是2的计数变成了0, 而后strA释放内存,strB释放内存(crash).这个bug在如今双核的cpu上发生的几率比较高.(单核的cpu几率低多了).
设计模式

解决的办法和上面的bug同样,经过.c_str()方法(void setstr(LPCSTR lpszA){ strB=lpszA;})消除共享内存.缓存

 

 补充2:安全

昨天作一个dll,代码很快写完了,然而使用得时候老是遇到string内部指针删除错误,郁闷了一天,今天没去公司,好好研究了一下。
首先看下下面这段代码,声明两个string对象:
网络

=   " wlwlxj " ;
std:: string   s2  =   " lxjwlwww " ;

调试状态下能够看到内部指针:
s1=0x00364ff9
s2=0x00365061
而后执行app

=  s1;

按下f11,进入xstring源文件:分布式

operator = ( const  _Myt &  _X)         // 赋值操做符
   { return  (assign(_X)); }                     // 调用assign函数

继续进入assign(_X)函数:

 assign( const  _Myt &  _X)
         { return  (assign(_X,  0 , npos)); }   // 调用assign函数

继续进入assign函数,好戏都在这里面:

 assign( const _Myt& _X, size_type _P, size_type _M)
        {if (_X.size() < _P)
            _Xran();
        size_type _N = _X.size() - _P;
        if (_M < _N)
            _N = _M;
        if (this == &_X)
            erase((size_type)(_P + _N)), erase(0, _P);
        else if (< _N && _N == _X.size()                        // 这个分支意思就是若是拷贝源有内容且就是就是源自己,而且
            && _Refcnt(_X.c_str()) < _FROZEN 1          // 源字符串引用次数少于255-1次(可见引用次数最多255次),
            && allocator == _X.allocator)                           //且源字符和目的字符分配器一致
            {_Tidy(true);                                             // 删除自己
            _Ptr = (_E *)_X.c_str();                                    // 复制内容到目的串
            _Len = _X.size();
            _Res = _X.capacity();
            ++_Refcnt(_Ptr); }                                             // 增长一次引用
        else if (_Grow(_N, true))
            {_Tr::copy(_Ptr, &_X.c_str()[_P], _N);
            _Eos(_N); }
        return (*this); }

这样结果就是调用=号之后,s2地址和s1地址同样,都是0x00364ff9。

假如咱们动态库有这样一个类class DLL接口:

SetString(std::string str)
{
m_str = str;
}

在客户调用时候:

"wlwlxj";
DLL d;
d.SetString(str); // 此时没有深拷贝,而是引用了str内部指针地址

在调用结束的时候,dll内部删除成员变量的时候,会判断m_str内部指针合法性,因为实际分配是在调用端,在dll内部天然检查指针非法。

解决方法就是避免std::string引用计数,接口处修改成SetString(const char*),这样在dll内部分配内存,内部释放,就不会有问题。

bug分析:

此是老问题了,即跨module(exe、dll)间申请/释放内存违例的问题,对发生在传递c++对象并使用时,不只仅发生在std::string上

缘由是因为程序中使用的内存管理多来源于crt提供的例程,而非直接使用操做系统的接口,这些例程都须要维护一些module全局数据(例如维护 池、维护空闲块、或者标记已申请的块等等,不一样的实现中有不一样的做用),当他们被静态连编时,实际上这些“全局数据”就不“全局”了,不一样的module 各自为政,每份module都有本身的“全局数据”,自身的内存信息不为他人所知,module A的合法内存快天然不可能通得过module B的合法性验证

解决问题的方法有:
一、不要跨module传递c++对象,或者避免释放跨module申请的内存

二、将参与合做的module通通以multithreaded dll方式链入crt库,让他们的“全局”数据真正全局,注意,全部有交互的module都须要动态链入crt,

 

STL std::string class causes crashes and memory corruption on multi-processor machines:

http://support.microsoft.com/kb/813810/en-us

相关文章
相关标签/搜索