內存模型與c++中的memory order


  • c++的atomic使用總會配合各種各樣的memory order進行使用,memory order控制了執行結果在多核中的可見順序,,這個可見順序與代碼序不一定一致(第一句代碼執行完成的結果不一定比第二句早提交到內存),其一是進行匯編的進行了指令優化重排,其二是cpu實際執行時亂序執行以及部分cpu架構上沒有做到內存強一致性(內存強一致性:可以簡單的理解為,執行結果出現的順序應該和指令順序一樣,不存在重排亂序),導致后面的代碼執行完成的時候,前面的代碼修改的內存卻還沒改變

  • 結果序和代碼序不一致不一定會導致問題,但在可能出現問題的場景下,就需要手動干預以避免問題,匯編(軟件)和cpu(硬件)都提供了相應的指令取進行干預控制,c++的atomic中的memory order可以看成是這些控制的封裝,隱藏了底層,之所以有六種是因為這種控制是有代價,從松散到嚴格開銷越來越高,在某些場景下,我們是允許部分重排的,只是對於小部分重排會導致問題的才需要加以控制,那么只需要衉一些低開銷的控制即可,java也有類似的工具;

  • 這種memory order的控制常見使用在多核的happened-before, synchronized with的模型下,要做的事情就是管理好happened-before和synchronized with順序,按照箭頭的方式運行,

2. 正文

2.1 c++中的memroy order

