併發編程重要知識點:內存模型
大家好,我是程序喵,最近事情太多,好久才能肝出一篇文章,大家見諒。
這篇文章簡單介紹下併發編程中一個重要的知識點:內存模型。
直接看這段代碼:
#include <iostream>
#include <thread>
int x = 0;
int y = 0;
void func1() {
x = 100;
y = 2;
}
void func2() {
while (y == 2) {
std::cout << x << std::endl;
break;
}
}
int main() {
std::thread t1(func1);
std::thread t2(func2);
t1.join();
t2.join();
}
大家猜一猜這段代碼會輸出什麼?100 嗎?絕大數時候會是 100,但是也有極小概率會是 0,理論上有輸出 0 的可能。
這裏涉及到內存序(memory order)的知識點,在這之前,需要先了解下什麼是改動序列。
在一個 C++ 程序中,每個對象都具有一個改動隊列,它由所有線程在對象上的全部寫操作構成,變量的值會隨着時間推移形成一個序列,不同線程觀察同一個變量的序列,正常情況下是一致的,如果出現不一致,就說明出現了數據競爭線程不安全的問題。
看圖,隨着時間的推移,一個變量可能做了圖中的改動,產生了一個改動序列,即(1,2,3,4,5,6,7,8,9,10),然而理論上來說,不同線程不能保證他們看見的是最新的值,比如同一時刻,線程 a 可能看見的是 5,線程 b 可能看見的是 4,線程 c 可能看見的是 3,d 是 4,e 是 2。然後過了一段時間,可能變成了 a(10),b(4),c(8),d(6),e(3)。
每個線程看到的只會是序列中上一次看到的之後的值,不可能是之前的,時光不能倒流。
同理,如圖:
如果有兩個變量,它們的改動序列如圖,然而同一時刻,理論上可能不同線程看到的值不同。
再回到上面那段代碼:
#include <iostream>
#include <thread>
int x = 0;
int y = 0;
void func1() {
x = 100;
y = 2;
}
void func2() {
while (y == 2) {
std::cout << x << std::endl;
break;
}
}
int main() {
std::thread t1(func1);
std::thread t2(func2);
t1.join();
t2.join();
}
t1 和 t2 線程看到的 x 和 y 值可能有(0,0)(0,2)(100,0)(100,2),所以上面的代碼運行時,正常會輸出 100,但是也有可能會輸出 0。
你可能會說,可得了吧,我測試了幾十萬次,輸出的都是 100,從沒出現過 0。是的,大概率都是 100,這種現象只有理論上會出現,而且估計意外只會在內存序相對寬鬆的 Arm 機器上會出現,正常的 X86 應該不會出現這種問題。但我們寫代碼還是要往標準了寫。
爲什麼會出現這種現象?
因爲編譯器有指令亂序的優化。
還是上面那段代碼:
int x = 0;
int y = 0;
void func() {
x = 100;
y = 2;
}
函數 func() 中,按順序來看可能是先執行 x = 100 再執行 y = 2,但實際情況可能不同,有可能編譯器會做一些指令重排序的優化,真正優化後的結果可能會是 y = 2,再 x = 100,重排序後再運行,結果和順序執行完全相同。
(你可能會問,爲什麼要做這種優化,先執行誰後執行誰都需要執行,有意義嗎?文中我只是舉一個比較簡單的例子,可能這裏沒有意義,但遇到真正複雜的代碼時指令重排序還是很有效的優化策略,其實如果你學過計算機體系結構就會知道,這種沒有任何依賴關係的指令是可以做並行優化的,具體是什麼術語我也記不起來了,好像是 SIMD。)
編譯器只會保證單線程環境下,優化執行的最終結果是一致的,所以這種優化就會導致多線程情況下的數據衝突問題,比如上面的代碼:
void func1() {
x = 100;
y = 2;
}
void func2() {
while (y == 2) {
std::cout << x << std::endl;
break;
}
}
int main() {
std::thread t1(func1);
std::thread t2(func2);
t1.join();
t2.join();
}
由於執行重排序的原因,無法保證另一個線程在執行 func2 的時候,x 和 y 的賦值順序,所以上面 x 的輸出,有可能是 0,也有可能是 100。
這也就是爲什麼會出現上面介紹的改動序列的原因。
那怎麼解決這種問題?
肯定是要在某些情況下,禁止這種指令重排序。
可以引入原子操作,我們可以把上面的 x 和 y 的定義改爲:
std::atomic<int> x = 0;
std::atomic<int> y = 0;
結果自然而然就會變得正常。
爲什麼?
因爲 C++ 的 atomic 不僅僅是原子操作,它很重要的一點是可以禁止這種指令重排序。
我們平時使用 atomic 可能都是這樣使用:
int value = x.load();
x.store(100);
但其實atomic的多數函數都是重載函數,它可以配置一些參數,這些參數就是內存序的類型參數:
x.store(100, std::memory_order_relaxed);
C++ 裏關於一共引入了 6 種內存序的類型:
-
memory_order_relaxexd:只有普通的原子性,沒有任何內存次序的要求。
-
memory_order_seq_cst:與代碼順序嚴格一致。
-
memory_order_acquire:載入語義,當前線程,load 操作之後的讀寫操作不能被重排序到當前指令前面。如果其它線程對此變量使用 release 的 store 操作,在當前線程是可見的。
-
memory_order_release:存儲語義,當前線程,store 操作之前的讀寫操作不能重排序到當前指令後面,如果其它線程對此變量使用了 acquire 的 load 操作,當前線程 store 之前的任何讀寫操作都對其它線程可見。
-
memory_order_acq_rel:它等於 acquire + release
-
memory_order_consume:C++17 中明確建議我們不使用此次序,以後會被廢棄掉,咱也就不糾結它了。
儘管有 6 種內存序,但其實可簡單劃分爲 3 種模式:
-
先後一致次序(Sequential Consistency Ordering):這就是 atomic 默認的內存次序,它是最直觀、最符合直覺的內存次序,所有關於此次序的實例,都嚴格保持先後順序,這種內存模型無法重新編排次序,它要求在所有線程間進行全局同步,因此也是代價最高的內存次序。
-
寬鬆次序(Relaxed Ordering):你可以理解爲使用搭配這種次序的 atomic,只有原子性,而對內存次序沒有任何要求,指令重排序之類的優化還是正常進行。
-
獲取 - 釋放次序(Acquire-Release Ordering):它比寬鬆次序嚴格一些,卻沒有先後一致次序那樣特別嚴格。在此次序模型中,載入(load)操作可以使用 memory_order_acquire 語義,存儲(store)可以使用 memory_order_release 語義,而讀 - 改 - 寫(fetch_add、exchange)可以使用 memory_order_acq_rel 語義。
所以,我們看下這段使用 relaxed 模型的代碼會不會觸發 assert:
#include <assert.h>
#include <atomic>
#include <thread>
std::atomic<bool> x, y;
std::atomic<int> z;
void write_x_then_y() {
x.store(true, std::memory_order_relaxed);
y.store(true, std::memory_order_relaxed);
}
void read_y_then_x() {
while (!y.load(std::memory_order_relaxed))
;
if (x.load(std::memory_order_relaxed))
++z;
}
int main() {
x = false;
y = false;
z = 0;
std::thread a(write_x_then_y);
std::thread b(read_y_then_x);
a.join();
b.join();
assert(z.load() != 0);
}
再看下使用 acquire-release 模型的代碼會不會觸發 assert:
#include <assert.h>
#include <atomic>
#include <thread>
std::atomic<bool> x, y;
std::atomic<int> z;
void write_x_then_y() {
x.store(true, std::memory_order_relaxed);
y.store(true, std::memory_order_release);
}
void read_y_then_x() {
while (!y.load(std::memory_order_acquire))
;
if (x.load(std::memory_order_relaxed)) ++z;
}
int main() {
x = false;
y = false;
z = 0;
std::thread a(write_x_then_y);
std::thread b(read_y_then_x);
a.join();
b.join();
assert(z.load() != 0);
}
使用先後一致次序模型的代碼這裏就不過多介紹了,atomic 的默認次序,肯定沒問題的。
所以在我們平時開發過程中,普通開發者不用管那麼多,使用默認的 atomic 次序就行,資深程序員可以自由選用,充分利用更加細分的次序關係來提升性能,比如寫一個高性能的無鎖隊列。
一般使用默認的 atomic 足以,我估計大多數人寫的代碼,性能瓶頸一般都在業務邏輯上,而不是這種內存模型上。
這裏還有個 memory fence 的概念,大體作用和上面介紹的類似,感興趣的可以自己瞭解一下哈。
寫到這裏,推薦大家看看這段無鎖隊列的代碼 https://github.com/taskflow/taskflow/blob/master/taskflow/core/tsq.hpp ,有助於理解 C++ 的內存模型。
參考資料:
https://en.cppreference.com/w/cpp/atomic/memory_order
https://mp.weixin.qq.com/s/t5_Up2YZEZt1NLbvgYz9FQ
《C++ 併發編程第二版》
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/vFU8pfOYSEEw7u_sKCGjBQ