好好說說c++內存序--以單例模式為例子


1.首先寫一個單例模式,面試中很容易遇見的,一聽到單例,小猿忍不住投去鄙夷的目光,不過他還是挺謹慎的,並沒有立即下筆,思索一番后,決定把自己曾經在公司某久經考驗的框架里看過的一段代碼搬運過來:  

template<typename T>
class XX_Singleton {
public:
    typedef T  instance_type;
    typedef volatile T volatile_type;

    static T* getInstance() {
        // 加鎖, 雙check機制, 保證正確和效率
        if(!_pInstance) {
            std::lock_guard<mutex> lock(_tl);
            if(!_pInstance) {
                _pInstance = new T;
            }
        }
        return (T*)_pInstance;
    }

    virtual ~XX_Singleton() {
    }; 
protected:
    static mutex _tl;
    static volatile T* _pInstance;

protected:
    XX_Singleton() {
    }
    XX_Singleton (const TC_Singleton &); 
    XX_Singleton& operator=(const TC_Singleton &);
};

// 靜態成員變量的定義
template <typename T>
volatile T* XX_Singleton<T>::_pInstance = nullptr; 

 大部分磚工應該見過類似上面這段代碼實現的singleton,它采用 DCLP (Double-Checked Locking Pattern),期望做到多線程安全的同時又兼顧性能。然而,著名 c++ 專家 Scott Meyers 早在2004年就寫過一篇論文 C++ and the Perils of Double-Checked Locking 專門討論過上面這段代碼,文中第 3 節和第 4 節主要從內存順序(memory order)角度指出了這種實現存在問題,所以這實際上是一種錯誤的實現。然而糟糕的是,作者指出 c++ 標准(2004年c++11還沒出現)在語言層面並沒有一個可用的機制來獲得這里需要的 memory order,正確的實現需要依賴系統相關的庫,例如linux系統下的pthread庫。在c++11出現后,這個問題得到了解決,c++11從語言層面提供了編寫跨平台多線程程序所需的基礎組件,例如多線程內存模型、原子變量、thread等,本文主要討論原子變量(atomic)以及memory order相關的內容,在討論過程中相關的地方會分析這個singleton實現的問題以及正確的做法。  

(1) atomic類型和std::memory_order

  c++11標准在頭文件<atomic>里定義了 模板類型atomic<T> ,它封裝了原子操作以及memory order相關的特性,並對各種整型(char、short、int、long等)、指針等類型提供了特化版本。需要注意的是,atomic<T>類型既不可copy也不可move,特化版本在atomic<T>的成員函數之外,一般會提供額外的成員函數,例如atomic<int>有額外的成員函數fetch_add、fetch_sub、fetch_and、fetch_or 等。

// 頭文件<atomic>
template<class T> struct atomic;

// 頭文件<memory>
template<class T>
struct atomic<T*>;

  多線程同時訪問(修改或讀取)同一個原子變量,其行為是well-defined(相應的,多線程同時讀寫非原子變量,其行為是undefinde behavior)。除此之外,對原子變量的訪問,還能建立線程間的同步關系,並對非原子變量類型的內存訪問提供一定的順序保證。

       原子變量有兩個最基本的成員函數,load 讀取原子變量的值,store 寫入某個值到原子變量,讀取和寫入都是原子的:

 

   這些操作都有一個std::memory_order類型的參數,它是一個enum類型,定義了c++11標准里不同的memory order:

typedef enum memory_order {
    memory_order_relaxed,
    memory_order_consume,
    memory_order_acquire,
    memory_order_release,
    memory_order_acq_rel,
    memory_order_seq_cst
} memory_order;

  這些枚舉值在多線程的內存訪問順序上提供了不同程度的約束,其中memory_order_relaxed是最弱的,最強的是memory_order_seq_cst(它也是所有原子操作的默認參數)。關於這些memory_order值的含義以及它們對內存訪問順序的約束,后面會逐步來討論。

c++11對原子變量的訪問可以分為如下幾類:

relaxed operation:

       以memory_order_relaxed為參數的load、store、read-modify-write(例如fetch_add)操作稱為relaxed operation,它只保證操作的原子性,不帶來任何memory order的約束。

release operation:

       以memory_order_release(或更強的memory order,例如memory_order_seq_cst)為參數的store操作稱為release operation(Mutex類型對象的lock()操作也是release operation,這里 Mutex 泛指符合互斥變量要求的類型,例如std::mutex,std::timed_mutex都符合)

acquire operation:

       以memory_order_acquire(或更強的memory_order,例如memory_order_seq_cst)為參數的load操作稱為acquire operation(Mutex類型對象的unlock()操作也是release operation,這里 Mutex 泛指符合互斥變量要求的類型,例如std::mutex,std::timed_mutex都符合)

