從CPU快取看快取的套路

mghio發表於2020-09-24

一、前言

不同儲存技術的訪問時間差異很大,從 計算機層次結構 可知,通常情況下,從高層往底層走,儲存裝置變得更慢、更便宜同時體積也會更大,CPU 和記憶體之間的速度存在著巨大的差異,此時就會想到電腦科學界中一句著名的話:電腦科學的任何一個問題,都可以通過增加一箇中間層來解決。

二、引入快取層

為了解決速度不匹配問題,可以通過引入一個快取中間層來解決問題,但是也會引入一些新的問題。現代計算機系統中,從硬體到作業系統、再到一些應用程式,絕大部分的設計都用到了著名的區域性性原理,區域性性通常有如下兩種不同的形式:

時間區域性性:在一個具有良好的時間區域性性的程式當中,被引用過一次的記憶體位置,在將來一個不久的時間內很可能會被再次引用到。

空間區域性性:在一個具有良好的空間區域性性的程式當中,一個記憶體位置被引用了一次,那麼在不久的時間內很可能會引用附近的位置。

有上面這個區域性性原理為理論指導,為了解決二者速度不匹配問題就可以在 CPU 和記憶體之間加一個快取層,於是就有了如下的結構:

Xnip2020-08-16_22-51-12.jpg

三、何時更新快取

CPU 中引入快取中間層後,雖然可以解決和記憶體速度不一致的問題,但是同時也面臨著一個問題:當 CPU 更新了其快取中的資料之後,要什麼時候去寫入到記憶體中呢?,比較容易想到的一個解決方案就是,CPU 更新了快取的資料之後就立即更新到記憶體中,也就是說當 CPU 更新了快取的資料之後就會從上到下更新,直到記憶體為止,英文稱之為write through,這種方式的優點是比較簡單,但是缺點也很明顯,由於每次都需要訪問記憶體,所以速度會比較慢。還有一種方法就是,當 CPU 更新了快取之後並不馬上更新到記憶體中去,在適當的時候再執行寫入記憶體的操作,因為有很多的快取只是儲存一些中間結果,沒必要每次都更新到記憶體中去,英文稱之為write back,這種方式的優點是 CPU 執行更新的效率比較高,缺點就是實現起來會比較複雜。

上面說的在適當的時候寫入記憶體,如果是單核 CPU 的話,可以在快取要被新進入的資料取代時,才更新記憶體,但是在多核 CPU 的情況下就比較複雜了,由於 CPU 的運算速度超越了 1 級快取的資料 I\O 能力,CPU 廠商又引入了多級的快取結構,比如常見的 L1、L2、L3 三級快取結構,L1 和 L2 為 CPU 核心獨有,L3 為 CPU 共享快取。

Xnip2020-08-16_23-39-28.jpg

如果現在分別有兩個執行緒執行在兩個不同的核 Core 1Core 2 上,記憶體中 i 的值為 1,這兩個分別執行在兩個不同核上的執行緒要對 i 進行加 1 操作,如果不加一些限制,兩個核心同時從記憶體中讀取 i 的值,然後進行加 1 操作後再分別寫入記憶體中,可能會出現相互覆蓋的情況,解決的方法相信大家都能想得到,第一種是隻要有一個核心修改了快取的資料之後,就立即把記憶體和其它核心更新。第二種是當一個核心修改了快取的資料之後,就把其它同樣複製了該資料的 CPU 核心失效掉這些資料,等到合適的時機再更新,通常是下一次讀取該快取的時候發現已經無效,才從記憶體中載入最新的值。

四、快取一致性協議

不難看出第一種需要頻繁訪問記憶體更新資料,執行效率比較低,而第二種會把更新資料推遲到最後一刻才會更新,讀取記憶體,效率高(類似於懶載入)。 快取一致性協議(MESI) 就是使用第二種方案,該協議主要是保證快取內部資料的一致,不讓系統資料混亂。MESI 是指 4 種狀態的首字母。每個快取儲存資料單元(Cache line)有 4 種不同的狀態,用 2 個 bit 表示,狀態和對應的描述如下:

