理解 C++ 的 Memory Order

爲什麼需要 Memory Order

如果不使用任何同步機制(例如 mutex 或 atomic),在多線程中讀寫同一個變量,那麼,程序的結果是難以預料的。簡單來說,編譯器以及 CPU 的一些行爲,會影響到程序的執行結果:

  • 即使是簡單的語句,C++ 也不保證是原子操作。
  • CPU 可能會調整指令的執行順序。
  • 在 CPU cache 的影響下,一個 CPU 執行了某個指令,不會立即被其它 CPU 看見。

原子操作說的是,一個操作的狀態要麼就是未執行,要麼就是已完成,不會看見中間狀態。例如,在 C++11 中,下面程序的結果是未定義的:

 			int64_t i = 0;     // global variable
Thread-1:              Thread-2:
i = 100;               std::cout << i;

C++ 並不保證i = 100是原子操作,因爲在某些 CPU Architecture 中,寫入int64_t需要兩個 CPU 指令,所以 Thread-2 可能會讀取到i在賦值過程的中間狀態。

另一方面,爲了優化程序的執行性能,CPU 可能會調整指令的執行順序。爲闡述這一點,下面的例子中,讓我們假設所有操作都是原子操作:

		  int x = 0;     // global variable
          int y = 0;     // global variable
		  
Thread-1:              Thread-2:
x = 100;               while (y != 200)
y = 200;                   ;
                       std::cout << x;

如果 CPU 沒有亂序執行指令,那麼 Thread-2 將輸出100。然而,對於 Thread-1 來說,x = 100;y = 200;這兩個語句之間沒有依賴關係,因此,Thread-1 允許調整語句的執行順序:

Thread-1:
y = 200;
x = 100;

在這種情況下,Thread-2 將輸出0或100。

CPU cache 也會影響到程序的行爲。下面的例子中,假設從時間上來講,A 操作先於 B 操作發生:

   				int x = 0;     // global variable
		  
Thread-1:                      Thread-2:
x = 100;    // A               std::cout << x;    // B

儘管從時間上來講,A 先於 B,但 CPU cache 的影響下,Thread-2 不能保證立即看到 A 操作的結果,所以 Thread-2 可能輸出0或100。

從上面的三個例子可以看到,多線程讀寫同一變量需要使用同步機制,最常見的同步機制就是std::mutex和std::atomic。然而,從性能角度看,通常使用std::atomic會獲得更好的性能。

C++11 爲std::atomic提供了 4 種 memory ordering:

  • Relaxed ordering
  • Release-Acquire ordering
  • Release-Consume ordering
  • Sequentially-consistent ordering

默認情況下,std::atomic使用的是 Sequentially-consistent ordering。但在某些場景下,合理使用其它三種 ordering,可以讓編譯器優化生成的代碼,從而提高性能。

Relaxed ordering

在單個線程內,所有原子操作是順序進行的。按照什麼順序?基本上就是代碼順序(sequenced-before)。這就是唯一的限制了!兩個來自不同線程的原子操作是什麼順序?兩個字:任意。

在這種模型下,std::atomic的load()和store()都要帶memory_order_relaxed參數。Relaxed ordering 僅僅保證load()和store()是原子操作,除此之外,不提供任何跨線程的同步。

先看看一個簡單的例子:

				   std::atomic<int> x = 0;     // global variable
                   std::atomic<int> y = 0;     // global variable
		  
Thread-1:                                  Thread-2:
r1 = y.load(memory_order_relaxed); /* A */   r2 = x.load(memory_order_relaxed); /* C */
x.store(r1, memory_order_relaxed); /* B*/    y.store(42, memory_order_relaxed); /* D*/

執行完上面的程序,可能出現r1 == r2 == 42。理解這一點並不難,因爲編譯器允許調整 C 和 D 的執行順序。如果程序的執行順序是 D -> A -> B -> C,那麼就會出現r1 == r2 == 42