consume operation:

      以memory_order_consume(或更強的memory_order,例如memory_order_seq_cst)為參數的load操作稱為consume operation。

(2) memory location

  對c++程序來說,內存就是一串連續的字節序列。我們說內存訪問,到底是訪問什么,是某個字,某個字節,某個bit,還是某塊buffer ?

       對於這個問題,c++11定義了memory location的概念,一個memory location指的是某個scalar type的對象(包括算術類型、指針類型、枚舉類型和std::nullptr_t類型),或者最長的 相鄰 非0長度bit域序列,下面這個例子來自cppreference,很好的解釋了什么是memory location,注意變量 b 和 c 是同一個memory location, 變量 c 和 d 之間有一個長度為0的bit域隔開,因此變量 d 構成一個單獨的memory location。

struct S {
    char a;     // memory location #1
    int b : 5;  // memory location #2
    int c : 11, // memory location #2 (continued)
          : 0,
        d : 8;  // memory location #3
    struct {
        int ee : 8; // memory location #4
    } e;
} obj; // The object 'obj' consists of 4 separate memory locations

(3) data race

  在多線程環境下,同一個 memory location 完全有可能同時被不同的線程訪問到(c++ 允許不同的線程在沒有任何同步的情況下同時訪問同一個memory location),如果不加任何限制,很可能陷入可怕的data race。

  所謂data race,容易理解的說法(不那么嚴密),是指兩個線程同時訪問同一個memory location,且至少有一個線程是修改操作。例如一個線程在寫一個整型變量,另一個線程同時在讀取這個整型變量,就會引起data race。需要注意,如果訪問的是原子變量,或者兩個線程對同一個memory location的訪問遵循一定的先后順序,則不認為發生了data race。

        一旦程序發生了data race,便會產生未定義行為(undefined behavior)。啥叫"未定義行為", 在 C++ Concurrency IN ACTION 這本書里作者舉了個例子(5.1.2節,p106), 那就是含有未定義行為的程序導致顯示器着火了。

       現在回頭看小猿默寫的那段代碼,潛在的第一個問題就是可能存在如下圖所示的data race,_pInstance 是一個普通的指針類型變量,因而是一個memory location,線程2是修改操作,如果線程1 和 線程2 同時訪問該memory location,則完全符合data race的定義, 程序可能會產生未定義行為,幸好服務器不帶顯示器啊!

 

 

   針對該單例實現代碼的data race, 可以利用原子變量來修復(改動用黃色高亮顯示),因為多線程同時訪問同一個原子變量不構成data race:

template<typename T>
class XX_Singleton {
public:
    static T* getInstance() {
        // 加鎖, 雙check機制, 保證正確和效率
        T* tmp = _pInstance.load(memory_order_relaxed);
        if(tmp == nullptr) {
            std::lock_guard<mutex> lock(_tl);
            tmp = _pInstance.load(memory_order_relaxed);
            if(tmp == nullptr) {
                tmp = new T;
                _pInstance.store(tmp, memory_order_relaxed);
            }
        }
        return tmp;
    }

protected:
    static atomic<T*> _pInstance; // 用一個原子變量來存儲單例對象的指針
};

// 靜態成員變量的定義
template <typename T>
volatile T* XX_Singleton<T>::_pInstance = {nullptr}; 

(4) memory order

  什么是memory order? 我們先通過一個簡單的代碼片段來了解:

// 全局變量
atomic<int> x; // 整型原子變量x, 初值為0
atomic<int> y; // 整型原子變量y, 初值為0

// 線程A
y.store(2, memory_order_relaxed); // 給y賦值2
x.store(1, memory_order_relaxed); // 給x賦值1
// 線程B
if (x.load(memory_order_relaxed) == 1) { // 如果讀到x的值為1
  assert(y.load(memory_order_relaxed) == 2)); // assert會失敗嗎?
}

  

這里對原子變量 x 和 y 的訪問都使用 memory_order_relaxed,前面說過 memory_order_relaxed 可以確保操作的原子性,且多個線程同時訪問同一個原子變量不會引起data race。現在的問題是, 線程2的assert有可能失敗嗎?

       答案是會! 也就是說,線程B在讀到 x 的值為1的前提下,仍然可能讀到 y 的值為0(初值)。可能你感到有點困惑,線程A所執行的代碼,是先給 y 賦值2,然后給 x 賦值1, 而線程B已經讀到 x 的值為1了, 怎么可能讀到的 y 值不是2呢?

       原因可能有多種,其一,就是代碼的書寫順序不一定和代碼的執行順序相同,這個順序的不同,可能是因為編譯器在生成指令的時候做了重排【Memory Ordering at Compile Time】,也可能是cpu在運行時進行了指令重排(instruction reorder),而編譯器和cpu之所以這么做, 都是為了最大程度優化程序性能。其二,cpu一般是有多級緩存的,一個寫入操作不一定立刻把新的值更新到內存,而一個讀取操作也可能讀取的是緩存值。

       因此,雖然線程A所執行代碼的書寫順序是先給 y 賦值后給 x 賦值, 但編譯器生成的指令順序可能是相反的(先給 x 賦值后給 y 賦值),也可能是線程B讀到了 x 的最新值,但是讀到的 y 值是運行B線程的cpu緩存的 y 值,結果就是線程B里面的assert判斷有可能失敗。

       前面說到過,Scotter Meyes在論文 C++ and the Perils of Double-Checked Locking 中指出了小猿默寫的單例實現代碼里存在 memory order 的錯誤,現在我們可以來看看問題是什么了。

       創建T類型的實例並保存其指針到_pInstance,實際上包括下面三個步驟:

