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

高效能架構探索發表於2022-06-16

本文始發於公眾號【高效能架構探索】,本公眾號致力於分享乾貨、硬貨以及工作上的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++的記憶體模型為了提供足夠的靈活性和高效能,將各種約束符都暴露給了開發人員,給高手足夠的發揮空間,也讓新手一臉茫然。

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

相關文章