C++11 中的雙重檢查鎖定模式

jopen 10年前發布 | 14K 次閱讀 C++11 C/C++開發

雙重檢查鎖定模式(DCLP)在無鎖編程方面是有點兒臭名昭著案例學術研究的味道。直到2004年,使用java開發并沒有安全的方式來實現它。在c++11之前,使用便捷式c+開發并沒有安全的方式來實現它。由于引起人們關注的缺點模式暴露在這些語言之中,人們開始寫它。一組高調的java聚集在一起開發人員并簽署了一項聲明,題為:“雙重檢查鎖定壞了”。在2004年斯科特 、梅爾斯和安德烈、亞歷山發表了一篇文章,題為:“c+與雙重檢查鎖定的危險”對于DCLP是什么?這兩篇文章都是偉大的引物,為什么呢?在當時看來,這些語言都不足以實現它。

在過去。java現在可以為修訂內存模型,為thevolatileeyword注入新的語義,使得它盡可然安全實現DCLP.同樣地,c+11有一個全新的內存模型和原子庫使得各種各樣的便捷式DCLP得以實現。c+11反過來啟發Mintomic,一個小型圖書館,我今年早些時候發布的,這使得它盡可能的實現一些較舊的c/c++編譯器以及DCLP.

在這篇文章中,我將重點關注c++實現的DCLP.

什么是雙重檢查鎖定?

假設你有一個類,它實現了著名的Singleton 模式,現在你想讓它變得線程安全。顯然的一個方法就是通過增加一個鎖來保證互斥共享。這樣的話,如果有兩個線程同時調用了Singleton::getInstance,將只有其中之一會創建這個單例。

Singleton* Singleton::getInstance() { Lock lock; // scope-based lock, released automatically when the function returns if (m_instance == NULL) {
        m_instance = new Singleton;
    } return m_instance;
}

這是完全合法的方法,但是一旦單例被創建,實際上就不再需要鎖了。鎖不一定慢,但是在高并發的條件下,不具有很好的伸縮性。

雙重檢查鎖定模式避免了在單例已經存在時候的鎖定。不過如Meyers-Alexandrescu的論文所顯示的,它并不簡單。在那篇論文中,作者描述了幾個有缺陷的用C++實現DCLP的嘗試,并剖析了每種情況為什么是不安全的。最后,在第12頁,他們給出了一個安全的實現,但是它依賴于非指定的,特定平臺的內存屏障(memory barriers)

(譯注:內存屏障就是一種干預手段. 他們能保證處于內存屏障兩邊的內存操作滿足部分有序)

Singleton* Singleton::getInstance() {
    Singleton* tmp = m_instance; ... // insert memory barrier if (tmp == NULL) {
        Lock lock;
        tmp = m_instance; if (tmp == NULL) {
            tmp = new Singleton; ... // insert memory barrier m_instance = tmp;
        }
    } return tmp;
}

這里,我們可以發現雙重檢查鎖定模式是由此得名的:在單例指針m_instance為NULL的時候,我們僅僅使用了一個鎖,這個鎖使偶然訪問到該單例的第一組線程繼續下去。而在鎖的內部,m_instance被再次檢查,這樣就只有第一個線程可以創建這個單例了。

這與可運行的實現非常相近。只是在突出顯示的幾行漏掉了某種內存屏障。在作者寫這篇論文的時候,還沒有填補此項空白的輕便的C/C++函數。現在,C++11已經有了。

用 C++11 獲得與釋放屏障

你可以用獲得與釋放屏障 安全的完成上述實現,在我以前的文章中我已經詳細的解釋過這個主題。不過,為了讓代碼真正的具有可移植性,你還必須要將m_instance包裝成原子類型,并且用放松的原子操作(譯注:即非原子操作)來操作它。這里給出的是結果代碼,獲取與釋放屏障部分高亮了。

std::atomic<SINGLETON*> Singleton::m_instance;
std::mutex Singleton::m_mutex;

Singleton Singleton::getInstance() { Singleton tmp = m_instance.load(std::memory_order_relaxed); std::atomic_thread_fence(std::memory_order_acquire); if (tmp == nullptr) { std::lock_guard<?xml:namespace prefix = std />std::mutex lock(m_mutex); tmp = m_instance.load(std::memory_order_relaxed); if (tmp == nullptr) { tmp = new Singleton; std::atomic_thread_fence(std::memory_order_release); m_instance.store(tmp, std::memory_order_relaxed); } } return tmp; }/std::mutex</SINGLETON*></pre>

即使是在多核系統上,它也可以令人信賴的工作,因為內存屏障在創建單例的線程與其后任何跳過這個鎖的線程之間,創建了一種同步的關系。Singleton::m_instance充當警衛變量,而單例本身的內容充當有效載荷