狀態 描述 監聽任務
M 修改 (Modified) 該 Cache line 有效,資料被修改了,和記憶體中的資料不一致,資料只存在於本 Cache 中 Cache line 必須時刻監聽所有試圖讀該快取行相對就主存的操作,這種操作必須在快取將該快取行寫回主存並將狀態變成 S(共享)狀態之前被延遲執行
E 獨享、互斥 (Exclusive) 該 Cache line 有效,資料和記憶體中的資料一致,資料只存在於本 Cache 中 Cache line 必須監聽其它快取讀主存中該快取行的操作,一旦有這種操作,該快取行需要變成 S(共享)狀態
S 共享 (Shared) 該 Cache line 有效,資料和記憶體中的資料一致,資料存在於很多 Cache 中 Cache line 必須監聽其它快取使該快取行無效或者獨享該快取行的請求,並將該 Cache line 變成無效
I 無效 (Invalid) 該 Cache line 無效 無監聽任務

下面看看基於快取一致性協議是如何進行讀取和寫入操作的, 假設現在有一個雙核的 CPU,為了描述方便,簡化一下只看其邏輯結構:

Xnip2020-08-17_08-44-37.jpg

單核讀取步驟Core 0 發出一條從記憶體中讀取 a 的指令,從記憶體通過 BUS 讀取 a 到 Core 0 的快取中,因為此時資料只在 Core 0 的快取中,所以將 Cache line 修改為 E 狀態(獨享),該過程用示意圖表示如下:

dnmKYQ.jpg

雙核讀取步驟:首先 Core 0 發出一條從記憶體中讀取 a 的指令,從記憶體通過 BUS 讀取 a 到 Core 0 的快取中,然後將 Cache line 置為 E 狀態,此時 Core 1 發出一條指令,也是要從記憶體中讀取 a,當 Core 1 試圖從記憶體讀取 a 的時候, Core 0 檢測到了發生地址衝突(其它快取讀主存中該快取行的操作),然後 Core 0 對相關資料做出響應,a 儲存於這兩個核心 Core 0Core 1 的快取行中,然後設定其狀態為 S 狀態(共享),該過程示意圖如下:

dnQsoV.jpg

假設此時 Core 0 核心需要對 a 進行修改了,首先 Core 0 會將其快取的 a 設定為 M(修改)狀態,然後通知其它快取了 a 的其它核 CPU(比如這裡的 Core 1)將內部快取的 a 的狀態置為 I(無效)狀態,最後才對 a 進行賦值操作。該過程如下所示:

dnQxeI.jpg

細心的朋友們可能已經注意到了,上圖中記憶體中 a 的值(值為 1)並不等於 Core 0 核心中快取的最新值(值為 2),那麼要什麼時候才會把該值更新到記憶體中去呢?就是當 Core 1 需要讀取 a 的值的時候,此時會通知 Core 0a 的修改後的最新值同步到記憶體(Memory)中去,在這個同步的過程中 Core 0 中快取的 a 的狀態會置為 E(獨享)狀態,同步完成後將 Core 0Core 1 中快取的 a 置為 S(共享)狀態,示意圖描述該過程如下所示:

dn8HHA.jpg

至此,變數 aCPU 的兩個核 Core 0Core 1 中回到了 S(共享)狀態了,以上只是簡單的描述了一下大概的過程,實際上這些都是在 CPU 的硬體層面上去保證的,而且操作比較複雜。

五、總結

現在很多一些實現快取功能的應用程式都是基於這些思想設計的,快取把資料庫中的資料進行快取到速度更快的記憶體中,可以加快我們應用程式的響應速度,比如我們使用常見的 Redis 資料庫可能是採用下面這些策略:① 首先應用程式從快取中查詢資料,如果有就直接使用該資料進行相應操作後返回,如果沒有則查詢資料庫,更新快取並且返回。② 當我們需要更新資料時,先更新資料庫,然後再讓快取失效,這樣下次就會先查詢資料庫再回填到快取中去,可以發現,實際上底層的一些思想都是相通的,不同的只是對於特定的場景可能需要增加一些額外的約束。基礎知識才是技術這顆大樹的根,我們先把根栽好了,剩下的那些枝和葉都是比較容易得到的東西了。

相關文章