<input id="ohw05"></input>
  • <table id="ohw05"><menu id="ohw05"></menu></table>
  • <var id="ohw05"></var>
  • <code id="ohw05"><cite id="ohw05"></cite></code>
    <label id="ohw05"></label>
    <var id="ohw05"></var>
  • 聊聊 內存模型與內存序

    本文始發于公眾號【高性能架構探索】,本公眾號致力于分享干貨、硬貨以及工作上的bug分析。

    原文鏈接如下:
    https://mp.weixin.qq.com/s/t5_Up2YZEZt1NLbvgYz9FQ

    最近群里聊到了Memory Order相關知識,恰好自己對這塊的理解是模糊的、無序的,所以借助本文,重新整理下相關知識。

    寫在前面

    在真正了解Memory Model的作用之前,曾經簡單地將Memory Order等同于mutex和atomic來進行線程間數據同步,或者用來限制線程間的執行順序,其實這是一個錯誤的理解。直到后來仔細研究了Memory Order之后,才發現無論是功能還是原理,Memory Order與他們都不是同一件事。實際上,Memory Order是用來用來約束同一個線程內的內存訪問排序方式的,雖然同一個線程內的代碼順序重排不會影響本線程的執行結果(如果結果都不一致,那么重排就沒有意義了),但是在多線程環境下,重排造成的數據訪問順序變化會影響其它線程的訪問結果。

    正是基于以上原因,引入了內存模型。C++的內存模型解決的問題是如何合理地限制單一線程中的代碼執行順序,使得在不使用鎖的情況下,既能最大化利用CPU的計算能力,又能保證多線程環境下不會出現邏輯錯誤。

    指令亂序

    現在的CPU都采用的是多核、多線程技術用以提升計算能力;采用亂序執行、流水線、分支預測以及多級緩存等方法來提升程序性能。多核技術在提升程序性能的同時,也帶來了執行序列亂序和內存序列訪問的亂序問題。與此同時,編譯器也會基于自己的規則對代碼進行優化,這些優化動作也會導致一些代碼的順序被重排。

    首先,我們看一段代碼,如下:

    int A = 0;
    int B = 0;
    
    void fun() {
        A = B + 1; // L5
        B = 1; // L6
    }
    
    int main() {
        fun();
        return 0;
    }
    

    如果使用 g++ test.cc,則生成的匯編指令如下:

    movl    B(%rip), %eax
    addl    $1, %eax
    movl    %eax, A(%rip)
    movl    $1, B(%rip)
    

    通過上述指令,可以看到,先把B放到eax,然后eax+1放到A,最后才執行B + 1。

    而如果我們使用g++ -O2 test.cc,則生成的匯編指令如下:

    movl    B(%rip), %eax
    movl    $1, B(%rip)
    addl    $1, %eax
    movl    %eax, A(%rip)
    

    可以看到,先把B放到eax,然后執行B = 1,再執行eax + 1,最后將eax賦值給A。從上述指令可以看出執行B賦值(語句L6)語句先于A賦值語句(語句L5)執行。

    我們將上述這種不按照代碼順序執行的指令方式稱之為指令亂序

    對于指令亂序,這塊需要注意的是:編譯器只需要保證在單線程環境下,執行的結果最終一致就可以了,所以,指令亂序在單線程環境下完全是允許的。對于編譯器來說,它只知道:在當前線程中,數據的讀寫以及數據之間的依賴關系。但是,編譯器并不知道哪些數據是在線程間共享,而且是有可能會被修改的。而這些是需要開發人員去保證的。

    那么,指令亂序是否允許開發人員控制,而不是任由編譯器隨意優化?

    可以使用編譯選項停止此類優化,或者使用預編譯指令將不希望被重排的代碼分隔開,比如在gcc下可用asm volatile,如下:

    void fun() {
        A = B + 1;
        asm volatile("" ::: "memory");
        B = 0;
    }
    

    類似的,處理器也會提供指令給開發人員使用,以避免亂序控制,例如,x86,x86-64上的指令如下:

    lfence (asm), void _mm_lfence(void)
    sfence (asm), void _mm_sfence(void)
    mfence (asm), void _mm_mfence(void)
    

    為什么需要內存模型

    多線程技術是為了最大限度的壓榨cpu,提升計算能力。在單核時代,多線程的概念是在宏觀上并行,微觀上串行,多線程可以訪問相同的CPU緩存和同一組寄存器。但是在多核時代,多個線程可能執行在不同的核上,每個CPU都有自己的緩存和寄存器,在一個CPU上執行的線程無法訪問另一個CPU的緩存和寄存器。CPU會根據一定的規則對機器指令的內存交互進行重新排序,特別是允許每個處理器延遲存儲并且從不同位置裝載數據。與此同時,編譯器也會基于自己的規則對代碼進行優化,這些優化動作也會導致一些代碼的順序被重排。這種指令的重排,雖然不影響單線程的執行結果,但是會加劇多線程訪問共享數據時的數據競爭(Data Race)問題。

    以上節例子中的A、B兩個變量為例,在編譯器將其亂序后,雖然對于當前線程是沒問題的。但是在多線程環境下,如果其它線程依賴了A 和 B,會加劇多線程訪問共享數據的競爭問題,同時可能會得到意想不到的結果。

    正是因為指令亂序以及多線程環境數據競爭的不確定性,我們在開發的時候,經常會使用信號量或者鎖來實現同步需求,進而解決數據競爭導致的不確定性問題。但是,加鎖或者信號量是相對接近操作系統的底層原語,每一次加鎖或者解鎖都有可能導致用戶態和內核態的互相切換,這就導致了數據訪問開銷,如果鎖使用不當,可能會造成嚴重的性能問題,所以就需要一種語言層面的機制,既沒有鎖那樣的大開銷,又可以滿足數據訪問一致性的需求。2004年,Java5.0開始引入適用于多線程環境的內存模型,而C++直到C++11才開始引入。

    Herb Sutter在其文章中這樣來評價C++11引入的內存模型:

    The memory model means that C++ code now has a standardized library to call regardless of who made the compiler and on what platform it's running. There's a standard way to control how different threads talk to the processor's memory.

    "When you are talking about splitting [code] across different cores that's in the standard, we are talking about the memory model. We are going to optimize it without breaking the following assumptions people are going to make in the code," Sutter said

    從內容可以看出,C++11引入Memory model的意義在于有了一個語言層面的、與運行平臺和編譯器無關的標準庫,可以使得開發人員更為便捷高效的控制內存訪問順序。

    一言以蔽之,引入內存模型的原因,有以下幾個原因:

    • 編譯器優化:在某些情況下,即使是簡單的語句,也不能保證是原子操作
    • CPU out-of-order:CPU為了性能,可能會調整指令的執行順序
    • CPU Cache不一致:在CPU Cache的影響下,在某個CPU下執行了指令,不會立即被其它CPU所看到

    關系術語

    為了便于更好的理解后面的內容,我們需要理解幾種關系術語。

    sequenced-before

    sequenced-before是一種單線程上的關系,這是一個非對稱,可傳遞的成對關系。

    在了解sequenced-before之前,我們需要先看一個概念evaluation(求值)

    對一個表達式進行求值(evaluation),包含以下兩部分:

    • value computations: calculation of the value that is returned by the expression. This may involve determination of the identity of the object (glvalue evaluation, e.g. if the expression returns a reference to some object) or reading the value previously assigned to an object (prvalue evaluation, e.g. if the expression returns a number, or some other value)
    • Initiation of side effects: access (read or write) to an object designated by a volatile glvalue, modification (writing) to an object, calling a library I/O function, or calling a function that does any of those operations.

    上述內容簡單理解就是,value computation就是計算表達式的值,side effect就是對對象進行讀寫。

    對于C++來說,語言本身并沒有規定表達式的求值順序,因此像是f1() + f2() + f3()這種表達式,編譯器可以決定先執行哪個函數,之后再按照加法運算的規則從左邊加到右邊,因此編譯器可能會優化成為(f1() + f2()) + f(3),但f1() + f2()和f3()都可以先執行。

    經常可以看到如下這種代碼:

    i = i++ + i;
    

    正是因為語言本身沒有規定表達式的求值順序,所以上述代碼中兩個子表達式(i++和i)無法確定先后順序,因此這個語句的行為是未定義的。

    sequenced-before就是對在同一個線程內,求值順序關系的描述:

    • 如果A sequenced-before B,代表A的求值會先完成,才進行對B的求值
    • 如果A not sequenced-before B,而B sequenced-before A,則代表先對B進行求值,然后對A進行求值
    • 如果A not sequenced-before B,而B not sequenced-before A,則A和B都有可能先執行,甚至可以同時執行

    happens-before

    happens-before是sequenced-before的擴展,因為它還包含了不同線程之間的關系。當A操作happens-before B操作的時候,操作A先于操作B執行,且A操作的結果對B來說可見。

    看下cppreference對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

    從上述定義可以看出,happens-before包含兩種情況,一種是同一線程內的happens-before關系(等同于sequenced-before),另一種是不同線程的happens-before關系。

    對于同一線程內的happens-before,其等同于sequenced-before,所以在此忽略,著重講下線程間的happens-before關系

    假設有一個變量x,其初始化為0,如下:

    int x = 0;
    

    此時有兩個線程同時運行,線程A進行++x操作,線程B打印x的值。因為這兩個線程不具備happens-before關系,也就是說沒有保證++x操作對于打印x的操作是可見的,因此打印的值有可能是0,也有可能是1。

    對于這種場景,語言本身必須提供適當的手段,可以使得開發人員能夠在多線程場景下達到happens-before的關系,進而得到正確的運行結果。這也就是上面說的第二點A inter-thread happens before B

    C++中定義了5種能夠建立跨線程的happens-before的場景,如下:

    • A synchronizes-with B
    • A is dependency-ordered before B
    • A synchronizes-with some evaluation X, and X is sequenced-before B
    • A is sequenced-before some evaluation X, and X inter-thread happens-before B
    • A inter-thread happens-before some evaluation X, and X inter-thread happens-before B

    synchronizes-with

    synchronized-with描述的是不同線程間的同步關系,當線程A synchronized-with線程B的時,代表線程A對某個變量或者內存的操作,對于線程B是可見的。換句話說,synchronized-with就是跨線程版本的happens-before

    假設在多線程環境下,線程A對變量x進行x = 1的寫操作,線程B讀取x的值。在未進行任何同步的條件下,即使線程A先執行,線程B后執行,線程B讀取到的x的值也不一定是最新的值。這是因為為了讓程序執行效率更高編譯器或者CPU做了指令亂序優化,也有可能A線程修改后的值在寄存器內,或者被存儲在CPU cache中,還沒來得及寫入內存 。正是因為種種操作 ,所以在多線程環境下,假如同時存在讀寫操作,就需要對該變量或者內存做同步操作。

    所以,synchronizes-with是這樣一種關系,它可以保證線程A的寫操作結果,在線程B是可見的。

    在2014年C++的官方標準文件(Standard for Programming Language C++)N4296的第12頁,提示了C++提供的同步操作,也就是使用atomic或mutex:

    The library defines a number of atomic operations and operations on mutexes that are specially identified as synchronization operations. These operations play a special role in making assignments in one thread visible to another.

    memory_order

    C++11中引入了六種內存約束符用以解決多線程下的內存一致性問題(在頭文件中),其定義如下:

    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_acquire memory_order_consume)
    • 寫操作(memory_order_release)
    • 讀-修改-寫操作(memory_order_acq_rel memory_order_seq_cst)

    ps: 因為memory_order_relaxed沒有定義同步和排序約束,所以它不適合這個分類。

    舉例來說,因為store是一個寫操作,當調用store時,指定memory_order_relaxed或者memory_order_release或者memory_order_seq_cst是有意義的。而指定memory_order_acquire是沒有意義的。

    從訪問控制的角度可以分為以下三種:

    • Sequential consistency模型(memory_order_seq_cst)
    • Relax模型(memory_order_relaxed)
    • Acquire-Release模型(memory_order_consume memory_order_acquire memory_order_release memory_order_acq_rel)

    從從訪問控制的強弱排序,Sequential consistency模型最強,Acquire-Release模型次之,Relax模型最弱。

    在后面的內容中,將結合這6中約束符來進一步分析內存模型。

    內存模型

    Sequential consistency模型

    Sequential consistency模型又稱為順序一致性模型,是控制粒度最嚴格的內存模型。最早追溯到Leslie Lamport在19799月發表的論文《How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs》,在該文里面首次提出了里提出了Sequential consistency定義:

    the result of any execution is the same as if the operations of all the processors were executed in some sequential order, and the operations of each individual processor appear in this sequence in the order specified by its program

    根據這個定義,在順序一致性模型下,程序的執行順序與代碼順序嚴格一致,也就是說,在順序一致性模型中,不存在指令亂序。

    順序一致性模型對應的約束符號是memory_order_seq_cst,這個模型對于內存訪問順序的一致性控制是最強的,類似于很容易理解的互斥鎖模式,先得到鎖的先訪問。

    假設有兩個線程,分別是線程A和線程B,那么這兩個線程的執行情況有三種:第一種是線程A先執行,然后再執行線程B;第二種情況是線程 B 先執行,然后再執行線程A;第三種情況是線程A和線程B同時并發執行,即線程A的代碼序列和線程B的代碼序列交替執行。盡管可能存在第三種代碼交替執行的情況,但是單純從線程A或線程B的角度來看,每個線程的代碼執行應該是按照代碼順序執行的,這就順序一致性模型。總結起來就是:

    • 每個線程的執行順序與代碼順序嚴格一致
    • 線程的執行順序可能會交替進行,但是從單個線程的角度來看,仍然是順序執行

    為了便于理解上述內容,舉例如下:

    x = y = 0;
    
    thread1:
    x = 1;
    r1 = y;
    
    thread2:
    y = 1;
    r2 = x;
    

    因為多線程執行順序有可能是交錯執行的,所以上述示例執行順序有可能是:

    • x = 1; r1 = y; y = 1; r2 = x
    • y = 1; r2 = x; x = 1; r1 = y
    • x = 1; y = 1; r1 = y; r2 = x
    • x = 1; r2 = x; y = 1; r1 = y
    • y = 1; x = 1; r1 = y; r2 = x
    • y = 1; x = 1; r2 = x; r1 = y

    也就是說,雖然多線程環境下,執行順序是亂的,但是單純從線程1的角度來看,執行順序是x = 1; r1 = y;從線程2角度來看,執行順序是y = 1; r2 = x

    std::atomic的操作都使用memory_order_seq_cst 作為默認值。如果不確定使用何種內存訪問模型,用 memory_order_seq_cst能確保不出錯。

    順序一致性的所有操作都按照代碼指定的順序進行,符合開發人員的思維邏輯,但這種嚴格的排序也限制了現代CPU利用硬件進行并行處理的能力,會嚴重拖累系統的性能。

    Relax模型

    Relax模型對應的是memory_order中的memory_order_relaxed。從其字面意思就能看出,其對于內存序的限制最小,也就是說這種方式只能保證當前的數據訪問是原子操作(不會被其他線程的操作打斷),但是對內存訪問順序沒有任何約束,也就是說對不同的數據的讀寫可能會被重新排序。

    為了便于理解Relax模型,我們舉一個簡單的例子,代碼如下:

    #include <atomic>
    #include <thread>
    #include <iostream>
    
    std::atomic<bool> x{false};
    int a = 0;
    
    void fun1() { // 線程1
      a = 1; // L9
      x.store(true, std::memory_order_relaxed); // L10
    }
    void func2() { // 線程2
      while(!x.load(std::memory_order_relaxed)); // L13
      if(a) { // L14
        std::cout << "a = 1" << std::endl;
      }
    }
    int main() {
      std::thread t1(fun1);
      std::thread t2(fun2);
      t1.join();
      t2.join();
      return 0;
    }
    

    上述代碼中,線程1有兩個代碼語句,語句L9是一個簡單的賦值操作,語句L10是一個帶有memory_order_relaxed標記的原子寫操作,基于reorder原則,這兩句的順序沒有確定下即不能保證哪個在前,哪個在后。而對于線程2,也有兩個代碼句,分別是帶有memory_order_relaxed標記的原子讀操作L13和簡單的判斷輸出語句L14。需要注意的是語句L13和語句L14的順序是確定的,即語句L13 happens-before 語句L14,這是由while循環代碼語義保證的。換句話說,while語句優先于后面的語句執行,這是編譯器或者CPU的重排規則。

    對于上述示例,我們第一印象會輸出a = 1 這句。但實際上,也有可能不會輸出。這是因為在線程1中,因為指令的亂序重排,有可能導致L10先執行,然后再執行語句L9。如果結合了線程2一起來分析,就是這4個代碼句的執行順序有可能是#L10-->L13-->L14-->L9,這樣就不能得到我們想要的結果了。

    那么既然memory_order_relaxed不能保證執行順序,它們的使用場景又是什么呢?這就需要用到其特性即只保證當前的數據訪問是原子操作,通常用于一些統計計數的需求場景,代碼如下:

    #include <cassert>
    #include <vector>
    #include <iostream>
    #include <thread>
    #include <atomic>
    std::atomic<int> cnt = {0};
    void fun1() {
      for (int n = 0; n < 100; ++n) {
        cnt.fetch_add(1, std::memory_order_relaxed);
      }
    }
    
    void fun2() {
      for (int n = 0; n < 900; ++n) {
        cnt.fetch_add(1, std::memory_order_relaxed);
      }
    }
    
    int main() {
      std::thread t1(fun1);
      std::thread t2(fun2);
      t1.join();
      t2.join();
      
      return 0;
    }
    

    在上述代碼執行完成后,cnt == 1000。

    通常,與其它內存序相比,寬松內存序具有最少的同步開銷。但是,正因為同步開銷小,這就導致了不確定性,所以我們在開發過程中,根據自己的使用場景來選擇合適的內存序選項。

    Acquire-Release模型

    Acquire-Release模型的控制力度介于Relax模型和Sequential consistency模型之間。其定義如下:

    • Acquire:如果一個操作X帶有acquire語義,那么在操作X后的所有讀寫指令都不會被重排序到操作X之前
    • Relase:如果一個操作X帶有release語義,那么在操作X前的所有讀寫指令操作都不會被重排序到操作X之后

    結合上面的定義,重新解釋下該模型:假設有一個原子變量A,對A的寫操作(Release)和讀操作(Acquire)之間進行同步,并建立排序約束關系,即對于寫操作(release)X,在寫操作X之前的所有讀寫指令都不能放到寫操作X之后;對于讀操作(acquire)Y,在讀操作Y之后的所有讀寫指令都不能放到讀操作Y之前。

    Acquire-Release模型對應六種約束關系中的memory_order_consume、memory_order_acquire、memory_order_release和memory_order_acq_rel。這些約束關系,有的只能用于讀操作(memory_order_consume、memory_order_acquire),有的適用于寫操作(memory_order_release),有的技能用于讀操作也能用于寫操作(memory_order_acq_rel)。這些約束符互相配合,可以實現相對嚴格一點的內存訪問順序控制。

    memory_order_release

    假設有一個原子變量A,對其進行寫操作X的時候施加了memory_order_release約束符,則在當前線程T1中,該操作X之前的任何讀寫操作指令都不能放在操作X之后。當另外一個線程T2對原子變量A進行讀操作的時候,施加了memory_order_acquire約束符,則當前線程T1中寫操作之前的任何讀寫操作都對線程T2可見;當另外一個線程T2對原子變量A進行讀操作的時候,如果施加了memory_order_consume約束符,則當前線程T1中所有原子變量A所依賴的讀寫操作都對T2線程可見(沒有依賴關系的內存操作就不能保證順序)。

    需要注意的是,對于施加了memory_order_release約束符的寫操作,其寫之前所有讀寫指令操作都不會被重排序寫操作之后的前提是:其他線程對這個原子變量執行了讀操作,且施加了memory_order_acquire或者 memory_order_consume約束符。

    memory_order_acquire

    一個對原子變量的load操作時,使用memory_order_acquire約束符:在當前線程中,該load之后讀和寫操作都不能被重排到當前指令前。如果其他線程使用memory_order_release約束符,則對此原子變量進行store操作,在當前線程中是可見的。

    假設有一個原子變量A,如果A的讀操作X施加了memory_order_acquire標記,則在當前線程T1中,在操作X之后的所有讀寫指令都不能重排到操作X之前;當其它線程如果對A進行施加了memory_order_release約束符的寫操作Y,則這個寫操作Y之前所有的讀寫指令對當前線程T1是可見的(這里的可見請結合 happens-before 原則理解,即那些內存讀寫操作會確保完成,不會被重新排序)。也就是說從線程T2的角度來看,在原子變量A寫操作之前發生的所有內存寫入在線程T1中都會產生作用。也就是說,一旦原子讀取完成,線程T1就可以保證看到線程 A 寫入內存的所有內容。

    為了便于理解,使用cppreference中的例子,如下:

    #include <thread>
    #include <atomic>
    #include <cassert>
    #include <string>
     
    std::atomic<std::string*> ptr;
    int data;
     
    void producer() {
      std::string* p  = new std::string("Hello");  // L10
      data = 42; // L11
      ptr.store(p, std::memory_order_release); // L12
    }
     
    void consumer() {
      std::string* p2;
      while (!(p2 = ptr.load(std::memory_order_acquire))); // L17
      assert(*p2 == "Hello"); // L18
      assert(data == 42); // L19
    }
     
    int main() {
      std::thread t1(producer);
      std::thread t2(consumer);
      t1.join(); 
      t2.join();
      
      return 0;
    }
    

    在上述例子中,原子變量ptr的寫操作(L12)施加了memory_order_release標記,根據前面所講,這意味著在線程producer中,L10和L11不會重排到L12之后;在consumer線程中,對原子變量ptr的讀操作L17施加了memory_order_acquire標記,也就是說L8和L19不會重排到L17之前,這也就意味著當L17讀到的ptr不為null的時候,producer線程中的L10和L11操作對consumer線程是可見的,因此consumer線程中的assert是成立的。

    memory_order_consume

    一個load操作使用了memory_order_consume約束符:在當前線程中,load操作之后的依賴于此原子變量的讀和寫操作都不能被重排到當前指令前。如果有其他線程使用memory_order_release內存模型對此原子變量進行store操作,在當前線程中是可見的。

    在理解memory_order_consume約束符的意義之前,我們先了解下依賴關系,舉例如下:

    std::atomic<std::string*> ptr;
    int data;
    
    std::string* p  =newstd::string("Hello");
    data =42;                                   
    ptr.store(p,std::memory_order_release);
    

    在該示例中,原子變量ptr依賴于p,但是不依賴data,而p和data互不依賴

    現在結合依賴關系,理解下memory_order_consume標記的意義:有一個原子變量A,在線程T1中對原子變量的寫操作施加了memory_order_release標記符,同時線程T2對原子變量A的讀操作被標記為memory_order_consume,則從線程T1的角度來看,在原子變量寫之前發生的所有讀寫操作,只有與該變量有依賴關系的內存讀寫才會保證不會重排到這個寫操作之后,也就是說,當線程T2使用了帶memory_order_consume標記的讀操作時,線程T1中只有與這個原子變量有依賴關系的讀寫操作才不會被重排到寫操作之后。而如果讀操作施加了memory_order_acquire標記,則線程T1中所有寫操作之前的讀寫操作都不會重排到寫之后(此處需要注意的是,一個是有依賴關系的不重排,一個是全部不重排)。

    同樣,使用cppreference中的例子,如下:

    #include <thread>
    #include <atomic>
    #include <cassert>
    #include <string>
     
    std::atomic<std::string*> ptr;
    int data;
     
    void producer() {
      std::string* p  = new std::string("Hello"); // L10
      data = 42; // L11
      ptr.store(p, std::memory_order_release); // L12
    }
     
    void consumer() {
      std::string* p2;
      while (!(p2 = ptr.load(std::memory_order_consume))); // L17
      assert(*p2 == "Hello"); // L18
      assert(data == 42); // L19
    }
     
    int main() {
      std::thread t1(producer);
      std::thread t2(consumer);
      t1.join(); 
      t2.join();
      
      return 0;
    }
    

    與memory_order_acquire一節中示例相比較,producer()沒有變化,consumer()函數中將load操作的標記符從memory_order_acquire變成了memory_order_consume。而這個變動會引起如下變化:producer()中,ptr與p有依賴 關系,則p不會重排到store()操作L12之后,而data因為與ptr沒有依賴關系,則可能重排到L12之后,所以可能導致L19的assert()失敗。

    截止到此,分析了memory_order_acquire&memory_order_acquire組合以及memory_order_release&memory_order_consume組合的對重排的影響:當對讀操作使用memory_order_acquire標記的時候,對于寫操作來說,寫操作之前的所有讀寫都不能重排到寫操作之后,對于讀操作來說,讀操作之后的所有讀寫不能重排到讀操作之前;當讀操作使用memory_order_consume標記的時候,對于寫操作來說,與原子變量有依賴關系的所有讀寫操作都不能重排到寫操作之后,對于讀操作來說,當前線程中任何與這個讀取操作有依賴關系的讀寫操作都不會被重排到當前讀取操作之前。

    當對一個原子變量的讀操作施加了memory_order_acquire標記時,對那些使用 memory_order_release標記的寫操作線程來說,這些線程中在寫之前的所有內存操作都不能被重排到寫操作之后,這將嚴重限制 CPU 和編譯器優化代碼執行的能力。所以,當確定只需對某個變量限制訪問順序的時候,應盡量使用 memory_order_consume,減少代碼重排的限制,以提升程序性能。

    memory_order_consume約束符是對acquire&release語義的一種優化,這種優化僅限定于與原子變量存在依賴關系的變量操作,因此在重新排序的限制上,其比memory_order_acquire更為寬容。需要注意的是,因為memory_order_consume實現的復雜性,自2016年6月起,所有的編譯器的實現中,memory_order_consume和memory_order_acquire的功能完全一致,詳見《P0371R1: Temporarily discourage memory_order_consume》

    memory_order_acq_rel

    Acquire-Release模型中的其它三個約束符,要么用來約束讀,要么用來約束寫。那么如何對一個原子操作中的兩個動作執行約束呢?這就要用到 memory_order_acq_rel,它既可以約束讀,也可以約束寫。

    對于使用memory_order_acq_rel約束符的原子操作,對當前線程的影響就是:當前線程T1中此操作之前或者之后的內存讀寫都不能被重新排序(假設此操作之前的操作為操作A,此操作為操作B,此操作之后的操作為B,那么執行順序總是ABC,這塊可以理解為同一線程內的sequenced-before關系);對其它線程T2的影響是,如果T2線程使用了memory_order_release約束符的寫操作,那么T2線程中寫操作之前的所有操作均對T1線程可見;如果T2線程使用了memory_order_acquire約束符的讀操作,則T1線程的寫操作對T2線程可見。

    理解起來可能比較繞,這個標記相當于對讀操作使用了memory_order_acquire約束符,對寫操作使用了memory_order_release約束符。當前線程中這個操作之前的內存讀寫不能被重排到這個操作之后,這個操作之后的內存讀寫也不能被重排到這個操作之前。

    cppreference中使用了3個線程的例子來解釋memory_order_acq_rel約束符,代碼如下:

    #include <thread>
    #include <atomic>
    #include <cassert>
    #include <vector>
     
    std::vector<int> data;
    std::atomic<int> flag = {0};
     
    void thread_1() {
        data.push_back(42); // L10
        flag.store(1, std::memory_order_release); // L11
    }
     
    void thread_2() {
        int expected=1; // L15
        // memory_order_relaxed is okay because this is an RMW,
        // and RMWs (with any ordering) following a release form a release sequence
        while (!flag.compare_exchange_strong(expected, 2, std::memory_order_relaxed)) { // L18
            expected = 1;
        }
    }
     
    void thread_3() {
        while (flag.load(std::memory_order_acquire) < 2); // L24
        // if we read the value 2 from the atomic flag, we see 42 in the vector
        assert(data.at(0) == 42); // L26
    }
     
    int main() {
        std::thread a(thread_1);
        std::thread b(thread_2);
        std::thread c(thread_3);
        a.join(); 
        b.join(); 
        c.join();
        
        return 0;
    }
    

    線程thread_2中,對原子變量flag的compare_exchange操作使用了memory_order_acq_rel約束符,這就意味著L15不能重排到L18之后,也就是說當compare_exchange操作發生的時候,能確保expected的值是1,使得這個 compare_exchange_strong操作能夠完成將flag替換成2的動作;thread_1線程中對flag使用了帶memory_order_release約束符的store,這意味著當thread_2線程中取flag的值得時候,L10已經完成(不會被重排到L11之后)。當thread_2線程compare_exchange操作將2寫入flag的時候,thread_3線程中帶memory_order_acquire標記的load操作能看到L18之前的內存寫入,自然也包括L10的內存寫入,所以L26的斷言始終是成立的。

    上面例子中,memory_order_acq_rel約束符用于同時存在讀和寫的場景,這個時候,相當于使用了memory_order_acquire&memory_order_acquire組合組合。其實,它也可以單獨用于讀或者單獨用于寫,示例如下:

    // Thread-1:
    a = y.load(memory_order_acq_rel); // A
    x.store(a, memory_order_acq_rel); // B
    
    // Thread-2:
    b = x.load(memory_order_acq_rel); // C
    y.store(1, memory_order_acq_rel); // D
    

    再看另外一個實例:

    // Thread-1:                              
    a = y.load(memory_order_acquire); // A
    x.store(a, memory_order_release); // B
    
    // Thread-2:
    b = x.load(memory_order_acquire); // C
    y.store(1, memory_order_release); // D
    

    上述兩個示例,效果完全一樣,都可以保證A先于B執行,C先于D執行。

    總結

    C++11提供的6種內存訪問約束符中:

    • memory_order_release:在當前線程T1中,該操作X之前的任何讀寫操作指令都不能放在操作X之后。如果其它線程對同一變量使用了memory_order_acquire或者memory_order_consume約束符,則當前線程寫操作之前的任何讀寫操作都對其它線程可見(注意consume的話是依賴關系可見)

    • memory_order_acquire:在當前線程中,load操作之后的依賴于此原子變量的讀和寫操作都不能被重排到當前指令前。如果有其他線程使用memory_order_release內存模型對此原子變量進行store操作,在當前線程中是可見的。

    • memory_order_relaxed:沒有同步或順序制約,僅對此操作要求原子性

    • memory_order_consume:在當前線程中,load操作之后的依賴于此原子變量的讀和寫操作都不能被重排到當前指令前。如果有其他線程使用memory_order_release內存模型對此原子變量進行store操作,在當前線程中是可見的。

    • memory_order_acq_rel:等同于對原子變量同時使用memory_order_release和memory_order_acquire約束符

    • memory_order_seq_cst:從宏觀角度看,線程的執行順序與代碼順序嚴格一致

    C++的內存模型則是依賴上面六種內存約束符來實現的:

    • Relax模型:對應的是memory_order中的memory_order_relaxed。從其字面意思就能看出,其對于內存序的限制最小,也就是說這種方式只能保證當前的數據訪問是原子操作(不會被其他線程的操作打斷),但是對內存訪問順序沒有任何約束,也就是說對不同的數據的讀寫可能會被重新排序
    • Acquire-Release模型:對應的memory_order_consume memory_order_acquire memory_order_release memory_order_acq_rel約束符(需要互相配合使用);對于一個原子變量A,對A的寫操作(Release)和讀操作(Acquire)之間進行同步,并建立排序約束關系,即對于寫操作(release)X,在寫操作X之前的所有讀寫指令都不能放到寫操作X之后;對于讀操作(acquire)Y,在讀操作Y之后的所有讀寫指令都不能放到讀操作Y之前。
    • Sequential consistency模型:對應的memory_order_seq_cst約束符;程序的執行順序與代碼順序嚴格一致,也就是說,在順序一致性模型中,不存在指令亂序。

    下面這幅圖大致梳理了內存模型的核心概念,可以幫我們快速回顧。

    后記

    這篇文章斷斷續續寫了一個多月,中間很多次都想放棄。不過,幸好還是咬牙堅持了下來。查了很多資料,奈何因為知識儲備不足,很多地方都沒有理解透徹,所以文章中可能存在理解偏差,希望友好交流,共同進步。

    在寫文的過程中,深切體會到了內存模型的復雜高深之處,C++的內存模型為了提供足夠的靈活性和高性能,將各種約束符都暴露給了開發人員,給高手足夠的發揮空間,也讓新手一臉茫然。

    好了,今天的文章就到這,我們下期見!

    posted @ 2022-06-16 19:10  高性能架構探索  閱讀(1261)  評論(8編輯  收藏  舉報
    国产美女a做受大片观看