C++ atomic 和 memory ordering 筆記

pokpok發表於2022-04-25

如果不使用任何同步機制(例如 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

我們所編寫的程式會定義一系列的 loadstore 操作,也就是 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 (任何一種執行結果都是相同的就好像)

  1. the operations of all threads are executed in some sequential order (所有執行緒的操作都在某種次序下執行)
  2. 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 發射 loadstore 的操作,而記憶體一個地一個地從上面 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 的限制是當 WriteRead 操作記憶體地址不是同一個的時候,R/W 才能同時進行甚至 R 能提前到 W 之前,但如果 Write Buffer 中有一個 Read 所依賴的記憶體地址就存在問題,Read 需要等在 Write buffer 中的 Write 執行完成才能繼續嗎?只需要 Read 能直接訪問這個 Write Buffer,如下(注:這裡的Load通常和Read等意,StoreWrite等意):

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_acquirestd::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_acquirestd::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++》)

相關文章