C++ 从双重检查锁定问题 到 内存屏障的一些思考
文章目錄
- 1. 問題描述
- 2. DCLP 的問題 和 指令執(zhí)行順序
- 2.1 Volatile 關鍵字
- 2.2 C++11 的內(nèi)存模型
- 3. C++11內(nèi)存模型 解決DCLP問題
- 3.1 內(nèi)存屏障和獲得、釋放語義
- 3.2 atomic 的完整介紹
- 3.2.1 原子操作的三種類型
- 3.2.2 atomic 內(nèi)存序
- 3.2.3 atomic 成員函數(shù)
- 4. 總結(jié)
1. 問題描述
單例模式 是我們?nèi)粘>幋a過程中比較常用的一種設計模式,用來對一個抽象進行限定,表示該類在當前程序生命周期內(nèi)僅能創(chuàng)建一次,被整個進程空間共享。
比如 FPGA處理單元在我們實際的處理過程中僅能擁有一個實例來完成Compaction操作,那么便可以將FPGA的處理過程抽象為一個單例模式,在整個進程啟動之后有且僅有一個FPGA實例完成整個rocksdb的compaction 過程。
查看如下單例代碼
#include <iostream>
// header file
class Singleton{
private:static Singleton *instance;Singleton(){}
public:static Singleton *get_instance();
};// implemetation file
Singleton *Singleton::instance = NULL;Singleton *Singleton::get_instance() {if(Singleton::instance == NULL) {Singleton::instance = new Singleton(); }return instance;
}int main() {Singleton *test = Singleton::get_instance();..... // do somethingreturn 0;
}
以上代碼是經(jīng)典懶漢模式(延遲加載)下實現(xiàn)的單例模式,主要通過get_instance函數(shù)創(chuàng)建單例;
這種實現(xiàn)在單線程下是完全滿足要求的,對于還未創(chuàng)建的單例,通過判斷靜態(tài)數(shù)據(jù)成員的單例實例Singleton::instance是否為空來確定之前是否創(chuàng)建過單例實例,如果沒有,則創(chuàng)建。后續(xù)的單例創(chuàng)建 則會直接返回之前創(chuàng)建的單例實例地址,并不會創(chuàng)建新的單例實例。
但是在多線程模式下這樣的實現(xiàn)并不可靠,每個線程擁有各自的函數(shù)棧空間,雖然指令層級是串型執(zhí)行,但在進/線程層級看起來是并行執(zhí)行的。
比如線程A進入了get_instance函數(shù),判斷Singleton::intance實例為空,并準備創(chuàng)建新的單例實例 但還味創(chuàng)建。此時線程B 也進入到了get_instance函數(shù),因為A還沒有創(chuàng)建Singleton::intance實例,則線程B也判斷其為空,此時就會出現(xiàn)兩個線程各自創(chuàng)建了自己的單例實例。這樣的結(jié)果顯然違背了單例模式實現(xiàn)的初衷。
那么對于以上多線程下單例的實現(xiàn)問題, 很容易便可以通過加鎖來解決,如下代碼:
std::mutex g_lock;
class Singleton{
private:static Singleton *instance;Singleton(){}
public:static Singleton *get_instance();
};Singleton *Singleton::instance = NULL;Singleton *Singleton::get_instance() {std::lock_guard<std::mutex> lock(g_lock); // 一進入函數(shù)即加鎖,來獨占接下來的邏輯操作if(instance == NULL) {instance = new Singleton(); }return instance;
}
這樣確實能夠保證多線程下的單例創(chuàng)建的可靠性,一進入get_instance函數(shù)通過鎖來獨占后續(xù)的CPU。
顯然這樣的實現(xiàn)在多線程模型下非常昂貴,對于每一個執(zhí)行get_instance函數(shù)的線程都需要獨占整個CPU ,而我們實際僅僅需要在該函數(shù)內(nèi)部執(zhí)行真正創(chuàng)建instance實例的時候才需要加鎖,所以直接粗暴的加鎖方法并不是最好的實現(xiàn)。
這個時候DCLP(Double-Check Locking Pattern) 雙重檢查鎖定 通過觀察instance實例是否為空來判斷是否需要進行加鎖,這樣便能夠避免每一個進入get_instance函數(shù)的線程獨占CPU的情況。
如下代碼:
std::mutex g_lock;
class Singleton{
private:static Singleton *instance;Singleton(){}
public:static Singleton *get_instance();
};Singleton *Singleton::instance = NULL;// DCLP implementation
Singleton *Singleton::get_instance() {if(instance == NULL) { // 第一重檢查std::lock_guard<std::mutex> lock(g_lock);if(instance == NULL) { // 第二重檢查instance = new Singleton(); }}return instance;
}
現(xiàn)在來看這樣的實現(xiàn)既能夠保證多線程下的可靠性 又能滿足我們想要的性能。
那么文章到此就結(jié)束了嗎? 這樣的思考并不夠深入,以上的實現(xiàn)能夠安全的在單處理器的設備上穩(wěn)定運行,但是在我們?nèi)缃褚远嗵幚砥鳛橹鞯脑O備上還是不夠安全,接下來就需要討論一下多處理器下的指令順序問題。
2. DCLP 的問題 和 指令執(zhí)行順序
繼續(xù)看我們節(jié)中實現(xiàn)的**雙重檢查鎖定(DCLP)**的代碼,其中在加鎖的邏輯中通過檢查instance成員是否為空來決定是否實例話還成員。
具體實例化的代碼如下:
instance = new Singleton();
這一行代碼在C++的語義中又可以被進一步拆分為如下幾步:
- 為先為
Singleton對象分配內(nèi)存 - 初始化
Singleton類,將該類的數(shù)據(jù)成員填充到分配好的內(nèi)存之中 - 創(chuàng)建一個
instance指針,指向已經(jīng)分配好的內(nèi)存
這里有非常關鍵的一點是 編譯器并不會嚴格按照上述的三個步驟執(zhí)行實例化instance成員的邏輯。在某一些場景下,編譯器能夠支持第二步和第三步交換執(zhí)行。關于編譯器為什么會這樣做,接下來會詳細提到。先將討論重心放在第二步和第三步如果發(fā)生了交換之后所產(chǎn)生的后果之上。
如下代碼展示了實例化過程中 第二步(初始化 Singleton)和 第三步(賦值Singleton地址) 交換的邏輯(實際代碼我們并不會這樣寫,這里僅僅為了展示):
Singleton *Singleton::get_instance() {if(instance == NULL) {std::lock_guard<std::mutex> lock(g_lock);if(instance == NULL) {instance = // 第三步operator new(sizeof(Singleton)); // 第一步new (instance)Singleton; // 第二步}}return instance;
}
通常情況下,以上代碼并不是對于DCLP實現(xiàn)的完整解釋,單例模式的構(gòu)造器在 調(diào)用到第二步的時候會拋異常;且大部分的場景編譯器并不會將 第三步 的執(zhí)行順序放在第二步之前,但是以上的情況是存在的,最簡單的一種情況是編譯器可以保證Singleton構(gòu)造函數(shù)不會拋出異常(例如通過內(nèi)聯(lián)化后的流分析(post-inlining flow analysis),當然這不是唯一情況。
針對以上拆分實例化過程 可能出現(xiàn)的問題 舉例如下:
- 線程A進入到
get_instance函數(shù),進行第一次instance判空的檢查通過。獲取到全局鎖,進入到第二次判空邏輯。并執(zhí)行由第三步和第一步組成的語句。接下來簡單暫停一下,執(zhí)行到這里instance 成員因為已經(jīng)分配了內(nèi)存地址,并不為空。但此時instance指向的內(nèi)存中并未填充Singleton類中的成員數(shù)據(jù)。 - 此時線程B進入到
get_instance函數(shù)中,進行第一次的instance成員判空的檢查,發(fā)現(xiàn)并不為空,則直接返回instance成員的地址,認為instance成員已經(jīng)實例化完成,并且釋放該函數(shù)內(nèi) instance 指針指向的地址,然而這樣的返回值并沒有完成真正意義的單例對象創(chuàng)建。
所以雙重檢查鎖定(DCLP) 當且僅當 第一步和第二步 在第三步之前執(zhí)行時才能夠保證多線程,多處理器下的可靠性。但是這在C/C++語言中 并不能真正意義上保證這種邏輯下的執(zhí)行執(zhí)行順序,也就是說多線程這樣的概念在C/C++語言中并不存在。
看看如下非常簡單的代碼:
void foo() {int x = 0, y = 0; // 語句1x = 5; // 語句2y = 10; // 語句3printf("x=%d, y=%d\n", x, y); // 語句4
}
通過設置編譯器的優(yōu)化選項 能夠看到具體語句1-4并不一定按照函數(shù)設置的語句邏輯來執(zhí)行。
如下,從上到下依次是為開啟編譯器的優(yōu)化選項,開啟-O1優(yōu)化選項,開啟-O2優(yōu)化選項的結(jié)果
那么C/C++程序員如何 寫出正常工作的多線程程序呢?也就是我們經(jīng)常使用的線程庫(Posix的pthreads線程庫)。多線程程序的編譯和鏈接需要依賴這一些線程庫,這一些線程庫的實現(xiàn)也已經(jīng)經(jīng)過嚴格的規(guī)范來約束關鍵指令的執(zhí)行順序(核心實現(xiàn)通過匯編語言來完成),保證不會受到編譯器的優(yōu)化干擾產(chǎn)生指令的重新排序。
然而針對DLCP這樣的代碼我們想要跳出編譯器對執(zhí)行指令的約束,使用一種語言(C++實現(xiàn))是無法達到跳出約束的目的,那么作為程序員,我們想要擺脫編譯器對我們的代碼的優(yōu)化,針對DLCP 這樣的代,嘗試這樣的邏輯。
在instance未完成初始化之前,不對instance做出任何修改:
Singleton *Singleton::get_instance() {if(instance == NULL) {std::lock_guard<std::mutex> lock(g_lock);if(instance == NULL) {Singleton *tmp = new Singleton();instance = tmp;}}return instance;
}
這樣的代碼 在那一些老奸巨猾的編譯器優(yōu)化程序員的眼中可是無用代碼的,使用了優(yōu)化選項之后 tmp的初始化顯然并不會被真正執(zhí)行到,正如foo代碼之中的O1以上的優(yōu)化選項,對于代碼開頭x=0,y=0這樣的代碼是直接跳過的。
如果我們想用一種語言和那一些專注于編譯器優(yōu)化幾十年的老程序員比拼實力,顯然沒有人家在行,分分鐘鐘將你不想被編譯器優(yōu)化的代碼給優(yōu)化掉。。。。
同時 ,在多處理器環(huán)境下,每個處理器都有各自的高速緩存,但所有處理器共享內(nèi)存空間。這種架構(gòu)需要設計者精確定義一個處理器該如何向共享內(nèi)存執(zhí)行寫操作,又何時執(zhí)行讀操作,并使這個過程對其他處理器可見。我們很容易想象這樣的場景:當某一個處理器在自己的高速緩存中更新的某個共享變量的值,但它并沒有將該值更新至共享主存中,更不用說將該值更新到其他處理器的緩存中了。這種緩存間共享變量值不一致的情況被稱為緩存一致性問題(cache coherency problem)。
假設處理器A改變了共享變量x的值,之后又改變了共享變量y的值,那么這些新值必須更新至內(nèi)存中,這樣其他處理器才能看到這些改變。然而,由于按地址順序遞增刷新緩存更高效,所以如果y的地址小于x的地址,那么y很有可能先于x更新至主存中。這樣就導致其他處理器認為y值的改變是先于x值的。
對DCLP而言,這種可能性將是一個嚴重的問題。正確的Singleton初始化要求先初始化Singleton對象,再初始化 Instance。如果在處理器A上運行的線程是按正確順序執(zhí)行,但處理器B上的線程卻將兩個步驟調(diào)換順序,那么處理器B上的線程又會導致pInstance被賦值為未完成初始化的Singleton對象。
那么問題來了,怎么能夠讓我們的C++代碼指令順序 在多處理器上 按照我們自己的想法來執(zhí)行呢?
2.1 Volatile 關鍵字
在某一些編譯器中使用volatile 關鍵字可以達到內(nèi)存同步的效果。但是我們必須記住,這不是volatitle的設計意圖,也不能通用地達到內(nèi)存同步的效果。volatitle的語義只是防止編譯器“優(yōu)化”掉對內(nèi)存的讀寫而已。它的合適用法,目前主要是用來讀寫映射到內(nèi)存地址上的IO操作。
由于volatile 不能在多處理器的環(huán)境下確保多個線程看到同樣順序的數(shù)據(jù)變化,在今天的通用程序中,不應該再看到volatitle的出現(xiàn)。
2.2 C++11 的內(nèi)存模型
為了從根本上解決上述問題,C++11 引入了更適合多線程的內(nèi)存模型。
跟我們實際開發(fā)過程密切相關的是:原子對象(atomic),使用原子對象的獲得(acquire)、釋放(release)語義,可以真正精確地控制內(nèi)存訪問的順序性,保證我們需要的內(nèi)存序。
3. C++11內(nèi)存模型 解決DCLP問題
3.1 內(nèi)存屏障和獲得、釋放語義
現(xiàn)在有兩個全局變量:
int x = 0;
int y = 0;
在一個線程內(nèi)執(zhí)行:
x = 1;
y = 2;
在另一個線程執(zhí)行:
if (y ==2) {x = 3;y = 4;
}
這樣的代碼按我們正常立即的程序邏輯來看,x和y的結(jié)果有兩種可能:1,2 和 3,4
但是之前已經(jīng)對編譯器的優(yōu)化選項導致的指令序列不同 以及 多處理器場景下內(nèi)存訪問問題 可能還會出現(xiàn)x和y的結(jié)果是1,4的情景(編譯器優(yōu)化后的執(zhí)行序 y先于x賦值 或者 多處理器場景下y在內(nèi)存中的地址低于x 也可能出現(xiàn)y先于x賦值)。
我們想要滿足程序員心中的完全存儲序,就需要在x=1和y=2兩個語句之間加入內(nèi)存屏障,從而禁止這兩個語句交換順序。這種情況下最常用的兩個概念是“獲得”和 “釋放”:
- 獲得 是對一個內(nèi)存的 讀 操作,當前線程后續(xù)的任何讀寫操作都不允許重排到這個操作之前
- 釋放 是對一個內(nèi)存的 寫 操作,當前線程的任何前面讀寫操作都不允許重排到這個操作之后
比如上面的代碼段,我們需要將y 聲明成 atomic<int>。然后在線程1中需要使用釋放語義:
x = 1;
y.store(2, memory_order_release);
在線程2 我們需要對y 的讀取使用獲得語義,但存儲只需要松散的內(nèi)存序即可:
if (y.load(memory_order_acquire) == 2) {x = 3;y.store(4, memory_order_relaxed);
}
如下圖,兩邊的代碼重排之后不允許越過虛線,如果y上的釋放早于y上的獲取,釋放前 對內(nèi)存的修改都在另一個線程的獲取操作后可見。
實際編碼過程中,我們把y直接改成atomic<int>之后,兩個線程的代碼不做任何的變更執(zhí)行結(jié)果都會是符合我們預期的,因為atomic 變量的寫操作默認是釋放語義,讀操作默認是獲得語義。
y = 2相當于y.store(2, memory_order_release)y==2相當于y.load(memory_order_acquire) == 2
那為什么要說顯式得使用內(nèi)存屏障的獲得釋放語義呢,因為缺省行為對性能不利:我們不需要在任何情況下都要保證操作的順序性。
另外,我們應當注意 acquire和release 通常都是配對出現(xiàn)的,目的是保證如果對同一個原子對象的release 發(fā)生在acquire 之前,release之前發(fā)生的內(nèi)存修改都能夠被acquire之后的內(nèi)存讀取全部看到。
比如 第一個線程y=2是在第二個線程y==2之前完成的,那么y=2之前針對x代表的內(nèi)存的修改 是能夠被y==2之后的語句看到。
3.2 atomic 的完整介紹
C++11 在<atomic> 頭文件中引入了 atomic 模版,對原子對象進行封裝,讓我們能夠應用到任何類型之上。當然這個過程對于不同類型的效果是不同的,對于整型量和指針等簡單類型,通常結(jié)果是無鎖的原子對象;而對于另外一些類型,比如64位機器上 大小不是1,2,4,8 的類型,編譯器會自動為這一些原子對象的操作加上鎖。同時,編譯器也提供了原子對象的成員函數(shù)is_lock_free 能夠檢查這個原子對象上的操作是否是無鎖操作。
3.2.1 原子操作的三種類型
- 讀 : 讀取的過程中,讀取位置的內(nèi)容不會發(fā)生任何變動
- 寫 : 在寫入的過程中,其他執(zhí)行線程不會看到部分寫入的結(jié)果(比如多處理器場景:寫入先寫到CPU cache,再寫入到內(nèi)存中,這兩個操作都完成才算當前寫入完成)
- 讀-修改-寫: 讀取內(nèi)存,修改數(shù)值,寫回內(nèi)存。整個操作的過程中間不會有其他寫入操作插入,同時其他線程也不會看到中間結(jié)果。
3.2.2 atomic 內(nèi)存序
memory_order_relaxed松散內(nèi)存序,只用來保證對原子對象的操作是原子的memory_order_consume消費語義,和acquire類似,只是在部分平臺的效果更好。更加詳細的介紹可以參考memory_order_consumememory_order_acquire獲得操作,在讀取某原子對象時,當前線程的任何后面的讀寫操作都不允許重排到這個操作的前面,并且其他線程在對同一個原子對象釋放之前的所有內(nèi)存寫入操作在當前線程都是可見的。memory_order_release釋放操作,在寫入某原子對象時,當前線程的任何前面的讀寫操作都不允許重排到這個操作的后面,并且當前線程的所有內(nèi)存寫入都在對同一個原子對象進行獲取的其他線程可見。memory_order_acq_rel獲得釋放操作,一個讀-修改-寫操作 同時具有獲得語義和釋放語義,即它前后的任何讀寫操作都不允許重排,并且其他線程在對同一個原子對象釋放之前的所有內(nèi)存寫入都在當前線程可見,當前線程的所有內(nèi)存寫入都在對同一個原子對象進行獲取的其他線程可見。memory_order_seq_cst順序一致性語義,對于讀操作相當于獲取,對于寫操作相當于釋放,對于讀-修改-寫 操作相當于獲得釋放,是所有原子操作的默認內(nèi)存序。
3.2.3 atomic 成員函數(shù)
- 默認構(gòu)造函數(shù)(只支持初始化零)
- 拷貝構(gòu)造函數(shù)被刪除
- 使用內(nèi)置對象類型的構(gòu)造函數(shù)(不是原子操作)
- 可以從內(nèi)置對象類型賦值到原子對象(相當于store)
- 可以從原子對象隱式轉(zhuǎn)換成內(nèi)置對象(相當于load)
- store, 寫入數(shù)據(jù)到原子對象,第二個可選參數(shù)是內(nèi)存序類型
- load,從原子對象讀取內(nèi)置對象,有一個可選參數(shù)是內(nèi)存序類型
- is_lock_free, 判斷原子對象的操作是否無鎖(是否可以用處理器指令直接完成原子操作)
- exchange , 交換操作,第二個可選參數(shù)是內(nèi)存序類型(讀-修改-寫 操作)
- compare_exchange_weak 和 compare_exchange_strong,兩個比較加交換(CAS)版本,可以分別制定成功和失敗時的內(nèi)存序,也可以只制定一個,或者使用默認的最安全的內(nèi)存序--
memory_order_seq_cst順序一致性語義。 (同樣是 讀-修改-寫 操作) - fetch_add 和 fetch_sub ,僅對整數(shù)和指針 內(nèi)置對象生效。對目標原子對象執(zhí)行加 或 減操作,返回其原始數(shù)值,第二個可選參數(shù)是內(nèi)存序類型。(同樣是 讀-修改-寫 操作)
- ++ 和 – (前置和后置) ,僅對整數(shù)和指針 內(nèi)置對象生效。對目標原子對象執(zhí)行加一 或 減一操作,使用順序一致性語義,返回的并不是原子對象的引用。(讀–修改–寫 操作)
- += 和 -= ,僅對整數(shù)和指針內(nèi)置對象有效,對目標原子對象執(zhí)行 加 或減操作, 返回操作后的數(shù)值。操作使用順序一致性語義,且返回的并不少原子對象的引用(讀-修改-寫 操作)
有了對atomic 內(nèi)存模型的理解之后 我們在一些全局共享變量的原子性維護上 就可以使用std::atomic_int count_;這樣的形式了。
這樣的聲明 在后續(xù)的 原子對象的自增自減 過程中 使用的是默認內(nèi)存序類型(順序一致性),其實整體的代價還是有有點大,因為順序一致性是需要完整執(zhí)行 獲取加釋放語義。
那么自增的實現(xiàn)可以通過如下定義完成:
void add_count() noexcept
{count_.fetch_add(1,std::memory_order_relaxed);
}
僅需增加內(nèi)存的松散內(nèi)存序,保證自增操作的原子性即可。
4. 總結(jié)
我們討論了 從單例模式 在并發(fā)場景出現(xiàn)的問題,到使用鎖來解決問題,到為了更可靠 和更高效 而提出DCLP,又因為編譯器的指令重排和多處理器的內(nèi)存一致性問題 而提出對DCLP的質(zhì)疑,感覺一個線程安全的實現(xiàn)是如此之艱難,和CPU緩存/內(nèi)存 幾十年的編譯器優(yōu)化程序員博弈 中 被人家吊打。
最后終于在C++11 的實現(xiàn)中看到了曙光,簡單 清晰得內(nèi)存屏障。烏干達兒童終于 從編譯器的指令重排 和 內(nèi)存一致性問題 的苦難中 走了出來。
總結(jié)
以上是生活随笔為你收集整理的C++ 从双重检查锁定问题 到 内存屏障的一些思考的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 亲爱的九零后们,心中的第一个偶像是谁?
- 下一篇: 好听清冷的名字女生