c++中的memory order中有如下幾種:

 namespace std {
      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(內存順序模型)

  • memory_order_seq_cst: 順序一致性模型,這個是默認提供的最強的一致性模型。
  • memory_order_release/acquire/consume: 提供release、acquire或者consume, release語意的一致性保障
  • memory_order_relaxed: 提供松散一致性模型保障,不提供operation order保證。

在概文中提到這些是c++提供的memory order(結果序)的控制,它們的作用是對匯編上做重排干預硬件上亂序執行干預執行結果在多核的可見性的控制,但乍看之下很懵,不知道這三類內存順序模型是啥,就執行結果在多核的可見性的控制入手,首先我們先從硬件的內存模型說起。

2.2 硬件的內存模型

關於此節,詳情可閱讀Ref 6: 內存一致性模型,以下是無恥的搬運總結

因CPU的不斷發展,使得CPU的計算能力遠超過從主存(DRAM)中讀寫速度,在硬件上慢慢的加入了cache, store buffer, invalidate queue等硬件以提升數據讀寫速度(想了解這些東西可具體見 Ref 4:高並發編程--多處理器編程中的一致性問題(上)的2.2.0),並增加了cpu的亂序執行(指令在實際的cpu運行過程中並非表現得一條執行完了才執行下一條指令的樣子,比如一個mov指令會導致的cpu的load unit忙而alu(邏輯計算單元)空閑等,故在等取值的同時,預先做下一個能夠做的計算指令,這樣的亂序執行提升了cpu的使用率),雖然cpu的亂序執行后面有模型規約着最終執行結果的一致性,但是store buffer, invalidate queue這些硬件上帶來的加速卻是有代價的,它們的出現帶來了不同的內存一致性模型導致執行結果在多核下的可見順序不同,所以需要人們編程的時候考慮這點, 以下介紹常見的四種內存一致性模型和它們對執行結果的在多核中可見順序的影響

2.2.1 順序存儲模型(sequential consistency model


順序模型(sequential consistency model)又被簡稱為SC,常對應的硬件結構圖如上,在順序模型的硬件架構中,只看到了cache,在這里額外科普補充以下,多核在cache中的讀寫使用的是MESI協議進行同步(詳情請見Ref 4:高並發編程--多處理器編程中的一致性問題(上)的2.3.1)
在這種模型下,多線程程序的運行所期望的執行情況是一致的,不會出現內存訪問亂序的情況;
比如說以下的指令運行,會按照 S1->S2->L1->L2 運行完成,最終的r2的值會是NEW;
這便是SC(順序模型)的特點,指令的執行行為於行為與UP(單核)上是一致的

2.2.2 完全存儲定序(total store order)

為了提高CPU的性能,芯片設計人員在CPU中包含了一個存儲緩存區(store buffer),它的作用是為store指令提供緩沖,使得CPU不用等待存儲器的響應。所以對於寫而言,只要store buffer里還有空間,寫就只需要1個時鍾周期(哪怕是ARM-A76的L1 cache,訪問一次也需要3個cycles,所以store buffer的存在可以很好的減少寫開銷),但這也引入了一個訪問亂序的問題。

相比於以前的內存模型而言,store的時候數據會先被放到store buffer里面,然后再被寫到L1 cache里。讓我們來看一段指令:

S1:store flag= set
S2:load r1=data
S3:store b=set

如果在順序存儲模型中,S1肯定會比S2先執行。但是如果在加入了store buffer之后,S1將指令放到了store buffer后會立刻返回,這個時候會立刻執行S2。S2是read指令,CPU必須等到數據讀取到r1后才會繼續執行。這樣很可能S1的store flag=set指令還在store buffer上,而S2的load指令可能已經執行完(特別是data在cache上存在,而flag沒在cache中的時候。這個時候CPU往往會先執行S2,這樣可以減少等待時間)

這里就可以看出再加入了store buffer之后,內存一致性模型就發生了改變。我們定義store buffer必須嚴格按照FIFO的次序將數據發送到主存(所謂的FIFO表示先進入store buffer的指令數據必須先於后面的指令數據寫到存儲器中),cpu必須要嚴格保證store buffer的順序執行,所以S3必須要在S1之后執行,這種內存模型就叫做完全存儲定序(TSO)。我們常用的物理機上的x86 CPU 就是這種內存模型。

這種架構在單核情況下沒問題,但在多核運行多線程的時候會出現問題,對於如下分別運行在core1 和 core2的指令,由於store buffer的存在,L1和S1的store指令會被先放到store buffer里面,然后CPU會繼續執行后面的load指令。Store buffer中的數據可能還沒有來得及往存儲器中寫,這個時候我們可能看到C1和C2的r1都為0的情況。這種亂序稱之為store-load亂序,對於可能出現store-load亂序的場景,cpu提供了一些指令去控制怎么把這些數據同步到其它核,后面會介紹這些的工具

2.2.3 部分存儲定序(part store order)

TSO在store buffer的情況下已經帶來了不小的性能提升,但是芯片設計人員並不滿足於這一點,於是他們在TSO模型的基礎上繼續放寬內存訪問限制,允許CPU以非FIFO來處理store buffer緩沖區中的指令。CPU只保證地址相關指令在store buffer中才會以FIFO的形式進行處理(大白話就是對同一個相同的地址做store,才會有嚴格執行順序制約),而其他的則可以亂序處理,所以這被稱為部分存儲定序(PSO)。

如上圖,S1與S2是地址無關的store指令,cpu執行的時候都會將其推到store buffer中。如果這個時候flag在C1的cahe中存在,那么CPU會優先將S2的store執行完,然后等data緩存到C1的cache之后,再執行store data=NEW指令。
可能的執行順序:S2->L1 >L2->S1, 這樣在C1將data設置為NEW之前,C2已經執行完,r2最終的結果會為0,而不是我們期望的NEW,這樣PSO帶來的store-store亂序將會對我們的代碼邏輯造成致命影響。

2.2.4 控制工具

  • store buffer控制工具
    (以下內容來自於 (Ref 4:高並發編程--多處理器編程中的一致性問題(上)的2.4.1, 2.4.2 和 2.4.5))
    以下是一個store-store亂序例子,如下圖所示,thread1中,由於 a = 1 與 c = 3 存在happen before關系,所以使用c是否等於3在thread2做同步,使得最終到assert()的時候a一定等於1,但是這個例子在pso模型下,是可能出現a的值還在store buffer中還沒同步到其它核上,而C值已經在cache中通過cache的一致性同步到core2中

這時就需要編碼人員介入了,編碼人員需要告訴CPU現在需要將store buffer的數據flush到cache里,於是CPU設計者提供了叫memory barrier的工具。

int a = 0, c = 0;

thread 1:
{
      a = 1;
      smp_mb(); // memory barrier
      c = 3
}

thread 2:
{
      while(c != 3);
      assert(a == 1); 
}

smp_mb()會在執行的時候將storebuffer中的數據全部刷進cache。這樣assert就會執行成功了。

storebuffer幫助core在進行store操作的時候盡快返回,這里的buffer和cache都是硬件,所以這些一般都比較小(或許就只有幾十個字節這么大),當storebuffer滿了之后就需要將buffer中的內容刷到cache,刷到cache這部分的數據被更新了,就會觸發cache的mesi進行同步,發送cacheline的invalidate message告知其它cache你們持有的數據失效了,趕緊標注一下然后同步,然后其他的core會返回invalidate ack之后這時才會繼續向下執行。那么這個時候問題又來了,這些message發到另外的core,這些core需要先invlidate,然后在返回ack,如果這些core本來就很忙的話就會導致message處理被延后,這中間需要等待很長的通訊時間,這對於CPU設計者來說同樣是不可接受的。因此CPU設計者又引入了invalidate queue。如下圖[4].

  • TSO下的invalidate message queue控制工具

有了invalidate queue之后,發送的invalidate message只需要push到對應core的invalidate queue即可,然后這個core就會返回繼續執行,中間不需要等待。這樣cache之間的溝通就不會有很大的阻塞了,但是這同樣帶來了問題。

如果a在invalidate queue中的invalidata message還沒到被其它的cpu給處理,那么它們就會持有舊數據,比如說下下面的thread2有可能assert還是會失敗,為此需要在 thread2中也添加內存屏障,它會將所在core的storebuffer和invalidate queue都flush上來做完處理再執行下面的指令。

int a = 0, c = 0;

thread 1:
{
      a = 1;
      smp_mb(); // memory barrier
      c = 3
}

thread 2:
{
      while(c != 3);
      //smp_mb()  此處也需要添加內存屏障
      assert(a == 1); 
}

上面提到的smp_mb()是一種full memory barrier,他會將store buffer和invalidate queue都flush一遍。但是就像上面例子中體現的那樣有時候我們不用兩個都flush,於是硬件設計者引入了read memory barrier和write memory barrier。
read memory barrier其實就是將invalidate queue flush。也稱lfence(load fence)
write memory barrier是將storebuffer flush。也稱sfence(sotre fence)

在x86下,還有一個lock指令:
Lock前綴,Lock不是一種內存屏障,但是它能完成類似內存屏障的功能。Lock會對CPU總線和高速緩存加鎖,可以理解為CPU指令級的一種鎖。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。Lock前綴實現了以下作用

  1. 它先對總線/緩存加鎖,然后執行后面的指令,最后釋放鎖后會把高速緩存中的臟數據全部刷新回主內存。

  2. 在Lock鎖住總線的時候,其他CPU的讀寫請求都會被阻塞,直到鎖釋放。Lock后的寫操作會讓其他CPU相關的cache line失效,從而從新從內存加載最新的數據。這就是通過cache的MESI協議實現的。

2.2.5 寬松存儲模型(relax memory order)

喪心病狂的芯片研發人員為了榨取更多的性能,在PSO的模型的基礎上,更進一步的放寬了內存一致性模型,不僅允許store-load,store-store亂序。還進一步允許load-load,load-store亂序, 只要是地址無關的指令,在讀寫訪問的時候都可以打亂所有load/store的順序,這就是寬松內存模型(RMO)。

在PSO模型里,由於S2可能會比S1先執行,從而會導致C2的r2寄存器獲取到的data值為0。在RMO模型里,不僅會出現PSO的store-store亂序,C2本身執行指令的時候,由於L1與L2是地址無關的,所以L2可能先比L1執行,這樣即使C1沒有出現store-store亂序,C2本身的load-load亂序也會導致我們看到的r2為0。從上面的分析可以看出,RMO內存模型里亂序出現的可能性會非常大,這是一種亂序隨可見的內存一致性模型, RM的很多微架構就是使用RMO模型,所以我們可以看到ARM提供的dmb內存指令有多個選項:

LD              load-load/load-store
ST              store-store/store-load
SY              any-any

2.2.5 內存模型總結

以上就是我們所有說到的內存模型:SC(完全一致性),TSO(完全存儲一致性),PSO(部分存儲一致性),RMO(完全寬松),這些都是硬件架構上的不同會帶來內存可見性問題,但是除此之外,cpu在執行的時候也會亂序,編譯器在編譯優化的時候是會對指令做重排的,,也會產生如上的內存模型,所幸c++給我們封裝了memory_order,讓我們直接忽視硬件-cpu-指令這些運行細節,就只是從內存模型的角度去控制程序的運行;

2.3 c++ 中的memory order

在atomic變量的store和load中提供以下選項,現在解釋它們的含義:

2.3.1 c++中的memory_order的含義解釋

  • memory_order_seq_cst:
    這個選項語義上就是要求底層提供順序一致性模型,這個是默認提供的最強的一致性模型,在這種模型下不存在任何重排,可以解決一切問題
    在底層實現上:程序的運行底層架構如果是非內存強一致模型,會使用cpu提供的內存屏障等操作保證強一致,在軟件上,並要求代碼進行編譯的時候不能夠做任何指令重排,

  • memory_order_release/acquire/consume:
    提供release、acquire或者consume, release語意的一致性保障
    它的語義是:我們允許cpu或者編譯器做一定的指令亂序重排,但是由於tso, pso的存在,可能產生的store-load亂序store-store亂序導致問題,那么涉及到多核交互的時候,就需要手動使用release, acquire去避免這樣的這個問題了,與memory_order_seq_cst最大的不同的是,其是對具體代碼可能出現的亂序做具體解決而不是要求全部都不能重排

  • memory_order_relaxed: 提供松散一致性模型保障,不提供operation order保證。
    這種內存序使用在,完全放開,讓編譯器和cpu自由搞,如果cpu是SC的話,cpu層不會出現亂序,但是編譯層做重排,結果也是無法保證的,很容易出問題,但可用在代碼上沒有亂序要求的場景或者沒有多核交互的情況下,提升性能。

2.3.2 memory order使用例子

這一塊的具體使用見Ref 12.這里介紹了使用c++的memory model的例子,以下是搬運:

  • 寫順序保證
std::atomic<bool> has_release;

// thread_2 
void release_software(int *data) {
    int a = 100;                // line 1
    int c = 200;                // line 2
    if (!data) {
        data = new int[100];    // line 3
    }

    has_release.store(true, std::memory_order_release); // line 4
}

std::memory_order_release功能如果用一句比較長的話來說,就是:在本行代碼之前,有任何寫內存的操作,都是不能放到本行語句之后的。簡單地說,就是寫不后。即,寫語句不能調到本條語句之后。以這種形式通知編譯器/CPU保證真正執行的時候,寫語句不會放到has_release.store(true, std::memory_order_relese)之后。

盡管要求{1,2,3}代碼的執行不能放到4的后面,但是{1,2,3}本身是可以被亂序的。比如按照{3,2,1,4}的順序執行也是可以的,release可以認為是發布一個版本。也就是說,應該在發布之前做的,那么不能放到release之后。

  • 讀順序的保證
    假設thread_1想要按照{line 1, line2}的順序呈現給其他線程,比如thread_2。如果thread_2寫法如下:
std::atomic<bool> has_release;
int *data = nullptr;

// thread_1
void releae_software() {
    if (!data) {
        data = new int[100];                            // line 1
    }
    has_release.store(true, std::memory_order_release); // line 2

    //.... do something other.
}

// thread_use
void use_software() {
    // 檢查是否已經發布了
    while (!has_release.load(std::memory_order_relaxed));
    // 即然已經發布,那么就從里面取值
    int x = *data;
}

在這里,thread_use的代碼執行順序依然是有可能被改變的。因為編譯器和cpu在執行的時候,可能會針對thread_use進行代碼的優化或者執行流程的變化。比如完全全有可能會被弄成:

void use_software() {
    int x = *data;
    while (!has_release.load(std::memory_order_relax));
}

到這個時候,thread_use看到的順序卻完全不是thread_1想呈現出來的順序。這個時候,需要改進一下thread_use -> thread_2。

void acquire_software(void) {
    while (!has_release.load(std::memory_order_acquire));
    int x = *data;
}

std::memory_order_acquire表示的是,后續的讀操作都不能放到這條指令之前。簡單地可以寫成讀不前

  • 讀順序的削弱
    有時候,std::memory_order_release和std::memory_order_acquire會波及無辜
std::atomic<int> net_con{0};
std::atomic<int> has_alloc{0};
char buffer[1024];
char file_content[1024];

void release_thread(void) {
    sprintf(buffer, "%s", "something_to_read_tobuffer");

    // 這兩個是與buffer完全無關的代碼
    // net_con表示接收到的鏈接
    net_con.store(1, std::memory_order_release);
    // 標記alloc memory for connection
    has_alloc.store(1, std::memory_order_release);
}

void acquire_thread(void) {
    // 這個是與兩個原子變量完全無關的操作。
    if (strstr(file_content, "auth_key =")) {
        // fetch user and password
    }

    while (!has_alloc.load(std::memory_order_acquire));
    bool v = has_alloc.load(std::memory_order_acquire);
    if (v) {
         net_con.load(std::memory_order_relaxed);
    }

仔細分析代碼,可以看出,buffer與file_content的使用,與兩個原子變量就目前的這段簡短的代碼而言是沒有任何聯系的。按理說,這兩部分的代碼是可以放到任何位置執行的。但是,由於使用了release-acquire,那么會導致的情況就是,buffer和file_content的訪問都被波及。

兩者的前面。這樣無疑對性能會帶來一定的影響。所以c++11這里又定義了sonsume和acquire,就是意圖把與真正變量無關的代碼剝離出去,讓他們能夠任意排列。不要被release-acquire誤傷。其實就是對PSO模型進行約束

這就是std::memory_order_consume的語義是,所有后續對本原子類型的操作,必須在本操作完成之后才可以執行。簡單點就是不得前。但是這個操作只能用來對讀進行優化。也就是說release線程是不能使用這個的。也就是說,只能對讀依賴的一方進行優化.

注意:std::memory_order_acquire與std::memory_order_consume的區別在於:

std::memory_order_acquire是要求后面所有的讀都不得提前。
std::memory_order_consume是要求后面依賴於本次形成讀則不能亂序。 一個是針對所有的讀,容易形成誤傷。而consume只是要求依賴於consume這條語句的讀寫不得亂序。


// consume example
std::atomic<int*> global_addr{nullptr};

void func(int *data) {
    int *addr = global_addr.load(std::memory_order_consume);
    int d = *data;
    int f = *(data+1);
    if (addr) {
        int x = *addr;
    }

由於global_addr, addr, x形成了讀依賴,那么這時候,這幾個變量是不能亂序的。但是d,f是可以放到int *addr = global_addr.load(std::memory_order_consume);前面的。

而std::memory_order_acquire則要求d,f都不能放到int *addr = global_addr.load(std::memory_order_consume);的前面。這就是acquire與consume的區別。

  • 讀寫的加強
    有時候,可能還需要對讀寫的順序進行加強。想一下std::memory_order_release要求的是寫不后,也就是后面對內存的寫都不能放到本條寫語句之后。 但是,有時候可能需要解決這種情況。假設我們需要響應一個硬件上的中斷。硬件上的中斷需要進行如下步驟。
- a.讀寄存器地址1,取出數據checksum
- b.寫寄存器地址2,表示對中斷進行響應
- c.寫flag,標記中斷處理完成

由於讀寄存器地址1與寫flag,標記中斷處理完成這兩者之間的關系是讀寫關系。 並不能被std::memory_order_releaes約束。所以需要更強的約束來處理。

這里可以使用std::memory_order_acq_rel,即對本條語句的讀寫進行約束。即表示寫不后,讀不前同時生效。 那么就可以保證a, b, c三個操作不會亂序。

即std::memory_order_acq_rel可以同時表示寫不后 && 讀不前

  • 最強約束
    std::memory_order_seq_cst表示最強約束。所有關於std::atomic的使用,如果不帶函數。比如x.store or x.load,而是std::atomic a; a = 1這樣,那么就是強一制性的。即在這條語句的時候 所有這條指令前面的語句不能放到后面,所有這條語句后面的語句不能放到前面來執行。

2.4 匯編層的控制

上述都在討論c++層和cpu層的控制,可以稍微了解一下匯編指令是如何要用什么東西做控制的, 在使用內存屏障的時候,會使用以下宏

#define set_mb(var, value) do { var = value; mb(); } while (0)
#define mb() __asm__ __volatile__ ("" : : : "memory")

mb()對應的內聯匯編上:

volatile 用於告訴編譯器,嚴禁將此處的匯編語句與其它的語句重組合優化。即:原原本本按原來的樣子處理這這里的匯編, 注意這個__volatile__與c/c++中的volatile是兩個東西,c/c++中的volatile 告訴編譯器不要將定義的變量優化掉;告訴編譯器總是從緩存取被修飾的變量的值,而不是寄存器取值。

memory 強制 gcc 編譯器假設 RAM 所有內存單元均被匯編指令修改,這樣 cpu 中的 registers 和 cache 中已緩存的內存單元中的數據將作廢。cpu 將不得不在需要的時候重新讀取內存中的數據。這就阻止了 cpu 又將 registers, cache 中的數據用於去優化指令,而避免去訪問內存。

"":::表示這是個空指令

在linux/include/asm-i386/system.h將mb()定義成如下:

#define mb() __asm__ __volatile__ ("lock; addl $0,0(%%esp)": : :"memory")

lock前綴表示將后面這句匯編語句:"addl $0,0(%%esp)"作為cpu的一個內存屏障。
addl $0,0(%%esp)表示將數值0加到esp寄存器中,而該寄存器指向棧頂的內存單元。加上一個0,esp寄存器的數值依然不變。即這是一條無用的匯編 指令。在此利用這條無價值的匯編指令來配合lock指令,在__asm__,volatile,memory的作用下,用作cpu的內存屏障。

關於lock的作用請見2.2.4,其起內存作用的方式是將總線鎖住,不給其它cpu進行讀寫,執行該條指令后,會將臟數據刷到主存,並使所有core的cache lines失效

以上是老板的mb(),后來又引入了新的mfence, lfence, sfence,如下,關於這三者也在2.2.4中進行了詳述

#define mb() 	asm volatile("mfence":::"memory")
#define rmb()	asm volatile("lfence":::"memory")
#define wmb()	asm volatile("sfence" ::: "memory")

3. ref

1.內存屏障
2.lfence, sfence, mfence的作用
3.幾個使用fence的實例:X86/GCC memory fence的一些見解
4.一定要看,兩篇系統介紹多處理器編程的問題:高並發編程--多處理器編程中的一致性問題(上)
7.一定要看,兩篇系統介紹多處理器編程的問題:高並發編程--多處理器編程中的一致性問題(下)
5.大白話的說明c++的memory order的實際使用:如何理解 C++11 的六種 memory order?
6.介紹了四種內存一致性模型:內存一致性模型
8.介紹了常用不同架構下的內存模型是什么 : Weak vs. Strong Memory Models
9.如何理解c++中的memory order
10.進一步介紹x86下的 lfence的一個問題: intel x86系列CPU既然是strong order的,不會出現loadload亂序,為什么還需要lfence指令?
11.提到了lock具體怎么實現和fen一樣的作用的文章: mb,smp_mb() barrier()
12.這里介紹了使用c++的memory model的例子,說的很好: c++並發編程1.內存序
13.c++中的volatile


免責聲明!

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



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