天天看點

聊聊 記憶體模型與記憶體序

最近群裡聊到了​

​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在1979年9月發表的論文《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++的記憶體模型為了提供足夠的靈活性和高性能,将各種限制符都暴露給了開發人員,給高手足夠的發揮空間,也讓新手一臉茫然。

繼續閱讀