記憶體屏障在CPU、JVM、JDK中的實現

等不到的口琴發表於2021-02-02

前言

記憶體屏障(英語:Memory barrier),也稱記憶體柵欄,記憶體柵障,屏障指令等,是一類同步屏障指令,它使得 CPU 或編譯器在對記憶體進行操作的時候, 嚴格按照一定的順序來執行, 也就是說在記憶體屏障之前的指令和記憶體屏障之後的指令不會由於系統優化等原因而導致亂序。

大多數現代計算機為了提高效能而採取亂序執行,這使得記憶體屏障成為必須。

語義上,記憶體屏障之前的所有寫操作都要寫入記憶體;記憶體屏障之後的讀操作都可以獲得同步屏障之前的寫操作的結果。因此,對於敏感的程式塊,寫操作之後、讀操作之前可以插入記憶體屏障。

為什麼要有記憶體屏障

因為重排序,同步的目的是保證不同執行流對共享資料併發操作的一致性。在單核時代,使用原子變數就很容易達成這一目的。甚至因為CPU的一些訪存特性,對某些記憶體對齊資料的讀或寫也具有原子的特性。但在多核架構下即使操作是原子的,仍然會因為其他原因導致同步失效。

首先是現代編譯器的程式碼優化和編譯器指令重排可能會影響到程式碼的執行順序。

其次還有指令執行級別的亂序優化,流水線、亂序執行、分支預測都可能導致處理器次序(Process Ordering,機器指令在CPU實際執行時的順序)和程式次序(Program Ordering,程式程式碼的邏輯執行順序)不一致。可惜不影響語義依舊只能是保證單核指令序列間,單核時代CPU的Self-Consistent特性在多核時代已不存在(Self-Consistent即重排原則:有資料依賴不會進行重排,單核最終結果肯定一致)。

除此還有硬體級別Cache一致性(Cache Coherence)帶來的問題:CPU架構中傳統的MESI協議中有兩個行為的執行成本比較大。一個是將某個Cache Line標記為Invalid狀態,另一個是當某Cache Line當前狀態為Invalid時寫入新的資料。所以CPU通過Store Buffer和Invalidate Queue元件來降低這類操作的延時。如圖:

當一個核心在Invalid狀態進行寫入時,首先會給其它CPU核傳送Invalid訊息,然後把當前寫入的資料寫入到Store Buffer中。然後非同步在某個時刻真正的寫入到Cache Line中。當前CPU核如果要讀Cache Line中的資料,需要先掃描Store Buffer之後再讀取Cache Line(Store-Buffer Forwarding)。但是此時其它CPU核是看不到當前核的Store Buffer中的資料的,要等到Store Buffer中的資料被刷到了Cache Line之後才會觸發失效操作。

而當一個CPU核收到Invalid訊息時,會把訊息寫入自身的Invalidate Queue中,隨後非同步將其設為Invalid狀態。和Store Buffer不同的是,當前CPU核心使用Cache時並不掃描Invalidate Queue部分,所以可能會有極短時間的髒讀問題。這裡的Store Buffer和Invalidate Queue的說法是針對一般的SMP架構來說的,不涉及具體架構。

記憶體對於快取更新策略,要區分Write-Through和Write-Back兩種策略。前者更新內容直接寫記憶體並不同時更新Cache,但要置Cache失效,後者先更新Cache,隨後非同步更新記憶體。通常X86 CPU更新記憶體都使用Write-Back策略。

編譯器屏障 Compiler Barrior

/* The "volatile" is due to gcc bugs */
#define barrier() __asm__ __volatile__("": : :"memory") 

阻止編譯器重排,保證編譯程式時在優化屏障之前的指令不會在優化屏障之後執行。

CPU屏障 CPU Barrior

CPU級別記憶體屏障其作用有兩個:

  1. 防止指令之間的重排序
  2. 保證資料的可見性

指令重排中Load和Store兩種操作會有Load-Store、Store-Load、Load-Load、Store-Store這四種可能的亂序結果。

