如果不使用任何同步機制(例如 mutex 或 atomic),在多執行緒中讀寫同一個變數,那麼,程式的結果是難以預料的。簡單來說,編譯器以及 CPU 的一些行為,會影響到程式的執行結果:
- 即使是簡單的語句,C++ 也不保證是原子操作。
- CPU 可能會調整指令的執行順序。
- 在 CPU cache 的影響下,一個 CPU 執行了某個指令,不會立即被其它 CPU 看見。
利用 C++ 的 atomic<T>
能完成物件的原子的讀、寫以及RMW(read-modify-write),而引數 std::memory_order
規定了如何圍繞原子物件的操作進行排序。memory order
記憶體操作順序其實是 記憶體一致性模型 (Memory Consistency Model),解決處理器的 write
操作什麼時候能夠影響到其他處理器,或者說解決其他處理處理器什麼時候能夠觀測到當且 寫CPU/寫執行緒 寫入記憶體的值,有了 memory odering,我們就能知道其他處理器是怎麼觀測到 store
指令的影響的。
一致模型有很多種,在 Wikipedia 裡面搜尋 Consistency model 即可看到,目前 C++ 所用到有 Sequential Consistency 和 Relaxed Consistency 以及 Release consistency。
Memory Operation Ordering
我們所編寫的程式會定義一系列的 load
和 store
操作,也就是 Program ordering
,這些 load 和 store 的操作應用在記憶體上就有了記憶體操作序(memory operation ordering),一共有四種記憶體操作順序的限制,不同的記憶體一致模型需要保持不同級別的操作限制,其中 W
代表寫,R
代表讀:
- W -> R:寫入記憶體地址 X 的操作必須比在後面的程式定義序列的讀取地址 Y 之前提交 (commit), 以至於當讀取記憶體地址 Y 的時候,寫入地址 X 的影響已經能夠在讀取Y時被觀測到。
- R -> R: 讀取記憶體地址 X 的操作必須在後序序列中的讀取記憶體地址 Y 的操作之前提交。
- R -> W:讀取記憶體地址 X 的操作必須在後序序列中讀取記憶體地址 Y 的操作之前提交。
- W -> W:寫入記憶體地址 X 的操作必須在後續序列中寫入記憶體地址 Y 的操作之前提交。
提交的意思可以理解為,後面的操作需要等前面的操作完全執行完才能進行下一個操作。
Sequential consistency
序列一致是 Leslie Lamport 提出來的,如果熟悉分散式共識演算法 Paxos ,那麼應該不陌生這位大科學家,而序列一致的定義是:
the result of any execution is the same as-if (任何一種執行結果都是相同的就好像)
- the operations of all threads are executed in some sequential order (所有執行緒的操作都在某種次序下執行)
- the operations of each thread appear in this sequence in the order specified by their program (在全域性序列中的,各個執行緒內的操作順序由程式指定的一致)
組合起來:全域性序列中的操作序列要和執行緒所指定的操作順序要對應,最終的結果是所有執行緒指定順序操作的排列,不能出現和程式指定順序組合不出來的結果。
怎麼做會違反 sequcential consistency(SC)?也就是 SC 的反例是什麼?
- 亂序執行 (out-of-order)
- 記憶體訪問重疊,寫A的過程中讀取A,寬於計算機word的,64位機器寫128位變數
更加形象的理解可以從記憶體的角度來看:
所有的處理器都按照 program order
發射 load
和 store
的操作,而記憶體一個地一個地從上面 4 個處理器中讀取指令,並且僅當完成一個操作後才會去執行下一個操作,類似於多個 producer
一個 consumer
的情況。
(Lamport 一句話,讓我為他理解了一下午)
SC 需要保持所有的記憶體操作序(memory operation ordering),也是最嚴格的一種,並且 SC 是 c++ atomic<T>
預設的以一種記憶體模型,對應 std::memory_order_seq_cst
,可以看到標準庫中的函式定義將其設定為了預設值:
bool
load(memory_order __m = memory_order_seq_cst) const noexcept
{ return _M_base.load(__m); }
Relaxed Consistency
鬆弛記憶體序,對應的 std::memory_order_relaxed
,在 cppreference 上的說明是:"不保證同步操作,不會將一定的順序強加到併發記憶體訪問上,只保證原子性和修改順序一致性",並且通常用於計數器,比如 shared_ptr
的引用計數。
鬆弛記憶體序不再保證 W -> R,不相互依賴的讀寫操作可以在 write 之前或者在同一時間段並行處理。(讀記憶體並不是想象中的那麼簡單,有記憶體定址過程,將記憶體資料對映到 cache block,傳送不合法位用於快取替換)
好處是什麼?效能,執行命令的寫操作的延遲都被抹去了,cpu 能夠更快的執行完一段帶有讀寫的指令序列。
具體實現是通過在 cpu 和 cache 之間加入一個 write buffer,如下圖:
處理器 Write
命令將會傳送到 Write Buffer
,而 Read
命令就直接能訪問 cache,這樣可以省去寫操作的延遲。Write Buffer
還有一個細節問題,放開 W -> R 的限制是當 Write
和 Read
操作記憶體地址不是同一個的時候,R/W 才能同時進行甚至 R 能提前到 W 之前,但如果 Write Buffer
中有一個 Read
所依賴的記憶體地址就存在問題,Read
需要等在 Write buffer
中的 Write
執行完成才能繼續嗎?只需要 Read
能直接訪問這個 Write Buffer
,如下(注:這裡的Load
通常和Read
等意,Store
和Write
等意):
Release Consistency
在這種一致性下,所有的 memory operation ordering 都將不再維護,是最激進的一種記憶體一致模型,進入臨界區叫做 Acquire
,離開臨界區叫做 Release
。所有的 memory operation ordering
都將不再維護,處理器支援特殊的同步操作,所有的記憶體訪問指令必須在 fence
指令傳送之前完成,在 fench
命令完成之前,其他所有的命令都不能開始執行。
Intel x86/x64 晶片在硬體層面提供了 total store ordering 的能力,如果軟體要求更高階別的一致性模型,處理器提供了三種指令:
- mm_lfence:load fence,等待所有 load 完成
- mm_sfence:store fence,等待所有 store 完成
- mm_mfence:完全讀寫屏障
而在 ARM 架構上,提供的是一種非常鬆弛(very relaxed)記憶體一致模型。
PS. 曾經有個公司做出了支援 Sequential Consistency 的硬體,但是最終還是敗給了市場。
Acquire/Release
Acquire/release 對應 std::memory_order_acquire
和 std::memory_order_acquire
,它們的語義解釋如下:
- Acquire:如果一個操作 X 帶有 acquire 語義,那麼在操作 X 後的所有
load/store
指令都不會被重排序到操作 X 之前,其他處理器會在看到操作X後序操作的影響之前看到操作 X 的影響,也就是必須先看到 X 的影響,再是後續操作的影響。 - Relase:如果一個操作 X 帶有 release 語義,那麼在操作 X 之前的所有
load/store
指令操作都不會被重排序到操作 X 之後,其他處理器會先看到操作 X 之前的操作。
Acquire/Release 常用在互斥鎖(mutex lock)和自旋鎖(spin lock),獲得一個鎖和釋放一個鎖需要分別使用 Acquire 和 Release 語義防止指令操作被重排出臨界區,從而造成資料競爭。
Acquire/Consume
Acquire/Consume 對應 std::memory_order_acquire
和 std::memory_order_consume
,兩種記憶體模型的組合僅有 consume 不同於 release,不同點在於,假設原子操作 X, Release 會防止 X 之前的所有指令不會被重排到 X 之後,而 Consume 只能保證依賴的變數不會被重排到 X 之後,引入了依賴關係。
但是在 cppreference 上面寫著,“釋放消費順序的規範正在修訂中,而且暫時不鼓勵使用 memory_order_consume
。”,所以暫時不對其做深入的研究。
Volatile
volatile 關鍵詞通常會被拿出來說,因為通常會在併發程式設計中被錯誤使用:
volatile 的翻譯是“不穩定的,易發生變化的”,編譯器會始終讀取 volatile 修飾的變數,不會將變數的值優化掉,但是這不是用線上程同步的工具,而是一種錯誤行為,cppreference上面寫道:“volatile 訪問不建立執行緒間同步,volatile 訪問不是原子的,且不排序記憶體,非 volatile 記憶體訪問可以自由地重排到 volatile 訪問前後。”(Visual Studio 是個例外)。
volatile 變數的作用是用在非常規記憶體上的記憶體操作,常規記憶體在處理器不去操作的時候是不會發生變化的,但是像非常規記憶體如記憶體對映I/O的記憶體,實際上是在和外圍裝置做串列埠通訊,所以不能省去。(《modern effective c++》)