天天看點

C++ 從雙重檢查鎖定問題 到 記憶體屏障的一些思考

文章目錄

  • ​​1. 問題描述​​
  • ​​2. DCLP 的問題 和 指令執行順序​​
  • ​​2.1 Volatile 關鍵字​​
  • ​​2.2 C++11 的記憶體模型​​
  • ​​3. C++11記憶體模型 解決DCLP問題​​
  • ​​3.1 記憶體屏障和獲得、釋放語義​​
  • ​​3.2 atomic 的完整介紹​​
  • ​​3.2.1 原子操作的三種類型​​
  • ​​3.2.2 atomic 記憶體序​​
  • ​​3.2.3 atomic 成員函數​​
  • ​​4. 總結​​

1. 問題描述

單例模式 是我們日常編碼過程中比較常用的一種設計模式,用來對一個抽象進行限定,表示該類在目前程式生命周期内僅能建立一次,被整個程序空間共享。

比如 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 something
    return 0;
}      

以上代碼是經典懶漢模式(延遲加載)下實作的單例模式,主要通過​

​get_instance​

​​函數建立單例;

這種實作在單線程下是完全滿足要求的,對于還未建立的單例,通過判斷靜态資料成員的單例執行個體​​

​Singleton::instance​

​是否為空來确定之前是否建立過單例執行個體,如果沒有,則建立。後續的單例建立 則會直接傳回之前建立的單例執行個體位址,并不會建立新的單例執行個體。

但是在多線程模式下這樣的實作并不可靠,每個線程擁有各自的函數棧空間,雖然指令層級是串型執行,但在進/線程層級看起來是并行執行的。

比如線程A進入了​

​get_instance​

​​函數,判斷​

​Singleton::intance​

​執行個體為空,并準備建立新的單例執行個體 但還味建立。此時線程B 也進入到了​

​get_instance​

​​函數,因為A還沒有建立​

​Singleton::intance​

​執行個體,則線程B也判斷其為空,此時就會出現兩個線程各自建立了自己的單例執行個體。這樣的結果顯然違背了單例模式實作的初衷。

那麼對于以上多線程下單例的實作問題, 很容易便可以通過加鎖來解決,如下代碼:

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); // 一進入函數即加鎖,來獨占接下來的邏輯操作
    if(instance == NULL) {
        instance = new Singleton(); 
    }

    return instance;
}      

這樣确實能夠保證多線程下的單例建立的可靠性,一進入​

​get_instance​

​​函數通過鎖來獨占後續的CPU。

顯然這樣的實作在多線程模型下非常昂貴,對于每一個執行​​

​get_instance​

​​函數的線程都需要獨占整個CPU ,而我們實際僅僅需要在該函數内部執行真正建立​

​instance​

​執行個體的時候才需要加鎖,是以直接粗暴的加鎖方法并不是最好的實作。

這個時候DCLP(Double-Check Locking Pattern) 雙重檢查鎖定 通過觀察​

​instance​

​​執行個體是否為空來判斷是否需要進行加鎖,這樣便能夠避免每一個進入​

​get_instance​

​​函數的線程獨占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;
}      

現在來看這樣的實作既能夠保證多線程下的可靠性 又能滿足我們想要的性能。

那麼文章到此就結束了嗎? 這樣的思考并不夠深入,以上的實作能夠安全的在單處理器的裝置上穩定運作,但是在我們如今以多處理器為主的裝置上還是不夠安全,接下來就需要讨論一下多處理器下的指令順序問題。

2. DCLP 的問題 和 指令執行順序

繼續看我們節中實作的**雙重檢查鎖定(DCLP)**的代碼,其中在加鎖的邏輯中通過檢查​

​instance​

​​成員是否為空來決定是否執行個體話還成員。

具體執行個體化的代碼如下:

instance = new Singleton();      

這一行代碼在C++的語義中又可以被進一步拆分為如下幾步:

  • 為先為​

    ​Singleton​

    ​ 對象配置設定記憶體
  • 初始化​

    ​Singleton​

    ​ 類,将該類的資料成員填充到配置設定好的記憶體之中
  • 建立一個​

    ​instance​

    ​ 指針,指向已經配置設定好的記憶體

這裡有非常關鍵的一點是 編譯器并不會嚴格按照上述的三個步驟執行執行個體化​

​instance​

​成員的邏輯。在某一些場景下,編譯器能夠支援第二步和第三步交換執行。關于編譯器為什麼會這樣做,接下來會詳細提到。先将讨論重心放在第二步和第三步如果發生了交換之後所産生的後果之上。

