C++ 中的 volatile 和 atomic

Zijian/TENG發表於2024-03-31

C++ 中的 volatile 和 atomic

0. TL;DR

  • std::atomic 用於多執行緒併發場景,有兩個典型使用場景:

    • 原子操作:對 atomic 變數的操作(讀/寫/自增/自減)彷彿受互斥量保護。一般透過特殊的機器指令實現,比使用互斥量更高效
    • 限制編譯器/硬體對賦值操作的重新排序
  • volatile 和多執行緒併發沒有任何關係,用於防止編譯器最佳化掉對特殊變數的“冗餘”讀寫操作

1. Data Race 和未定義行為

C++ 中有很多未定義行為,Data Race 便是其中之一。

⚠️ 如果一個變數不是 atomic 變數,且沒有互斥量保護,在一個執行緒中執行寫操作,同時在另一個執行緒中讀取,則會產生 Data Race,其行為未定義!

例如在執行下面程式碼的過程中,另一個執行緒同時讀取 i 的值,讀到的值可能是 -13,42,0,43923... 任何值!

int i = 0;
++i;
--i;

雖然在這種場景下,你讀到的大機率是0/1,但要知道,對於未定義行為,理論上可能讀到任何值!!

2. atomic

std::atomic 是 C++ 中的模版類,一般用於 bool、整型、指標型別,如 atomic<bool>atomic<int>atomic<Widget*> 等。對 atomic 變數的操作(讀/寫/自增/自減)彷彿受互斥量保護(底層一般透過特殊的機器指令實現,比使用互斥量更高效)。

2.1 原子操作

atomic 的第一個應用場景就是多執行緒讀寫變數

atomic<int> ai {0};
ai = 10;         // 原子寫
std::cout << ai; // 原子讀
++ai;            // 原子自增為11
--ai;            // 原子自減為10

執行上述程式碼期間,如果在另一個執行緒讀取 ai 的值,只可能讀到 0/10/11,不可能有其他結果

2.2 限制重排序

atomic 的第二個應用場景是,當某個變數在兩個任務之間傳遞資訊時,防止對該變數賦值進行重新排序。假設 a、b、x、y 是 4 個獨立的變數:

a = b;
x = y;

為了提升效能,編譯器可能會對不相關的賦值進行重新排序為:

x = y;
a = b;

即使編譯器不這麼做,底層硬體也可能這麼做。atomic 可以避免這種重排序。例如在一個任務中計算 value,另一個任務中依賴 value 的可用性,則可以藉助 atomic 變數實現:

atomic<bool> valueAvailable(false);
auto value = computeValue();
valueAvailable = true;

std::atomic 預設採用順序一致性模型,會限制重新排序:不僅編譯器會保證 value 在 valueAvailable 之前賦值,底層硬體也保證這個順序。

⚠️ C++ 還提供了其他更靈活的一致性模型(如 memory_order_relaxed),除非你是這方面的專家,很清楚不同記憶體序產生的影響,否則不要輕易使用。

2.3 load/store

有的開發者喜歡使用 load()/store() 成員函式,這不是必須,但可以起到強調作用:

  • 強調該變數涉及多執行緒併發操作
  • 強調 atomic 變數可能導致效能問題
    • 雖然 atomic 比互斥量更高效,但仍然比普通變數慢、開銷大
    • atomic 變數可能會阻止重新排序最佳化

3. volatile

如果將上面的例子中的 atomic<bool> 換成 volatile bool,既無法保證操作的原子性,也無法限制對 value 和 valueAvailable 賦值的重新排序。

volatile bool valueAvailable(false);
auto value = computeValue();
valueAvailable = true;

對於普通記憶體來說,如果向某個記憶體寫一個值,該值會一直保留在記憶體,直到被下一個寫操作覆蓋。編譯器可以對普通記憶體的變數讀寫進行最佳化,例如下面這段程式碼(雖然一般開發者不會直接寫出這樣的程式碼,但是經過模版例項化、內聯、重排序等最佳化後,很可能產生類似的程式碼):

int x;
auto y = x; // 讀取 x
y = x;  // 再次讀取 x
x = 10; // 寫入 x
x = 20; // 再次寫入 x

可能被最佳化為:

auto y = x;
x = 20;

但是在有些特殊場景下(例如和外設互動),變數對應特殊的記憶體區域:

  • x 可能是一個感測器的值,連續兩次讀取 x 的值可能不同,不應該被最佳化

  • x 可能對應某個無線電發射器的控制埠,x = 10x = 20 對應兩條不同的指令,不應該被最佳化

volatile 的作用正是告訴編譯器,某個變數所對應記憶體是特殊記憶體,不要進行任何最佳化!

將 x 宣告為 volatile int x 即可避免上述最佳化,而宣告為 atomic<int> x 則沒有這個效果。

⚠️ 經常聽到一種說法:開啟編譯器最佳化選項之後,會導致程式行為異常。但通常都不是編譯器的問題,而是自己的程式碼不規範,或依賴了未定義行為導致的!

思考:

  • volatile 和 atomic 可以同時使用嗎,用於什麼場景?
  • volatile 和 const 可以同時使用嗎,用於什麼場景?

4. Reference

《Effective Morden C++》條款 40

相關文章