爲什麼需要 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