如下代碼展示了執行個體化過程中 第二步(初始化 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實作的完整解釋,單例模式的構造器在 調用到第二步的時候會抛異常;且大部分的場景編譯器并不會将 第三步 的執行順序放在第二步之前,但是以上的情況是存在的,最簡單的一種情況是編譯器可以保證Singleton構造函數不會抛出異常(例如通過内聯化後的流分析(post-inlining flow analysis),當然這不是唯一情況。

針對以上拆分執行個體化過程 可能出現的問題 舉例如下:

  • 線程A進入到​

    ​get_instance​

    ​​函數,進行第一次​

    ​instance​

    ​​判空的檢查通過。擷取到全局鎖,進入到第二次判空邏輯。并執行由第三步和第一步組成的語句。接下來簡單暫停一下,執行到這裡instance 成員因為已經配置設定了記憶體位址,并不為空。但此時instance指向的記憶體中并未填充​

    ​Singleton​

    ​類中的成員資料。
  • 此時線程B進入到​

    ​get_instance​

    ​​函數中,進行第一次的​

    ​instance​

    ​成員判空的檢查,發現并不為空,則直接傳回instance成員的位址,認為instance成員已經執行個體化完成,并且釋放該函數内 instance 指針指向的位址,然而這樣的傳回值并沒有完成真正意義的單例對象建立。

是以雙重檢查鎖定(DCLP) 當且僅當 第一步和第二步 在第三步之前執行時才能夠保證多線程,多處理器下的可靠性。但是這在C/C++語言中 并不能真正意義上保證這種邏輯下的執行執行順序,也就是說多線程這樣的概念在C/C++語言中并不存在。

看看如下非常簡單的代碼:

void foo() {
    int x = 0, y = 0; // 語句1
    x = 5;            // 語句2
    y = 10;           // 語句3

    printf("x=%d, y=%d\n", x, y); // 語句4
}      

通過設定編譯器的優化選項 能夠看到具體語句1-4并不一定按照函數設定的語句邏輯來執行。

如下,從上到下依次是為開啟編譯器的優化選項,開啟​

​-O1​

​​優化選項,開啟​

​-O2​

​優化選項的結果

C++ 從雙重檢查鎖定問題 到 記憶體屏障的一些思考

那麼C/C++程式員如何 寫出正常工作的多線程程式呢?也就是我們經常使用的線程庫(Posix的pthreads線程庫)。多線程程式的編譯和連結需要依賴這一些線程庫,這一些線程庫的實作也已經經過嚴格的規範來限制關鍵指令的執行順序(核心實作通過彙編語言來完成),保證不會受到編譯器的優化幹擾産生指令的重新排序。

然而針對DLCP這樣的代碼我們想要跳出編譯器對執行指令的限制,使用一種語言(C++實作)是無法達到跳出限制的目的,那麼作為程式員,我們想要擺脫編譯器對我們的代碼的優化,針對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;
}      

這樣的代碼 在那一些老奸巨猾的編譯器優化程式員的眼中可是無用代碼的,使用了優化選項之後 tmp的初始化顯然并不會被真正執行到,正如foo代碼之中的O1以上的優化選項,對于代碼開頭​

​x=0,y=0​

​這樣的代碼是直接跳過的。

如果我們想用一種語言和那一些專注于編譯器優化幾十年的老程式員比拼實力,顯然沒有人家在行,分分鐘鐘将你不想被編譯器優化的代碼給優化掉。。。。

同時 ,在多處理器環境下,每個處理器都有各自的高速緩存,但所有處理器共享記憶體空間。這種架構需要設計者精确定義一個處理器該如何向共享記憶體執行寫操作,又何時執行讀操作,并使這個過程對其他處理器可見。我們很容易想象這樣的場景:當某一個處理器在自己的高速緩存中更新的某個共享變量的值,但它并沒有将該值更新至共享主存中,更不用說将該值更新到其他處理器的緩存中了。這種緩存間共享變量值不一緻的情況被稱為緩存一緻性問題(cache coherency problem)。

假設處理器A改變了共享變量x的值,之後又改變了共享變量y的值,那麼這些新值必須更新至記憶體中,這樣其他處理器才能看到這些改變。然而,由于按位址順序遞增重新整理緩存更高效,是以如果y的位址小于x的位址,那麼y很有可能先于x更新至主存中。這樣就導緻其他處理器認為y值的改變是先于x值的。

對DCLP而言,這種可能性将是一個嚴重的問題。正确的Singleton初始化要求先初始化Singleton對象,再初始化 Instance。如果在處理器A上運作的線程是按正确順序執行,但處理器B上的線程卻将兩個步驟調換順序,那麼處理器B上的線程又會導緻pInstance被指派為未完成初始化的Singleton對象。

C++ 從雙重檢查鎖定問題 到 記憶體屏障的一些思考

那麼問題來了,怎麼能夠讓我們的C++代碼指令順序 在多處理器上 按照我們自己的想法來執行呢?

2.1 Volatile 關鍵字

在某一些編譯器中使用volatile 關鍵字可以達到記憶體同步的效果。但是我們必須記住,這不是volatitle的設計意圖,也不能通用地達到記憶體同步的效果。volatitle的語義隻是防止編譯器“優化”掉對記憶體的讀寫而已。它的合适用法,目前主要是用來讀寫映射到記憶體位址上的IO操作。

由于volatile 不能在多處理器的環境下確定多個線程看到同樣順序的資料變化,在今天的通用程式中,不應該再看到volatitle的出現。

2.2 C++11 的記憶體模型

為了從根本上解決上述問題,C++11 引入了更适合多線程的記憶體模型。

跟我們實際開發過程密切相關的是:原子對象(atomic),使用原子對象的獲得(acquire)、釋放(release)語義,可以真正精确地控制記憶體通路的順序性,保證我們需要的記憶體序。

3. C++11記憶體模型 解決DCLP問題

3.1 記憶體屏障和獲得、釋放語義

現在有兩個全局變量:

int x = 0;
int y = 0;      

在一個線程内執行:

x = 1;
y = 2;      

在另一個線程執行:

if (y ==2) {
  x = 3;
  y = 4;
}      

這樣的代碼按我們正常立即的程式邏輯來看,x和y的結果有兩種可能:1,2 和 3,4

但是之前已經對編譯器的優化選項導緻的指令序列不同 以及 多處理器場景下記憶體通路問題 可能還會出現x和y的結果是1,4的情景(編譯器優化後的執行序 y先于x指派 或者 多處理器場景下y在記憶體中的位址低于x 也可能出現y先于x指派)。

我們想要滿足程式員心中的完全存儲序,就需要在x=1和y=2兩個語句之間加入記憶體屏障,進而禁止這兩個語句交換順序。這種情況下最常用的兩個概念是“獲得”和 “釋放”:

  • 獲得 是對一個記憶體的 讀 操作,目前線程後續的任何讀寫操作都不允許重排到這個操作之前
  • 釋放 是對一個記憶體的 寫 操作,目前線程的任何前面讀寫操作都不允許重排到這個操作之後

比如上面的代碼段,我們需要将​

​y​

​​ 聲明成 ​

​atomic<int>​

​。然後線上程1中需要使用釋放語義:

x = 1;
y.store(2, memory_order_release);      

線上程2 我們需要對​

​y​

​ 的讀取使用獲得語義,但存儲隻需要松散的記憶體序即可:

if (y.load(memory_order_acquire) == 2) {
  x = 3;
  y.store(4, memory_order_relaxed);
}      

如下圖,兩邊的代碼重排之後不允許越過虛線,如果y上的釋放早于y上的擷取,釋放前 對記憶體的修改都在另一個線程的擷取操作後可見。

C++ 從雙重檢查鎖定問題 到 記憶體屏障的一些思考

實際編碼過程中,我們把y直接改成​

​atomic<int>​

​之後,兩個線程的代碼不做任何的變更執行結果都會是符合我們預期的,因為atomic 變量的寫操作預設是釋放語義,讀操作預設是獲得語義。

  • ​y = 2​

    ​​ 相當于 ​

    ​y.store(2, memory_order_release)​

  • ​y==2​

    ​​ 相當于​

    ​y.load(memory_order_acquire) == 2​

那為什麼要說顯式得使用記憶體屏障的獲得釋放語義呢,因為預設行為對性能不利:我們不需要在任何情況下都要保證操作的順序性。

另外,我們應當注意 ​

​acquire​

​​和​

​release​

​​ 通常都是配對出現的,目的是保證如果對同一個原子對象的​

​release​

​​ 發生在​

​acquire​

​​ 之前,​

​release​

​​之前發生的記憶體修改都能夠被​

​acquire​

​​之後的記憶體讀取全部看到。

比如 第一個線程​​

​y=2​

​​是在第二個線程​

​y==2​

​​之前完成的,那麼y=2之前針對x代表的記憶體的修改 是能夠被​

​y==2​

​之後的語句看到。

3.2 atomic 的完整介紹

C++11 在<atomic> 頭檔案中引入了 atomic 模版,對原子對象進行封裝,讓我們能夠應用到任何類型之上。當然這個過程對于不同類型的效果是不同的,對于整型量和指針等簡單類型,通常結果是無鎖的原子對象;而對于另外一些類型,比如64位機器上 大小不是1,2,4,8 的類型,編譯器會自動為這一些原子對象的操作加上鎖。同時,編譯器也提供了原子對象的成員函數​

​is_lock_free​

​ 能夠檢查這個原子對象上的操作是否是無鎖操作。

3.2.1 原子操作的三種類型

  • 讀 : 讀取的過程中,讀取位置的内容不會發生任何變動
  • 寫 : 在寫入的過程中,其他執行線程不會看到部分寫入的結果(比如多處理器場景:寫入先寫到CPU cache,再寫入到記憶體中,這兩個操作都完成才算目前寫入完成)
  • 讀-修改-寫: 讀取記憶體,修改數值,寫回記憶體。整個操作的過程中間不會有其他寫入操作插入,同時其他線程也不會看到中間結果。

3.2.2 atomic 記憶體序

  • ​memory_order_relaxed​

    ​ 松散記憶體序,隻用來保證對原子對象的操作是原子的
  • ​memory_order_consume​

    ​​ 消費語義,和​

    ​acquire​

    ​類似,隻是在部分平台的效果更好。更加詳細的介紹可以參考​​memory_order_consume​​
  • ​memory_order_acquire​

    ​ 獲得操作,在讀取某原子對象時,目前線程的任何後面的讀寫操作都不允許重排到這個操作的前面,并且其他線程在對同一個原子對象釋放之前的所有記憶體寫入操作在目前線程都是可見的。
  • ​memory_order_release​

    ​ 釋放操作,在寫入某原子對象時,目前線程的任何前面的讀寫操作都不允許重排到這個操作的後面,并且目前線程的所有記憶體寫入都在對同一個原子對象進行擷取的其他線程可見。
  • ​memory_order_acq_rel​

    ​ 獲得釋放操作,一個讀-修改-寫操作 同時具有獲得語義和釋放語義,即它前後的任何讀寫操作都不允許重排,并且其他線程在對同一個原子對象釋放之前的所有記憶體寫入都在目前線程可見,目前線程的所有記憶體寫入都在對同一個原子對象進行擷取的其他線程可見。
  • ​memory_order_seq_cst​

    ​ 順序一緻性語義,對于讀操作相當于擷取,對于寫操作相當于釋放,對于讀-修改-寫 操作相當于獲得釋放,是所有原子操作的預設記憶體序。

3.2.3 atomic 成員函數

  • 預設構造函數(隻支援初始化零)
  • 拷貝構造函數被删除
  • 使用内置對象類型的構造函數(不是原子操作)
  • 可以從内置對象類型指派到原子對象(相當于store)
  • 可以從原子對象隐式轉換成内置對象(相當于load)
  • store, 寫入資料到原子對象,第二個可選參數是記憶體序類型
  • load,從原子對象讀取内置對象,有一個可選參數是記憶體序類型
  • is_lock_free, 判斷原子對象的操作是否無鎖(是否可以用處理器指令直接完成原子操作)
  • exchange , 交換操作,第二個可選參數是記憶體序類型(讀-修改-寫 操作)
  • compare_exchange_weak 和 compare_exchange_strong,兩個比較加交換(CAS)版本,可以分别制定成功和失敗時的記憶體序,也可以隻制定一個,或者使用預設的最安全的記憶體序-- ​

    ​memory_order_seq_cst​

    ​ 順序一緻性語義。 (同樣是 讀-修改-寫 操作)
  • fetch_add 和 fetch_sub ,僅對整數和指針 内置對象生效。對目标原子對象執行加 或 減操作,傳回其原始數值,第二個可選參數是記憶體序類型。(同樣是 讀-修改-寫 操作)
  • ++ 和 – (前置和後置) ,僅對整數和指針 内置對象生效。對目标原子對象執行加一 或 減一操作,使用順序一緻性語義,傳回的并不是原子對象的引用。(讀–修改–寫 操作)
  • += 和 -= ,僅對整數和指針内置對象有效,對目标原子對象執行 加 或減操作, 傳回操作後的數值。操作使用順序一緻性語義,且傳回的并不少原子對象的引用(讀-修改-寫 操作)

有了對atomic 記憶體模型的了解之後 我們在一些全局共享變量的原子性維護上 就可以使用​

​std::atomic_int count_;​

​​這樣的形式了。

這樣的聲明 在後續的 原子對象的自增自減 過程中 使用的是預設記憶體序類型(順序一緻性),其實整體的代價還是有有點大,因為順序一緻性是需要完整執行 擷取加釋放語義。

那麼自增的實作可以通過如下定義完成:

void add_count() noexcept
{
  count_.fetch_add(
    1,std::memory_order_relaxed); 
}      

僅需增加記憶體的松散記憶體序,保證自增操作的原子性即可。

4. 總結

繼續閱讀