如果某個操作只要求是原子操作,除此之外,不需要其它同步的保障,就可以使用 Relaxed ordering。程序計數器是一種典型的應用場景:

#include <cassert>
#include <vector>
#include <iostream>
#include <thread>
#include <atomic>
std::atomic<int> cnt = {0};
void f()
{
    for (int n = 0; n < 1000; ++n) {
        cnt.fetch_add(1, std::memory_order_relaxed);
    }
}
int main()
{
    std::vector<std::thread> v;
    for (int n = 0; n < 10; ++n) {
        v.emplace_back(f);
    }
    for (auto& t : v) {
        t.join();
    }
    assert(cnt == 10000);    // never failed
    return 0;
}

Release-Acquire ordering

來自不同線程的兩個原子操作順序不一定?那怎麼能限制一下它們的順序?這就需要兩個線程進行一下同步(synchronize-with)。同步什麼呢?同步對一個變量的讀寫操作。線程 A 原子性地把值寫入 x (release), 然後線程 B 原子性地讀取 x 的值(acquire). 這樣線程 B 保證讀取到 x 的最新值。注意 release – acquire 有個牛逼的副作用:線程 A 中所有發生在 release x 之前的寫操作,對在線程 B acquire x 之後的任何讀操作都可見!本來 A, B 間讀寫操作順序不定。這麼一同步,在 x 這個點前後, A, B 線程之間有了個順序關係,稱作 inter-thread happens-before.

在這種模型下,store()使用memory_order_release,而load()使用memory_order_acquire。這種模型有兩種效果,第一種是可以限制 CPU 指令的重排:

  • 在store()之前的所有讀寫操作,不允許被移動到這個store()的後面。
  • 在load()之後的所有讀寫操作,不允許被移動到這個load()的前面。

除此之外,還有另一種效果:假設 Thread-1 store()的那個值,成功被 Thread-2 load()到了,那麼 Thread-1 在store()之前對內存的所有寫入操作,此時對 Thread-2 來說,都是可見的。

下面的例子闡述了這種模型的原理:

#include <thread>
#include <atomic>
#include <cassert>
#include <string>
std::atomic<bool> ready{ false };
int data = 0;
void producer()
{
    data = 100;                                       // A
    ready.store(true, std::memory_order_release);     // B
}
void consumer()
{
    while (!ready.load(std::memory_order_acquire))    // C
        ;
    assert(data == 100); // never failed              // D
}
int main()
{
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join();
    t2.join();
    return 0;
}

讓我們分析一下這個過程:

  • 首先 A 不允許被移動到 B 的後面。
  • 同樣 D 也不允許被移動到 C 的前面。
  • 當 C 從 while 循環中退出了,說明 C 讀取到了 B store()的那個值,此時,Thread-2 保證能夠看見 Thread-1 執行 B 之前的所有寫入操作(也即是 A)。

Release-consume ordering

我去,我只想同步一個 x 的讀寫操作,結果把 release 之前的寫操作都順帶同步了?如果我想避免這個額外開銷怎麼辦?用 release – consume 唄。同步還是一樣的同步,這回副作用弱了點:在線程 B acquire x 之後的讀操作中,有一些是依賴於 x 的值的讀操作。管這些依賴於 x 的讀操作叫 賴B讀. 同理在線程 A 裏面, release x 也有一些它所依賴的其他寫操作,這些寫操作自然發生在 release x 之前了。管這些寫操作叫 賴A寫. 現在這個副作用就是,只有 賴B讀 能看見 賴A寫. (臥槽真累)

有人問了,說什麼叫數據依賴(carries dependency)?其實這玩意兒巨簡單:

S1. c = a + b;
S2. e = c + d;

S2 數據依賴於 S1,因爲它需要 c 的值。

參考資料
C++ atomics and memory ordering
cppreference.com - std::memory_order
Atomic Usage examples
C++11 introduced a standardized memory model. What does it mean?
bRPC - Memory fence
Acquire and Release Semantics

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章