聊聊高併發(三十四)Java內存模型那些事(二)理解CPU高速緩存的工作原理

在上一篇聊聊高併發(三十三)從一致性(Consistency)的角度理解Java內存模型 我們說了Java內存模型是一個語言級別的內存模型抽象,它屏蔽了底層硬件實現內存一致性需求的差異,提供了對上層的統一的接口來提供保證內存一致性的編程能力。

在一致性這個問題域中,各個層面扮演的角色大致如下:

1. 一致性模型,定義了各種一致性模型的理論基礎

2. 硬件層,提供了實現某些一致性模型的硬件能力。硬件在默認情況下按照最基本的方式運行,比如

  • 對同一個線程沒有數據依賴的指令可以重排序優化執行,有數據依賴的指令按照程序順序執行,從而保證單線程程序運行的正確性
  • 保證讀操作讀到的數據肯定是之前在同一位置寫入的數據

3. 語言層,少數語言提供了語言層面的滿足一致性模型的編程能力,另外一些語言則直接使用硬件層提供了一致性編程的能力。提供一致性能力語言的工作方式如下:

  • 把滿足一致性需求的編程能力作爲一種資源,指定一些規則,比如volitile, synchronized,Happens-before規則等
  • 當應用層需要使用這種編程能力的時候,需要顯式地提出申請,比如顯式地使用volatile來標識變量
  • 通過編譯器適配底層各種硬件平臺提供了一致性編程的能力,比如有些平臺使用內存屏障,有些平臺使用read-modified-write,需要語言層來屏蔽這種差異性


4. 應用層,比如分佈式系統,比如併發的服務器程序,它們在一致性問題中的工作有

  • 根據實際需求來定義應用所需要滿足的一致性需求
  • 定義和選擇相應的實現一致性需求的算法,比如分佈式存儲中通過消息協議實現的Paxos,Zab,多階段提交等
  • 利用編程語言提供了基本的一致性編程的能力作爲實現一致性需求算法的基礎


說了一堆一致性需求相關的,那麼問題來了,爲什麼有內存一致性的這個需求呢?


內存一致性需求的出現主要是因爲多核CPU的出現,並且存在多級的高速緩存,這樣就出現了對內存讀寫的併發問題,從而出現了內存的一致性問題。

所以高速緩存是造成內存一致性問題的一個重要原因。很多寫Java內存模型的文章籠統的說CPU寫操作的時候存在一個寫緩衝區write buffer,導致寫操作不能及時寫回到主存,造成了其他線程不能看到新寫入的值,也就是所謂的可見性問題; 並且由於寫緩存區是一種lazy write,導致了CPU可以在寫沒有刷新到內存的時候就開始後續的讀,也形成了重排序的場景,所謂的有序性的問題。


這篇文章寫寫CPU高速緩存相關的工作原理,來看看寫緩存區到底是個什麼東西。本人不是研究硬件的,一些觀點也是基於自己的理解,如果說的不對請進一步查閱資料。

先來看一張圖,這張就是Java內存模型的概念模型圖,工作內存 work memory是對CPU寄存器和高速緩存的抽象。

再來看一張圖,摘自《深入理解計算機系統》中描述Intel Core i7處理器的高速緩存的概念模型。



對比這兩張圖,我們可以看到Java內存模型中每個線程的工作內存實際上就是寄存器以及高速緩存的抽象。在目前主流的多核處理器設計中,一般每個核心都會包含1個L1緩存和L2緩存,多個核心共享一個L3高速緩存。各個核心直接通過系統總線連接。系統總線包括數據總線,地址總線,控制總線,統稱系統總線。我們要記住的是總線是一種共享的資源,如果不合理的使用,比如聊聊高併發(五)理解緩存一致性協議以及對併發編程的影響 這篇中說的緩存一致性協議導致的總線流量風暴,會影響程序執行的效率。

這張圖說了各級高速緩存的一些參數,有幾個要點:

1. CPU只直接和寄存器已經L1緩存交互

2. 現代的L1緩存分爲兩個單獨的物理塊:

  • i-cache存儲指令,是隻讀的,
  • d-cache存儲數據,是讀寫的

3. L2和L3緩存存儲指令和數據

4. 注意高速緩存的大小,Core i7的L1緩存大小爲64KB, L2緩存是256KB,L3是8MB

5. 緩存是分塊,分組的

6. L1的訪問週期是4, L2是L1的3倍,L3是L2的3倍

7. 一次內存訪問的時鐘週期是L3的3倍左右,和L1差2個數量級

8. 一次硬盤(普通磁盤)訪問的時間在1-10ms級別,和一次內存訪問差4個數量級,和1次高速緩存訪問差6個數量級以上