Intel為此提供三種記憶體屏障指令:

  • sfence ,實現Store Barrior 會將store buffer中快取的修改刷入L1 cache中,使得其他cpu核可以觀察到這些修改,而且之後的寫操作不會被排程到之前,即sfence之前的寫操作一定在sfence完成且全域性可見;
  • lfence ,實現Load Barrior 會將invalidate queue失效,強制讀取入L1 cache中,而且lfence之後的讀操作不會被排程到之前,即lfence之前的讀操作一定在lfence完成(並未規定全域性可見性);
  • mfence ,實現Full Barrior 同時重新整理store buffer和invalidate queue,保證了mfence前後的讀寫操作的順序,同時要求mfence之後寫操作結果全域性可見之前,mfence之前寫操作結果全域性可見;
  • lock 用來修飾當前指令操作的記憶體只能由當前CPU使用,若指令不操作記憶體仍然由用,因為這個修飾會讓指令操作本身原子化,而且自帶Full Barrior效果;還有指令比如IO操作的指令、exch等原子交換的指令,任何帶有lock字首的指令以及CPUID等指令都有記憶體屏障的作用。

X86-64下僅支援一種指令重排:Store-Load ,即讀操作可能會重排到寫操作前面,同時不同執行緒的寫操作並沒有保證全域性可見,例子見《Intel® 64 and IA-32 Architectures Software Developer’s Manual》手冊8.6.1、8.2.3.7節。要注意的是這個問題只能用mfence解決,不能靠組合sfence和lfence解決。(用sfence+lfence組合僅可以解決重排問題,但不能解決全域性可見性問題,簡單理解不如視為sfence和lfence本身也能亂序重拍)

X86-64一般情況根本不會需要使用lfence與sfence這兩個指令,除非操作Write-Through記憶體或使用 non-temporal 指令(NT指令,屬於SSE指令集),比如movntdq, movnti, maskmovq,這些指令也使用Write-Through記憶體策略,通常使用在圖形學或視訊處理,Linux程式設計裡就需要使用GNC提供的專門的函式(例子見參考資料13:Memory part 5: What programmers can do)。

下面是GNU中的三種記憶體屏障定義方法,結合了編譯器屏障和三種CPU屏障指令

#define lfence() __asm__ __volatile__("lfence": : :"memory") 
#define sfence() __asm__ __volatile__("sfence": : :"memory") 
#define mfence() __asm__ __volatile__("mfence": : :"memory") 

程式碼中仍然使用lfence()與sfence()這兩個記憶體屏障應該也是一種長遠的考慮。按照Interface寫程式碼是最保險的,萬一Intel以後出一個採用弱一致模型的CPU,遺留程式碼出問題就不好了。目前在X86下面視為編譯器屏障即可。

GCC 4以後的版本也提供了Built-in的屏障函式__sync_synchronize(),這個屏障函式既是編譯屏障又是記憶體屏障,程式碼插入這個函式的地方會被安插一條mfence指令。

C++11為記憶體屏障提供了專門的函式std::atomic_thread_fence,方便移植統一行為而且可以配合記憶體模型進行設定,比如實現Acquire-release語義:

#include <atomic>
std::atomic_thread_fence(std::memory_order_acquire);
std::atomic_thread_fence(std::memory_order_release);

記憶體模型

Acquire與Release語義

  • 對於Acquire來說,保證Acquire後的讀寫操作不會發生在Acquire動作之前
  • 對於Release來說,保證Release前的讀寫操作不會發生在Release動作之後

Acquire & Release 語義保證記憶體操作僅在acquire和release屏障之間發生

X86-64中Load讀操作本身滿足Acquire語義,Store寫操作本身也是滿足Release語義。但Store-Load操作間等於沒有保護,因此仍需要靠mfence或lock等指令才可以滿足到Synchronizes-with規則。

Happens-before

相對於Synchronizes-with規則更寬鬆,happens-before規則定義指令執行順序與變數的可見性,類似偏序關係,具有可傳遞性,因此可以運用於並行邏輯分析。

Happens-before關係是對在一個執行緒內執行的操作在另一個執行緒內的操作的可見性保證。

