標準C++類std::string的內存共享和Copy-On-Write技術(轉)

  1. http://www.cnblogs.com/abiao/articles/1267102.aspx

原文作者:陳皓
1、             概念
 

Scott Meyers在《More Effective C++》中舉了個例子,不知你是否還記得?在你還在上學的時候,你的父母要你不要看電視,而去複習功課,於是你把自己關在房間裏,做出一副正在複習功課的樣子,其實你在幹着別的諸如給班上的某位女生寫情書之類的事,而一旦你的父母出來在你房間要檢查你是否在複習時,你才真正撿起課本看書。這就是“拖延戰術”,直到你非要做的時候纔去做。

 

當然,這種事情在現實生活中時往往會出事,但其在編程世界中搖身一變,就成爲了最有用的技術,正如C++中的可以隨處聲明變量的特點一樣,Scott Meyers推薦我們,在真正需要一個存儲空間時纔去聲明變量(分配內存),這樣會得到程序在運行時最小的內存花銷。執行到那纔會去做分配內存這種比較耗時的工作,這會給我們的程序在運行時有比較好的性能。必竟,20%的程序運行了80%的時間。

 

當然,拖延戰術還並不只是這樣一種類型,這種技術被我們廣泛地應用着,特別是在操作系統當中,當一個程序運行結束時,操作系統並不會急着把其清除出內存,原因是有可能程序還會馬上再運行一次(從磁盤把程序裝入到內存是個很慢的過程),而只有當內存不夠用了,纔會把這些還駐留內存的程序清出。

 

寫時才拷貝(Copy-On-Write)技術,就是編程界“懶惰行爲”——拖延戰術的產物。舉個例子,比如我們有個程序要寫文件,不斷地根據網絡傳來的數據寫,如果每一次fwrite或是fprintf都要進行一個磁盤的I/O操作的話,都簡直就是性能上巨大的損失,因此通常的做法是,每次寫文件操作都寫在特定大小的一塊內存中(磁盤緩存),只有當我們關閉文件時,才寫到磁盤上(這就是爲什麼如果文件不關閉,所寫的東西會丟失的原因)。更有甚者是文件關閉時都不寫磁盤,而一直等到關機或是內存不夠時才寫磁盤,Unix就是這樣一個系統,如果非正常退出,那麼數據就會丟失,文件就會損壞。

 

呵呵,爲了性能我們需要冒這樣大的風險,還好我們的程序是不會忙得忘了還有一塊數據需要寫到磁盤上的,所以這種做法,還是很有必要的。

 

 

2、             標準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.1、         特性
 

由表及裏,由感性到理性,我們先來看一看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.2、         深入
在深入這前,通過上述的演示,我們應該知道在string類中,要實現寫時才拷貝,需要解決兩個問題,一個是內存共享,一個是Copy-On-Wirte,這兩個主題會讓我們產生許多疑問,還是讓我們帶着這樣幾個問題來學習吧:

1、  Copy-On-Write的原理是什麼?

2、  string類在什麼情況下才共享內存的?

3、  string類在什麼情況下觸發寫時才拷貝(Copy-On-Write)?

4、  Copy-On-Write時,發生了什麼?

5、  Copy-On-Write的具體實現是怎麼樣的?

 

喔,你說只要看一看STL中stirng的源碼你就可以找到答案了。當然,當然,我也是參考了string的父模板類basic_string的源碼。但是,如果你感到看STL的源碼就好像看機器碼,並嚴重打擊你對C++自信心,乃至產生了自己是否懂C++的疑問,如果你有這樣的感覺,那麼還是繼續往下看我的這篇文章吧。

 

OK,讓我們一個問題一個問題地探討吧,慢慢地所有的技術細節都會浮出水面的。

 

2.3、         Copy-On-Write的原理是什麼?
 

有一定經驗的程序員一定知道,Copy-On-Write一定使用了“引用計數”,是的,必然有一個變量類似於RefCnt。當第一個類構造時,string的構造函數會根據傳入的參數從堆上分配內存,當有其它類需要這塊內存時,這個計數爲自動累加,當有類析構時,這個計數會減一,直到最後一個類析構時,此時的RefCnt爲1或是0,此時,程序纔會真正的Free這塊從堆上分配的內存。

 

是的,引用計數就是string類中寫時才拷貝的原理!

 