C++11 中的雙重檢查鎖定模式

所有那些有缺陷的DCLP實現都忽視了這一點:如果沒有同步的關系,將無法保證第一個線程的所有寫操作——特別是,那些在單例構造器中執行的寫操作——可以對第二個線程可見,雖然m_instance指針本身是可見的!第一個線程具有的鎖也對此無能為力,因為第二個線程不必獲得任何鎖,因此它能并發的運行。

如果你想更深入的理解這些屏障為什么以及如何使得DCLP具有可信賴性,在我以前的文章中有一些背景信息,就像這個博客早前的文章一樣。

使用 Mintomic 屏障

Mintomic 是一個小型的C語言的庫,它提供了C++11原子庫的一個功能子集,其中包含有獲取與釋放屏障,而且它是運行于更老的編譯器之上的。Mintomic依賴于這樣的假設 ,即C++11的內存模型——特殊的是,其中包括無中生有的存儲 ——因為它不被更老的編譯器支持,不過這已經是我們不通過C++11能做到的最佳程度了。記住這些東西可是若干年來我們在寫多線程C++代碼時的環境。無中生有的存儲(Out-of-thin-air stores)已被時間證明是不流行的,而且好的編譯器也基本上不會這么做。

這里有一個DCLP的實現,就是用Mintomic來獲取與釋放屏障的。和前面使用C++11獲取和釋放屏障的例子比起來,它基本上是等效的。

mint_atomicPtr_t Singleton::m_instance = { 0 };
mint_mutex_t Singleton::m_mutex;

Singleton Singleton::getInstance() { Singleton tmp = (Singleton) mint_load_ptr_relaxed(&m_instance); mint_thread_fence_acquire(); if (tmp == NULL) { mint_mutex_lock(&m_mutex); tmp = (Singleton) mint_load_ptr_relaxed(&m_instance); if (tmp == NULL) { tmp = new Singleton; mint_thread_fence_release(); mint_store_ptr_relaxed(&m_instance, tmp); } mint_mutex_unlock(&m_mutex); } return tmp; }</pre>

為了實現獲取與釋放屏障,Mintomic試圖在所有它所支持的平臺上,生成最有效的機器代碼。舉個例子,這里是Xbox 360上的機器代碼結果,Xbox 360是基于PowerPC的。在這個平臺上,單行的lwsync是一條最精簡的指令,它既可以獲取也可以釋放屏障。

C++11 中的雙重檢查鎖定模式

如果啟用了優化,之前基于C++11的例子也可以(理想情況下將會)生成完全相同的PowerPC機器代碼。可惜的是,我并沒有找來兼容C++11的PowerPC編譯器來證明它。

使用c++ 11低級排序約束

C++11的獲取與釋放屏障可以正確的實現DCLP,而且應該能夠針對當今大多數的多核設備,生成優化的機器代碼(就像Mintomic做的那樣),但是它們似乎不是非常時髦。在C++11中獲得同等效果的首選方法,應該是使用基于低級排序約束的原子操作。正如我先前所說,一條寫釋放(write-release)可以同步于一條讀獲取(read-acquire)。

std::atomic<SINGLETON*> Singleton::m_instance;
std::mutex Singleton::m_mutex;

Singleton Singleton::getInstance() { Singleton tmp = m_instance.load(std::memory_order_acquire); if (tmp == nullptr) { std::lock_guardstd::mutex lock(m_mutex); tmp = m_instance.load(std::memory_order_relaxed); if (tmp == nullptr) { tmp = new Singleton; m_instance.store(tmp, std::memory_order_release); } } return tmp; }/std::mutex</SINGLETON*></pre>

從技術上說,這種無鎖的同步形式,比使用獨立屏障的形式,要不那么嚴格;上面的操作只是意味著阻止它們自己周圍的內存重新排序,這與獨立的屏障不同,后者意味著阻止所有相鄰的操作的特定類型的內存重排序。盡管如此,在x86/64, ARMv6/v7,以及 PowerPC架構上,對于這兩種形式,可能的最好代碼都是相同的。例如,在一篇早前文章中,我演示了在ARMv7編譯器上,C++11低級排序約束是如何發送dmb指令的,而這也正是你在使用獨立屏障時所期待的同樣事情。

這兩種形式有可能會生成不同機器代碼的一個平臺是Itanium。Itanium可以使用一條單獨的CPU指令,ld.acq來實現C++11的load(memory_order_acquire),并可以使用st.rel來實現store(tmp, memory_order_release)。我很想研究一下這些指令與獨立屏障之間的性能差異,可是我找不到可用的Itanium機器。

另一個這樣的平臺是最近出現的ARMv8架構。ARMv8提供了ldar和stlr指令,除了它們也增強了stlr指令以及任何后續的ldar指令之間的存儲加載排序以外,其它的都與Itanium的ld.acq和st.rel指令很相似。事實上,ARMv8的這些新指令意在實現C++11的SC原子操作,這在后面會講到。