9. 一次固態硬盤訪問的時間在10-100微秒級別,比普通硬盤快1到2個數量級,和一次內存訪問差2-3個數量級左右


說到高速緩存就不得不說到計算機領域的局部性原理(Principle of Locality)。局部性原理是緩存技術的底層理論基礎。局部性包括兩種形式:

1. 時間局部性,一個具有良好時間局部性的程序中,被引用過一次的存儲器位置很可能在不遠的將來再被多次引用

2. 空間局部性,一個具有良好空間局部性的程序中,如果一個存儲器位置被引用了一次,那麼程序很可能在不遠的將來引用附近的一個存儲器位置

我們知道64位機器一次內存數據讀取64位,也就是8個字節,8個連續的內存位置,所以高速緩存中存放的也是連續位置的數據,這是局部性的體現


局部性對編程的一些指導:

1. 重複引用同一個變量具有良好的時間局部性

2. 對於具有步長爲k的引用模式的程序,步長越短空間局部性越好。尤其是操作數組,多維數組,局部性的影響很大

3. 對於取指令來說,循環有好的時間和空間局部性,循環體越小,循環次數越多,局部性越好


另外來看一下存儲器的體系結構

有幾個要點

1. 越往上存儲容量越小,存取速度越快,成本越高,反之亦然

2. 一層存儲器只和下層存儲器打交道,不會跨級訪問

3. 下層作爲上層的一個緩存。CPU要訪問的數據的最終一般都經過主存,主存作爲下層其他設備的一個緩存,其他設備的數據最終都要進入主存才能被CPU訪問到。比如磁盤文件讀取操作,CPU只發起操作請求,具體的數據操作不需要經過CPU,由DMA(Direct Memory Access)來操作IO和主存的交互,當操作完成後,IO設備發出中斷,通知CPU操作完成

4. 每層緩存都需要一個管理器來管理緩存,比如將緩存劃分爲塊,在不同層中傳送塊,判定命中不命中。管理器可以是硬件,軟件或兩者的集合。比如高速緩存完全由內置在緩存中的硬件來管理


下面正式進入高速緩存工作原理的主題,先看一下高速緩存的基本結構

1. 劃分爲S個緩存組 cache set

2. 每組裏面有E個緩存行 cache line,也叫Cache線,行數E也叫緩存的相聯度

3. 每行裏面1個有效位來標記該緩存行是否dirty,有t個長度的標記位來輔助緩存地址定位,標識該緩存塊的唯一位置,有一個B個字節的緩存塊block。一行只有一個塊

4. 高速緩存的大小C = B * E * S,只計算有效的字節數,不包括有效位及標記位的大小

4. 一個高速緩存可以用一個四元組來表示(S, E, B, m),m表示計算機的位數。拿Core i7的L1緩存來說,S = 64, E = 8, B = 64, m = 64,可以表示爲(64,8,64,64).

可以看到L1的大小32K = 64個字節(塊大小) * 8(行數) * 64(組數)




先看高速緩存是如何在當前緩存中定位一個目標內存地址的緩存並讀命中的,分爲三步

1. 組選擇

2. 行匹配

3. 字抽取


這個定位的過程有點類似哈希操作,把一個m位的內存地址映射到一個高速緩存的組索引(s位),行(t位),塊偏移(b位)中去。



還拿Core i7的L1緩存(64,8,64,64)來說,拿到一個64位的內存地址

1. 組選擇:有64個組,那麼64位的內存地址中就要拿出s=6位(000000-111111)來表示64個組號,根據這個內存地址的s位定位到一個組

2. 行匹配:每個組有8行,大小爲64B的塊得到的b=6, 計算得到t = m - (b+s) = 64 - 12 = 52,也就是說64位地址的高52位作爲t,用這個t標記去這個組的8個行去匹配對應t標記位,如果有匹配的行,就命中,否則不命中

3. 如果命中,再由這個內存地址的低b位計算出這個地址在塊中的偏移位置。塊可以理解爲一個字節數組,64個字節的塊就有塊[0]....塊[63]個偏移量,有內存地址的低b位可以計算得到這個地址對應的偏移量,從而找到這個數


比如對於一個32個元素的int數組int[32]來說,int[0] - int[15]存放到高速緩存組[0]的第0行,一個塊是64個字節,正好可以存儲16個int數據。int[16] - int[31]存放到高速緩存組[0]的第1行。當訪問int[0]的時候,沒有命中,會從下一層存儲器加載0行的緩存塊,這樣int[0]-int[15]都加載到緩存塊中了,下一次訪問int[1] - int[15]的時候都命中。訪問到Int[16]的時候沒有命中,同樣從下一層存儲中加載int[16] - int[31]到第1行,這樣下次訪問int[16] - int[31]時就都命中