(Ⅰ)分配內存                            : tmp = operator new(sizeof(T));

(Ⅱ)調用T的構造函數               : new (tmp) T;

(Ⅲ)設置_pInstance的值          : _pInstance.store(tmp, memory_order_relaxed);

我們從形式上把代碼寫成上面這樣的三步,然后分析如下兩個線程同時調用getInstance()的情況:

 

 

 

  紅色箭頭表示兩個線程正在執行的位置,線程A 完成實例的創建,並將指針存入原子變量_pInstance(memory_order參數為memory_order_relaxed,只保證操作的原子性),線程B讀到_pInstance為非空指針,於是返回實例地址。問題來了,線程A和線程B各自返回的實例指針,有沒有問題?(請先思考一下再往下看)

       答案是,線程A返回的實例指針沒有問題,它指向一個完整構造的T類型的對象,因為在單個線程內(線程A),前面的語句同后面的語句有 sequenced-before 關系,也就是說,線程A里最后的return語句執行的時候,它前面的語句一定執行完成了,因此T的實例一定是構造好的。

       而線程B返回的實例指針,可能指向的是一個尚未完成構造的T類型對象!

       首先,線程A內的代碼書寫順序(Ⅰ) =》(Ⅱ)=》(Ⅲ)不一定就是其執行順序。根據Scotter Meyes 的論文C++ and the Perils of Double-Checked Locking 所述,編譯器傾向於產生(I)=》(Ⅲ)=》(Ⅱ)這樣的執行代碼順序,也就是構造函數最后才執行:

   (Ⅰ)分配內存                            : tmp = operator new(sizeof(T));

  (Ⅲ)設置_pInstance的值          : _pInstance.store(tmp, memory_order_relaxed);

  (Ⅱ)調用T的構造函數               : new (tmp) T;

       其次,即使線程A內代碼就是按照(Ⅰ) =》(Ⅱ)=》(Ⅲ)的順序執行,T的構造函數對成員變量的修改,也不一定對線程B全部可見。總之就是,memory_order_relaxed除了確保操作的原子性,並沒有在兩個線程之間提供任何同步。因此對於線程B來說,有可能返回的是一個指向未完成構造的實例。試想一下,如果T代表儲戶信息,你的的賬戶本來有1億元,   結果線程B返回的實例,告訴你余額為996元,那是不是很夢幻。【注:即使編譯器生成的執行代碼順序是(I)=》(Ⅲ)=》(Ⅱ),對線程A來說,最后的return語句返回的指針所指的實例依然是完成了構造的。】

       你說,這不是我想要的結果啊。對於本節開始的那個簡單例子,我就是希望線程B在讀到 x 值為1的情況下,讀到的 y 值一定是2,換句話說,希望對線程B來說的確就是 y.store 發生在 x.store 之前。這實際上是在要求線程A所產生的某些內存改變,先於線程B的某些操作發生,從而使得線程A的內存改變對線程B的某些操作具有可見性。在c++11里,通過給原子變量的讀/寫操作指定適當的memory_order參數是可以獲得預期的順序的,下面開始逐一介紹memory order相關的內容,先了解單線程情況下的操作間的順序:

(4.1) sequenced-before

  關於sequenced-before有幾十條規則來描述什么情況下兩個evaluation具有sequenced-before關系,這里不深究,詳見 evaluation order 。

       簡單來說,sequenced-before 描述的是同一個線程里不同表達式求值的先后順序【evaluation:姑且翻譯為 "求值" 吧,一個evaluation可能是一個完整的語句,也可能是某個操作數或者參數的求值,具體定義見 Evaluation of Expressions】,如果evaluation A sequenced-before evaluation B, 那么 A 一定在 B 開始之前完成。簡單的理解就是在同一個線程里,代碼的書寫順序就是其執行順序,寫在前面的語句執行完了才會執行后續語句,這個很符合我們對代碼執行順序的預期,即我怎么寫程序就怎么執行(按照書寫的順序執行)。【注:嚴格說 A 不一定發生在 B 之前,如果 A 和 B 訪問完全獨立的兩個變量,仍然有可能對 A 和 B 做指令重排,但是對於單線程來說,在內存效果上,A 和 B 重排與否並無影響,參見 Happens-Before Does Not Imply Happening Before

       需要注意的是調用一個多參數的函數時,其各個參數的計算,是沒有sequenced-before關系的,標准沒有規定函數參數的求值是從左到右或者從右到左的順序。在 Scott Meyers 的《Effective Modern C++》一書的 Item 21 里面有一個和 sequeced-before 相關的例子,下面這段代碼里對函數processWidget()的調用可能會引起Widget對象的泄漏:

/*
說明:
  因為函數參數的計算沒有sequeced-before關系,也就是computePriority()
  和shared_ptr<Widget>構造函數 的調用順序不確定,所以可能有如下的調用順序:
  (I)  new Widget
  (II) computePriority()
  (III) shared_ptr<Widget>的構造函數
  如果computePriority()函數調用過程中拋出異常,那么就會泄漏Widget對象
*/
processWidget(shared_ptr<Widget>(new Widget), computePriority());

  根據c++的規定,processWidget()函數的兩個參數計算,並沒有sequenced-before關系, computePriority()函數調用可能發生在new Widget和shared_ptr<Widget>構造函數調用之間,如果computePriority()發生異常,那就會泄漏Widget對象【注意,根據sequenced-before的規則,new Widget是 sequenced-before shared_ptr<Widget>構造函數的】。如果改成下面這樣就不會有問題,原因見注釋。

/*
說明:
  語句(1) sequenced-before 語句(2), 因此pw一定在computePriority()調用之前就完成了構造,
  即使computePriority()拋出異常,pw也能正確析構,不會有泄漏
*/
shared_ptr<Widget> pw(new Widget);    // (1)
processWidget(pw, computePriority()); // (2)

(4.2) carries dependency

  carries dependency 描述的是同一個線程里不同的表達式求值(evaluation)之間的依賴關系。在同一個線程里,如果evaluation A sequenced-before  evaluation B, 並且 A 是 B 的操作數,或者 A 寫入一個scaler類型的對象 M 而 B 讀取 M 【scalar類型包括算術類型、指針類型、枚舉類型和std::nullptr_t類型】,那么就說 A carries dependency into B。carries dependency 關系具有傳遞性,也就是說如果 A carries dependency into X,而 X carries dependency into B, 那么就有 A carries dependency into B。

/* *** 某個線程 *** */
// (1) carries dependency into (2)
x = 1;     // (1)
y = 3 + x; // (2)

// (3) carries dependency into (4)
m = 3;  // (3)
n = m;  // (4)

// (5) carries dependency into (6), 
// (6) carries dependency into (7),
// 所以 (5) carries dependency into (7)
a = 1;      // (5)
b = a + 1;  // (6)
c = b - 2;  // (7)

  sequenced-before 和 carries dependency描述了單線程情形下求值(evaluation)的先后順序,接下來介紹多線程情形下的操作順序,首先是多線程對單個原子變量修改順序的約束:

(4.3) modification order

  modification order描述的是多線程程序里,對單個原子變量修改順序的約束。c++11規定,在程序的一次運行期間,對同一個原子變量的修改操作具有一個全局順序(total order),這個順序對於所有線程來說是一樣的。注意,只有原子變量符合這個規則,非原子變量沒有這個約束。

  下面通過一個簡單例子來理解一下,線程 A 依次向原子變量 n 寫入0,1兩個值,線程 B 讀取原子變量 n 的值5次。這里只有一個線程修改原子變量 n, 因此原子變量 n 的 modification order 就由線程A決定,即依次寫入0, 1,根據 modification order 的規定,同一個原子變量的修改順序對所有線程來說有一個total order, 因此線程B看到的也是 "n 被依次寫入0, 1" 這個修改順序(好像是廢話啊,我同意你,但是確實得有這么個約束)。什么意思呢, 就是說對線程B來說,不可能先讀到1, 然后再讀到0,否則的話,對於B來說那 n 的修改順序就成了'先寫入1, 后寫入0,這不符合modification order的約束。需要注意的是,線程B是可能多次讀到0的,也就是可能讀到序列 [0,0,0,1,1],但是不可能讀到 [0,0,1,1,0] 這樣的序列。

atomic<int> n; // 初值為0

// 線程 A
for (int i = 0; i < 2; ++i) {
  n.store(i, memory_order_relaxed);
}

// 線程 B
int readed_values_B[5] = { 0 };
for (int i = 0; i < 5; ++i) {
  readed_values_B[i] = n.load(memory_order_relaxed);
}

  前面這些概念都比較容易理解,接下來介紹的內容相對來說要晦澀一些,請耐心閱讀,如果有什么疑問歡迎一起討論。

