存儲器 - 高速緩存(CPU Cache):為什么要使用高速緩存?
計算機組成原理目錄:https://www.cnblogs.com/binarylei/p/12585607.html
1. 為什么需要高速緩存
CPU 和內存訪問性能的差距非常大。如今,一次內存的訪問,大約需要 120 個 CPU Cycle。這也意味着,在今天,CPU 和內存的訪問速度已經有了 120 倍的差距。
- CPU:按照摩爾定律,CPU 的訪問速度每 18 個月便會翻一番,相當於每年增長 60%。比如我的筆記本是 Intel Core-i5-8250U 1.6GHz,也就是每秒可以訪問 16 億(= 1.6G)次。
- 內存:每年只增長 7% 左右。內存響應時間大概是 100us,也就是極限情況下,大概每秒可以訪問 1000 萬(= 1s / 100ns)次。
- HDD:磁盤尋道時間約 10ms,大概每秒可以訪問 100 次。

為了彌補兩者之間的性能差異,充分利用 CPU,現代 CPU 中引入了高速緩存(CPU Cache)。高速緩存分為 L1/L2/L3 Cache,不是一個單純的、概念上的緩存(比如使用內存作為硬盤的緩存),而是指特定的由 SRAM 組成的物理芯片。下圖是一張 Intel CPU 的放大照片。這里面大片的長方形芯片,就是這個 CPU 使用的 20MB 的 L3 Cache,可以看到現代 CPU 中大量的空間已經被 SRAM 占據。

程序運行的時間主要花在將對應的數據從內存中讀取出來,加載到 CPU Cache 里。CPU 從內存中讀取數據到 CPU Cache 的過程中,是一小塊一小塊來讀取數據的。這樣一小塊一小塊的數據,在 CPU Cache 里面,我們把它叫作緩存行(Cache Line)。在我們日常使用的 Intel 服務器或者 PC 里,Cache Line 的大小通常是 64 字節。
現在總結一下,為了平衡 CPU 和內存的性能差異,現在 CPU 引入高速緩存:
- 高速緩存(CPU Cache):用於平衡 CPU 和內存的性能差異,分為 L1/L2/L3 Cache。其中 L1/L2 是 CPU 私有,L3 是所有 CPU 共享。
- 緩存行(Cache Line):高速緩存的最小單元,一次從內存中讀取的數據大小。常用的 Intel 服務器 Cache Line 的大小通常是 64 字節。
知道了為什么需要 CPU Cache,接下來我們就來看一看,CPU 究竟是如何訪問 CPU Cache 的,以及 CPU Cache 是如何組織數據,使得 CPU 可以找到自己想要訪問的數據的。
2. 高速緩存讀操作
CPU Cache 和 Redis 緩存訪問類似,都是先訪問緩存,如果數據不存在再訪問內存。在各類基准測試(Benchmark) 和實際應用場景中,CPU Cache 的命中率通常能達到 95% 以上。

CPU 如何知道要訪問的內存數據,存儲在 Cache 的哪個位置呢?CPU 訪問 Cache 的訪問邏輯有以下幾種。
- 直接映射 Cache(Direct Mapped Cache)
- 全相連 Cache(Fully Associative Cache)
- 組相連 Cache(Set Associative Cache)
2.1 直接映射(Direct Mapped Cache)
CPU 如何知道要訪問的內存數據,存儲在 Cache 的哪個位置呢?接下來,我就從最基本的直接映射 Cache(Direct Mapped Cache) 說起,帶你來看整個 Cache 的數據結構和訪問邏輯。
CPU 訪問內存數據時按緩存行大小讀寫,通常是 64 byte。我們將內存將緩存行大小切分,每個內存地址必定會落到某個內存塊上,數據在這個內存塊的偏移量也是確定的。問題是如何將這個 "內存塊 block" 和 "緩存行 Cache Line" 索引號映射關系確定下來。
- 索引號:CPU 直接映射是通過求余運算來實現,並且要求緩存行的個數必須是 2n。這樣可以直接使用低位來表示索引號。
- 偏移量:可以根據塊大小確定偏移量。
現在,我們知道了 Cache Line 的索引號和在這個 Cache Line 的偏移量,CPU 就可以在緩存行中讀出這個數據了。通過索引號和偏移量建立內存映射關系,這在軟件工程中很常見,比如內存的虛擬地址和物理地址之間的映射關系,再比如 innodb 中數據地址定位(頁號 + 偏移量)。
比如說,我們的主內存被分成 0~31 號這樣 32 個塊。我們一共有 8 個緩存塊。用戶想要訪問第 21 號內存塊。如果 21 號內存塊內容在緩存塊中的話,它一定在 5 號緩存塊(21 % 8 = 5)中。

