std::string 的 Copy-on-Write:不如想象中美好

發表於2016-08-18

Copy-on-write(以下簡稱COW)是一種很重要的優化手段。它的核心思想是懶惰處理多個實體的資源請求,在多個實體之間共享某些資源,直到有實體需要對資源進行修改時,才真正為該實體分配私有的資源。

COW技術的一個經典應用在於Linux核心在程式fork時對程式地址空間的處理。由於fork產生的子程式需要一份和父程式內容相同但完全獨立的地址空間,一種做法是將父程式的地址空間完全複製一份,另一種做法是將父程式地址空間中的頁面標記為”共享的“(引用計數+1),使子程式與父程式共享地址空間,但當有一方需要對記憶體中某個頁面進行修改時,重新分配一個新的頁面(拷貝原內容),並使修改程式的虛擬地址重定向到新的頁面上。

COW技術有哪些優點呢?

1. 一方面減少了分配(和複製)大量資源帶來的瞬間延遲(注意僅僅是latency,但實際上該延遲被分攤到後續的操作中,其累積耗時很可能比一次統一處理的延遲要高,造成throughput下降是有可能的)

2. 另一方面減少不必要的資源分配。(例如在fork的例子中,並不是所有的頁面都需要複製,比如父程式的程式碼段(.code)和只讀資料(.rodata)段,由於不允許修改,根本就無需複製。而如果fork後面緊跟exec的話,之前的地址空間都會廢棄,花大力氣的分配和複製只是徒勞無功。)

COW的思想在資源管理上被廣泛使用,甚至連STL中的std::string的實現也要沾一下邊。陳碩的這篇部落格《C++工程實踐(10):再探std::string》充分探討了各個STL實現中對std::string的實現方式,其中g++ std::string和Apache stdcxx就使用了COW技術。(其他對std::string的實現包括eager copy和small string optimization,建議參考原部落格,圖文並茂十分清楚)

很簡單一段程式碼,就能檢視當前std::string實現是否使用了COW:

如果實現使用了COW,那麼第一個比較會返回true,第二個比較會返回false。經測試libstdc++(gcc 4.5)確實使用了COW,而檢視STL中string的原始碼,也確實採用了引用計數的手段。

但要注意,std::string的lazy-copy行為只發生在兩個string物件之間的拷貝構造,賦值和assign()操作上,如果一個string由(const)char*構造而來,則必然會分配記憶體和進行復制,因為string物件並不知道也無權控制char*所指記憶體的生命週期。

實際上,std::string c = a.data()確實是一種在字串賦值時禁止COW行為的方法。

看起來使用COW管理string來減少不必要的拷貝似乎很有效,然而在多數C++ STL實現中,只有寥寥兩種使用了COW,而同樣著名的Visual C++(2010)和clang libc++卻不約而同拋棄了COW,選擇了SSO(small string optimization,足夠小的字串直接放在物件本身的棧記憶體中,避免了向Heap動態請求記憶體的開銷)。

SSO對小字串的高效是原因之一(程式中通常會有大量的短字串),而COW本身的缺陷更是原因之一。

一、效能:for thread-safety!

想要實現COW,必須要有引用計數(reference count)。string初始化時rc=1,每當該string賦值給了其他sring,rc++。當需要對string做修改時,如果rc>1,則重新申請空間並複製一份原字串的拷貝,rc–。當rc減為0時,釋放原記憶體。

基於”共享“和”引用“計數的COW在多執行緒環境下必然面臨執行緒安全的問題。那麼:

std::string是執行緒安全的嗎?

stackoverflow上對這個問題的一個很好的回答:是又不是。

從在多執行緒環境下對共享的string物件進行併發操作的角度來看,std::string不是執行緒安全的,也不可能是執行緒安全的,像其他STL容器一樣

c++11之前的標準對STL容器和string的執行緒安全屬性不做任何要求,甚至根本沒有執行緒相關的內容。即使是引入了多執行緒程式設計模型的C++11,也不可能要求STL容器的執行緒安全:執行緒安全意味著同步,同步意味著效能損失,貿然地保證執行緒安全必然違背了C++的哲學:

Don’t pay for things you don’t use.

但從不同執行緒中操作”獨立“的string物件來看,std::string必須是執行緒安全的。咋一看這似乎不是要求,但COW的實現使兩個邏輯上獨立的string物件在物理上共享同一片記憶體,因此必須實現邏輯層面的隔離。C++0x草案(N2960)中就有這麼一段:

The C++0x draft (N2960) contains the section “data race avoidance” which basically says that library components may access shared data that is hidden from the user if and only if it activly avoids possible data races.

簡單說來就是:你瞞著使用者使用共享記憶體是可以的(比如用COW實現string),但你必須負責處理可能的競態條件。

而COW實現中避免競態條件的關鍵在於:

1. 只對引用計數進行原子增減

2. 需要修改時,先分配和複製,後將引用計數-1(當引用計數為0時負責銷燬)

先談談原子操作:

不同的體系結構一般會有不同的底層原語以支援原子操作。如Intel CPU本身就引入了#LOCK指令字首,該字首允許置於指定的操作(如演算法指令、邏輯指令、bit指令、exchange指令等)之前使用,如lock inc會在執行inc指令時鎖匯流排(鎖定包含目標地址的一片記憶體區域,防止其他CPU在此期間的併發訪問),從而序列化對同一地址的訪問。

比起mutex之類的同步手段,原子操作自然要輕上不少,但比起普通的算術指令,依然算得上完全的重量級:

1. 系統通常會lock住比目標地址更大的一片區域,影響邏輯上不相關的地址訪問。

2. lock指令具有”同步“語義,會阻止CPU本身的亂序執行優化。

Intel Developer’s Manual vol 3的chapter 8 : Multiple-Processor Management中就有提到:

“Locked instructions can be used to synchronize data written by one processor and read by another processor.”

也就是會等待之前發出的load和store指令的完成(由於CPU store buffer的存在,如果資料之前沒有依賴,不需要等待load和store的結果)

3. 兩個CPU對同一個地址進行原子操作,必然會導致cache-bounce。SMP系統中由於Cache一致性協議的存在,一個CPU對共享記憶體的修改必然會invalidate另一個CPU對該地址的cache,最終導致兩個CPU對同一片記憶體不斷”爭奪“(cache不斷被對方invalidate,需要重新從記憶體中讀取),這是多執行緒程式設計中經典的False Sharing問題。

歸根結底,COW為了保證”執行緒安全“而使用了原子操作,而原子操作本身的效率並不十分高。而且在多執行緒環境下,多個CPU對同一個地址的原子操作開銷更大。COW中”共享“的實現,反而影響了多執行緒環境下string”拷貝“的效能,並不scale。

再談談操作順序:

為了避免競態條件,在需要修改string時,如果引用計數>1,必須先分配和拷貝,然後才能將引用計數減1。(而不能先減1再拷貝)

某些條件下,這樣的操作序列會導致不必要的額外操作:

string A線上程1中訪問,string B線上程2中訪問,string A 和 string B 共享同一片內容(rc = 2)

假設當執行緒1操作string A時執行緒2恰好也在操作string B,雙方發現該string的內容是共享的,都遵守先分配複製,後減引用計數的執行序列。(最終會有一方發現rc=0,銷燬原string內容)。

到此為止,COW一共進行了3次記憶體分配和複製(初始化時1次,修改時2次)和1次記憶體釋放

實際上,如果沒有使用COW技術,從string的初始化到目前為止也只進行了2次記憶體分配和複製(都是在初始化時進行)

二、”失效“問題:草木皆兵!

假設當前的string實現是COW,考慮下面的程式碼:

程式僅僅以”只讀“的方式訪問了b中一個字元,但b已經進行了一份私有的拷貝,a與b不再共享同一片內容。

由於string的operator[]at()會返回某個字元的引用,而判斷程式是否更改了該字元實在是超出string本身能力範圍的東西,為了保證COW實現的正確性,string只得統統認定operator[]和at()具有修改的“語義”。

begin()/end()也不能倖免,天曉得使用者取得迭代器後會做什麼!

這些無奈的”write alarm“實際上是由於std::string本身介面的定義上沒有對”只讀“和”修改“語義做嚴格的區分。

為此,Alexandrescu在它的”Scalable Use of STL“的演講中對std::string的介面做了如下建議:

1. offer set(n, c)

2. make default iterator non-mutating

僅僅是建議而已,實際上他本人反對使用COW:

“The COW is dead, long live eager-copy”

總結:

1. string的COW實現確有諸多的弊端,並不如想象中那般美好,也因此受到了Visual C++和clang++的拋棄,轉而使用實現簡單,且對小字串更友好的SSO實現。

2. Alexandrescue在他的“Scalable Use of STL”中建議對效能敏感的程式實現自己的string,比如針對string的長度進行選擇優化(短字串SSO,中等長度eager copy,長字串COW),以及提供更加友好的介面等,並預期至少會有10%的整體效能提升。但我覺得,這實在不是普通程式設計師該乾的事:暫且不論10%的底限能否達到,維護非標準的介面本身就是一筆重大的開銷。

3. 雖然我們的選擇不多,但瞭解COW的缺陷依然可以使我們優化對string的使用:儘量避免在多個執行緒間false sharing同一個“物理string“,儘量避免在對string進行只讀訪問(如列印)時造成了不必要的內部拷貝。

最後發一句感慨:

C++中的效能陷阱無處不在!

參考資料:

相關文章