C++11 修復了雙重檢查鎖定問題

周昌鴻發表於2013-11-28

導讀:本文是關於C++11標準中修復了雙重檢查鎖定模式的訊息,同時作者闡述了實現雙重檢查鎖定模式的諸多方法,並逐一進行了分析,作者還提供了一個在早期編譯器上實現雙重檢查鎖定模式的庫。

雙重檢查鎖定模式(DCLP)在無鎖程式設計(lock-free programming)中經常被討論,直到2004年,JAVA才提供了可靠的雙重檢查鎖定實現。而在C++11之前,C++沒有提供一種該模式的可移植的可靠實現。

隨著雙重檢查鎖定模式在各語言實現上存在的缺點暴露,人們開始研究如何安全可靠地實現它。2000年,一個JAVA高效能研究小組釋出了一篇宣告《雙重檢查鎖定可能導致鎖定無效》。2004年,Scott Meyers 和Andrei Alexandrescu聯合發表了一篇名為《C++實現雙重檢查鎖定存在嚴重缺陷》。這兩篇論文都是重點闡述了雙重檢查鎖定(DCLP)是什麼,以及雙重檢查鎖定的意義,和當前的各語言實現存在諸多不足。

現如今,JAVA為了安全地實現雙重檢查鎖定修改了其記憶體模型,並引入了關鍵詞volatile。與此同時,C++構建了一個全新的記憶體模型和原子操作庫(atomic),使得不同編譯器實現雙重檢查鎖定(DCLP)更為容易。為了在更早期的C\C++編譯器中實現DCLP,在C++11引入了一個名為Mintomic的庫,在今年早些時候由我釋出了。

過去的一段時間,我都著力於C++中實現DCLP的研究。

 

什麼是雙重檢查鎖定?

如果你想在多執行緒程式設計中安全使用單件模式(Singleton),最簡單的做法是在訪問時對其加鎖,使用這種方式,假定兩個執行緒同時呼叫Singleton::getInstance方法,其中之一負責建立單件:

使用這種方式是可行的,但是當單件被建立之後,實際上你已經不需要再對其進行加鎖,加鎖雖然不一定導致效能低下,但是在重負載情況下,這也可能導致響應緩慢。

使用雙重檢查鎖定模式避免了在單件物件已經建立好之後進行不必要的鎖定,然而實現卻有點複雜,在Meyers-Alexandrescu的論文中也有過闡述,文中提出了幾種存在缺陷的實現方式,並逐一解釋了為什麼這樣實現存在問題。在論文的結尾的第12頁,給出了一種可靠的實現方式,實現依賴一種標準中未規範的記憶體柵欄技術。

這裡,我們可以看到:如模式名稱一樣,程式碼中實現了雙重校驗,在m_instance指標為NULL時,我們做了一次鎖定,這一過程在最先建立該物件的執行緒可見。在建立執行緒內部構造塊中,m_instance被再一次檢查,以確保該執行緒僅建立了一份物件副本。

這是雙重檢查鎖定的實現,只不過在被高亮的程式碼行中還缺乏了記憶體柵欄技術做保證,在此文寫就之際,C/C++各編譯器未對該實現進行統一,而在C++11標準中,對這種情況下的實現進行了完善和統一。

 

在C++11中獲取和釋放記憶體柵欄

在C++11中,你可以獲取和釋放記憶體柵欄來實現上述功能(如何獲取和釋放記憶體柵欄在我上一篇博文中有講述)。為了使你的程式碼在C++各種實現中具備更好的可移植性,你應該使用C++11中新增的atomic型別來包裝你的m_instance指標,這使得對m_instance的操作是一個原子操作。下面的程式碼演示瞭如何使用記憶體柵欄,請注意程式碼高亮部分:

上述程式碼在多核系統中仍然工作正常,這是因為記憶體柵欄技術在建立物件執行緒和使用物件執行緒之間建立了一種“同步-與”的關係(synchronizes-with)。Singleton::m_instance扮演了守衛變數的角色,而單件本身則作為負載內容。

two-cones-dclp

而其他存在缺陷的雙重檢查鎖定實現都缺乏該機制的保障:在沒有“同步-與”關係保證的情況下,第一個建立執行緒的寫操作,確切地說是在其建構函式中,可以被其他執行緒感知,即m_instance指標能被其他執行緒訪問!建立單件執行緒中的鎖也不起作用,由於該鎖對其他執行緒不可見,從而導致在某些情況下,建立物件被執行多次。

如果你想了解關於記憶體柵欄技術是如何可靠實現雙重檢查鎖定的內部原理,在我的前一篇文章中有一些背景資訊(previous post),之前的部落格也有一些相關內容。

 

使用Mintomic 記憶體柵欄

Mintomic是一個很小的c庫,提供了C++11 atomic庫中的一些功能函式子集,包含獲取和釋放記憶體柵欄,同時它能工作在早期的編譯器之上。Mintomic依賴於與C++11相似的記憶體模型——確切地說是不使用Out-of-thin-air儲存——這一技術在早期編譯器中未進行實現,而這是在沒有C++11標準情況下我們能做的最好實現。以我多年C++多執行緒開發的經驗看來,Out-of-thin-air儲存並不流行,而且大多數編譯器會避免實現它。

下面的程式碼演示瞭如何使用Mintomic的獲取和釋放記憶體柵欄機制實現雙重檢查鎖定,基本上與上面的例子類似:

為了實現獲取和釋放記憶體柵欄,Mintomic會試圖在其支援的編譯器平臺產生最高效的機器碼。例如,下面的彙編程式碼來自Xbox 360,使用的是PowerPC處理器。在該平臺上,內聯的lwsync關鍵字是針對獲取和釋放記憶體柵欄的優化指令。