不過,問題又來了,這個RefCnt該存在在哪裏呢?如果存放在string類中,那麼每個string的實例都有各自的一套,根本不能共有一個RefCnt,如果是聲明成全局變量,或是靜態成員,那就是所有的string類共享一個了,這也不行,我們需要的是一個“民主和集中”的一個解決方法。這是如何做到的呢?呵呵,人生就是一個糊塗後去探知,知道後和又糊塗的循環過程。別急別急,在後面我會給你一一道來的。

 

2.3.1、      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.2、      string類在什麼情況下觸發寫時才拷貝(Copy-On-Write)?
 

哦,什麼時候會發現寫時才拷貝?很顯然,當然是在共享同一塊內存的類發生內容改變時,纔會發生Copy-On-Write。比如string類的[]、=、+=、+、操作符賦值,還有一些string類中諸如insert、replace、append等成員函數。

 

修改數據纔會觸發Copy-On-Write,不修改當然就不會改啦。這就是託延戰術的真諦,非到要做的時候纔去做。

 

2.3.3、      Copy-On-Write時,發生了什麼?
 

我們可能根據那個訪問計數來決定是否需要拷貝,參看下面的代碼:

 

If  ( RefCnt>0 ) {

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

    strcpy(tmp, _Ptr);

    _Ptr = tmp;

}

 
 


上面的代碼是一個假想的拷貝方法,如果有別的類在引用(檢查引用計數來獲知)這塊內存,那麼就需要把更改類進行“拷貝”這個動作。

 

我們可以把這個拷的運行封裝成一個函數,供那些改變內容的成員函數使用。

2.3.4、      Copy-On-Write的具體實現是怎麼樣的?
 

最後的這個問題,我們主要解決的是那個“民主集中”的難題。請先看下面的代碼:

 

string h1 = “hello”;

string h2= h1;

string h3;

h3 = h2;

 

string w1 = “world”;

string w2(“”);

w2=w1;
 


 

很明顯,我們要讓h1、h2、h3共享同一塊內存,讓w1、w2共享同一塊內存。因爲,在h1、h2、h3中,我們要維護一個引用計數,在w1、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.4、         臭蟲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();

 

 

3、             後記
 

文章到這裏也應該結束了,這篇文章的主要有以下幾個目的:

 

1)    向大家介紹一下寫時才拷貝/內存共享這種技術。

2)    以STL中的string類爲例,向大家介紹了一種設計模式。

3)    在C++世界中,無論你的設計怎麼精巧,代碼怎麼穩固,都難以照顧到所有的情況。智能指針更是一個典型的例子,無論你怎麼設計,都會有非常嚴重的BUG。

4)    C++是一把雙刃劍,只有瞭解了原理,你才能更好的使用C++。否則,必將引火燒身。如果你在設計和使用類庫時有一種“玩C++就像玩火,必須千萬小心”的感覺,那麼你就入門了,等你能把這股“火”控制的得心應手時,那纔是學成了。

 

最後,還是利用這個後序,介紹一下自己。我目前從事於所有Unix平臺下的軟件研發,主要是做系統級的產品軟件研發,對於下一代的計算機革命——網格計算非常地感興趣,同於對於分佈式計算、P2P、Web Service、J2EE技術方向也很感興趣,另外,對於項目實施、團隊管理、項目管理也小有心得,希望同樣和我戰鬥在“技術和管理並重”的陣線上的年輕一代,能夠和我多多地交流。我的MSN和郵件是:[email protected]

 

我的專欄是:

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

 


補充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對象:

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

調試狀態下可以看到內部指針:
s1=0x00364ff9
s2=0x00365061
然後執行

s2  =  s1;

按下f11,進入xstring源文件:

_Myt &   operator = ( const  _Myt &  _X)         // 賦值操作符
  
{ return  (assign(_X)); }                     // 調用assign函數

繼續進入assign(_X)函數:

_Myt &  assign( const  _Myt &  _X)
        
{ return  (assign(_X,  0 , npos)); }   // 調用assign函數
繼續進入assign函數,好戲都在這裏面:
_Myt& 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 (0 < _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;
}

在客戶調用時候:

std::string 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的合法性驗證

解決問題的方法有:
1、不要跨module傳遞c++對象,或者避免釋放跨module申請的內存

2、將參與合作的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



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章