(4.4) synchronize-with

  如果你是第一次接觸這個概念,很可能會覺得每個單詞都認識,放一起就一臉懵逼。(一看就懂的大神,請收下我膝蓋Orz)

  我們先看比較簡單的2個線程的情況( C++ Concurrency IN ACTION ,5.3.3節,p132):

  Synchronization is pairwise, between the thread that does the release and the thread that does the acquire. A release operation synchronizes-with an acquire operation that reads the value written.

  理解一下就是,如果線程A以release operation(memory_order_release或更強的memory_order)修改了一個原子變量,而另一個線程B以acquire operation(memory_order_acquire或更強的memory_order)讀到線程A修改的值,那就說線程A 的修改操作 synchonize-with 線程B 的讀取操作,舉個例子:

struct Point {
  int x_;
  int y_;
};

Point g_point;
std::atomic<int> g_guard(0);

// 線程A
void writePoint() {
    g_point.x_ = 1;
    g_point.y_ = 2;
    
    // 以memory_order_release寫入1到原子變量
    g_guard.store(1, memory_order_release);
}

// 線程B
void readPoint() {
    // 以memory_order_acquire讀取原子變量,直到讀到1 (線程A所寫入的值)
    while (g_guard.load(memory_order_acquire) != 1) {
      this_thread::yield();
    }
// while 循環結束時,一定是以memory_order_acquire讀到線程A寫入的值1

    assert(g_point.x_ == 1 && g_point.y_ == 2); // 不會失敗
}

  線程A 的 g_guard.store(1, memory_order_release)同線程B里while循環的最后一次g_guard.load(memory_order_acquire)具有 synchronize-with 關系【注意是"while循環最后一次g_guard.load(memory_order_acquire)",因為只有這一次g_guard.load(memory_order_acquire)才讀到線程A寫入的值1】

  這里synchronize-with 描述的是兩個線程對同一個原子變量的修改和讀取之間的關系,它在兩個相關線程建提供了一個比較強的memory order約束: 線程A的store操作之前的所有內存修改(memory effects),對線程B的load之后的操作都可見,並且線程A的store操作之前的指令不允許reorder到store操作之后,線程B的load操作之后的指令不允許reorder到load之前。synchronize-with 關系像是在兩個線程之間建立了一個內存屏障,這個屏障引入了一種較強的先后順序,屏障前的內存修改對屏障后的所有操作都可見。

  具體到上面這個例子,就是線程A對結構體g_point的修改,在線程B的“while循環最后一次g_guard.load(memory_order_acquire)”之后都是可見的,因此線程B的assert不會失敗:

 

   除了上面這種兩個線程之間的synchronize-with關系, 還有一種情況涉及到多個線程對同一個原子變量的讀寫操作,理解這種情況需要引入 release sequence 的概念。

(4.5) release sequence

   release sequence 在 cppreference 上的定義

  翻譯一下就是,對原子變量 M 的 release operation A ,以及同一個線程對該原子變量的修改,或者別的線程對該原子變量的read-modify-write操作(例如fetch_add、fetch_sub等成員函數),所構成的最長連續序列,就稱為以A為首的 release sequence

        除了為首的操作A要求是 release operation(memory order參數為memory_order_release或更強的約束),該原子變量的release sequence里其余的操作的memory order參數可以隨意(例如可以是memory_order_relaxed)。

 A synchronize-with B 的另外一種情況,就是最后的 acquire operation B(以memory_order_acquire或更強的memory_order讀)所讀到的值,是以 release operation A為首的release sequence所寫入的某個值。

下面是c++語言標准(29.3節 "order and consistency" 第2條)里對 synchronize-with 的定義:

  An atomic operation A that performs a release operation on an atomic object M synchronizes with an atomic operation B that performs an acquire operation on M and takes its value from any side effect in the release sequence headed by A。

  前面(4.4)節介紹的兩個線程的synchronize-with關系,是其中一種簡單的特殊情形。

  現在我們知道,synchronize-with在兩個線程之間提供了一種比較強的同步,不過有時候這種同步可能太強了,下面再看一個例子:

struct Payload {
  int x_;
  int y_;
};

atomic<Payload*> g_payload;
atomic<int> g_nothing;

// 線程A,創建Payload實例
void produce_data() {

    g_nothing.store(996, memory_order_relaxed);

    // 創建並初始化對象
    Payload* p = new Payload;
    p.x_ = 1;
    p.y_ = 2;

    // 以memory_order_release寫入原子變量
    g_payload.store(p, memory_order_release);

}