了解 HashMap 的都知道,通過求余算法一定會出現哈希碰撞。同樣的道理,此時也會出現多個內存塊映射同一個緩存行的情況,CPU 如何判斷這是不是我們想要訪問的數據呢?
-
組標記(Tag):最簡單的辦法,當然是在緩存行中存儲完整真實的物理內存地址,但有點浪費空間。前面已經說了緩存行的個數是 2n,可以直接使用低位表示索引號,也就是每個緩存行對應的低位地址是固定的,緩存行中只需要保存高位地址即可。
如 21 的低 3 位 101,緩存塊本身的地址已經涵蓋了對應的信息、對應的組標記,我們只需要記錄 21 剩余的高 2 位的信息,也就是 10 就可以了。
-
有效位(valid bit):標記對應的緩存塊中的數據是否是有效的,確保不是機器剛剛啟動時候的空數據。如果有效位是 0,無論其中的組標記和 Cache Line 里的數據內容是什么,CPU 都不會管這些數據,而要直接訪問內存,重新加載數據。
現在我們總結一下:一個內存的訪問地址,最終包括高位代表的組標記、低位代表的索引,以及在對應的 Data Block 中定位對應字的位置偏移量。

而內存地址對應到 Cache 里的數據結構,則多了一個有效位和對應的數據,由 "索引 + 有效位 + 組標記 + 數據" 組成。如果內存中的數據已經在 CPU Cache 里了,那一個內存地址的訪問,就會經歷這樣 5 個步驟:
- 根據內存地址的低位,計算在 Cache 中的索引;
- 判斷有效位,確認 Cache 中的數據是有效的;
- 對比內存訪問地址的高位,和 Cache 中的組標記,確認 Cache 中的數據就是我們要訪問的內存數據,從 Cache Line 中讀取到對應的數據塊(Data Block);
- 根據內存地址的 Offset 位,從 Data Block 中,讀取希望讀取到的字。
- 如果在 2、3 這兩個步驟中,CPU 發現,Cache Line 並不是要訪問的內存地址的數據,那 CPU 就會訪問內存,並把對應的 Block Data 更新到 Cache Line 中,同時更新該 Cache Line 對應的有效位和組標記的數據。
總結: 要想確定一個內存地址在緩存中的映射關系,主要是確定索引號和偏移量。 CPU Cache 直接映射類似 HashMap,也是通過求余來建立映射關系,當然也無法避免哈希碰撞。CPU 判斷是不是我們想要訪問的數據時,最簡單的辦法當然是在緩存中存儲完整真實的物理內存地址,但這樣太浪費空間了,CPU 巧妙地將內存地址拆分成高位和低位,用高位代表組標記,低位代表索引號。最終通過 "索引 + 組標記 + 偏移量" 建立映射關系,使得我們可以將很大的內存地址,映射到很小的 CPU Cache 地址里。
3. 高速緩存寫操作
在搞清楚從內存加載數據到 Cache,以及從 Cache 里讀取到想要的數據之后,我們又要面臨一個新的挑戰。CPU 不僅要讀數據,還需要寫數據,我們不能只把數據寫入到 Cache 里面就結束了。CPU 要寫入數據的時候,怎么既不犧牲性能,又能保證數據的一致性。
3.1 寫操作挑戰