使用 C++11的順序一致原子

C++11提供了一種完全不同的方法來寫無鎖代碼。(我們可以認為在某些特定的代碼路徑上DCLP是“無鎖”的,因為并不是所有的線程都具有鎖。)如果在所有原子庫函數上,你忽略了可選的std::memory_order參數,那么默認值std::memory_order_seq_cst就會將所有的原子變量轉變為順序一致的(sequentially consistent) (SC)原子。通過SC原子,只要不存在數據競爭,整個算法就可以保證是順序一致的。SC原子Java 5+中的volatile變量非常相似。

這里是使用SC原子的一個DCLP實現。如之前所有例子一樣,一旦單例被創建,第二行高亮將與第一行同步

std::atomic<SINGLETON*> Singleton::m_instance;
std::mutex Singleton::m_mutex;

Singleton Singleton::getInstance() { Singleton tmp = m_instance.load(); if (tmp == nullptr) { std::lock_guardstd::mutex lock(m_mutex); tmp = m_instance.load(); if (tmp == nullptr) { tmp = new Singleton; m_instance.store(tmp); } } return tmp; }/std::mutex</SINGLETON*></pre>

SC原子被認為可以使程序員更容易思考。其代價是生成的機器代碼似乎比之前的例子效率要低。例如,這里有有一些關于上面代碼清單的x64機器代碼,由Clang 3.3在啟用代碼優化的條件下生成:

C++11 中的雙重檢查鎖定模式

由于我們使用了SC原子,保存到m_instance是由xchg指令實現的,在x64上它具有內存屏障作用。這比x64中DCLP實際需要的指令更強。只需一條簡單的mov指令就可以做這項工作。不過這并不十分要緊,因為在單例首次創建的代碼路徑上,xchg指令只下發一次。

另一方面,如果你給PowerPC 或 ARMv6/v7編譯SC原子指令,你十有八九會得到糟糕的機器代碼。其中的細節,請看Herb Sutter的atomic<> 武器說話,第 2部分的00:44:25 - 00:49:16段落。

使用 C++11 的數據相關性排序

在上面所有我給出的例子中,在創建單例的那個線程,與其后任何越過鎖的線程之間,有一種同步的關系。警衛變量就是單例指針,有效載荷是單例自身的內容。在本例中,有效載荷被認為是警衛指針的一個相關性數據

人們后來發現,當存在數據相關性時,上面所有例子中都用到的讀獲取(read-acquire)操作,將極富殺傷力!我們用消費操作(consume operation)來替代它要好一點。消費操作很酷,因為它們消除了PowerPC中的一條lwsync指令,以及ARMv7中的一條dmb指令。在將來的一篇文章中,我將更多的談論到有關數據相關性和消費操作的內容。

使用C++11中的靜態初始化器

有些讀者已經知道這篇文章的妙語:如果你想得到一個線程安全的實例,C++11不允許你跳過以上的所有步驟。你可以簡單使用一個靜態初始化器

Singleton& Singleton::getInstance() { 
    static Singleton instance; return instance;
}

讓我們回到6.7.6節查看C++11的標準:

如果控制進入申明同時變量將被初始化的時候,那么并發執行將會等到初始化的完成。

由編譯器來臨時代替實現的細節,DCLP明顯是一個不錯的選擇。不能保證編譯器將會使用DCLP,但一些(也許更多)卻碰巧發生了。使用the-std=c++0x選項對ARM進行編譯,生成了下面的一些機器碼,這些機器碼是由GCC 4.6生成的。

C++11 中的雙重檢查鎖定模式

由于單例創建于固定地址,為了同步的目的,編譯器引進了一個獨立的警衛變量。特別需要注意的是,在最初讀到這個警衛變量之后,并沒有現成的dmb指令可以用來獲取內存屏障。警衛變量是指向單例的指針,因此編譯器可以利用數據相關性,省略掉這種dmb指令。__cxa_guard_release對警衛變量執行了一個寫釋放(write-release)操作,這樣只要警衛變量已設置,在讀消費(read-consume)之前就建立了依賴順序,就像前面所有例子里那樣,基于對內存的重新排序,整個事情開始變得有彈性。

如你所見,我們已伴隨C++11走過了一段漫長的道路。雙重檢查鎖定是一種穩定的模式,而且還遠不止此!

就個人而言,我常常想,如果是需要初始化一個單例,最好是在程序啟動的時候做這個事情。但是顯然DCLP可以拯救你于泥潭。而且在實際的使用中,你還可以用DCLP來將任意數值類型存儲到一個無鎖的哈希表。在以后的文章中會有更多關于它的論述。

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!