// 線程B,讀取線程A創建的Payload數據
void consume_data() {
    Payload* payload = nullptr;

    // 以memory_order_acquire讀取原子變量,直到讀到非空 (線程A所寫入的值)
    while ((payload = g_payload.load(memory_order_acquire)) != nullptr) {
      this_thread::yield();
    }

    assert(payload.x_ == 1 && payload.y_ == 2); // 不會失敗
    // 使用payload干一些事情。。。
    assert(g_nothing.load(memory_order_relaxed) == 996); // 不會失敗,但是線程B並不關心g_nothing的值
}

  由於兩個線程之間的synchronize-with關系,線程B(consume_data()函數)的兩個assert語句都不會失敗。但是線程B可能只關心 g_payload 的數據,對 g_nothing 不關心,可是synchronize-with順帶把線程A對 g_nothing 的修改也同步到線程B了,這自然會帶來額外的開銷

  這個例子舉得不咋的,不過它代表了一類使用場景,即通過一個原子類型的指針在兩個線程間傳遞某個結構體數據。消費數據的線程一般只關心結構體的數據,其余的它不關心,所以只需要確保生產數據的線程對結構體所做的內存修改,對消費線程可見就行了,其余的數據不需要同步。c++11提供了一種比synchronize-with更弱的內存順序比較適合這種場景,那就是 depency-ordered before 關系。

(4.6) dependency-ordered before

  dependency-ordered before有兩種情況:

  (Ⅰ)一個線程以 release operation A(memory order為memory_order_release或者更強)修改原子變量,另一個線程以 consume operation(memory order為memory_order_consume或者更強)讀該原子變量,讀到的值是以A為首的release sequence中任意一個修改值;【同synchronize-with的定義非常類似,只是讀取操作從acquire operation變成consume operation】

  (Ⅱ)A dependency-ordered before X(跨線程),而 X carries dependency into B(同一個線程內)。

注意一下情況(Ⅱ),它結合了跨線程的dependency-ordered before和同一個線程內的carries dependency into關系,說明在讀線程里,只有依賴於原子變量的操作才會和寫線程產生同步關系。

       現在再來看一下通過一個原子類型的指針在兩個線程間傳遞數據結構的例子,我們把讀取操作從memory_order_acquire改成memory_order_consume(黃色高亮代碼):

struct Payload {
  int x_;
  int y_;
};

atomic<Payload*> g_payload;
atomic<int> g_nothing;

// 線程A,創建Payload實例
void produce_data() {

    g_nothing.store(996, memory_order_relaxed);

    // 創建並初始化對象
    Payload* p = new Payload;
    p.x_ = 1;
    p.y_ = 2;

    // 以memory_order_release寫入原子變量
    g_payload.store(p, memory_order_release);

}

// 線程B,讀取線程A創建的Payload數據
void consume_data() {
    Payload* payload = nullptr;

    // 以memory_order_consume讀取原子變量,直到讀到非空 (線程A所寫入的值)
    while ((payload = g_payload.load(memory_order_consume)) != nullptr) {
      this_thread::yield();
    }

    assert(payload.x_ == 1 && payload.y_ == 2); // 不會失敗
    // 使用payload干一些事情。。。

    assert(g_nothing.load(memory_order_relaxed) == 996); // 這里可能失敗
}

  線程A的g_payload.store(p, memory_order_release)同線程B里while循環的最后一個payload = g_payload.load(memory_order_consume)具有dependency-ordered before關系,后者又carries dependency into線程B里的assert(payload.x_ == 1 && payload.y_ == 2)語句,因此符合(Ⅱ)所描述的denpendency-ordered before關系,也就是說線程A對payload數據的修改,對線程B的assert(payload.x_ == 1 && payload.y_ == 2)可見,因此這個assert語句不會失敗。但是線程B的assert(g_nothing.load(memory_order_relaxed) == 996)語句,並不依賴於payload, 因此無法跟線程A的g_payload.store(p, memory_order_release)建立起denpendency-ordered before關系,沒有機制保證線程A的g_nothing.store(996, memory_order_relaxed)操作對線程B可見,因此線程B的assert(g_nothing.load(memory_order_relaxed) == 996)語句可能會失敗。

       雖然dependency-ordered before比synchronize-with更高效,因為它只需要在有依賴關系的數據間建立同步,但實際上,大多數編譯器並沒有真正實現release-consume操作,而是直接用memory_order_acquire代替memory_order_consume(參見The Purpose of memory_order_consume in C++11這篇文章里Today's Compiler Support is Lacking小節)

(4.7) inter-thread happens before

  Between threads, evaluation A inter-thread happens before evaluation B if any of the following is true

  1) A  synchronizes-with B
  2) A is  dependency-ordered before B
  3) A  synchronizes-with some evaluation X, and X is  sequenced-before B
  4) A is  sequenced-before some evaluation X, and X  inter-thread happens-before B
  5) A  inter-thread happens-before some evaluation X, and X  inter-thread happens-before B

  顧名思義,inter-thread happens before描述的是不同線程間操作的先后順序,只有符合這5條規則之一的情況,才具備inter-thread happens before關系。這里需要注意下第3條,這里一定是 A synchronize-with X,如果是 A dependency-ordered before X,是不能得出 A inter-thread happens before B 的。

   基於上面的各種概念,c++11還定義了更一般的happens-before關系,它既包括同一個線程里的操作順序,也包括跨線程的操作間的順序:

