原子,鎖,還有記憶體屏障
http://www.dutor.net/index.php/2013/10/atomic-lock-memory-barrier/
原子
“在古希臘文中,原子就是不可再分的含義“。在程式設計的內涵下,『原子』性表示一個操作的中間狀態對外的不可見性,體現在記憶體修改的中間狀態不可見,體現在 CPU 指令的不可中斷。原子操作是併發環境的基礎,是互斥鎖實現的必要條件。這裡說的併發環境,是指多個執行序列,共享了某些狀態,執行在單個或多個 CPU 核心之上。為了說明哪些操作是原子的,哪些不是,以一個對整型計數器的遞增操作為例。考慮下面三種實現,為了『清晰』,使用 GCC inline assembly 呈現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
|
程式中,兩個執行緒,分別對初值為 0 的全域性變數 counter 遞增 1000000 次,改變 INCR 巨集呼叫不同實現的遞增函式。在一臺 64 位 i7 雙核 4 執行緒 CPU 上面測試。
顯而易見,non_atomic_incr 不是原子的,因為遞增操作使用了 3 條指令。
ump_atomic_incr 比較有趣:沒有定義 BINDING 巨集時非原子,定義了 BINDING 巨集時,兩個執行緒執行在同一個 CPU核心上,為原子操作。因為 ump_atomic_incr 的遞增只有一條指令,而『指令的執行』是原子的,不會因排程而中斷。因此,當兩個執行緒執行在同一個核心上,指令執行的原子性就保證了遞增操作的原子性。另外,由於 incl 指令是一個RMW(Read,Modify,Write) 操作,指令執行包括三個階段:讀記憶體,修改變數,寫記憶體。如果兩個執行緒執行在不同的CPU 核心,不同的核心在訪問記憶體時,就會對記憶體匯流排的使用發起見縫插針式的競爭,從而就可能看到其他核心對記憶體修改的中間狀態。
下面言論只針對現代 Intel 64(X86-64) 架構,參考於 《Intel Developer Manual V3A》。CPU 訪內指令可以分為三類:只讀,只寫,讀寫。如果寫操作或者讀操作的運算元不大於處理器字長(通常就是匯流排寬度),且該運算元對齊於其自然邊界,這個寫操作就只需要一次訪存,是原子的。由於讀寫操作需要多次訪問記憶體,CPU 預設不保證其原子性,但是引入一個被稱作 lock 的指令字首(smp_atomic_incr)。lock 字首只能用於訪存指令,該指令執行期間,記憶體匯流排會被鎖定,直至指令執行結束。但是,冠以 lock 字首的訪存指令並不一定每次都鎖匯流排:如果運算元已經存在於 Cache 中,就沒有必要訪問記憶體,也就沒有必要鎖定匯流排。
互斥鎖
很多時候,我們的共享資料都大於一個字長,更新操作也不是一條指令就可以完成的。更多時候,我們還需要保證一組共享資料的一系列更新的原子性。這時候就用到了鎖。鎖提供了一種同步機制,實現臨界區的互斥訪問(通過 lock/unlock),維護共享狀態的一致性及基於共享資料的操作的原子性。
如何實現鎖機制呢?首先,鎖本身也是一個共享資料,對鎖的狀態的更新也應該是安全的,否則臨界區的安全就無從談起,這就需要用到上面描述的原子操作。然後,翻翻課本,實現同步機制需要滿足幾個條件:
- 空閒讓進,如果鎖沒有被佔用,lock 就應該成功;
- 忙則等待,如果鎖已經被佔用,lock 呼叫方就需要等待(忙等/休眠);
- 有限等待,無限等待就是通常所說的『死鎖』了;
- 讓權等待,不要佔著茅坑不拉屎。
對於鎖的實現者,我們只要滿足前兩個條件就行了,其他的交給使用者來處理。下面是一個自選鎖的簡單實現(有問題,後面講):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
|
記憶體屏障
說到記憶體屏障(Memory Barrier),就不得不提記憶體模型(Memory Model),但記憶體模型的話題太大太複雜,我仍處在初級的探索階段,沒有能力做過多的展開。籠統地講,記憶體模型主要是針對多處理器環境之上的記憶體訪問順序、處理器間記憶體狀態修改的可見性做了說明和限制。每個處理器架構都有自己的記憶體模型,屬於硬體級別的記憶體模型。另外,很多語言(Java
5+,C++11,Go)在不同硬體記憶體模型的基礎上抽象出了一致的記憶體模型,屬於軟體級別的記憶體模型。總之,記憶體模型是一個相當抽象的概念,一般來講,只有在多處理環境做 lock-free 程式設計的時候才需要考慮到。理解抽象的東西,最好的方法就是通過大量接地氣的具體例子,眼見為實,反覆把玩,獲得感性的認識,在此基礎上聯絡抽象概念,這樣才能建立系統的認知框架。
怎樣才算具體呢,首先要有一段可以執行的程式碼,有明確的測試目的,當然,還要有一臺知道 CPU 型號的計算機。下面就通過一個活生生的例子來說明記憶體屏障的必要性,使用的仍然是 X86-64 架構的 Intel
Core(TM) i7-3520。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
|
class Peterson 實現了一個互斥鎖,是 Peterson 演算法的一個 C++ 實現,僅適用於兩個執行緒的同步操作,其正確性由 Gary L. Peterson 老先生的人品來保證。執行此程式:
1 2 3 4 5 6 7 8 9 10 11 |
$ g++ peterson.cpp -pthread $ ./a.out counter: 1999980 $ ./a.out counter: 1999986 $ ./a.out counter: 2000000 $ ./a.out counter: 1999946 $ ./a.out counter: 1999911 |
可以看到,大部分情況下,程式結果是錯誤的。
在解釋錯誤原因之前,要先講一下『執行亂序』(Out of Order Execution)。程式指令的實際執行和 C++ 程式碼的書寫順序可能是不一致的,主要體現在兩個階段:編譯和執行。
在編譯階段,編譯器可能對程式進行優化,在不影響程式執行結果(單執行緒角度)的情況下,調整語句的順序,從而影響最終生成的二進位制程式碼。在多執行緒環境,如果編譯器對(未加鎖保護的)共享變數的訪問做了順序調整,就可能造成程式結果不符合預期。有兩種方式應對這種情況:禁止編譯優化,編譯器會老老實實地生成程式碼;
顯式地在特定位置加入『指示』,告訴編譯器在調整指令順序時不要跨越這個位置(即屏障),這種方式不會生成任何可執行的指令。GCC 中這個指示是一個不包含任何指令的內聯彙編語句 asm volatile(“”:::”memory”)。
在執行階段,程式指令已經確定,CPU 根據程式計數器(PC)『順序』載入和執行程式碼。但現代處理器由於引入了指令流水和 Cache,為了最大化處理速度,減少由於 Cache Miss 造成的執行流阻塞,在保證正確性(單處理器角度)的前提下,允許後加載的指令先執行(Memory Barriers: a Hardware View for Software Hackers)。這也是為什麼這種亂序被稱作Memory Reordering,而不是 Instruction Reordering 的原因。這種亂序執行,對於單處理器是沒有任何問題的。但在多處理環境下,對多執行緒間的共享資料的亂序訪問,就可能出現記憶體可見性帶來的邏輯問題。之前也做過一個相關的、簡單但沒有多少實際意義的測試,見這裡。
現在回到 Peterson 程式上來。程式執行結果不符合預期,那一定是兩個執行緒同時進入了臨界區。在 Peterson 演算法正確性可以保證的前提下,是什麼造成『忙則等待』被打破呢?不錯,亂序執行(變數的更新只有單純的讀和寫,原子性可以保證)。那麼亂序是因為編譯優化造成的嗎?不是,為什麼不是呢?因為我們沒有讓編譯器進行優化,這一點可以通過分析彙編程式碼加以驗證(略)。那麼就是指令執行時發生亂序了。哪些操作亂序了呢?一定是共享變數(廢話)。執行緒共享的變數有三個,兩個 _interested[0],還有 _victim。至於是哪些操作發生了亂序,在不瞭解程式執行的 CPU 所使用的記憶體模型的情況下,是無法定位的。現在簡單地給出來:是 _interested[me] 的寫操作和 _intersted[he] 的讀操作發生了亂序,即讀操作在寫操作之前完成了。
修復的方式,就是使用『記憶體屏障』。記憶體屏障提供了一些指令,每種指令都有自己的語義,可以杜絕一種型別的亂序。記憶體屏障的種類因 CPU 架構的不同而不同,基本上,如果一個架構允許某種亂序,這種亂序可能帶來問題,那麼它就必須提供相應的指令,防止這種亂序。亂序種類越多,說明該架構越容易發生亂序,這種架構的記憶體模型就是一種 Weak(Relaxed) Memory Model;反之,說明該架構相對地是一種 Strong(Strict) Memory Model。如果抽象地講記憶體屏障的種類,篇幅太大,難以展開,那麼就以 X86-64 架構為例。這是一種相對更加 Strong 的記憶體模型,它只允許一種亂序:讀操作可以在前面的寫操作完成之前完成,即『寫讀』亂序。其他型別,例如『寫寫』『讀寫』『讀讀』形式的亂序都是不會發生的:
- Loads are not reordered with other loads.
- Stores are not reordered with other stores.
- Stores are not reordered with older loads.
- Loads may be reordered with older writes to different locations but not with older writes to the same location.
- In a multiprocessor system, memory ordering obeys causality (memory ordering respects transitive visibility).
- In a multiprocessor system, stores to the same location have a total order.
- In a multiprocessor system, locked instructions have a total order.
- Loads and stores are not reordered with locked instructions.
X86-64 提供三個指令做記憶體屏障:
- sfence,是一個單向屏障。在執行 sfence 指令之前,sfence 之前的所有 store 指令都已經完成,而不關心 load 指令,以及 sfence 之後的 store 指令。
- lfence,也是一個單向屏障。在執行 lfence 之前,lfence 之前的所有 load 指令都已經完成,而不關心 store 指令,以及 lfence 之後的 load 指令。
- mfence,是一個全能屏障,Full Barrier。執行 mfence 之前,mfence 之前所有的 load 和 store 都已經完成,而且 mfence 之後的所有 load 和 store 都沒有發生。
- 另外,lock 字首的訪存指令相當於 Full Barrier,xchg 已隱式加 lock 字首。
考慮 Peterson 演算法中的『寫讀』亂序,lfence 和 sfence 都是不合適的。只能使用 mfence 指令,也就是程式碼中被註釋掉的 mb() 巨集。加入該指令之後,該程式就『暫時』是安全的了。
至此,X86-64 中的記憶體屏障似乎講完了,咱們來看一個更加實際的例子,看它是不是安全的。
1 2 3 4 5 6 7 8 9 |
|
顯然不是安全的,因為 producer 中的兩個寫操作和 consumer 中的兩個讀操作都可能會亂序,造成 consumer 拿到的 msg_to_send 為空。解決方法是分別新增『寫寫』『讀讀』屏障。但是在 X86-64 下,程式碼是安全的。
現在回頭看看之前實現的自旋鎖,如果這樣使用,有問題嗎?
1 2 3 4 5 |
|
看起來似乎沒有。但如果發生亂序,臨界區內的資料訪問穿過 lock/unlock 操作,在臨界區外『就/才』可見呢?於是,需要有另外兩種語義的記憶體屏障,即 acquire 和 release。具有 acquire 語義的記憶體屏障,保證其後的讀寫不會提前到屏障之前;具有 release 語義的屏障,保證之前的讀寫已經生效/可見。這兩種記憶體屏障都是單向的,即允許臨界區外的讀寫進入到臨界區內。具體到 X86-64 架構,考慮前面描述的記憶體模型,讀操作就具有 acquire 語義,而寫操作具有release 語義,所以這個自旋鎖實現在 X86-64 下是正確的。
總結
記憶體屏障只是解決問題的一種方式,其背後是各式各樣記憶體模型的龐然大物,隱藏著辛酸和無奈。現代程式語言都已經或者開始重視這個問題,在語言層面定義統一的記憶體模型,減輕程式設計師的痛苦。比如 Java 中的 volatile 關鍵字具有 read-acquire 和 write-release 語義;C++11 開始的 std::atomic 也提供 acquire/release 語義,還有秉承 C++哲學的 relaxed
store。
一般來講,在所有共享記憶體的更新操作上面顯式使用同步機制,無論是語言本身還是程式庫,都可以不考慮記憶體模型帶來的問題(記憶體對映的 IO 操作除外)。但是,編寫無鎖程式碼時,對記憶體模型的理解和記憶體屏障的使用是無法逾越的必修課。