漫談C++11多線程內存模型

寫在前面

        “C++11 feels like a new language” - Bjarne Stroustrup

        的確,C++11核心已經發生了巨大的變化,它現在支持Lambda表達式、對象類型自動推斷、統一初始化語法、DeletedDefaulted函數、nullptr、委託構造函數、右值引用等等,本文主要討論C++11對於多線程編程的支持。

一些例子

        爲何C++多線程編程需要對標準進行修訂,基於多線程庫如POSIXboost.Thread的大量代碼不是都工作得很好嗎?詳見《Threads Cannot be Implemented as a Library》,簡單概括如下:

        因爲C++03標準是單線程的,所以即便是完全符合標準的編譯器也可能各個腦袋裏面只裝着一個線程,於是在對代碼作優化的時候總是一不小心就可能做出危害多線程正確性的優化來。

        簡單示例:

      x = y = 0;
Thread1		Thread2
x = 1;		y = 1;
r1 = y;		r2 = x;
        理論上來說,r1==r2==0這種輸出是不可能的(此處不展開推理,請各位看官自行分析),但現實往往是殘酷的,編譯器只需把Thread1中的x=1r1=y操作互換即可。

        極端示例:

for (…) {
…
if (mt) pthread_mutex_lock(…);
x = … x …
if (mt) pthread_mutex_unlock(…);
}

        對於x的訪問已經被pthread_mutex_lock/pthread_mutex_unlock包圍了,這下總算安全了吧?在Hans Boehmpaper中提到,編譯器可以運用“Register Promotion”的技術進行優化,對此,POSIX線程庫也無能爲力:

r = x;
for (…) {
…
if (mt) { x = r; pthread_mutex_lock(…); r = x; }
r = … r …
if (mt) { x = r; pthread_mutex_unlock(…); r = x; }
}
x = r;

Memory Model

        那麼,究竟如何才能編寫出正確的多線程代碼呢?最簡單的辦法就是禁止編譯器作任何優化:所有的操作嚴格按照“Program Order”執行,所有的操作都觸發“Cache Coherence”以確保它們的副作用在跨線程間的“Memory Visibility”順序。

        但這樣做顯然是不切實際的,需要付出巨大的效率代價。於是編譯器說:“不如這樣,你來告訴我哪些數據是線程間共享的。這樣,我就可以在必要的時候保守優化,一般情況下全力優化。只要你保證自己的程序是正確同步的,那我保證程序執行時就是你要的那個樣子。”這樣一個在程序員和語言間的約定,就是“Memory Model”。

        PS:上面的例子可能會給大家一個錯覺,這一切都是編譯器的錯,實際上這裏的“罪魁禍首”是一種稱爲“Memory Reordering”的存在,而“Compiler Reordering”僅是其中的一個來源,另一個就是更爲底層的“Processor Reordering”,因此才需要前文提到的“Cache Coherence”。本文不打算對硬件做過多的探討,有興趣的讀者推薦閱讀《Memory Barriers: a Hardware View for Software Hackers》,非常精彩!

Memory Order

        好了,下面讓我們切入正題,看看C++11到底給我們帶來了什麼?

        Thread support library”可以簡單想象成POSIX線程庫的OO版本,對常用的ThreadsMutexCondition VariablesFutures等概念進行了很好的封裝,其實它的前身就是Boost::Thread,本文略過不提。

        Atomic operations library”顧名思義,其實就是原子操作庫。而在以往,我們往往需要藉助彙編語言或者第三方線程庫方能實現。atomic對於多線程編程,尤其是lock-free算法,其重要性不言而喻,有了std::atomic庫,我們終於可以擺脫那些繁瑣的彙編代碼了!

        PS:樂衷於lock-free編程的讀者需要注意的一點,並非所有的atomic內置類型操作均是lock-free的,與具體平臺相關,可以調用is_lock_free接口進行查詢。