(4.8) happens-before

  Regardless of threads, evaluation A happens-before evaluation B if any of the following is true:

  1) A is  sequenced-before B
  2) A  inter-thread happens before B
 

(4.9) 常見和默認的memory ordering

    c++11為memory_order定義了6個枚舉值(參見本文第(1)節),在操作原子變量的時候,讀和寫所用的memory_order參數通常需要配對使用,不能隨意組合,例如memory_order_acquire和memory_order_release可以配對使用,產生前面所介紹的synchronize-with關系。本節簡單介紹一下常見的memory ordering:

(4.9.1) relaxed ordering

  以memory_order_relaxed作為參數的原子操作,這個對於操作順序沒有任何約束,只保證操作的原子性。例如:

atomic<int> x = {0};
atomic<int> y = {0};

// Thread 1:
r1 = y.load(std::memory_order_relaxed); // A
x.store(r1, std::memory_order_relaxed); // B

// Thread 2:
r2 = x.load(std::memory_order_relaxed); // C 
y.store(42, std::memory_order_relaxed); // D

  可能產生r1 == r2 == 42 的結果,盡管在線程1里 A sequenced-before B, 在線程2里 C sequenced-before D,但是因為memory_order_relaxed不提供任何順序約束,對於線程1來說,可能是D發生在C的前面,整個執行順序可能是D ==> A ==> B ==> C,從而導致r1 == r2 == 42

(4.9.2) release-acquire ordering

  一個線程以memory_order_release對原子變量進行store操作,另一個線程以memory_order_acquire對同一個原子變量進行load操作,這個是(4.4)節synchronize-with關系的一種具體表現形式,這里就不再舉例了。

(4.9.3) release-consume ordering

  一個線程以memory_order_release對原子變量進行store操作,另一個線程以memory_order_consume對同一個原子變量進行load操作,這個是(4.6)節dependency-ordered before關系的一種具體表現形式,也不再舉例。

(4.9.4) sequentially-consistent ordering(默認參數)

  以 memory_order_seq_cst 為參數對原子變量進行load、store或者read-modify-write操作。c++11所有的原子操作都是采用memory_order_seq_cst作為默認參數,中文翻譯為“順序一致性”,這是所有memory_order類型里最強的一種【注:java只有這種memory order,通過java的關鍵字volatile來提供】。sequentially-consistent(順序一致性)在原子變量操作順序上有一個很強的約束:所有以memory_order_seq_cst為參數的原子操作(不限於同一個原子變量),對所有線程來說有一個全局順序(total order),並且兩個相鄰memory_order_seq_cst原子操作之間的其它操作(包括非原子變量操作),不能reorder到這兩個相鄰操作之外。【注:同一個程序的不同運行,這個全局順序是可以不一樣的】。

我們通過下面的例子來理解一下順序一致性:

std::atomic<bool> x = {false};
std::atomic<bool> y = {false};
std::atomic<int> z = {0};

// 線程A
void write_x() {
    x.store(true, memory_order_seq_cst);
}

// 線程B
void write_y() {
    y.store(true, memory_order_seq_cst);
}

// 線程C
void read_x_then_y() {
    while (!x.load(memory_order_seq_cst))
        ;
    if (y.load(memory_order_seq_cst)) {
        ++z;
    }
}

// 線程D
void read_y_then_x() {
    while (!y.load(memory_order_seq_cst))
        ;
    if (x.load(memory_order_seq_cst)) {
        ++z;
    }
}

int main() {
    std::thread thread_a(write_x);
    std::thread thread_b(write_y);
    std::thread thread_c(read_x_then_y);
    std::thread thread_d(read_y_then_x);

    thread_a.join(); thread_b.join(); thread_c.join(); thread_d.join();
    assert(z.load() != 0);  // 一定不會失敗
} 

  4個線程對兩個原子變量x和y的操作都是memory_order_seq_cst,構成了順序一致性(sequentially-consistent),因此對4個線程來說,對原子變量x和y的操作順序是一致的,在這個全局一致的操作順序里:

  (Ⅰ)要么x.store(true, memory_order_seq_cst)發生在y.store(true, memory_order_seq_cst)之前,這時候線程D的x.load(memory_order_seq_cst)一定讀到true,從而執行z++ ;

  (Ⅱ)要么y.store(true, memory_order_seq_cst)發生在x.store(true, memory_order_seq_cst)之前,這時候線程C的y.load(memory_order_seq_cst)一定讀到true,從而執行z++ ;

  不論哪種情況,z值都會被修改,因此main()函數最后的assert語句一定不會失敗。

         在本文第(4)節"memory order"部分,我們分析了小猿所寫單例代碼所存在的memory order問題(線程B可能返回一個沒有完成構造的實例)。現在有了c++11定義的memory order相關的概念,這個問題就可以很容易得到修復了。

