曹大談內存重排

寫這篇文章的緣由很簡單,公司內部的 Golang 社區組織了第一期分享,主講嘉賓就是咱們敬愛的曹大。這個一定是要去聽的,只是曹大的講題很是硬核,因此提早找他要了參考資料,花了 1 個小時提早預習,纔不至於在正式分享的時候什麼也不懂。固然了,這也是對本身和主講者的尊重。全部的參考資料都在文章最後一部分,歡迎自行探索。html

在我讀曹大給個人中英文參考資料時,我發現英文的我能讀懂,讀中文卻很費勁。通過對比,我發現,英文文章是由一個例子引入,按部就班,逐步深刻。跟着做者的腳步探索,很是有意思。而中文的博客上來就直奔主題,對於第一次接觸的人很是不友好。python

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

因此啊,技術文章寫好不容易,我也自省一下。程序員

什麼是內存重排

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

CPU 重排

引用參考資料 【內存一致模型】 裏的例子:golang

2 thread

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

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

執行順序 輸出結果
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。markdown

從 01 的排列組合來看,總共有4種:00、0一、十、11。表格中還差兩種:十、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.cn/post/684490…

QR
相關文章
相關標籤/搜索