高速緩存有直接映射高速緩存,E路相聯高速緩存,全相聯高速緩存之分,區別是直接相聯高速緩存每一組只有1行,所以只要定位到組就能知道是否命中。全相聯高速緩存則相反,只有1組,只要匹配到t位的標記位就知道是否命中。

E路相聯高速緩存則是折中,比如Core i7的L1 d-cache就是8路相聯高速緩存,每組有8行,這樣定位到組之後,還需要在組的8個行裏面去匹配標記位來判斷是否命中。


緩存的常用術語命中hit表示在當前緩存中定位到了目標地址的緩存,不命中表示在當前緩存中沒有找到目標地址的緩存。

結合讀寫動作,所以有4個狀態

1. 讀命中

2. 讀不命中

3. 寫命中

4. 寫不命中


知道了如何把一個內存地址映射到高速緩存塊中之後,我們來分析這4種情況各自的表現

讀命中

最簡單的情況,按照組選擇,行匹配,數據抽取的步驟返回命中的數據


讀不命中

讀不命中的話就需要從下一層存儲去加載對應的數據項來對應的緩存行中,注意加載的時候是整個緩存塊都會被新的緩存塊所代替。替換的時候比較複雜,要判斷替換掉哪個緩存行。最常用的作法是使用LRU(least recently used)算法,最近最少使用算法,替換最後一次訪問時間最久遠的那一行。然後返回加載後找到的數據


關於寫,情況就更復雜,這也是常說的CPU lazy write的原因。CPU寫高速緩存有兩種方式

1. 直寫 write-through, 這種方式會寫高速緩存和內存

2. 寫回,也有叫回寫的,write-back,這種方式只寫高速緩存,將相應的緩存行標記爲髒dirty,我們前面說了每個緩存行有一個有效位,0表示dirty/空, 1表示有效。只有當這個髒的緩存行要被替換掉時,纔會寫到內存中去


寫命中的情況下,由於write-through要寫高速緩存和內存,每次寫都會造成總線流量。write-back只寫高速緩存,不產生總線流量

寫不命中的情況下,有兩種方法:寫分配 write-allocate 和非寫分配 not-write-allocate。寫分配會從下一層存儲加載相應的塊到高速緩存,然後更新這個緩存塊。非寫分配會直接避開高速緩存,直接寫到主存。一般都是write-back使用write-allocate的方式,write-through使用not-write-allocate的方式。


我們比較一下write-through和write-back的特點

write-through: 每次寫都會寫內存,造成總線流量,性能較差,優點是實時性強,不會因爲斷電丟失數據

write-back: 充分利用局部性原理,髒的緩存線也能被後面的讀立刻讀取,性能較高。缺點是實時性不高,出現故障可能會丟失數據


目前基本上CPU的寫緩存都採用write-back的方式,不過可以通過BIOS或者操作系統內核參數來配置CPU採取哪種寫的方式。


下面這兩張來自wiki的圖說清了write-through和write-back的流程




那麼別人經常提到的寫緩衝區write-buffer到底是個什麼東西呢,write-buffer被write-through時使用,用來緩存寫回到主內存的數據,我們知道寫一次內存要100ns左右,CPU不會等待寫直到寫入內存才繼續執行後續指令,它是把要寫到主存的數據放到write-buffer,然後就執行後面的指令了,可以理解爲一種異步的方式,來優化write-through的性能。如果write buffer滿了,那麼後續的寫要等待write buffer中有空位置才能繼續寫。

理解下緩衝區的概念,緩衝區是用來適配兩個流速不同的組件常用的方式,比如IO中的BufferedWriter,生產者-消費者模式的緩衝隊列等等,它可以很好地提高系統的性能。


可以看到,不管是write-through,還是write-back,由於高速緩存和寫緩衝區的存在,它們都造成了lazy write的現象,寫不是馬上就寫回到主內存,從而造成了數據可見性和有序性的問題,所以需要定義內存模型來提供一些手段來保證一些一致性需求,比如通過使用內存屏障強制把高速緩存/寫緩衝區中的數據寫回到內存,或者強制把高速緩存中的數據刷新,來保證數據的可見性和有序性。


這篇分析了高速緩存的原理,應該能對Java內存模型的起因有了更深刻認識。這些緩存的原理不僅適合高速緩存,而且適合所有的緩存系統,


參考資料:

《深入理解計算機系統》

Cache

Write Buffer

Writing into Cache


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章