(4.10) 利用c++11的memory order正確實現單例模式

  首先利用 synchronize-with關系 可以修正這個錯誤.

template<typename T>
class XX_Singleton {
public:
    static T* getInstance() {
        T* tmp = _pInstance.load(memory_order_acquire);
        if(tmp == nullptr) {
            std::lock_guard<mutex> lock(_tl);
            tmp = _pInstance.load(memory_order_relaxed);
            if(tmp == nullptr) {
                tmp = new T;
                _pInstance.store(tmp, memory_order_release);
            }
        }
        return tmp;
    }
};

  只有黃色高亮的兩個地方做了修改,把load的memory_order參數從memory_order_relaxed 改成 memory_order_acquire,把store的memory_order參數從memory_order_relaxed 改成 memory_order_release。

 

   本文只關注線程B第一行讀到tmp不為空直接返回的情況(其余的情況不涉及這里的memory_order,可以自己思考),如果讀到的恰好是線程A的(Ⅲ)寫入的值,那么會形成如上圖所示的synchronize-with關系,它可以確保線程A的(Ⅰ)(Ⅱ)兩步產生的內存變化,對線程B的 _pInstance.load(memory_order_acquire) 語句之后都可見,從而線程B返回的實例一定是完成了構造的。

  因為順序一致性(sequentially-consistent)在多線程的原子變量操作順序上提供了比 synchronize-with 更強的約束,因此用默認的原子操作,可以達到一樣的目的,但是性能比synchronize-with略低:

template<typename T>
class XX_Singleton {
public:
    static T* getInstance() {
        T* tmp = _pInstance.load(); // 等價於_pInstance.load(memory_order_seq_cst)
        if(tmp == nullptr) {
            std::lock_guard<mutex> lock(_tl);
            tmp = _pInstance.load(memory_order_relaxed);
            if(tmp == nullptr) {
                tmp = new T;
                _pInstance.store(tmp); // 等價於_pInstance.store(tmp, memory_order_seq_cst)
            }
        }
        return tmp;
    }
};

  最后,利用dependency-ordered before關系也可以達成正確實現單例模式所需的memory ordering(正確性分析跟本節開頭利用synchronize-with所做的實現類似,讀者可以自行思考一下):

template<typename T>
class XX_Singleton {
public:
    static T* getInstance() {
        T* tmp = _pInstance.load(memory_order_consume);
        if(tmp == nullptr) {
            std::lock_guard<mutex> lock(_tl);
            tmp = _pInstance.load(memory_order_relaxed);
            if(tmp == nullptr) {
                tmp = new T;
                _pInstance.store(tmp, memory_order_release);
            }
        }
        return tmp;
    }
};  

  說到單例,不得不說c++11另一個重要的細節改動,那就是多線程情況下,局部static變量的初始化是線程安全的,語言確保局部靜態變量只會初始化一次(參見stackoverflow回答,回答里有給出相關的語言標准)。標准 6.7節第4段有如下說明:

  If control enters the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for
  completion of the initialization.

基於此,從c++11開始,簡單可靠的單例實現可以直接通過static局部變量實現:

template<typename T>
class XX_Singleton {
public:
    static T* getInstance() {
        static T _instance;
        return &_instance;
};

  

   編譯器會確保這里的多線程安全(通常情況下, 編譯器生成的代碼也是采用DCLP來實現的)。

         另外,c++11還提供了std::call_once函數,也可以用來實現多線程安全的單例對象初始化,這里就不再贅述了。

 

完整的單利模式

template<typename T>
class XX_Singleton {
    public:
    static T* getInstance() {
        // 加鎖, 雙check機制, 保證正確和效率
        T* tmp = _pInstance.load(memory_order_accquire);
        if(tmp == nullptr) {
            std::lock_guard<mutex> lock(_tl);
            tmp = _pInstance.load(memory_order_relaxed);
            if(tmp == nullptr) {
                tmp = new T;
                _pInstance.store(tmp, memory_order_release);
            }
        }
        return tmp;
    }
    virtual ~XX_Singleton() {
    };

    protected:
    XX_Singleton() {
    }
    XX_Singleton (const XX_Singleton &);
    XX_Singleton& operator=(const XX_Singleton &);

    protected:
    static mutex _tl;
    static atomic<T*> _pInstance; // 用一個原子變量來存儲單例對象的指針
};

// 靜態成員變量的定義
template <typename T>
volatile T* XX_Singleton<T>::_pInstance = {nullptr};

  

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM