C++六種記憶體序詳解

ling_jian發表於2024-04-19

前言

要理解C++的六種記憶體序,我們首先須要明白一點,處理器讀取一個資料時,可能從記憶體中讀取,也可能從快取中讀取,還可能從暫存器讀取。對於一個寫操作,要考慮這個操作的結果傳播到其他處理器的速度。
並且,編譯器的指令重排和CPU處理器的亂序執行也是我們需要考慮的因素。

我們先看一個具體的例子,下圖中P1和P2指代不同的processor,假設P2快取了Data的值

  1. P1 先完成了 Data 在記憶體上的寫操作, Data=2000;
  2. P1 沒有等待 Data 的寫結果傳播到 P2 的快取中,繼續進行 Head 的寫操作, Head=1;
  3. P2 讀取到了記憶體中 Head 的新值;
  4. P2 繼續執行,讀到了快取中 Data 的舊值, Data=0。

在講六種記憶體序之前,先明白兩種關係

Happens-before

happens-before 關係是一種邏輯關係,它確保記憶體操作的有序性和可見性。如果在程式中操作A happens-before操作B,那麼操作A的效果(包括對記憶體的修改)保證對啟動操作B的執行緒可見,並且A的執行在時間上先於B。

Synchronizes-with

synchronizes-with 是C++記憶體模型中的一個特定型別的 happens-before 關係,它主要用於描述同步機制(如互斥鎖、原子操作等)。這種關係指明瞭程式中的兩個操作之間透過某種同步機制直接建立的順序關係。如果操作A synchronizes-with操作B,則A happens-before B。

例如:

  • 一個執行緒釋放(unlock)一個互斥鎖,然後另一個執行緒獲取(lock)這個互斥鎖,釋放操作與獲取操作之間存在synchronizes-with關係。
  • 對原子變數執行store操作(使用memory_order_release或更強的順序)與另一個執行緒上對該原子變數進行load操作(使用memory_order_acquire或更強的順序)之間也存在synchronizes-with關係。

memory_order_relaxed

唯一的要求是在同一執行緒中,對同一原子變數的訪問不可以被重排,不同的原子變數的操作順序是可以重排的。它不提供任何跨執行緒的記憶體順序保證。

1 int x = 0;
2 int y = 0;
3 // Thread 1:
4 r1 = y.load(std::memory_order_relaxed); // A
5 x.store(r1, std::memory_order_relaxed); // B
6 // Thread 2:
7 r2 = x.load(std::memory_order_relaxed); // C 
8 y.store(42, std::memory_order_relaxed); // D

程式碼執行後,y1=y2=42的情況是可能出現的,因為第8行的程式碼可以被重排到第7行之前執行。

memory_order_release & memory_order_acquire & memory_order_consume

Acquire-Release能保證不同執行緒之間的Synchronizes-With關係,這同時也約束到同一個執行緒中前後語句的執行順序。release語句之前的所有變數的讀寫操作(including non-atomic and relaxed atomic)都對另一個執行緒中的acquire之後的程式碼可見。

 1 #include <atomic>
 2 #include <thread>
 3 #include <assert.h>
 4 
 5 std::atomic<bool> x,y;
 6 std::atomic<int> z;
 7 
 8 void write_x_then_y()
 9 {
10     x.store(true,std::memory_order_relaxed);// 1
11     y.store(true,std::memory_order_release);// 2
12 }
13 
14 void read_y_then_x()
15 {
16     while(!y.load(std::memory_order_acquire));// 3
17     if(x.load(std::memory_order_relaxed)) //4
18         ++z;
19 }
20 
21 int main()
22 {
23     x=false;
24     y=false;
25     z=0;
26     std::thread a(write_x_then_y);
27     std::thread b(read_y_then_x);
28     a.join();
29     b.join();
30     assert(z.load()!=0);
31 }

程式碼執行後assert永遠為true. 程式碼中1一定發生在2之前(happens-before), 2一定發生在3之前(Synchronizes-With), 3一定發生在4之前(happens-before);

而Release-Consume只約束有明確的carry-a-dependency關係的語句的執行順序,同一個執行緒中的其他語句的執行先後順序並不受這個記憶體模型的影響。release語句之前的有依賴關係的變數的讀寫操作都對另一個執行緒中的consume之後的程式碼可見。

上面的程式碼的第16行如果從std::memory_order_acquire改成std::memory_order_consume, 最後z是有可能為0的,因為變數x和y之間不存在依賴關係,thread b不一定能看到thread a中的對x的寫操作。

根據cppreference,目前memory_order_consume是不建議使用的。

memory_order_acq_rel

它結合了memory_order_acquire 和 memory_order_release 的特性,確保了本執行緒原子操作的讀取時能看到其他執行緒的寫入(acquire 語義),並且本執行緒的寫入對其他執行緒可見(release 語義),主要用於read-modify-write操作,如fetch_sub/add或compare_exchange_strong/weak。

 1 #include <atomic>
 2 #include <iostream>
 3 #include <thread>
 4 
 5 struct Node {
 6     int data;
 7     Node* next;
 8 };
 9 
10 std::atomic<Node*> head{nullptr};
11 
12 void append(int value) {
13     Node* new_node = new Node{value, nullptr};
14     
15     // 使用 memory_order_acq_rel 來確保對 head 的修改對其他執行緒可見, 同時確保看到其他執行緒對 head 的修改
16     Node* old_head = head.exchange(new_node, std::memory_order_acq_rel);
17     
18     new_node->next = old_head;
19 }
20 
21 void print_list() {
22     Node* current = head.load(std::memory_order_acquire);
23     while (current != nullptr) {
24         std::cout << current->data << " ";
25         current = current->next;
26     }
27     std::cout << std::endl;
28 }
29 
30 void thread_func() {
31     for (int i = 0; i < 10; ++i) {
32         append(i);
33     }
34 }
35 
36 int main() {
37     std::thread t1(thread_func);
38     std::thread t2(thread_func);
39     
40     t1.join();
41     t2.join();
42     
43     print_list();
44     
45     return 0;
46 }

memory_order_seq_cst

它滿足memory_order_acq_rel的所有特性,除此之外,它強制受影響的記憶體訪問傳播到每個CPU核心。它不僅保證了單個原子變數操作的全域性順序,而且保證了所有使用順序一致性記憶體序的原子變數之間的操作順序在所有執行緒的觀測中是一致的。

我們先看一個例子

 1 #include <atomic>
 2 #include <cassert>
 3 #include <thread>
 4  
 5 std::atomic<bool> x = {false};
 6 std::atomic<bool> y = {false};
 7 std::atomic<int> z = {0};
 8  
 9 void write_x()
10 {
11     x.store(true, std::memory_order_release);
12 }
13  
14 void write_y()
15 {
16     y.store(true, std::memory_order_release);
17 }
18  
19 void read_x_then_y()
20 {
21     while (!x.load(std::memory_order_acquire))
22         ;
23     if (y.load(std::memory_order_acquire))
24         ++z;
25 }
26  
27 void read_y_then_x()
28 {
29     while (!y.load(std::memory_order_acquire))
30         ;
31     if (x.load(std::memory_order_acquire))
32         ++z;
33 }
34  
35 int main()
36 {
37     std::thread a(write_x);
38     std::thread b(write_y);
39     std::thread c(read_x_then_y);
40     std::thread d(read_y_then_x);
41     a.join(); b.join(); c.join(); d.join();
42     assert(z.load() != 0); 
43 }

執行程式碼,最後是可能出現z=0的情況的

原因是read_x_then_y和read_y_then_x看到的x和y的修改順序並不一致,也就是說read_x_then_y看到的是先修改了x再修改y, 而read_y_then_x看到的是先修改y再修改x, 這種現象出現的原因仍然可以歸結到寫操作的傳播上,在某一時刻write_x的修改只傳播到了read_x_then_y執行緒所在的processor,還沒有傳播到read_y_then_x執行緒所在的processor, 而write_y的修改只傳播到了read_y_then_x執行緒所在的processor,還沒有傳播到read_x_then_y執行緒所在的processor.

如果我們要保證最後z一定不等於0,需要將程式碼中std::memory_order_acquire和std::memory_order_release都換成std::memory_order_seq_cst, 這樣才能保證read_y_then_x和read_x_then_y觀察到的對x和y的修改順序是一致的。

參考文件

https://en.cppreference.com/w/cpp/atomic/memory_order

https://www.codedump.info/post/20191214-cxx11-memory-model-1/

https://www.codedump.info/post/20191214-cxx11-memory-model-2/

https://www.zhihu.com/question/24301047/answer/83422523

相關文章