CPU 數據寫入時會有以下兩個挑戰:
- 什么時候寫入主存?緩存什么時候失效?寫直達 vs 寫回策略。寫入 Cache 的性能也比寫入主內存要快,那我們寫入的數據,到底應該寫到 Cache 里還是主內存呢?如果我們直接寫入到主內存里,Cache 里的數據是否會失效呢?CPU 提供了寫直達 vs 寫回兩種策略。
- 多核 CPU 緩存一致性的問題。無論是寫直達還是寫回策略都不能解決多核 CPU 緩存一致性的問題。現在計算機采用緩存一致性協議 MESI 解決一致性問題。
下面先介紹這兩種寫入策略。
- 寫直達(Write-Through):每一次數據都要寫入到主內存里面。
- 寫回(Write-Back):數據寫到 CPU Cache 就結束。只有當 CPU Cache 是臟數據時,才把數據寫入主內存。
3.2 寫直達(Write-Through)
寫直達策略中,每一次數據都要寫入到主內存里面。寫入前,先判斷數據是否已經在 Cache 里面了。
- 命中緩存。如果數據已經在 Cache 里,先把數據寫入更新到 Cache 里面,再寫入到主內存里面;
- 未命中緩存。如果數據不在 Cache 里,只需要更新主內存。
寫直達的這個策略很直觀,但是問題也很明顯,那就是這個策略很慢。
3.3 寫回(Write-Back)
既然可以從 CPU Cache 里面加載數據,那么寫入時能否只寫入 CPU Cache 中,不用同步到主內存里呢?當然是可以的。CPU 提供了寫回策略,不再是每次都把數據寫入到主內存,而是只寫到 CPU Cache 里。只有當 CPU Cache 里面的數據要被“替換”的時候,我們才把數據寫入到主內存里面去。
- 命中緩存。如果要寫入的數據,就在 CPU Cache 里面,那么只是更新 CPU Cache 里面的數據。同時標記 CPU Cache 里的這個 Block 是臟(Dirty)的。所謂臟的,就是指這個時候,我們的 CPU Cache 里面的這個 Block 的數據,和主內存是不一致的。
- 未命中緩存。如果要寫入的數據所對應的 Cache Block 里,放的是別的內存地址的數據,需要判斷 Cache Block 里面的數據有沒有被標記成臟的。
- 如果是臟數據,我們要先把這個 Cache Block 里面的數據,寫入到主內存里面。然后,再把當前要寫入的數據,寫入到 Cache 里,同時把 Cache Block 標記成臟的。
- 如果沒有被標記成臟數據,那么我們直接把數據寫入到 Cache 里面,然后再把 Cache Block 標記成臟的就好了。
- 加載緩存。加載內存數據到 Cache 里面的時候,也要多出一步同步臟 Cache 的動作。如果加載內存里面的數據到 Cache 的時候,發現 Cache Block 里面有臟標記,我們也要先把 Cache Block 里的數據寫回到主內存,才能加載數據覆蓋掉 Cache。
可以看到,在寫回這個策略里,如果我們大量的操作,都能夠命中緩存。那么大部分時間里,我們都不需要讀寫主內存,自然性能會比寫直達的效果好很多。
參考:
- What Every Programmer Should Know About Memory:深入了解 CPU 和內存之間的訪問性能。
- Fixing Java Memory Model:JSR-133 為什么增強 volatile 的內存語義。
- CPU 高速緩存的讀操作處理:《計算機組成與設計:硬件 / 軟件接口》的 5.4.1 小節。現代 CPU 已經很少使用直接映射 Cache 了,通常用的是組相連 Cache(set associative cache)。
- CPU 高速緩存的寫操作處理:《計算機組成與設計:硬件 / 軟件接口》的 5.3.3 小節。
每天用心記錄一點點。內容也許不重要,但習慣很重要!