void store( T desired, memory_order = std::memory_order_seq_cst );
T load( memory_order = std::memory_order_seq_cst ) const;
T exchange( T desired, memory_order = std::memory_order_seq_cst );
… (略) 

        查看std::atomic接口可以發現,幾乎每個方法都有一個類型爲memory_order的默認參數,默認值是std::memory_order_seq_cst

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的讀者看到這裏估計已經暈了,不幸的是我們還必須引入更多概念才能講清楚。首先,我們必須銘記在心的是,c++11引入這些概念本質上是爲了解決 “visible side-effects”的問題,用通俗的話來講:

        線程1執行寫操作A之後,如何可靠並高效地保證線程2執行讀操作B時,操作A的結果是完整可見的?

        爲了解決上述問題,C++11引入了“happens-before”關係,其比較完整的定義如下:

        Let A and B represent operations performed by a multithreaded process. If A happens-before B, then the memory effects of A effectively become visible to the thread performing B before B is performed.

        OK,現在問題就轉化爲:如何在AB兩個操作之間建立起happens-before關係呢?下面爲大家奉上一張新鮮出爐的關係推導圖譜,此圖信息量巨大,請仔細琢磨回味:


  • sequenced-before(線程內)

        在同一個線程內,操作A先於操作B

  • dependency-ordered before(線程間)

        case 1線程1的操作A對變量M執行“release”寫,線程2的操作B對變量M執行“consume”讀,並且操作B讀取到的值源於操作A之後的“release”寫序列中的任何一個(包括操作A本身)

        case 2線程1的操作與線程2的操作X之間存在dependency-ordered before關係,同時線程2的操作Bdepends on”操作X(所謂Bdepends onA,這裏就不給出精確的定義,舉個直觀的例子:B=M[A]

  • synchronizes-with(線程間)

        線程1的操作A對變量M執行“release”寫,線程2的操作B對變量M執行“acquire”讀,並且操作B讀取到的值源於操作A之後的“release”寫序列中的任何一個(包括操作A本身)

        基礎知識鋪墊到此結束,下面我們總算可以來具體談談不同memory order的使用了!

代碼分析

  • Relaxed ordering
x = y = 0;
// Thread 1:
r1 = y.load(memory_order_relaxed); // A
x.store(r1, memory_order_relaxed); // B
// Thread 2:
r2 = x.load(memory_order_relaxed); // C
y.store(42, memory_order_relaxed); // D

        簡單來說,標記爲memory_order_relaxedatomic操作對於memory order幾乎不作保證,它們唯一的承諾就是“atomicity”,當然,不能破壞“modification order”的一致性要求。

        對於上述代碼片段而言,輸出r1 == r2 == 42是合法的。這裏,我們可以推導出的關係只有A sequenced-before B、C sequenced-before D,僅此而已。

        TIPSRelaxed ordering比較適用於“計數器”一類的原子變量,不在意memory order的場景。

  • Release-Acquire ordering
#include 
#include 
#include 
#include 
 
std::atomic<std::string*> ptr;
int data;
 
void producer()
{
    std::string* p = new std::string("Hello");			// A
    data = 42;							// B
    ptr.store(p, std::memory_order_release);			// C
}
 
void consumer()
{
    std::string* p2;
    while (!(p2 = ptr.load(std::memory_order_acquire)))	        // D
        ;
    assert(*p2 == "Hello");					// E
    assert(data == 42);						// F
}
 
int main()
{
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join(); t2.join();
}

        首先,我們可以直觀地得出如下關係:A sequenced-before B sequenced-before C、C synchronizes-with D、D sequenced-before E sequenced-before F

        利用前述happens-before推導圖,不難得出A happens-before E、B happens-before F,因此,這裏的EF兩處的assert永遠不會fail

        TIPSRelease-Acquire ordering難度係數與性能指數相對均衡,屬於實現lock-free算法的首選。

  • Release-Consume ordering
void producer()
{
    std::string* p = new std::string("Hello");			// A
    data = 42;							// B
    ptr.store(p, std::memory_order_release);			// C
}
 
void consumer()
{
    std::string* p2;
    while (!(p2 = ptr.load(std::memory_order_consume)))		// D
        ;
    assert(*p2 == "Hello");					// E
    assert(data == 42);						// F
}

        這次我們把D處修改爲memory_order_consume,情況又會有何不同呢?首先,基本的關係對毋庸置疑:sequenced-before B sequenced-before C、C dependency-ordered before D、D sequenced-before E sequenced-before F

        那麼我們還能那麼輕易地推導出happens-before E、B happens-before F嗎?答案是:AE關係成立,而BF關係破裂。根據我們之前的定義,E depends-on D,從而可以推導出,接着就是水到渠成了。反觀DF之間並不存在這種依賴關係。因此,這裏的E永遠不會fail,而F有可能fail

        TIPSRelease-Consume ordering難度係數最高,強烈不推薦初學者使用,很多大師級人物都在這上面栽過跟頭,當然,它的系統開銷可能小於Release-Acquire ordering,適用於極致追求性能的場景,前提是你得能夠hold住它。

  • Sequentially-consistent ordering
std::atomic x = ATOMIC_VAR_INIT(false);
std::atomic y = ATOMIC_VAR_INIT(false);
std::atomic z = ATOMIC_VAR_INIT(0);
 
void write_x()
{
    x.store(true, std::memory_order_seq_cst);	// A
}
 
void write_y()
{
    y.store(true, std::memory_order_seq_cst);	// B
}
 
void read_x_then_y()
{
    while (!x.load(std::memory_order_seq_cst))
        ;
    if (y.load(std::memory_order_seq_cst)) {
        ++z;
    }
}
 
void read_y_then_x()
{
    while (!y.load(std::memory_order_seq_cst))
        ;
    if (x.load(std::memory_order_seq_cst)) {
        ++z;
    }
}
 
int main()
{
    std::thread a(write_x);
    std::thread b(write_y);
    std::thread c(read_x_then_y);
    std::thread d(read_y_then_x);
    a.join(); b.join(); c.join(); d.join();
    assert(z.load() != 0);			// C
}

        所謂的Sequentially-consistent ordering,其實就是“順序一致性”,它是最嚴格的memory order,除了滿足前面所說的Release-Acquire/Consume約束之外,所有的線程對於該順序必須達成一致。

        這裏,C處的assert是永遠不會faile的。反證法:在線程c的世界裏,如果++z未執行,需要操作A先於操作B完成;在線程d的世界裏,如果++z未執行,需要操作B先於操作A完成。由於這些操作都是memory_order_seq_cst類型,因此,所有的線程需要達成一致,出現矛盾。

        TIPSSequentially-consistent ordering難度係數最低,潛在開銷可能最大,最符合人類常規思維模型,因此,在多線程編程中最易推理,最不容易出錯,強烈推薦初學者使用,當出現性能瓶頸時再考慮優化。

聊聊volatile

        很多讀者可能比較奇怪,爲啥突然會聊到volatile的話題?其實volatileC/C++陣營裏的爭論從來沒有停止過,而我們公司的代碼庫裏,同樣可以發現volatile大量地用於多線程/多進程編程,這真的可行嗎?

        這裏我不打算展開進行討論,僅列出一些個人理解:

  • 禁止編譯器優化(禁用寄存器優化,直接讀寫內存)
  • 無法保證atomicity機器字長內的變量可以保證?注意內存對齊!)
  • 無法保證memory order(對於Processor Reordering無能爲力)

        爲何我們的代碼仍然work呢?這和具體硬件平臺相關,x86平臺屬於strongly-ordered模型,絕大多數場景下,Release-Acquire ordering是可以自動獲得的。至於atomicity的問題,一定要留意機器字長以及內存對齊。

        推薦使用場景:信號處理函數中使用到的信號標誌變量。關於volatile更多精彩討論推薦閱讀《Nine ways to break your systems code using volatile》。

        PS:這裏的討論僅限於C/C++,不同語言對於volatile賦予的語義並不相同,比如java中的volatile是保證順序一致性的。

寫在最後

        本文主要致力於梳理清楚C++11的多線程Memory Order概念,幫助大家更好地理解多線程lock-free代碼應該如何編寫,如何分析其正確性。其實更高級的同步工具,如mutexspinlock等,同樣可以提供Release-Acquire ordering,只是並非本文關注的重點,故略過不提。而且限於篇幅所限,對於硬件層面的Processor Reordering幾乎未作解釋,後面有機會再寫個硬件篇和大家一起探討吧!

        PS:個人水平有限,理解偏差在所難免,歡迎討論交流!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章