ppc-double-checked-mintomic

上述採用C++11標準庫編譯的例子在PowerPC處理器編譯應該會產生一樣的彙編程式碼(理想情況下)。不過,我沒有能夠在PowerPC下編譯C++11來驗證這一點。

 

使用C++11低階指令順序約束

在C++11中使用記憶體柵欄鎖定技術可以很方便地實現雙重檢查鎖定。同時也保證在現今流行的多核系統中產生優化的機器碼(Mintomic也能做到這一點)。不過使用這種方式並不是常用,在C++11中更好的實現方式是使用保證低階指令執行順序約束的原子操作。之前的圖片中可以看到,一個寫-釋放操作可以與一個獲取-讀操作同步:

從技術上講,使用這種形式的無鎖同步比獨立記憶體柵欄技術限制更低。上述操作只是為了防止自身操作的記憶體排序,而記憶體柵欄技術則阻止了臨近操作的記憶體排序。儘管如此,現今的x86/64,ARMv6 / v7,和PowerPC處理器架構,針對這兩種形式產生的機器碼應該是一致的。在我之前的博文中,我展示了C++11低階指令順序約束在ARM7中使用了dmb指令,這和使用記憶體柵欄技術產生的彙編程式碼相一致。

上述兩種方式在Itanium平臺可能產生不一樣的機器碼,在Itanium平臺上,C++11標準中的load(memory_order_acquire)可以用單CPU指令:ld.acq,而store(tmp, memory_order_release)使用st.rel就可以實現。

在ARMv8處理器架構中,也提供了和Itanium指令等價的ldar 和 stlr 指令,而不同的地方是:這些指令還會導致stlr和後續ldar之間進一級的儲存裝載指令進行排序。實際上,ARMv8的新指令試圖實現C++11標準中的順序約束原子操作,這會在後面進一步講述。

 

使用C++順序一致的原子操作

C++11標準提供了一個不同的方式來編寫無鎖程式(可以把雙重檢查鎖定歸類為無鎖程式設計的一種,因為不是所有執行緒都會獲取鎖)。在所有原子操作庫方法中使用可選引數std::memory_order可以使得所有原子變數變為順序的原子操作(sequentially consistent),方法的預設引數為std::memory_order_seq_cst。使用順序約束(SC)原子操作庫,整個函式執行都將保證順序執行,並且不會出現資料競態(data races)。順序約束(SC)原子操作和JAVA5版本之後出現的volatile變數很相似。

使用SC原子操作實現雙重檢查鎖定的程式碼如下:和前面的例子一樣,高亮的第二行會與第一次建立單件的執行緒進行同步與操作。

順序約束(SC)原子操作使得開發者更容易預測程式碼執行結果,不足之處在於使用順序約束(SC)原子操作類庫的程式碼效率要比之前的例子低一些。例如,在x64位機器上,上述程式碼使用Clang3.3優化後產生如下彙編程式碼:

x64-double-checked-seq-cst

由於使用了順序約束(SC)原子操作類庫,變數m_instance的儲存操作使用了xchg指令,在x64處理器上相當於一個記憶體柵欄操作。該指令在x64位處理器是一個長週期指令,使用輕量級的mov指令也可以完成操作。不過,這影響不大,因為xchg指令只被單件建立過程呼叫一次。

不過,在PowerPC or ARMv6/v7處理器上編譯上述程式碼,產生的彙編操作要糟糕得多,具體情形可以參見Herb Sutter的演講(atomic Weapons talk, part 2.00:44:25 – 00:49:16)。

 

使用C++11資料順序依賴原理

上面的例子都是使用了建立單件執行緒和使用單件其他執行緒之間的同步與關係。守衛的是資料指標單個元素,開銷也是建立單件內容本身。這裡,我將演示一種使用資料依賴來保護防衛的指標。

在使用資料依賴時候,上述例子中都使用了一個讀-獲取操作,這也會產生效能消耗,我們可以使用消費指令來進一步優化。消費指令(consume instruction)非常酷,在PowerPc處理器上它使用了lwsync指令,在ARMv7處理器上則編譯為dmd指令。今後我會寫一些文章來講述消費指令和資料依賴機制。

 

使用C++11靜態初始化

一些讀者可能已經知道C++11中,你可以跳過之前的檢查過程而直接得到執行緒安全的單件。你只需要使用一個靜態初始化:

C++11標準在6.7.4節中規定:

如果指令邏輯進入一個未被初始化的宣告變數,所有併發執行應當等待完成該變數完成初始化。

上述操作在編譯時由編譯器保證。雙重檢查鎖定則可以利用這一點。編譯器並不保證會使用雙重檢查鎖定,但是大部分編譯器會這樣做。gcc4.6使用-std=c++0x編譯選項在ARM處理器產生的彙編程式碼如下:

clang-arm-static-init

由於單件使用的是一個固定地址,編譯器會使用一個特殊的防衛變數來完成同步。請注意這裡,在初始化變數讀操作時沒有使用dmb指令來獲取一個記憶體柵欄。守衛變數指向了單件,因此編譯器可以使用資料依賴原則來避免使用dmb指令的開銷。__cxa_guard_release指令扮演了一個寫-釋放來解除變數守衛。一旦守衛柵欄被設定,這裡存在一個指令順序強制在讀-消費操作之前。這裡和前面的例子一樣,對記憶體排序的進行適應性的變更。

前面的長篇累牘主要講述了C++11標準修復了雙層檢查鎖定實現,並且講述了其他一些相關知識。

就我個人而言,我認為應當在程式初始化時就初始化一個singleton。使用雙重檢查鎖定可以幫你將任意資料型別儲存在一個無鎖的雜湊表中。這會在後續的文章進一步闡述。

相關文章