曹大談記憶體重排

else發表於2021-09-09

寫這篇文章的原因很簡單,公司內部的 Golang 社群組織了第一期分享,主講嘉賓就是我們敬愛的曹大。這個必定是要去聽的,只是曹大的講題非常硬核,所以提前找他要了參考資料,花了 1 個小時提前預習,才不至於在正式分享的時候什麼也不懂。當然了,這也是對自己和主講者的尊重。所有的參考資料都在文章最後一部分,歡迎自行探索。

在我讀曹大給我的中英文參考資料時,我發現英文的我能讀懂,讀中文卻很費勁。經過對比,我發現,英文文章是由一個例子引入,循序漸進,逐步深入。跟著作者的腳步探索,非常有意思。而中文的部落格上來就直奔主題,對於第一次接觸的人非常不友好。

兩者就像演繹法和歸納法區別。國內的教材通常是演繹法,也就是上來先講各種概念、原理,再推出另一些定理,比較枯燥;國外的教材更喜歡由例子引入,步步深入,引人入勝。這裡,不去評判孰孰劣。多看看一些英文原版材料,總是有益的。據我所知,曹大經常從亞馬遜上購買英文書籍,這個側面也可以反映曹大的水平高啊。據說英文書一般都很貴,可見曹大也是很有錢的。

所以啊,技術文章寫好不容易,我也自省一下。

什麼是記憶體重排

分兩種,硬體和軟體層面的,包括 CPU 重排、編譯器重排。

CPU 重排

引用參考資料 【記憶體一致模型】 裡的例子:

2 thread

在兩個執行緒裡同時執行上面的程式碼,A 和 B 初始化值都是 0,那最終的輸出是什麼?

先說幾種顯而易見的結果:

執行順序 輸出結果
1-2-3-4 01
3-4-1-2 01
1-3-2-4 11
1-3-4-2 11

當然,還有一些對稱的情形,和上面表格中列出的輸出是一樣的。例如,執行為順序為 3-1-4-2 的輸出為 11。

從 01 的排列組合來看,總共有4種:00、01、10、11。表格中還差兩種:10、00。我們來重點分析下這兩種結果究竟會不會出現。

首先是 10,假設 (2) 輸出 1,(4) 輸出 0。那麼首先給 2,3 排個序:(3) -> (2),因為先要將 B 賦值為 1,(2) 才能列印出 1;同理,(4) -> (1)。另外,因為先列印 1,所以 (2) 要在 (4) 前面,合起來:(3) -> (2) -> (4) -> (1)。(2) 竟然在 (1) 前面執行了,不可能的!

那我們再分析下 00,要想列印 00,列印語句必須在相應變數賦值前執行:

00

圖中箭頭表示先後順序。這就尷尬了,形成了一個環。如果先從 (1) 開始,那順序就是 (1) -> (2) -> (3) -> (4) -> (1)。(1) 要被執行了 2 次,怎麼可能?所以 00 這種情形也是不可能出現的。

但是,上面說的兩種情況在真實世界是有可能發生的。曹大的講義裡有驗證的方法,感興起的同學自己去嘗試。總共測試了 100 百萬次,測試結果如下:

test result

非常反直覺,但是在多執行緒的世界,各種詭異的問題,只有你想不到,沒有計算機做不到的。

我們知道,使用者寫下的程式碼,先要編譯成彙編程式碼,也就是各種指令,包括讀寫記憶體的指令。CPU 的設計者們,為了榨乾 CPU 的效能,無所不用其極,各種手段都用上了,你可能聽過不少,像流水線、分支預測等等。

其中,為了提高讀寫記憶體的效率,會對讀寫指令進行重新排列,這就是所謂的 記憶體重排,英文為 Memory Reordering

這一部分說的是 CPU 重排,其實還有編譯器重排。

編譯器重排

來看一個程式碼片段:

X = 0
for i in range(100):
    X = 1
    print X
複製程式碼

這段程式碼執行的結果是列印 100 個 1。一個聰明的編譯器會分析到迴圈裡對 X 的賦值 X = 1 是多餘的,每次都要給它賦上 1,完全沒必要。因此會把程式碼優化一下:

X = 1
for i in range(100):
    print X
複製程式碼

優化後的執行結果完全和之前的一樣,完美!

但是,如果這時有另外一個執行緒同時幹了這麼一件事:

X = 0
複製程式碼

由於這兩個執行緒並行執行,優化前的程式碼執行的結果可能是這樣的:11101111...。出現了 1 個 0,但在下次迴圈中,又會被重新賦值為 1,而且之後一直都是 1。

但是優化後的程式碼呢:11100000...。由於把 X = 1 這一條賦值語句給優化掉了,某個時刻 X 變成 0 之後,再也沒機會變回原來的 1 了。

在多核心場景下,沒有辦法輕易地判斷兩段程式是“等價”的。

可見編譯器的重排也是基於執行效率考慮的,但以多執行緒執行時,就會出各種問題。

為什麼要記憶體重排

引用曹大的一句話:

軟體或硬體系統可以根據其對程式碼的分析結果,一定程度上打亂程式碼的執行順序,以達到其不可告人的目的。

軟體指的是編譯器,硬體是 CPU。不可告人的目的就是:

減少程式指令數 最大化提高 CPU 利用率

曹大又皮了!

記憶體重排的底層原理

CPU 重排的例子裡提到的兩種不可能出現的情況,並不是那麼顯而易見,甚至是難以理解。原因何在?

因為我們相信在多執行緒的程式裡,雖然是並行執行,但是訪問的是同一塊記憶體,所以沒有語句,準確說是指令,能“真正”同時執行的。對同一個記憶體地址的寫,一定是有先有後,先寫的結果一定會被後來的操作看到。

當我們寫的程式碼以單執行緒執行的時候,語句會按我們的本來意圖 順序 地去執行。一旦單執行緒變成多執行緒,情況就變了。

想像一個場景,有兩個執行緒在執行,作業系統會在它們之間進行排程。每個執行緒在執行的時候,都會順序地執行它的程式碼。由於對同一個變數的讀寫,會訪問記憶體的同一地址,所以同一時刻只能有一個執行緒在執行,即使 CPU 有多個核心:前一個指令操作的結果要讓後一個指令看到。

這樣帶來的後果就是效率低下。兩個執行緒沒法做到並行,因為一個執行緒所做的修改會影響到另一個執行緒,那後者只能在前者的修改所造成的影響“可見”了之後,才能執行,變成了序列。

重新來思考前面的例子:

2 thread

考慮一個問題,為什麼 (2) 要等待 (1) 執行完之後才能執行呢?它們之間又沒有什麼聯絡,影響不到彼此,完全可以並行去做啊!

由於 (1) 是寫語句,所以比 (2) 更耗時,從 a single view of memory 這個視角來看,(2) 應該等 (1) 的“效果”對其他所有執行緒可見了之後才可以執行。但是,在一個現代 CPU 裡,這需要花費上百個 CPU 週期。

現代 CPU 為了“撫平” 核心、記憶體、硬碟之間的速度差異,搞出了各種策略,例如三級快取等。

cpu cache

為了讓 (2) 不必等待 (1) 的執行“效果”可見之後才能執行,我們可以把 (1) 的效果儲存到 store buffer

store buffer

當 (1) 的“效果”寫到了 store buffer 後,(2) 就可以開始執行了,不必等到 A = 1 到達 L3 cache。因為 store buffer 是在核心裡完成的,所以速度非常快。在這之後的某個時刻,A = 1 會被逐級寫到 L3 cache,從而被其他所有執行緒看到。store buffer 相當於把寫的耗時隱藏了起來。

store buffer 對單執行緒是完美的,例如:

store buffer 1 thread

將 (1) 存入 store buffer 後,(2) 開始執行。注意,由於是同一個執行緒,所以語句的執行順序還是要保持的。

(2) 直接從 store buffer 裡讀出了 A = 1,不必從 L3 Cache 或者記憶體讀取,簡直完美!

有了 store buffer 的概念,我們再來研究前面的那個例子:

store buffer 2 threads

先執行 (1) 和 (3),將他們直接寫入 store buffer,接著執行 (2) 和 (4)。“奇蹟”要發生了:(2) 看了下 store buffer,並沒有發現有 B 的值,於是從 Memory 讀出了 0,(4) 同樣從 Memory 讀出了 0。最後,列印出了 00

所有的現代 CPU 都支援 store buffer,這導致了很多對程式設計師來說是難以理解的現象。從某種角度來說,不等 A = 1 擴散到 Memory,就去執行 print(B) 語句,可以看成讀寫指令重排。有些 CPU 甚至優化得更多,幾乎所有的操作都可以重排,簡直是噩夢。

因此,對於多執行緒的程式,所有的 CPU 都會提供“鎖”支援,稱之為 barrier,或者 fence。它要求:

A barrier instruction forces all memory operations before it to complete before any memory operation after it can begin.
複製程式碼

barrier 指令要求所有對記憶體的操作都必須要“擴散”到 memory 之後才能繼續執行其他對 memory 的操作。

barrier 指令要耗費幾百個 CPU 週期,而且容易出錯。因此,我們可以用高階點的 atomic compare-and-swap,或者直接用更高階的鎖,通常是標準庫提供。

正是 CPU 提供的 barrier 指令,我們才能實現應用層的各種同步原語,如 atomic,而 atomic 又是各種更上層的 lock 的基礎。

以上說的是 CPU 重排的原理。編譯器重排主要是依據語言自己的“記憶體模型”,不深入了。

出現前面描述的詭異現象的根源在於程式存在 data race,也就是說多個執行緒會同時訪問記憶體的同一個地方,並且至少有一個是寫,而且導致了記憶體重排。所以,最重要的是當我們在寫併發程式的時候,要使用一些“同步”的標準庫,簡單理解就是各種鎖,來避免由於記憶體重排而帶來的一些不可預知的結果。

總結

記憶體重排是指程式在實際執行時對記憶體的訪問順序和程式碼編寫時的順序不一致,主要是為了提高執行效率。分別是硬體層面的 CPU 重排 和軟體層面的 編譯器重排

單執行緒的程式一般不會有太大問題;多執行緒情況下,有時會出現詭異的現象,解決辦法就是使用標準庫裡的鎖。鎖會帶來效能問題,為了降低影響,鎖應該儘量減小粒度,並且不要在互斥區(鎖住的程式碼)放入耗時長的操作。

lock contention 的本質問題是需要進入互斥區的 goroutine 需要等待獨佔 goroutine 退出後才能進入互斥區,並行 → 序列。

本文講的是曹大講座的一部分,我沒有深入研究其他內容,例如 MESI協議、cache contention 等,講清這些又要牽扯到很多,我還是聚集到深度解密 Go 語言系列吧。有興趣的話,去曹大部落格,給我們提供了很多參考連結,可以自行探索。

參考資料

【曹大 github】github.com/cch123/gola…

【曹大講義】cch123.github.io/ooo/

【記憶體一致模型】homes.cs.washington.edu/~bornholt/p…

【掘金咔嘰咔嘰,譯】juejin.im/post/5d0519…

QR

相關文章