計算機的緩存一致性
計算機在運行程序時,每條指令都是在CPU中執行的,在執行過程中勢必會涉及到數據的讀寫。我們知道程序運行的數據是存儲在主存中,這時就會有一個問題,讀寫主存中的數據沒有CPU中執行指令的速度快,如果任何的交互都需要與主存打交道則會大大影響效率,所以就有了CPU高速緩存(Cache Memory)。CPU高速緩存為某個CPU獨有,只與在該CPU運行的線程有關。
有了CPU高速緩存雖然解決了效率問題,但是它會帶來一個新的問題:數據一致性。在程序運行中,會將運行所需要的數據復制一份到CPU高速緩存中,在進行運算時CPU不再與主存打交道,而是直接從高速緩存中讀寫數據,只有當運行結束后才會將數據刷新到主存中。
解決緩存一致性方案有兩種:
- 通過在總線加LOCK#鎖的方式
- 通過緩存一致性協議
CPU高速緩存(Cache Memory)
存在的意義
CPU高速緩存是為了解決CPU速率和主存訪問速率差距過大問題。
- CPU:根據摩爾定律,CPU會以每18個月的時間將訪問速度翻一番,相當於每年增長60%。
- 內存:內存的訪問速度雖然也在不斷增長,卻遠沒有這么快,每年只增長 7% 左右
到今天來看,一次內存的訪問,大約需要 120 個 CPU Cycle,這也意味着,在今天,CPU 和內存的訪問速度已經有了 120 倍的差距。
因此引入了“高速緩存”,CPU廠商在CPU中內置了少量的高速緩存以解決I\O速度和CPU運算速度之間的不匹配問題。(高速緩存是插在CPU寄存器和主存之間的緩存存儲器)
- 高速緩存(CPU Cache):用於平衡 CPU 和內存的性能差異,分為 L1/L2/L3 Cache。其中 L1/L2 是 CPU 私有,L3 是所有 CPU 共享。
- 緩存行(Cache Line):高速緩存的最小單元,一次從內存中讀取的數據大小。常用的 Intel 服務器 Cache Line 的大小通常是 64 字節。
存儲器層次結構
存儲器在計算機內是有層次,就像一個金字塔,塔頂的存儲器速度極高,但容量很小,越往下,速度越慢,但容量越大。
緩存如何提高效率
計算機程序運行遵循局部性原則。局部性原理是指程序在執行時呈現出局部性規律,即在一段時間內,整個程序的執行僅限於程序中的某一部分。相應地,執行所訪問的存儲空間也局限於某個內存區域。
局部性原理又表現為:時間局部性和空間局部性。
- 時間局部性(Temporal Locality):指如果某條指令一旦被執行,很有可能不久后還會再次被執行;如果某個數據一旦被訪問了,很有可能不久之后還會再次被訪問。如:循環、遞歸等。
- 空間局部性(Spatial Locality):指如果某個存儲單元一旦被訪問了,很有可能不久后它附件的存儲單元也會被訪問。如連續創建多個對象、數組等。
具有良好局部性的程序比差的程序更多的傾向於從存儲器層次結構較高層次處訪問數據,因此運行的更快,尤其是執行大數據量的算術運算。
單核下高速緩存的CPU執行流程
- 1.程序和數據都被加載到主內存中
- 2.執行指令和數據被加載到CPU的高速緩存中,進行邏輯處理
- 3.CPU執行指令再將處理后的結果寫到CPU的高速緩存中
- 4.CPU的高速緩存再將數據寫回(更新)到主內存中
列舉緩存結構圖:
多級緩存結構
高速緩存是插在CPU寄存器和主存之間的緩存存儲器,稱為L1高速緩存,基本是由SRAM(static RAM)構成,訪問時大約需要4個始終周期。剛開始只有L1高速緩存,后來CPU和主存訪問速度差距不斷增大,在L1和主存之間增加了L2高速緩存,可以在10個時鍾周期內訪問到。現代CPU又增加了一個更大的L3高速緩存,可以在大約50個時鍾周期內訪問到它。
列舉多級緩存結構圖:
多核CPU多級緩存下的MESI
MESI的緩存狀態
CPU中每個緩存行(Caceh line)使用4種狀態進行標記,使用2bit來表示:
狀態 |
描述 |
監聽任務 |
狀態轉換 |
M 修改 (Modified) |
該Cache line有效,數據被修改了,和內存中的數據不一致,數據只存在於本Cache中。 |
緩存行必須時刻監聽所有試圖讀該緩存行相對就主存的操作,這種操作必須在緩存將該緩存行寫回主存並將狀態變成S(共享)狀態之前被延遲執行。 |
當被寫回主存之后,該緩存行的狀態會變成獨享(exclusive)狀態。 |
E 獨享、互斥 (Exclusive) |
該Cache line有效,數據和內存中的數據一致,數據只存在於本Cache中。 |
緩存行也必須監聽其它緩存讀主存中該緩存行的操作,一旦有這種操作,該緩存行需要變成S(共享)狀態。 |
當CPU修改該緩存行中內容時,該狀態可以變成Modified狀態 |
S 共享 (Shared) |
該Cache line有效,數據和內存中的數據一致,數據存在於很多Cache中。 |
緩存行也必須監聽其它緩存使該緩存行無效或者獨享該緩存行的請求,並將該緩存行變成無效(Invalid)。 |
當有一個CPU修改該緩存行時,其它CPU中該緩存行可以被作廢(變成無效狀態 Invalid)。 |
I 無效 (Invalid) |
該Cache line無效。 |
無 |
無 |
注意: 對於M和E狀態而言總是精確的,他們在和該緩存行的真正狀態是一致的,而S狀態可能是非一致的。
MESI狀態間的轉換
MESI狀態轉換圖:
- 本地讀取(Local Read): 本地cache讀取本地cache中的數據
- 遠端讀取(Remote Read): 其它cache讀取本地cache中的數據
- 本地寫入(Local Write): 本地cache將數據寫入本地cache中
- 遠端寫入(Remote Write): 其它cache將數據寫入本地cache中
裝換說明:
第一:
某個CPU(CPU A)發起本地寫請求(Local Write),比如對某個內存地址的變量賦值,如果此時所有CPU的Cache中都沒加載此內存地址,即此內存地址對應的Cache Line為無效狀態(Invalid),則CPU A中的Cache Line保存了最新內存變量值以后,其狀態被修改為Modified。
隨后,如果CPU B發起對同一個變量的讀操作(Remote Read),則CPU A在總線上嗅探到這個讀請求以后,先將Cache Line里修改過的數據回寫(Write Back)到Memory中,然后在內存總線上放一份Cache Line的拷貝作為應答,最后再將自身的Cache Line的狀態修改為Shared,由此產生的結果是CPU A與CPU B里對應的Cache Line的狀態都為Shared。
第二:
在第一點的基礎上,CPU A發起本地寫請求導致自身的Cache Line狀態變為Modified以后,如果此時CPU B發起同一個內存地址的寫請求(Remote Write),則我們看到狀態圖里此時CPU A的Cache Line狀態為Invalid。
其原因是如下:CPU B此時發出的是一個特殊的請求——“讀並且打算修改數據”(read with intent to modify),當CPU A從總線上嗅探到這個請求后,會先阻止此請求並取得總線的控制權(Takes control of bus),隨后將Cache Line里修改過的數據回寫(Write Back)到Memory中,再將此Cache Line的狀態修改為Invalid(這是因為其他CPU要改數據,所以沒必要改為Shared了)。
與此同時,CPU B發現之前的請求並沒有得到響應,於是重新再發起一次請求,此時由於所有CPU的Cache里都沒有內存副本了,所以CPU B的Cache就從Memory中加載最新的數據到Cache Line中,隨后修改數據,然后改變Cache Line的狀態為Modified。
下圖表示了當一個緩存行(Cache line)的調整的狀態的時候,另外一個緩存行(Cache line)需要調整的狀態。
狀態 |
M |
E |
S |
I |
M |
× |
× |
× |
√ |
E |
× |
× |
× |
√ |
S |
× |
× |
√ |
√ |
I |
√ |
√ |
√ |
√ |
舉例: 比如有某個變量a=1; 1.cache line處於M(修改)狀態,其它cache對此變量都應是I(無效)狀態 2.cache line處於S(共享)狀態,其它cache對此變量可以是I(無效)狀態,也可以是S(共享)狀態
多核緩存示意圖
比如有多個線程(此處3個),共同讀取主存中的某個變量int z=1;
單核下的數據讀取
1.CPU A發出了一條讀取數據的指令,需要從主存中讀取變量z。
2.首先從主存中將數據讀取到BUS總線中。
3.再通過BUS總線讀取到CPU A的緩存中。也就是Remote Read,此時cache line的狀態需修改為E(獨享)。
多核下的數據讀取
1.CPU A發出了一條讀取數據的指令,需要從主存中讀取變量z。
2.首先從主存中將數據讀取到BUS總線中。
3.再通過BUS總線讀取到CPU A的緩存中。也就是Remote Read,此時cache line的狀態需修改為E(獨享)。
4.CPU B也發出了一條讀取數據的指令,需要從主存中讀取變量z。
5.CPU B嘗試從主存中讀取變量z,但被CPU A嗅探到了有內存地址的沖突。此時CPU A對數據做出狀態更改,為S(共享),根據上面表格得到其它cache line的此變量需要是S或者I,於此當變量被讀取到CPU B時也是S狀態。
單核下的數據修改
1.CPU A發出了一條修改數據的指令,需要從主存中修改變量z。(一開始沒其它cache讀取,狀態為I)
2.首先從主存中將數據讀取到BUS總線中。
3.再通過BUS總線讀取到CPU A的緩存中,進行Local write,此時cache line的狀態需修改為M(修改)。
4.修改完了,再將數據回寫到主存中。
多核下的數據修改及數據同步
修改:(承接上面多核讀取結束后,CPU A對數據進行了修改)
1.CPU A進行Local write,修改變量z=2,此時要將其cache line的狀態修改為M(修改),並通知有緩存了z變量的CPU,此處即CPU B。
2.CPU B需要將本地cache 中的z設置為I(無效)
3.CPU A對變量z進行賦值
同步:(涉及兩種情況:其它CPU,如CPU B此時要讀取z,或者CPU B此時要讀取並修改z)
CPU B此時要讀取z
1.CPU B發出讀取z的指令(Remote read)
2.CPU A在總線上嗅探到這個讀請求以后,先將Cache Line里修改過的數據回寫(Write Back)到Memory中,然后在內存總線上放一份Cache Line的拷貝作為應答。
3.將自身的Cache Line的狀態修改為Shared,由此產生的結果是CPU A與CPU B里對應的Cache Line的狀態都為Shared。
CPU B此時要讀取並修改z
1.CPU B發出讀取z的指令(Remote Write)
2.CPU A在總線上嗅探到這個讀請求以后,先阻止CPU B修改,然后將Cache Line里修改過的數據回寫(Write Back)到Memory中,直接將自身cache line設置為I(無效)狀態
3.CPU B再次獲取修改請求,此時變量z在其它cache中沒有緩存副本了,CPU B直接從主存中拿到最新的數據,進行修改操作,狀態設置為M(修改)。
MESI問題及優化
偽共享(False Sharing)
問題定義
說回CPU緩存,緩存行(cache line)是CPU緩存的基本單位,緩存行通常是 32/64 字節,前面說了局部性原理。
當我們訪問一個數據時,獲取一個值后,其相鄰的值也被緩存到就近的緩存行中。比如訪問一個long類型數組,當數組中的一個值被加載到緩存中,它會額外加載另外7個,以致你能非常快地遍歷這個數組。因此可以非常快速的遍歷在連續的內存塊中分配的任意數據結構。
但是沒有任何是完美的存在,比如:當有多個線程操作不同的成員變量,但正好這多個變量處於相同的緩存行。如圖:
注釋:一個運行在處理器core1上的線程想要更新變量 X 的值,同時另外一個運行在處理器core2上的線程想要更新變量 Y 的值。 但是,這兩個頻繁改動的變量都處於同一條緩存行。兩個線程就會輪番發送 RFO (Request For Owner) 消息,占得此緩存行的擁有權。 當 core1 取得了擁有權開始更新 X,則 core2 對應的緩存行需要設為 I 狀態(失效態)。 當 core2 取得了擁有權開始更新 Y,則core1對應的緩存行需要設為 I 狀態(失效態)。 輪番奪取擁有權不但帶來大量的 RFO 消息,而且如果某個線程需要讀此行數據時,L1 和 L2 緩存上都是失效數據,只有L3緩存上是同步好的數據。從前面的內容我們知道,讀L3的數據會影響性能,更壞的情況是跨槽讀取,L3 都出現緩存未命中,只能從主存上加載。
問題解決
1.padding
防止其他數據導致偽共享的問題常用增加padding,叫做緩存行填充的方式來解決,例如在前后加上無用的數據。
2.注解
在JDK1.8中,新增了一種注解@sun.misc.Contended,來使各個變量在Cache line中分隔開。
注意,jvm需要添加參數-XX:-RestrictContended才能開啟此功能 。類前加上代表整個類的每個變量都會在單獨的cache line中。屬性前加代表該屬性會在單獨的cacheline中。
CPU切換狀態堵塞
問題定義
眾所周知,CPU的處理數據是非常快的,但MESI下,涉及到各個不同cache之間狀態的轉換通知(消息傳遞),這會耽誤大量的時間(處理延遲)。而且CPU會一直等待消息傳遞和回應完成,其中的時間遠大於一個指令的執行時間。
比如:CPU A需進行變量z的修改(Local Write),那必須通知其它CPU需要對緩存了z的緩存行置為I(無效)狀態,並且要等所有CPU都響應確認。這等待期間會堵塞處理器,降低其性能等。
問題解決
存儲緩存(Store Buffere)
為了解決等待太長時間避免資源浪費等,引入了store buffere。
處理器將想要寫回到主存的數據寫入到store buffere中,然后繼續處理自己的事情。當發出去的所有設置無效狀態的通知都響應了后,數據才會最終被同步到主存中去。
風險一:處理器會從store buffere中嘗試加載數據,但其還沒提交。稱為store forwading,即當加載的時候,如果store buffere中有數據就進行返回;如果沒有才能讀取自己緩存里面的數據。
風險二:store buffere中的緩存什么時候能同步到主存中,沒有任何保證。
內存屏障(Nenory Barriers)
- 寫屏障 Store Memory Barrier是一條告訴處理器在執行這之后的指令之前,應用所有已經在存儲緩存(store buffer)中的保存的指令。
- 讀屏障Load Memory Barrier是一條告訴處理器在執行任何的加載前,先應用所有已經在失效隊列中的失效操作的指令。
在相關代碼前使用對應的讀寫屏障,保證數據的一致性。