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:
1 std::string a = "A medium-sized string to avoid SSO";
2 std::string b = a;
3 //a.data() == b.data()?
4
5 b.append('A');
6 //a.data() == b.data()?
如果實現使用了COW,那么第一個比較會返回true,第二個比較會返回false。經測試libstdc++(gcc 4.5)確實使用了COW,而查看STL中string的源碼,也確實采用了引用計數的手段。
但要注意,std::string的lazy-copy行為只發生在兩個string對象之間的拷貝構造,賦值和assign()操作上,如果一個string由(const)char*構造而來,則必然會分配內存和進行復制,因為string對象並不知道也無權控制char*所指內存的生命周期。
1 std::string a = "Hello";
2 std::string b = "Hello";//Never COW!
3 assert(b.data() != a.data());
4
5 std::string c = a.data();//Never COW!
6 assert(c.data() != a.data());
實際上,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 A在線程1中訪問,string B在線程2中訪問,string A 和 string B 共享同一片內容(rc = 2)假設當線程1操作string A時線程2恰好也在操作string B,雙方發現該string的內容是共享的,都遵守先分配復制,后減引用計數的執行序列。(最終會有一方發現rc=0,銷毀原string內容)。
二、”失效“問題:草木皆兵!
std::string a = "some string";
std::string b = a;
assert(b.data() == a.data());// We assume std::string is COW-ed
std::cout << b[0] << std::endl;
assert(b.data() != a.data()); // Oops!
1. offer set(n, c)2. make default iterator non-mutating
"The COW is dead, long live eager-copy"