本文通過研究STL原始碼來剖析C++中標準模板塊庫std::string執行機理,重點研究了其中的引用計數和Copy-On-Write技術。
平臺:x86_64-redhat-linux
gcc version 4.4.6 20110731 (Red Hat 4.4.6-3) (GCC)
1. 問題提出
最近在我們的專案當中,出現了兩次與使用string相關的問題。
1.1. 問題1:新程式碼引入的Bug
前一段時間有一個老專案來一個新需求,我們新增了一些程式碼邏輯來處理這個新需求。測試階段沒有問題,但上線之後,偶爾會引起錯誤的邏輯輸出甚至崩潰。這個問題困擾著我們很久。我們對新增程式碼做周詳單元測試和整合測試都沒有發現問題,最後只能逼迫我們去看那一大段未修改過原始程式碼邏輯。該專案中經常會碰到使用string,原始程式碼中有這樣一段邏輯引起了我們的懷疑:
2 |
//... 對string_info的賦值操作 |
3 |
char * p = ( char *)string_info.data(); |
在嚴格的檢查下和邏輯判斷後,某些邏輯分支會對p指向的內容進行一些修改。這樣雖然危險,但一直工作正常。聯想到我們最近的修改:將string_info這個string物件拷貝了一份,然後進行一些處理。我們意識到string的Copy-On-Write和引用計數技術可能會導致我們拷貝的這個string並沒有真正的實現資料拷貝。在做了一些測試和研究之後,我們確信了這一點。如是對上述程式碼進行了修正處理如下:
1 |
char * p = &(string_info[0]); |
然後對專案類似的地方都做了這樣的處理之後,測試,上線,一切OK,太完美了。
1.2. 問題2:效能優化
最近做一個專案的重構,對相關程式碼進行效能分析profile時發現memcpy的CPU佔比比較高,達到8.7%,仔細檢查程式碼中,發現現有程式碼大量的map查詢操作。map定義如下:
1 |
typedef std::map ssmap; |
查詢的操作如下:
1 |
info_map[ "some_key" ]
= some_value; |
我們不經意間就會寫出上述程式碼,如果改為下述程式碼,效能會好很多:
1 |
static const std::string
__s_some_key = "some_key" ; |
2 |
info_map[__s_some_key] = some_value; |
這是因為第一種程式碼,每次查詢都構造一個臨時的string物件,同時會將“some_key”這個字串拷貝一份。修改之後的程式碼,只需要在第一次初始化時候構造一次,以後每次呼叫都不會進行拷貝,因此效率上要好很多。類似程式碼都經過這樣優化之後,memcpy的CPU佔比下來了,降到4.3%。
下面我們通過深入string的原始碼內部來解釋上述兩個問題的解決過程和思路。
2. std::string定義
STL中的字串類string的定義如下:
1 |
template < typename _CharT, typename _Traits
, typename _Alloc> class basic_string; |
2 |
typedef basic_string < char ,
char_traits< char >, allocator< char >
> string; |
不難發現string在棧記憶體空間上只佔用一個指標(_CharT* _M_p)的大小空間,因此sizeof(string)==8。其他資訊都儲存在堆記憶體空間上。
問題1:
我們有下面這一條C++語句:
請問,name這個變數總共帶來多大的記憶體開銷?這個問題我們稍後解答。
3. std::string記憶體空間佈局
下面我們通過常見的用法來剖析一下string物件內部記憶體空間佈局情況。
最常見的string用法是通過c風格字串構造一個string物件,例如:
string name(“zieckey”);
其呼叫的建構函式定義如下:
1 |
basic_string( const _CharT*
__s, const _Alloc& __a) |
2 |
: _M_dataplus( _S_construct(__s , __s ? __s + traits_type ::length( __s) : |
3 |
__s + npos , __a), __a) |
該建構函式直接呼叫 _S_construct 來構造這個物件,定義如下:
01 |
template < typename _CharT, typename _Traits
, typename _Alloc> |
02 |
template < typename _InIterator> |
04 |
basic_string<_CharT , _Traits, _Alloc>:: |
05 |
_S_construct(_InIterator __beg, _InIterator __end , const _Alloc&
__a , |
08 |
// Avoid reallocation for common case. |
11 |
while (
__beg != __end && __len < sizeof (__buf ) / sizeof (
_CharT)) |
13 |
__buf[__len ++] = *__beg; |
17 |
//構造一個 _Rep 結構體,同時分配足夠的空間,具體見下面記憶體映像圖示 |
18 |
_Rep* __r = _Rep ::_S_create( __len, size_type (0), __a); |
21 |
_M_copy( __r->_M_refdata (), __buf, __len); |
24 |
while (__beg
!= __end) |
26 |
if (__len
== __r-> _M_capacity) |
28 |
// Allocate more space. |
29 |
_Rep* __another = _Rep:: _S_create(__len + 1, __len, __a); |
30 |
_M_copy(__another ->_M_refdata(), __r->_M_refdata (), __len); |
31 |
__r->_M_destroy (__a); |
34 |
__r->_M_refdata ()[__len++] = * __beg; |
40 |
__r->_M_destroy (__a); |
41 |
__throw_exception_again; |
43 |
//設定字串長度、引用計數以及賦值最後一個位元組為結尾符 char_type() |
44 |
__r-> _M_set_length_and_sharable(__len ); |
47 |
return __r->_M_refdata
(); |
50 |
template < typename _CharT, typename _Traits
, typename _Alloc> |
51 |
typename basic_string <_CharT, _Traits, _Alloc >::_Rep* |
52 |
basic_string<_CharT , _Traits, _Alloc>::_Rep :: |
53 |
_S_create(size_type __capacity, size_type __old_capacity , |
54 |
const _Alloc
& __alloc) |
57 |
// 一個陣列 char_type[__capacity] |
58 |
// 一個額外的結尾符 char_type() |
59 |
// 一個足以容納 struct _Rep 空間 |
60 |
// Whew. Seemingly so needy, yet so elemental. |
61 |
size_type __size = (__capacity + 1) * sizeof (
_CharT) + sizeof (_Rep); |
63 |
void *
__place = _Raw_bytes_alloc (__alloc). allocate(__size ); //申請空間 |
65 |
_Rep * __p = new (__place)
_Rep; // 在地址__place 空間上直接 new物件( 稱為placement new) |
66 |
__p-> _M_capacity = __capacity ; |
67 |
__p-> _M_set_sharable(); //
設定引用計數為0,標明該物件只為自己所有 |
_Rep定義如下:
5 |
_Atomic_word _M_refcount; |
至此,我們可以回答上面“問題1”中提出的問題:
上文中”string name;”這個name物件所佔用的總空間為33個位元組,具體如下:
1 |
sizeof (std::string) + 0 + sizeof ( '' )
+ sizeof (std::string::_Rep) |
其中:sizeof(std::string)為棧空間
上文中的提到的另一條C++語句 string name(“zieckey”); 定義了一個string變數name,其記憶體空間佈局如下:
4. 深入string內部原始碼
4.1. string copy與strncpy
長期以來,經常看到有人對std::string賦值拷貝與strncpy之間的效率進行比較和討論。下面我們通過測試用例來進行一個基本的測試:
09 |
const int array_size
= 200; |
10 |
const int loop_count
= 1000000; |
15 |
char *
s2= new char [ array_size]; |
16 |
memset (
s2, 'c' , array_size); |
17 |
size_t start= clock (); |
18 |
for ( int i
=0;i!= loop_count;++i ) strncpy ( s1,s2 , array_size); |
19 |
cout<< __func__ << "
: " << clock ()- start<<endl ; |
24 |
void test_string_copy () |
28 |
s2. append(array_size , 'c' ); |
29 |
size_t start= clock (); |
30 |
for ( int i
=0;i!= loop_count;++i ) s1= s2; |
31 |
cout<< __func__ << "
: " << clock ()- start<<endl ; |
使用g++ -O3編譯,執行時間如下:
test_strncpy : 40000
test_string_copy : 10000
字串strncpy的執行時間居然是string copy的4倍。究其原因就是因為,string copy是基於引用計數技術,每次copy的代價非常小。
測試中我們還發現,如果array_size在10個位元組以內的話,兩者相差不大,隨著array_size的變大,兩者的差距也越來越大。例如,在array_size=1000的時候,strncpy就要慢13倍。
4.2. 通過GDB除錯檢視引用計數變化
上面的測試結論非常好,打消了大家對string效能問題的擔憂。下面我們通過一段程式來驗證引用計數在這一過程中的變化和作用。
請先看一段測試程式碼:
09 |
string a = "0123456789abcdef" ; |
11 |
cout << "a.data()
=" << ( void *)a. data() << endl ; |
12 |
cout << "b.data()
=" << ( void *)b. data() << endl ; |
13 |
assert (
a.data () == b. data()); |
17 |
cout << "a.data()
=" << ( void *)a. data() << endl ; |
18 |
cout << "b.data()
=" << ( void *)b. data() << endl ; |
19 |
cout << "c.data()
=" << ( void *)c. data() << endl ; |
20 |
assert (
a.data () == c. data()); |
24 |
cout << "after
write:\n" ; |
25 |
cout << "a.data()
=" << ( void *)a. data() << endl ; |
26 |
cout << "b.data()
=" << ( void *)b. data() << endl ; |
27 |
cout << "c.data()
=" << ( void *)c. data() << endl ; |
28 |
assert (
a.data () != c. data() && a .data() == b.data ()); |
執行之後,輸出:
a.data() =0xc22028
b.data() =0xc22028
a.data() =0xc22028
b.data() =0xc22028
c.data() =0xc22028
after write:
a.data() =0xc22028
b.data() =0xc22028
c.data() =0xc22068
上述程式碼執行的結果輸出反應出,在我們對b、c賦值之後,a、b、c三個string物件的內部資料的記憶體地址都是一樣的。只有當我們對c物件進行修改之後,c物件的內部資料的記憶體地址才不一樣,這一點是是如何做到的呢?
我們通過gdb除錯來驗證引用計數在上述程式碼執行過程中的變化:
02 |
Breakpoint 1 at 0x400c35: file string_copy1.cc,
line 10. |
04 |
Breakpoint 2 at 0x400d24: file string_copy1.cc,
line 16. |
06 |
Breakpoint 3 at 0x400e55: file string_copy1.cc,
line 23. |
08 |
Starting program: [...] /unixstudycode/string_copy/string_copy1 |
09 |
[Thread debugging using libthread_db enabled] |
11 |
Breakpoint 1, main () at string_copy1.cc:10 |
14 |
(gdb) x /16ub a._M_dataplus._M_p-8 |
15 |
0x602020: 0 0 0 0 0 0 0 0 |
16 |
0x602028: 48 49 50 51 52 53 54 55 |
此時物件a的引用計數是0
2 |
11 cout << "a.data() =" <<
(void*)a.data() << endl; |
b=a 將a賦值給b,string copy
1 |
(gdb) x /16ub a._M_dataplus._M_p-8 |
2 |
0x602020: 1 0 0 0 0 0 0 0 |
3 |
0x602028: 48 49 50 51 52 53 54 55 |
此時物件a的引用計數變為1,表明有另一個物件共享該物件a
06 |
Breakpoint 2, main () at string_copy1.cc:16 |
08 |
(gdb) x /16ub a._M_dataplus._M_p-8 |
09 |
0x602020: 1 0 0 0 0 0 0 0 |
10 |
0x602028: 48 49 50 51 52 53 54 55 |
12 |
17 cout << "a.data() =" <<
(void*)a.data() << endl; |
c=a 將a賦值給c,string copy
1 |
(gdb) x /16ub a._M_dataplus._M_p-8 |
2 |
0x602020: 2 0 0 0 0 0 0 0 |
3 |
0x602028: 48 49 50 51 52 53 54 55 |
此時物件a的引用計數變為2,表明有另外2個物件共享該物件a
07 |
Breakpoint 3, main () at string_copy1.cc:23 |
10 |
24 cout << "after write:\n" ; |
對c的值進行修改
1 |
(gdb) x /16ub a._M_dataplus._M_p-8 |
2 |
0x602020: 1 0 0 0 0 0 0 0 |
3 |
0x602028: 48 49 50 51 52 53 54 55 |
此時物件a的引用計數變為1
1 |
(gdb) p a._M_dataplus._M_p |
2 |
$3 = 0x602028 "0123456789abcdef" |
3 |
(gdb) p b._M_dataplus._M_p |
4 |
$4 = 0x602028 "0123456789abcdef" |
5 |
(gdb) p c._M_dataplus._M_p |
6 |
$5 = 0x602068 "1123456789abcdef" |
此時物件c的內部資料記憶體地址已經與a、b不同了,即Copy-On-Write
上述GDB除錯過程,清晰的驗證了3個string物件a b c的通過引用計數技術聯絡在一起。
4.3. 原始碼分析string copy
下面我們閱讀原始碼來分析。上述過程。
先看string copy過程的原始碼:
02 |
basic_string( const basic_string&
__str) |
03 |
: _M_dataplus( __str._M_rep ()->_M_grab( _Alloc(__str .get_allocator()), |
04 |
__str.get_allocator ()), |
05 |
__str.get_allocator ()) |
08 |
_CharT* _M_grab( const _Alloc&
__alloc1, const _Alloc& __alloc2) |
10 |
return (!
_M_is_leaked() && __alloc1 == __alloc2) |
11 |
? _M_refcopy() : _M_clone (__alloc1); |
14 |
_CharT*_M_refcopy() throw () |
16 |
#ifndef _GLIBCXX_FULLY_DYNAMIC_STRING |
17 |
if (
__builtin_expect( this != &_S_empty_rep(), false )) |
19 |
__gnu_cxx::__atomic_add_dispatch (& this ->
_M_refcount, 1); |
上面幾段原始碼比較好理解,先後呼叫了basic_string (const basic_string& __str )拷貝建構函式、_M_grab、_M_refcopy,
_M_refcopy實際上就是呼叫原子操作__atomic_add_dispatch (確保執行緒安全)將引用計數+1,然後返回原物件的資料地址。
由此可以看到,string物件之間的拷貝/賦值代價非常非常小。
幾個賦值語句之後,a、b、c物件的記憶體空間佈局如下圖所示:
4.4. Copy-On-Write
下面再來看”c[0] = ‘1’; “做了些什麼:
01 |
reference operator []( size_type __pos ) |
04 |
return _M_data
()[__pos ]; |
07 |
void _M_leak () //
for use in begin() & non-const op[] |
09 |
//前面看到 c 物件在此時實際上與a物件的資料實際上指向同一塊記憶體區域 |
10 |
//因此會呼叫 _M_leak_hard() |
11 |
if (!
_M_rep ()->_M_is_leaked ()) |
17 |
if (
_M_rep ()->_M_is_shared ()) |
19 |
_M_rep()-> _M_set_leaked (); |
22 |
void _M_mutate ( size_type __pos , size_type __len1, size_type __len2
) |
24 |
const size_type
__old_size = this -> size (); //16 |
25 |
const size_type
__new_size = __old_size + __len2 - __len1 ; //16 |
26 |
const size_type
__how_much = __old_size - __pos - __len1 ; //16 |
28 |
if (
__new_size > this -> capacity() || _M_rep ()->_M_is_shared ()) |
31 |
const allocator_type
__a = get_allocator (); |
32 |
_Rep * __r = _Rep:: _S_create (__new_size , this ->
capacity (), __a ); |
36 |
_M_copy (__r -> _M_refdata(), _M_data (), __pos ); |
38 |
_M_copy (__r -> _M_refdata() + __pos + __len2 , |
39 |
_M_data () + __pos + __len1, __how_much ); |
42 |
_M_rep ()->_M_dispose ( __a); |
45 |
_M_data (__r -> _M_refdata()); |
47 |
else if (__how_much
&& __len1 != __len2 ) |
50 |
_M_move (_M_data () + __pos + __len2 , |
51 |
_M_data () + __pos + __len1, __how_much ); |
55 |
_M_rep()-> _M_set_length_and_sharable (__new_size ); |
上面原始碼稍微複雜點,對c進行修改的過程分為以下兩步:
- 第一步是判斷是否為共享物件,(引用計數大於0),如果是共享物件,就拷貝一份新的資料,同時將老資料的引用計數值減1。
- 第二步:在新的地址空間上進行修改,從而避免了對其他物件的資料汙染
由此可以看出,如果不是通過string提供的介面對string物件強制修改的話,會帶來潛在的不安全性和破壞性。例如:
1 |
char * p = const_cast < char *>(s1.data()); |
上述程式碼對c修改(“c[0] = ‘1’; “)之後,a b c物件的記憶體空間佈局如下:
Copy-On-Write的好處通過上文的解析是顯而易見是,但也帶來一些副作用。例如上述程式碼片段”c[0] = ‘1’; “如果是通過外部的強制操作可能會帶來意想不到的結果。請看下面程式碼:
1 |
char * pc = const_cast (c.c_str()); |
這段程式碼通過強制修改c物件內部資料的值,看似效率上比operator[] 高,但同時也修改a、b物件的值,而這可能不是我們所希望看到的。這是我們需要提高警惕的地方。
5. 不宜使用string的例子
我們專案組內部有一個分散式的記憶體kv系統,一般是md5做key,value是任意二進位制數。當初設計的時候,考慮到記憶體容量始終有限,沒有選擇使用string,而是單獨開發的key結構和value結構。下面是我們設計的key結構定義:
該結構所需記憶體大小為16位元組,保持二進位制的16位元組MD5。相對於string做key來說,要節省33(參考上文string記憶體空間佈局)個位元組。例如,現在我們某個專案正在使用該系統的搭建的一個分散式叢集,總共有100億條記錄,每條記錄都節省33位元組,總共節省記憶體空間:33*100億=330G。由此可見,僅僅對key的一個小小改進,就能節省如此大的記憶體,還是非常值得。
6. 對比微軟Visual Studio提供的STL版本
vc6.0的string實現是基於引用計數的,但不是執行緒安全的。但在後續版本的vc中去掉了引用計數技術,string copy 都直接進行深度記憶體拷貝。
由於string實現上的細節不一致,導致跨平臺程式的移植帶來潛在的風險。這種場合下,我們需要額外注意。
7. 總結
- 即使是一個空string物件,其所佔記憶體空間也達到33位元組,因此在記憶體使用要求比較嚴格的應用場景,例如memcached等,請慎重考慮使用string。
- string由於使用引用計數和Copy-On-Write技術,相對於strcpy,string copy的效能提升非常顯著。
- 使用引用計數後,多個string指向同一塊記憶體區域,因此,如果強制修改一個string的內容,會影響其他string。