Happens-before 定義程式中所有操作的偏序關係。為了保證操作 Y 的執行執行緒能觀察到操作 X 的結果(不管 X 和 Y 是否發生在不同的執行緒內),就必須在 X 和 Y 之間存在 Happens-before 關係。如果在兩個操作之間缺少 happens-before 順序,那麼 JVM 會任意地對操作進行重排序(JIT 編譯優化)。

Happens-before不僅僅是在時序上對操作進行重排序,它也是對記憶體讀寫順序的保證。兩個執行緒執行記憶體的讀寫操作可以在時間上對相互間的操作保持一致,但是可能不能一致地觀察到彼此的改變(記憶體一致性錯誤),除非它們之間存在 happens-before 關係。

記憶體一致性模型 Memory Model

記憶體一致性模型從程式設計師視角,由記憶體序Memory Ordering和寫操作原子性Store Atomicity來定義,針對不同執行緒中原子操作的全域性順序:

  • Strong Consistency / Sequential consistency 順序一致性
  • Release Consistency / release-acquire / release-consume
  • Relaxed Consistency

C++11相應定義了6種記憶體模型:

  • std::memory_order_seq_cst 所有讀寫操作不能跨過,寫順序全執行緒可見
  • std::memory_order_acq_rel 所有讀寫操作不能跨過,寫順序僅同步執行緒間可見、std::memory_order_release 所有讀寫操作不能往後亂序、std::memory_order_acquire 所有讀寫操作不能向前亂序、std::memory_order_consume 依賴該讀操作的後續讀寫操作不能向前亂序
  • std::memory_order_relaxed 無特殊要求

volatile 關鍵字

voldatile關鍵字首先具有“易變性”,宣告為volatile變數編譯器會強制要求讀記憶體,相關語句不會直接使用上一條語句對應的的暫存器內容,而是重新從記憶體中讀取。

其次具有”不可優化”性,volatile告訴編譯器,不要對這個變數進行各種激進的優化,甚至將變數直接消除,保證程式碼中的指令一定會被執行。

最後具有“順序性”,能夠保證Volatile變數間的順序性,編譯器不會進行亂序優化。不過要注意與非volatile變數之間的操作,還是可能被編譯器重排序的。

需要注意的是其含義跟原子操作無關,比如:volatile int a; a++; 其中a++操作實際對應三條彙編指令實現”讀-改-寫“操作(RMW),並非原子的。

思考:bool型別是不是適合使用,不會出問題。

不同程式語言中voldatile含義與實現並不完全相同,Java語言中voldatile變數可以被看作是一種輕量級的同步,因其還附帶了acuire和release語義。實際上也是從JDK5以後才通過這個措施進行完善,其volatile 變數具有 synchronized 的可見性特性,但是不具備原子特性。Java語言中有volatile修飾的變數,賦值後多執行了一個“load addl $0x0, (%esp)”操作,這個操作相當於一個lock指令,就是增加一個完全的記憶體屏障指令,這點與C++實現並不一樣。volatile 的讀效能消耗與普通變數幾乎相同,但是寫操作稍慢,因為它需要在原生程式碼中插入許多記憶體屏障指令來保證處理器不發生亂序執行。

Java實踐中僅滿足下面這些條件才應該使用volatile關鍵字:

  • 變數寫入操作不依賴變數當前值,或確保只有一個執行緒更新變數的值(Java可以,C++仍然不能)
  • 該變數不會與其他變數一起納入
  • 變數並未被鎖保護

C++中voldatile等於插入編譯器級別屏障,因此並不能阻止CPU硬體級別導致的重排。C++11 中volatile語義沒有任何變化,不過提供了std::atomic工具可以真正實現原子操作,而且預設加入了記憶體屏障(可以通過在store與load操作時設定記憶體模型引數進行調整,預設為std::memory_order_seq_cst)。

C++實踐中推薦涉及併發問題都使用std::atomic,只有涉及特殊記憶體操作的時候才使用volatile關鍵字。這些情況通常IO相關,防止相關操作被編譯器優化,也是volatile關鍵字發明的本意。

站在巨人的肩膀上

  1. volatile與記憶體屏障總結

  2. Java - Happens-before relationship

  3. Acquire and Release Semantics

相關文章