簡介 C++11 atomic 和 memory order

fcamel
fcamel的程式開發心得
7 min readNov 6, 2016

前一篇文章是以一個小例子從開發者的角度,從上層到下層說明 thread 之間何時會同步資料。這篇只從 C++ 的角度討論 C++11 訂的 API。

Architectures (e.g., x86, ARM) 為了提升效率,會作許多事,這裡借用《C++ and Beyond 2012: Herb Sutter — atomic<> Weapons》的圖:

從上表可知,有些最佳化會影響到程式執行的效果:

上述操作和 Memory model 有關。Memory model 討論 thread 或 CPU (看是從程式語言或 architectures 的角度看) 如何同步資料 (會被上述幾件事影響),並提供 API 讓開發者寫 multi-thread 程式的時候可以保證程式正確 。

在 C++11 定義 memory model 以前,開發者得自己用 Architectures 提供的 API,不同平台都要作一次,很累也容易出錯。有了 C++ 11 memory model,開發者只要用 C++11 提供的 API 寫一次就好了,剩下的就交給 compiler 去煩惱吧。

C++11 提供許多方法同時解決上面說的問題,像是易於使用的 mutex 和高效能的 atomicmutex 很好理解,以下我們將重點放在 atomic。

atomic 的 loadstore 本身是 atomic operation (不見得是 lock-free,可用 is_lock_free 確認)。確保 atomic variable 的 load 會讀到 store 寫入最新的值,並且 load/store 之間沒有 data race。舉例來說,在 32-bit architecture 上需要兩個指令才能寫入 int64_t,所以讀 int64_t 變數的時候可能會讀到寫到一半的結果。型別改用 std::atomic<std::int64_t> 的話,可以確保 load 不會讀到不完整的更新。

此外,load/store 帶有參數 memory_order 影響其它非 atomic 資料同步的情況。後面簡述 memory_order 的效果。

memory_order_relaxed

memory order 沒有額外保證。準官方文件有個有趣的例子:

// Thread 1:
r1 = y.load(memory_order_relaxed); // A
x.store(r1, memory_order_relaxed); // B
// Thread 2:
r2 = x.load(memory_order_relaxed); // C
y.store(42, memory_order_relaxed); // D

程式結果有可能是 r1 == r2 == 42。我的理解是 C 和 D 可以互調順序 (這樣沒改變 single thread 執行時的語意),互調後有可能造成 r1 == r2 == 42。

memory_order_consume

和 memory_order_acquire 功用相似,但對 architecture 的限制比較弱 (有更多最佳化空間)。但是直到 2016/06 的進展,全部 compiler 的實作都將 memory_order_consume 當作 memory_order_acquire,詳見《P0371R1: Temporarily discourage memory_order_consume》 (感謝 Scott 告知)。

關於 memory_order_consume 的語意和優勢,詳見《The Purpose of memory_order_consume in C++11》《N1525: Memory-Order Rationale》 舉的例子。

memory_order_acquire 和 memory_order_release

這兩個 order 要一起使用。假設 thread 1 使用 load-acquire X,thread 2 使用 store-release X。合起來的效果是:

  • thread 1 load-acquire 之後可以看見 thread 2 store-release 之前寫入的資料。
  • thread 1 load-acquire 之後的 read/write 不能搬到 load-acquire 之前。
  • thread 2 store-release 之前的 read/write 不能搬到 store-release 之後。

三者合在一起才有效果,所以有這樣的限制滿合理的。注意這個同步是所有用 X 的 thread 都有效。一個 thread store-release X,兩個 thread load-acquire X,結果是這三個 thread 都會看到第一個 thread 寫入 X 以及 store-release 之前寫入的值。更詳細的說明可以參考《Acquire and Release Semantics》,這位作者寫了許多簡短易懂的介紹。

memory_order_acq_rel

同時有 acquire + release。

memory_order_seq_cst

預設值,用起來和 single thread 一樣,效果最強。附帶一提,atomic::operator= 和 atomic::store() 等價。

Scott 說這個限制太強,和 architectures 運作方式差太多,有些情境可能會比 mutex 還慢。比方說 critical section 內有多個變數,考慮兩個作法:

  1. 用 mutex 保護,只作一次 acquire lock、一次 release lock。
  2. 變數都宣告成 atomic,然後 load/store 的 memory order 用 memory_order_seq_cst。

結果 1 可能會比 2 快,因為 2 要求太多不必要的資料同步,而同步資料的代價頗高的。一知半解的人可能會真的這麼作,需要留意一下。

結語

這裡都是從 C++11 的角度來看,compilers 依平台可能會提供更強的限制。看的時候不要管各個 architectures 如何運作,會比較容易理解。

C++11 atomic 還有提供 atomic_thread_fence,有多個 atomic operation 的時候,讓 atomic operation 使用 memory_order_relaxed,然後配上一個 fence 使用 memory_order_acquire/memory_order_release 會比較有效率。《N1525: Memory-Order Rationale》有個 C 的例子:

不過我覺得 load/store 直接使用 non- relaxed memory order 比較容易理解,這裡不深入討論 atomic_thread_fence,也可以參考《Acquire and Release Fences Don’t Work the Way You’d Expect》cppreference。。

總結來說:

  • 只需要單一變數是 atomic 也不關心前後資料同步的話,用 load/store 配 memory_order_relaxed (使用情境: 自己實作 shared pointer 的 reference count)。
  • 需要同步某個區塊的話用 load-acquire + store-release,同時影響程式執行順序和資料同步的時機。
  • 怕寫錯就用 mutex 吧,可以少煩惱很多事。

相關文章

--

--