事情是這樣的,一位讀者看了我的一篇文章,不認同我文章裡面的觀點,於是有了下面的交流。
可能是我發的那個狗頭的表情,讓這位讀者認為我不尊重他。於是,這位讀者一氣之下把我刪掉了,在刪好友之前,還叫我回家種田。
說實話,你說我菜我是承認的,但你要我回家種田,我不理解。為什麼要回家種田呢?養豬不比種田賺錢嗎?
我想了很久沒有想明白,突然,我看到了這個新聞,瞬間明白了讀者的用心良苦。
於是,我決定寫下這篇文章,好好地分析一下讀者提出的幾個問題。
針對這位讀者的幾個觀點:
我先給出我的看法:
至於為什麼我會這麼認為,我會說出我的理由,畢竟,我們都是是講道理的人,對不對?
讀者的觀點圍繞「快取一致性協定」,OK,那我們就從快取一致性協定講起!
從字面意思來看,快取一致性協定就是「用來解決 CPU 的快取不一致問題的協定」。將這句話拆開,就會有幾個問題:
我們一一分析。
CPU 是個運算器,主要負責運算;
記憶體是個儲存媒介,負責儲存資料和指令;
在沒有快取的年代,CPU 和記憶體是這樣配合工作的:
一句話總結就是:CPU 高速運轉,但取資料的速度非常慢,嚴重浪費了 CPU 的效能。
那怎麼辦呢?
在工程學上,解決速度不匹配的方式主要有兩種,分別是物理適配和空間緩衝。
物理適配很容易理解,多級機械齒輪就是物理適配的典型例子。
至於空間緩衝,更多的被運用在軟硬體上,CPU 多級快取就是經典的代表。
什麼是 CPU 多級快取?
簡單來說就是基於 時間 = 距離 / 速度 這個公式,通過在 CPU 和記憶體之間設定多層快取來減少取資料的距離,讓 CPU 和記憶體的速度能夠更好的適配。
因為快取離 CPU 近,而且結構更加合理,CPU 取資料的速度就縮短了,從而提高了 CPU 的利用率。
同時,因為 CPU 取資料和指令滿足時間區域性性和空間區域性性,有了快取之後,對同一資料進行多次操作時,中間過程可以用快取暫存資料,進一步分攤時間 = 距離 / 速度 中的距離,更好地提高了 CPU 利用率。
時間區域性性:如果一個資訊項正在被存取,那麼在近期它很可能還會被再次存取
空間區域性性:如果一個記憶體的位置被參照,那麼將來他附近的位置也會被參照
快取的出現讓 CPU 的利用率得到了大幅地提高。
在單核時代,CPU 既享受到了快取帶來的便利,又不用擔心資料會出現不一致。但這一切的前提建立在「單核」。
多核時代的到來打破了這種平衡。
進入多核時候之後,需要面臨的第一個問題就是:多個 CPU 是共用一組快取還是各自擁有一組快取呢?
答案是「各自擁有一組快取」。
為什麼呢?
我們不妨做個假設,假設多個 CPU 共用一組快取,會出現什麼情況呢?
如果共用一組快取,由於低階快取(離 CPU 近的快取)的空間非常小,多個 CPU 的時間會都花在等待使用低階快取上面,這意味著多個 CPU 變成了序列工作,如果變成序列,那就失去了多核的本質意義——並行。
我們用反證證明了多個 CPU 共用一組快取是行不通的,所以只能讓多個 CPU 各自擁有一組私有快取。
於是,多個 CPU 的快取結構就成了這樣(簡化了多級快取):
這樣的設計雖然解決了多個處理器搶佔快取的問題,但也帶來了一個新的問題,那就是令人頭疼的資料一致性問題:
具體來說,就是如果多個 CPU 同時用到某份資料,由於多組快取的存在,資料可能會出現不一致。
我們可以看下面這個例子:
如果有多組快取,在並行場景下,可能會出現如下情況:
可以看到,兩個 CPU 同時對 age=1 進行加一操作,因為多組快取的原因,CPU 之間無法感知對方的修改,資料出現了不一致,導致最後的結果不是預期的值。
沒有快取也會出現資料一致性問題,只是有快取會變得尤其嚴重。
資料不一致的問題對於程式來說是致命的。所以需要有一種協定,能夠讓多組快取看起來就像只有一組快取那樣。
於是,快取一致性協定就誕生了。
快取一致性協定就是為了解決快取一致性問題而誕生,它旨在通過維護多個快取空間中快取行的一致檢視來管理資料一致性。
這裡先補充一下快取行的概念:
快取行(cache line)是快取讀取的最小單元,快取行是 2 的整數冪個連續位元組,一般為 32-256 個位元組,最常見的快取行大小是 64 個位元組。
Linux 系統可以通過cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
命令檢視快取行大小。
Mac 系統可以通過sysctl hw.cachelinesize
檢視快取行的大小。
快取行也是快取一致性協定管理的最小單位。
實現快取一致性協定的機制主要有兩種,分別是基於目錄和匯流排嗅探。
什麼是基於目錄?
說直白一點就是用一個目錄去記錄快取行的使用情況,然後 CPU 要使用某個快取行的時候,先通過查目錄獲取此快取行的使用情況,用這樣的方式保證資料一致性。
目錄的格式有六種:
這些目錄的名字花裡胡哨的,實際上並沒有那麼複雜,只是資料結構和優化方式不一樣罷了。
比如全位向量格式就是用位元位(bit)去記錄每個快取行是否被某個 CPU 快取。
後面幾種格式的目錄,無非是在儲存和可延伸等方面做了一些優化。
記目錄相對於直接訊息通訊來說是比較耗時的,所以基於目錄這種機制實現的快取一致性協定延遲相對來說會偏高。但也有一個好處,第三方目錄的存在讓通訊過程變得簡單,通訊對匯流排頻寬的佔用也會相對偏少。
所以,基於目錄適用於 CPU 核心數量多的大型系統。
基於目錄依賴實現的快取一致性協定雖然頻寬佔用小,但是延遲高,並不適合作為小型的系統的快取一致性解決方案,小型系統更多的是用基於匯流排嗅探的快取一致性協定。
匯流排是 CPU 和記憶體地址和資料互動的橋樑,匯流排嗅探也就是監聽著這座互動橋樑,及時的感知資料的變化。
當 CPU 修改私有快取裡面的資料時,會給匯流排傳送一個事件訊息,告訴匯流排上的其他監聽者這個資料被修改了。
其他 CPU 感知到自己私有的快取中存在某個被修改的資料副本時,可以將快取的副本更新,也可以讓快取的副本失效。
將快取的副本更新會產生巨大的匯流排流量,影響系統的正常執行。所以,在監聽到更新事件時,更多的是將私有的快取副本失效處理,也就是丟棄這個資料副本。
將被修改的資料副本失效的這種方式,有個專業的術語,叫做「寫無效」(Write-invalidate),基於「寫無效」實現的快取一致性協定,叫做「寫無效」協定,常見的 MSI、MESI、MOSI、MOESI 和 MESIF 協定都屬於這一類。
MESI 協定是一個基於失效的快取一致性協定,是支援寫回(write-back)快取的最常用協定,也是現在一種使用最廣泛的快取一致性協定,它基於匯流排嗅探實現,用額外的兩位給每個快取行標記狀態,並且維護狀態的切換,達到快取一致性的目的。
MESI 是四個單詞的縮寫,每個單詞分別代表快取行的一個狀態:
M:modified,已修改。快取行與主記憶體的值不同。如果別的 CPU 核心要讀主記憶體這塊資料,該快取行必須回寫到主記憶體,狀態變為共用狀態(S)。
E:exclusive,獨佔的。快取行只在當前快取中,但和主記憶體資料一致。當別的快取讀取它時,狀態變為共用;當前寫資料時,變為已修改狀態(M)。
S:shared,共用的。快取行也存在於其它快取中且是乾淨的。快取行可以在任意時刻拋棄。
I:invalid,無效的。快取行是無效的。
MESI 協定中,快取行狀態的切換依賴訊息的傳遞,MESI 有以下幾種訊息:
Read: 讀取某個地址的資料。
Read Response: Read 訊息的響應。
Invalidate: 請求其他 CPU invalid 地址對應的快取行。
Invalidate Acknowledge: Invalidate 訊息的響應。
Read Invalidate: Read + Invalidate 訊息的組合訊息。
Writeback: 該訊息包含要回寫到記憶體的地址和資料。
MESI 通過訊息的傳遞維護了一個快取狀態機,實現共用記憶體,至於細節是怎麼樣的,這裡不做過多表述。
如果你對 MESI 不瞭解的話,我建議你去這個網站動手實驗,可以模擬各種場景,能實時生成動畫,比較好理解。
如果你打不開這個網站,不用著急,原始碼給你扒下來了,回覆「MESI」自取,解壓在本地執行就行了。
下面就用這個網站演示一個簡單的例子:
簡單分析一下:
MESI 的存在保證了快取一致性,讓多核 CPU 能夠更好地進行資料互動,那是否意味著 CPU 是否被壓榨到極致了呢?
答案是否定的,我們接著往下看。
文章的後半段依賴前文的知識點,如果我的表述沒讓你理解前半段的知識點,你可以直接翻到總結部分,那裡有我準備好的思路總結。
如果你準備好了,我們繼續開車,看看還能怎麼壓榨 CPU。
從上文得知:如果 CPU 對某個資料進行寫操作,且這個資料不在私有快取裡,那麼 CPU 就會傳送一個 Read Invalidate 訊息去讀取對應的資料,並讓其他的快取副本失效。
但有一個問題你思考了沒有,那就是從傳送訊息之後,到接收到所有的響應訊息,中間等待過程對於 CPU 來說是漫長的。
能不能減少 CPU 等待訊息的時間呢?
能!store buffer 就是幹這個的。
具體怎麼幹的呢?
store buffer 是 CPU 和快取中間的一塊結構
CPU 在寫操作時,可以不等待其他 CPU 響應訊息就直接寫到 store buffer,後續收到響應訊息之後,再把 store buffer 裡面的資料寫入快取行。
CPU 讀資料的時候,也會先判斷一下 store buffer 裡面有沒有資料,如果存在,就優先使用 store buffer 裡面的資料(這個機制,叫做「store forwarding」)。
從而提高了 CPU 的利用率,也能保證了在同一CPU,讀寫都能順序執行。
注意,這裡的讀寫順序執行說的是同一CPU,為什麼要強調同一呢?
因為,store buffer 的引入並不能保證多 CPU 全域性順序執行。
我們看下面這個例子:
// CPU0 執行
void foo() {
a = 1;
b = 1;
}
// CPU1 執行
void bar() {
while(b == 0) continue;
assert(a == 1);
}
假設 CPU0 執行 foo 方法,CPU1 執行 bar 方法,如果在執行之前,快取的情況是這樣的:
那麼,在有了 store buffer 之後,有可能出現這種情況(簡化了與記憶體互動的過程):
用文字表述就是:
我們站在不同的角度分析分析:
站在 CPU0 的角度看自己:a = 1 先於 b = 1,所以 b = 1 的時候 a 一定已經等於 1 了。
站在 CPU0 的角度看 CPU1:因為 b = 1 的時候 a 一定等於 1,所以 CPU1 因為 b == 1 跳出迴圈的時候,接下來執行 assert 一定為成功,但是實際上失敗了,也就是說站在 CPU0 的角度,CPU1 發生了重排序。
那如何解決 store buffer 的引入帶來的全域性順序性問題呢?
硬體設計師給開發者提供了記憶體屏障(memory-barrier)指令,我們只需要使用記憶體屏障將程式碼改造一下,在 a = 1 後面加上 smp_mb(),就能消除 store buffer 的引入帶來的影響。
// CPU0 執行
void foo() {
a = 1;
smp_mb();
b = 1;
}
// CPU1 執行
void bar() {
while(b == 0) continue;
assert(a == 1);
}
記憶體屏障是如何做到全域性順序性的呢?
有兩種方式,分別是等 store buffer 生效和進 store buffer 排隊。
等 store buffer 生效就是記憶體屏障後續的寫必須等待 store buffer 裡面的值都收到了對應的響應訊息,都被寫到快取行裡面。
進 store buffer 排隊就是記憶體屏障後續的寫直接寫到 store buffer 排隊,等 store buffer 前面的寫全部被寫到快取行。
從動圖上可以看出,兩種方式都需要等,但是等 store buffer 生效是在 CPU 等,而進 store buffer 排隊是進 store buffer 等。
所以,進 store buffer 排隊也會相對高效一些,大多數的系統採用的也是這種方式。
記憶體屏障能解決 store buffer 帶來的全域性順序性問題。但有一個問題,store buffer 容量非常小,如果在其他 CPU 繁忙的時候響應訊息的速度變慢,store buffer 會很容易地被填滿,會直接的影響 CPU 的執行效率。
怎麼辦呢?
這個問題的根源是響應訊息慢導致 store buffer 被填滿,那能不能提高訊息響應速度呢?
能!invalidate queue 出現了。
invalidate queue 的主要作用就是提高 invalidate 訊息的響應速度。
有了 invalidate queue 之後,CPU 在收到 invalidate 訊息時,可以先不講對應的快取行失效,而是將訊息放入 invalidate queue,立即返回 Invalidate Acknowledge 訊息,然後在要對外傳送 invalidate 訊息時,先檢查 invalidate queue 中有無該快取行的 Invalidate 訊息,如果有的話這個時候才處理 Invalidate 訊息。
invalidate queue 雖然能加快 invalidate 訊息的響應速度,但是也帶了全域性順序性問題,這個和 store buffer 帶來的全域性性問題類似。
看下面這個例子:
// CPU0 執行
void foo() {
a = 1;
smp_mb();
b = 1;
}
// CPU1 執行
void bar() {
while(b == 0) continue;
assert(a == 1);
}
上面這段程式碼,還是假設 CPU0 執行 foo 方法,CPU1 執行 bar 方法,如果在執行之前,快取的情況是這樣的:
那麼,在有了 invalidate queue 之後,有可能出現這種執行情況:
從這個例子可以看出,在引入 invalidate queue 之後,全域性順序性又得不到保障了。
怎麼解決呢,和 store buffer 的解決辦法是一樣的,用記憶體屏障改造程式碼:
// CPU0 執行
void foo() {
a = 1;
smp_mb();
b = 1;
}
// CPU1 執行
void bar() {
while(b == 0) continue;
smp_mb();
assert(a == 1);
}
改造之後的執行過程不做過多表述,但總結來說就是記憶體屏障可以解決 invalidate queue 帶來的全域性順序性問題。
從上文得知,記憶體屏障有兩個作用,處理 store buffer 和 invalidate queue,保持全域性順序性。
但很多情況下,只需要處理 store buffer 和 invalidate queue 中的其中一個即可,所以很多系統將記憶體屏障細分成了讀屏障(read memory barrier)和寫屏障(write memory barrier)。
讀屏障用於處理 invalidate queue,寫屏障用於處理 store buffer。
以場景的 X86 架構下,不同的記憶體屏障對應的指令分別是:
讀屏障:lfence
寫屏障:sfence
讀寫屏障:mfence
我們再回顧一下,在上一篇講 volatile 的文章中,我提到了 volatile 關鍵字的底層實現是 lock 字首指令。
lock 字首指令和記憶體屏障到底有什麼關係呢?
我認為是沒有什麼關係的。
只不過 lock 字首指令一部分功能能達到記憶體屏障的效果罷了。
這一點在《IA-32 架構軟體開發人員手冊》上也能找到對應的描述。
手冊上給 lock 字首指令的定義是匯流排鎖,也就是 lock 字首指令是通過鎖住匯流排保證可見性和禁止指令重排序的。
雖然「匯流排鎖」的說法過於老舊了,現在的系統更多的是「鎖快取行」。但我想表達的是,lock 字首指令的核心思想還是「鎖」,這和記憶體屏障有著本質的區別。
我們再來回顧讀者的這兩個觀點:
對於第一個觀點,我的看法是:
lock 字首指令的作用是鎖住快取行,能起到和讀寫屏障一樣的效果,而讀寫屏障解決的問題是 store buffer 和 invalidate queue 帶來的全域性順序性問題。
快取性一致性問題是用來解決多核系統下的快取一致性問題,是由硬體來保證的,對軟體來說是透明的,伴生於多核系統,是一個客觀存在的東西,並不需要觸發。
對於第二個觀點,我的看法是:
JMM 是一個虛擬的記憶體模型,它抽象了 JVM 的執行機制,讓 Java 開發人員能更好的理解 JVM 的執行機制,它封裝了 CPU 底層的實現,讓 Java 的開發人員可以更好的進行開發,不被底層的實現細節折磨。
JMM 想表達的是,在某種程度上,你可以通過一些 Java 關鍵字讓 Java 的記憶體模型達到一種強一致性。
所以 JMM 和快取一致性協定並不掛鉤,本質上就沒什麼聯絡。舉個例子,你不能因為你單身,然後劉亦菲也單身,你就說劉亦菲單身是因為在等你。
本文對一些沒有基礎的同學來說,理解起來會稍微吃力一點,所以我們總結一下全文的一個思路,應付應付普通面試是沒什麼問題的。
這篇文章, 主要參考了維基百科和 Linux 核心大牛 Paul E. McKenney 的論文以及書籍,如果你想對並行程式設計的底層有更深入的研究,Paul E. McKenney 的論文和書籍非常值得一看,有需要的後臺回覆「MESI」自取。
因為筆者水平有限,文章中難免會有錯誤,如果你發現了,歡迎指出!
好了,今天的文章就到這裡結束了,我是小汪,我們下期再見!