楔子
本文來自於公眾號《小林coding》
代碼都是由 CPU 跑起來的,我們代碼寫的好與壞就決定了 CPU 的執行效率,特別是在編寫計算密集型的程序,更要注重 CPU 的執行效率,否則將會大大影響系統性能。關於 CPU 的結構這里簡單介紹一下,它由運算器、控制器、寄存器和內部總線 四部分構成,起作用分別如下:
運算器:由算術邏輯單元 ALU、通用寄存器、數據暫存器等組成。負責接收從控制器發送的命令並執行相應的動作,主要負責對數據的加工和處理。
控制器:由程序計數 PC、指令寄存器、地址寄存器、數據寄存器 DR 以及指令譯碼器等組成。負責對程序規定的控制信息進行分析,控制並協調輸入、輸出操作或內存訪問。
寄存器:寄存器的功能是存儲二進制代碼,它是由具有存儲功能的觸發器組合起來的。
總線:總結是一種內部結構,它是CPU、內存、輸入、輸出設備傳遞信息的公用通道,主機的各個部件通過總線相連接,外部設備通過相應的接口電路再與總線相連接。
而 CPU 內部嵌入了 CPU Cache(高速緩存),它是我們本次的重點,首先 CPU Cache 的存儲容量很小,但是離 CPU 核心很近,所以緩存的讀寫速度是極快的。因此 CPU 運算時,如果直接從 CPU Cache 讀取數據,而不是從內存的話,那么運算速度就會變得更快。
但是,大多數人不知道 CPU Cache 的運行機制,以至於不知道如何才能夠寫出能夠配合 CPU Cache 工作機制的代碼,一旦你掌握了它,你寫代碼的時候,就有新的優化思路了。
那么,接下來我們就來看看,CPU Cache 到底是什么樣的,是如何工作的呢,又該寫出讓 CPU 執行更快的代碼呢?
CPU Cache 有多快?
你可能會好奇為什么有了內存,還需要 CPU Cache?首先 CPU 內部有一個寄存器,專門用來存儲相關指令,因為指令是需要從內存加載到寄存器當中的,然后CPU才可以操作它。而且 CPU 訪問寄存器的速度是最快的,根據摩爾定律,CPU 訪問寄存器的速度每 18 個月就會翻倍,相當於每年增長 60% 左右;盡管訪問內存的速度當然也會不斷增長,但是增長的速度遠小於 CPU 寄存器,平均每年只增長 7% 左右。於是,CPU 對寄存器與內存的訪問性能的差距不斷拉大。
到現在,一次內存訪問所需時間是 200~300
多個時鍾周期,這意味着 CPU 寄存器和內存的訪問速度已經相差 200~300
多倍了。
為了彌補兩者之間的性能差異,就在 CPU 內部引入了 CPU Cache,也稱高速緩存。
CPU Cache 通常將緩存分為三個級別,分別是:L1 Cache(一級緩存)、L2 Cache(二級緩存) 和 L3 Cache(三級緩存)。
由於 CPU Cache 所使用的材料是 SRAM,價格比內存使用的 DRAM 高出很多,在當今每生產 1 MB 大小的 CPU Cache 需要 7 美金的成本,而內存只需要 0.015 美金的成本,成本方面相差了 466 倍。所以 CPU Cache 不像內存那樣動輒以 GB 計算,它的大小是以 KB 或 MB 來計算的。
我們可以查看對應的緩存,在Windows上直接使用任務管理器來查看:
我們看到右下角顯示:L1 Cache 是385KB、L2 Cache 是1.5MB、L3 Cache是12.0MB。
如果是Linux上,可以通過如下方式查看。
以我當前的阿里雲服務器為例,我們看到 L1 Cache 是32KB、L2 Cache 是1MB、L3 Cache 是33MB。
其中,L1 Cache 通常會分為「數據緩存」和「指令緩存」,這意味着數據和指令在 L1 Cache 這一層是分開緩存的,上圖中的 index0
也就是數據緩存,而index1
則是指令緩存,它兩的大小通常是一樣的。
另外,你也會注意到,L3 Cache 比 L1 Cache 和 L2 Cache 大很多,這是因為L1 Cache 和 L2 Cache 都是每個 CPU 核心獨有的,而 L3 Cache 是多個 CPU 核心共享的。
程序執行時,會先將內存中的數據加載到共享的 L3 Cache 中,再加載到每個核心獨有的 L2 Cache,最后進入到最快的 L1 Cache,之后才會被 CPU 讀取到寄存器中。它們之間的層級關系,如下圖:
越靠近 CPU 核心的緩存其訪問速度越快,CPU 訪問 L1 Cache 只需要 2~4
個時鍾周期,訪問 L2 Cache 大約 10~20
個時鍾周期,訪問 L3 Cache 大約20~60
個時鍾周期,而訪問內存速度大概在 200~300
個 時鍾周期之間。如下表格所示:
所以,CPU 從 L1 Cache 讀取數據的速度,相比從內存讀取的速度,會快 100
多倍。
CPU Cache 的數據結構和讀取過程是什么樣的?
CPU Cache 的數據是從內存中讀取過來的,它是以一小塊一小塊讀取數據的,而不是按照單個數組元素來讀取數據的,在 CPU Cache 中的,這樣一小塊一小塊的數據,稱為Cache Line(緩存塊)
你可以在你的 Linux 系統,用下面這種方式來查看 CPU 的 Cache Line,你可以看我服務器的 L1 Cache Line 大小是 64 字節,也就意味着L1 Cache 一次載入數據的大小是 64 字節。
比如,有一個 int array[100]
的數組,當載入 array[0]
時,由於這個數組元素的大小在內存只占 4 字節,不足 64 字節,CPU 就會順序加載數組元素到array[15]
,意味着 array[0]~array[15]
數組元素都會被緩存在 CPU Cache 中了,因此當下次訪問這些數組元素時,會直接從 CPU Cache 讀取,而不用再從內存中讀取,大大提高了 CPU 讀取數據的性能。
事實上,CPU 讀取數據的時候,無論數據是否存放到 Cache 中,CPU 都是先訪問 Cache,只有當 Cache 中找不到數據時,才會去訪問內存,並把內存中的數據讀入到 Cache 中,CPU 再從 CPU Cache 中讀取數據。
所以估計有人又想起了Redis(緩存)和 數據庫,沒錯,我們完全可以按照這種方式來理解。
這樣的訪問機制,跟我們使用「內存作為硬盤的緩存」的邏輯是一樣的,如果內存有緩存的數據,則直接返回,否則要訪問龜速一般的硬盤。
那 CPU 怎么知道要訪問的內存數據,是否在 Cache 里?如果在的話,如何找到 Cache 對應的數據呢?我們從最簡單、基礎的直接映射 Cache(Direct Mapped Cache)說起,來看看整個 CPU Cache 的數據結構和訪問邏輯。
前面,我們提到 CPU 訪問內存數據時,是一小塊一小塊數據讀取的,具體這一小塊數據的大小,取決於 coherency_line_size
的值,一般 64 字節。在內存中,這一塊的數據我們稱為內存塊(block),讀取的時候我們要拿到數據所在內存塊的地址。
對於直接映射 Cache 采用的策略,就是把內存塊的地址始終「映射」在一個 CPU Line(緩存塊) 的地址,至於映射關系實現方式,則是使用「取模運算」,取模運算的結果就是內存塊地址對應的 CPU Line(緩存塊) 的地址。
舉個例子,內存共被划分為 32 個內存塊,CPU Cache 共有 8 個 CPU Line,假設 CPU 想要訪問第 15 號內存塊,如果 15 號內存塊中的數據已經緩存在 CPU Line 中的話,則是一定映射在 7 號 CPU Line 中,因為 15 % 8
的值是 7。
機智的你肯定發現了,使用取模方式映射的話,就會出現多個內存塊對應同一個 CPU Line,比如上面的例子,除了 15 號內存塊是映射在 7 號 CPU Line 中,還有 7 號、23 號、31 號內存塊都是映射到 7 號 CPU Line 中。
因此,為了區別不同的內存塊,在對應的 CPU Line 中我們還會存儲一個組標記(Tag)。這個組標記會記錄當前 CPU Line 中存儲的數據對應的內存塊,我們可以用這個組標記來區分不同的內存塊。
除了組標記信息外,CPU Line 還有兩個信息:
- 一個是,從內存加載過來的實際存放數據(Data)
- 另一個是有效位(Valid bit),它是用來標記對應的 CPU Line 中的數據是否是有效的,如果有效位是 0,則表示數據無效。那么無論 CPU Line 中是否有數據,CPU 都會直接訪問內存,重新加載數據。
CPU 在從 CPU Cache 讀取數據的時候,並不是讀取 CPU Line 中的整個數據塊,而是讀取 CPU 所需要的一個數據片段,這樣的數據統稱為一個字(Word)。那怎么在對應的 CPU Line 中數據塊中找到所需的字呢?答案是,需要一個偏移量(offset)。
因此,一個內存的訪問地址,包括組標記、CPU Line 索引、偏移量這三種信息,於是 CPU 就能通過這些信息,在 CPU Cache 中找到緩存的數據。而對於 CPU Cache 里的數據結構,則是由索引 + 有效位 + 組標記 + 數據塊組成。
如果內存中的數據已經在 CPU Cahe 中了,那 CPU 訪問一個內存地址的時候,會經歷這 4 個步驟:
1. 根據內存地址中索引信息,計算在 CPU Cahe 中的索引,也就是找出對應的 CPU Line 的地址;
2. 找到對應 CPU Line 后,判斷 CPU Line 中的有效位,確認 CPU Line 中數據是否是有效的,如果是無效的,CPU 就會直接訪問內存,並重新加載數據,如果數據有效,則往下執行;
3. 對比內存地址中組標記和 CPU Line 中的組標記,確認 CPU Line 中的數據是我們要訪問的內存數據,如果不是的話,CPU 就會直接訪問內存,並重新加載數據,如果是的話,則往下執行;
4. 根據內存地址中偏移量信息,從 CPU Line 的數據塊中,讀取對應的字;
到這里,相信你對直接映射 Cache 有了一定認識,但其實除了直接映射 Cache 之外,還有其他通過內存地址找到 CPU Cache 中的數據的策略,比如全相連 Cache (Fully Associative Cache)、組相連 Cache (Set Associative Cache)等,這幾種策策略的數據結構都比較相似,如果你有興趣可以去看看,相信很快就能理解。
如何寫出讓 CPU 跑得更快的代碼?
我們知道 CPU 訪問內存的速度,比訪問 CPU Cache 的速度慢了 100 多倍,所以如果 CPU 所要操作的數據在 CPU Cache 中的話,這樣將會帶來很大的性能提升。訪問的數據在 CPU Cache 中的話,意味着緩存命中,緩存命中率越高的話,代碼的性能就會越好,CPU 也就跑的越快。
於是,「如何寫出讓 CPU 跑得更快的代碼?」這個問題,可以改成「如何寫出 CPU 緩存命中率高的代碼?」。
在前面我也提到, L1 Cache 通常分為「數據緩存」和「指令緩存」,這是因為 CPU 會別處理數據和指令,比如 1+1
這個運算,+
就是指令,會被放在「指令緩存」中,而輸入數字 1
則會被放在「數據緩存」里。
因此,我們要分開來看「數據緩存」和「指令緩存」的緩存命中率。
如何提升數據緩存的命中率?
假設要遍歷二維數組,有以下兩種形式,雖然代碼執行結果是一樣,但你覺得哪種形式效率最高呢?為什么高呢?
經過測試,形式一 array[i][j]
執行時間比形式二 array[j][i]
快好幾倍。
之所以有這么大的差距,是因為二維數組 array
所占用的內存是連續的,比如長度 N
的指是 3
的話,那么內存中的數組元素的布局順序是這樣的:
array[0][0], array[0][1], array[0][2], array[1][0], array[1][1], array[1][2], array[2][0], array[2][1], array[2][2]
形式一用 array[i][j]
訪問數組元素的順序,正是和內存中數組元素存放的順序一致。當 CPU 訪問 array[0][0]
時,由於該數據不在 Cache 中,於是會「順序」把跟隨其后的 3 個元素從內存中加載到 CPU Cache,這樣當 CPU 訪問后面的 3 個數組元素時,就能在 CPU Cache 中成功地找到數據,這意味着緩存命中率很高,緩存命中的數據不需要訪問內存,這便大大提高了代碼的性能。
而如果用形式二的 array[j][i]
來訪問,則訪問的順序就是:
array[0][0], array[1][0], array[2][0], array[0][1], array[1][1], array[2][1], array[0][2], array[1][2], array[2][2]
你可以看到,訪問的方式跳躍式的,而不是順序的,那么如果 N 的數值很大,那么操作 array[j][i]
時,是沒辦法把 array[j+1][i]
也讀入到 CPU Cache 中的,既然 array[j+1][i]
沒有讀取到 CPU Cache,那么就需要從內存讀取該數據元素了。很明顯,這種不連續性、跳躍式訪問數據元素的方式,可能不能充分利用到了 CPU Cache 的特性,從而代碼的性能不高。
那訪問 array[0][0]
元素時,CPU 具體會一次從內存中加載多少元素到 CPU Cache 呢?這個問題,在前面我們也提到過,這跟 CPU Cache Line 有關,它表示CPU Cache 一次性能加載數據的大小,可以在 Linux 里通過coherency_line_size
配置查看 它的大小,通常是 64 個字節。
也就是說,當 CPU 訪問內存數據時,如果數據不在 CPU Cache 中,則會一次性會連續加載 64 字節大小的數據到 CPU Cache,那么當訪問 array[0][0]
時,由於該元素不足 64 字節,於是就會往后順序讀取 array[0][0]~array[0][15]
到 CPU Cache 中。順序訪問的 array[i][j]
因為利用了這一特點,所以就會比跳躍式訪問的 array[j][i]
要快。
因此,遇到這種遍歷數組的情況時,按照內存布局順序訪問,將可以有效的利用 CPU Cache 帶來的好處,這樣我們代碼的性能就會得到很大的提升。
如何提升指令緩存的命中率?
提升數據的緩存命中率的方式,是按照內存布局順序訪問,那針對指令的緩存該如何提升呢?
我們以一個例子來看看,有一個元素為 0 到 100 之間隨機數字組成的一維數組:
int array[N];
for (i = 0; i < N; i++) {
array[i] = rand() % 100;
}
接下來,對這個數組做兩個操作:
// 操作一:數組遍歷
for (i = 0; i < N; i++) {
if (arr[i] < 50) {
arr[i] = 50;
}
}
// 操作二:排序
sort(array, array + N)
那么問題來了,你覺得先遍歷再排序速度快,還是先排序再遍歷速度快呢?
在回答這個問題之前,我們先了解 CPU 的分支預測器。對於 if 條件語句,意味着此時至少可以選擇跳轉到兩段不同的指令執行,也就是 if 還是 else 中的指令。那么,如果分支預測可以預測到接下來要執行 if 里的指令,還是 else 指令的話,就可以「提前」把這些指令放在指令緩存中,這樣 CPU 可以直接從 Cache 讀取到指令,於是執行速度就會加快。
當數組中的元素是隨機的,分支預測就無法有效工作,而當數組元素都是順序的,分支預測器會動態地根據歷史命中數據對未來進行預測,這樣命中率就會很高。
因此,先排序再遍歷速度會更快,這是因為排序之后,數字是從小到大的,那么前幾次循環命中 if < 50
的次數會比較多,於是分支預測就會緩存 if
里的array[i] = 0
指令到 Cache 中,后續 CPU 執行該指令就只需要從 Cache 讀取就好了。
如果你肯定代碼中的 if
中的表達式判斷為 true
的概率比較高,我們可以使用顯示分支預測工具,比如在 C/C++ 語言中編譯器提供了 likely
和 unlikely
這兩種宏,如果 if
條件為 ture
的概率大,則可以用 likely
宏把 if
里的表達式包裹起來,反之用 unlikely
宏,有興趣可以了解一下。當然實際上,CPU 自身的動態分支預測已經是比較准的了,所以只有當非常確信 CPU 預測的不准,且能夠知道實際的概率情況時,才建議使用這兩種宏。
如果提升多核 CPU 的緩存命中率?
在單核 CPU,雖然只能執行一個進程,但是操作系統給每個進程分配了一個時間片,時間片用完了,就調度下一個進程,於是各個進程就按時間片交替地占用 CPU,從宏觀上看起來各個進程同時在執行。
而現代 CPU 都是多核心的,進程可能在不同 CPU 核心來回切換執行,這對 CPU Cache 不是有利的,雖然 L3 Cache 是多核心之間共享的,但是 L1 和 L2 Cache 都是每個核心獨有的,如果一個進程在不同核心來回切換,各個核心的緩存命中率就會受到影響,相反如果進程都在同一個核心上執行,那么其數據的 L1 和 L2 Cache 的緩存命中率可以得到有效提高,緩存命中率高就意味着 CPU 可以減少訪問 內存的頻率。
當有多個同時執行「計算密集型」的線程,為了防止因為切換到不同的核心,而導致緩存命中率下降的問題,我們可以把線程綁定在某一個 CPU 核心上,這樣性能可以得到非常可觀的提升。
在 Linux 上提供了 sched_setaffinity
方法,來實現將線程綁定到某個 CPU 核心這一功能。
#define _GNU_SOURCE
#include <sched.h>
int sched_setaffinity(pid_t pid, size_t cpusetsize, cpu_set_t *mask);
總結一下:
由於隨着計算機技術的發展,CPU自身的寄存器 與 內存的訪問速度相差越來越多,如今差距已經高達好幾百倍了,所以 CPU 內部嵌入了 CPU Cache 組件,作為內存與 CPU 之間的緩存層,CPU Cache 由於離 CPU 核心很近,所以訪問速度也是非常快的。但由於所需材料成本比較高,它不像內存動輒幾個 GB 大小,而是僅有幾十 KB 到 MB 大小。
當 CPU 訪問數據的時候,先是訪問 CPU Cache,如果緩存命中的話,則直接返回數據,就不用每次都從內存讀取速度了。因此,緩存命中率越高,代碼的性能越好。
但需要注意的是,當 CPU 訪問數據時,如果 CPU Cache 沒有緩存該數據,則會從內存讀取數據,但是並不是只讀一個數據,而是一次性讀取一塊一塊的數據存放到 CPU Cache 中,之后才會被 CPU 讀取。
內存地址映射到 CPU Cache 地址里的策略有很多種,其中比較簡單是直接映射 Cache,它巧妙的把內存地址拆分成「索引 + 組標記 + 偏移量」的方式,使得我們可以將很大的內存地址,映射到很小的 CPU Cache 地址里。
要想寫出讓 CPU 跑得更快的代碼,就需要寫出緩存命中率高的代碼,CPU L1 Cache 分為數據緩存和指令緩存,因而需要分別提高它們的緩存命中率:
對於數據緩存,我們在遍歷數據的時候,應該按照內存布局的順序操作,這是因為 CPU Cache 是根據 CPU Cache Line 批量操作數據的,所以順序地操作連續內存數據時,性能能得到有效的提升;
對於指令緩存,有規律的條件分支語句能夠讓 CPU 的分支預測器發揮作用,進一步提高執行的效率;
另外,對於多核 CPU 系統,線程可能在不同 CPU 核心來回切換,這樣各個核心的緩存命中率就會受到影響,於是要想提高進程的緩存命中率,可以考慮把線程綁定 CPU 到某一個 CPU 核心。
CPU Cache 的數據寫入
我們介紹了 CPU Cache 的原理和讀取過程,以及如何更加高效的利用 CPU 緩存,但這還沒有結束,因為數據不光有讀操作,還有寫操作。在寫數據的時候,也會將數據寫入 CPU Cache ,但此時會造成 Cache 中的數據和內存中的數據不一致,所以最終還是要將數據從 Cache 寫回到內存中。那么問題來了,要在什么時機才把 Cache 中的數據寫回到內存呢?為了應對這個問題,下面介紹兩種寫入數據的方法:
寫直達(Write Through)
寫回(Write Back)
寫直達
保持內存與 Cache 一致性最簡單的方式是,把數據同時寫入內存和 Cache 中,這種方法稱為「寫直達(Write Through)」
在這個方法里,寫入前會先判斷數據是否已經在 CPU Cache 里面了。
如果數據已經在 Cache 里面,先將數據更新到 Cache 里面,再寫入到內存里面
如果數據沒有在 Cache 里面,就直接把數據更新到內存里面
「寫直達」做法很直觀,也很簡單,但是問題也很明顯,無論數據在不在 Cache 里面,每次寫操作都會寫回到內存,這樣寫操作將會花費大量的時間,無疑性能會受到很大的影響。
寫回
既然寫直達由於每次寫操作都會把數據寫回到內存,而導致影響性能,於是為了要減少數據寫回內存的頻率,就出現了「寫回(Write Back)」的方法。
在寫回機制中,當發生寫操作時,新的數據僅僅被寫入緩存的數據塊中,只有當修改過的數據塊(Data Block)「被替換」時才需要寫到內存中,從而減少了數據寫回內存的頻率,這樣便可以提高系統的性能。
這里需要詳細解釋一下:
- 如果當發生寫操作時,數據已經在 CPU Cache 的 Data Block 里的話,則把數據更新到 Block 里,同時標記 CPU Cache 里的這個 Block 為無效(invalid)的,做法就是將有效位(Valid bit)設置為 0。當 Block 無效時,代表該 Block 的數據和內存是不一致的,這種情況是不用把數據寫到內存里的。
- 如果當發生寫操作時,數據所對應的 Data Block 里存放的是「別的內存地址的數據」的話,就要檢查這個 Cache Block 里的數據有沒有被標記為無效,如果是無效的話,我們就要把這個 Block 里的數據寫回到內存,然后再把當前要寫入的數據寫入到這個 Block 里,同時也把它標記為無效的;但如果 Block 里面的數據沒有被標記為無效,則就直接將數據寫入到這個 Block 里,然后再把這個 Block 標記為無效的就好了。
可以發現寫回這個方法,在把數據寫入到 Cache 的時候,只有在緩存不命中,同時數據對應的 Cache 中的 Data Block 被標記無效的情況下,才會將數據寫到內存中;而在緩存命中的情況下,則在寫入后 Cache 后,只需把該數據對應的 Cache Block 標記為無效即可,而不用寫到內存里。
如果覺得這里有點繞的話,那么可以先看下面的內容。
緩存一致性問題
重點來了,現在 CPU 都是多核的,由於 L1/L2 Cache 是多個核心各自獨有的,那么會帶來多核心的「緩存一致性(Cache Coherence)」的問題,如果不能保證緩存一致性的問題,就可能造成結果錯誤。
那緩存一致性的問題具體是怎么發生的呢?我們以一個含有兩個核心的 CPU 作為例子看一看。假設 A 核心和 B 核心同時運行兩個線程,都操作共同的變量 i(初始值為 0 )。
這時如果 A 核心執行了 i++ 語句的時候,為了考慮性能,使用了我們前面所說的寫回策略,先把值為 1 的執行結果寫入到 L1/L2 Cache 中,然后把 L1/L2 Cache 中對應的 Block 標記為臟的。但這個時候數據其實沒有被同步到內存中的,因為寫回策略,只有在 A 核心中的這個 Block 要被替換的時候,數據才會寫入到內存里。
如果這時旁邊的 B 核心嘗試從內存讀取 i 變量的值,則讀到的將會是錯誤的值,因為剛才 A 核心更新的 i 值還沒寫入到內存中,因此內存中的值依然是 0。這個就是所謂的緩存一致性問題,由於 A 核心和 B 核心的緩存在這個時候不一致,從而會導致執行結果的錯誤,本來它們都應該是 1 的。所以還是很好理解的,總的來說就是 L1/L2 Cache 是每個核心獨有的,一個核心不可能從另一個核心的 L1/L2 Cache 中讀取,B 核心如果想看到 A 核心對變量 i 做的修改,那么必須要等到 A 核心將數據寫回到內存或 L3 Cache,否則就會讀到錯誤的值。
那么,要解決這一問題,就需要一種機制,來同步兩個不同核心里面的緩存數據。要實現的這個機制的話,要保證做到下面這 2 點:
第一點,某個 CPU 核心里的 Cache 數據更新時,必須要傳播到其他核心的 Cache,這個稱為寫傳播(Write Propagation)
第二點,某個 CPU 核心里對數據的操作順序,必須在其他核心看起來順序是一樣的,這個稱為事務的串形化(Transaction Serialization)
第一點寫傳播很容易理解,就是當某個核心在 Cache 更新了數據,必須同步到其他核心的 Cache 里。而對於第二點事務的串形化,我們舉個例子來理解它。
假設我們有一個含有 4 個核心的 CPU,這 4 個核心都操作共同的變量 i(初始值為 0 )。A 核心先把 i 值變為 100,而此時同一時間,B 核心把 i 值變為 200,這里兩個修改,都會「傳播」到 C 和 D 核心。但問題是 C 核心和 D 核心看到的兩個操作的順序不一致要怎么辦?
C 核心先收到了 A 核心更新數據的事件,再收到 B 核心更新數據的事件,因此 C 核心看到的變量 i 是先變成 100,后變成 200;而 D 核心收到的事件是反過來的,則 D 核心看到的是變量 i 先變成 200,再變成 100,雖然是做到了寫傳播,但是各個 Cache 里面的數據還是不一致的。
所以,我們要保證 C 核心和 D 核心都能看到相同順序的數據變化,比如變量 i 都是先變成 100,再變成 200,這樣的過程就是事務的串形化。要實現事務串形化,需要做到 2 點:
CPU 核心對於 Cache 中數據的操作,需要同步給其他 CPU 核心
要引入「鎖」的概念,如果兩個 CPU 核心里有相同數據的 Cache,那么對於這個 Cache 數據的更新,只有拿到了「鎖」,才能進行對應的數據更新
那接下來我們看看,寫傳播和事務串形化具體是用什么技術實現的。
總線嗅探
寫傳播的原則就是當某個 CPU 核心更新了 Cache 中的數據,要把該事件廣播通知到其他核心。最常見實現的方式是「總線嗅探(Bus Snooping)」。
還是以前面的 i 變量例子來說明總線嗅探的工作機制,當 A 核心修改了 L1 Cache 中 i 變量的值,通過總線把這個事件廣播通知給其他所有的核心,然后每個 CPU 核心都會監聽總線上的廣播事件,並檢查是否有相同的數據在自己的 L1 Cache 里面,如果 B 核心的 L1 Cache 中有該數據,那么也需要把該數據更新到自己的 L1 Cache。
可以發現,總線嗅探方法很簡單,CPU 需要每時每刻監聽總線上的一切活動,但是不管別的核心的 Cache 是否緩存相同的數據,都需要發出一個廣播事件,這無疑會加重總線的負載。另外,總線嗅探只是保證了某個 CPU 核心的 Cache 更新數據這個事件能被其他 CPU 核心知道,但是並不能保證事務串形化。於是,有一個協議基於總線嗅探機制實現了事務串形化,也用狀態機機制降低了總線帶寬壓力,這個協議就是 MESI 協議,這個協議就做到了 CPU 緩存一致性。
MESI 協議
MESI 協議其實是 4 個狀態單詞的開頭字母縮寫,分別是:
Modified,已修改
Exclusive,獨占
Exclusive,獨享
Invalidated,已失效
這四個狀態來標記 Cache Line 四個不同的狀態,還記得 Cache Line 嗎?CPU Cache 的數據是從內存中讀取過來的,它是以一小塊一小塊讀取數據的,在 CPU Cache 中的,這樣一小塊一小塊的數據,稱為 Cache Line,而一個 Cache Line 由 "索引 + 有效位 + 組標記 + 數據塊組成"。我們在介紹 Cache 數據寫入的人時候,一直說的數據塊(Data Block)指的就是 Cache Line 里的數據塊。
- 「已修改」狀態就是我們前面提到的無效標記,代表該 Cache Block 上的數據已經被更新過,但是還沒有寫到內存里。而「已失效」狀態,表示的是這個 Cache Block 里的數據已經失效了,不可以讀取該狀態的數據。
- 「獨占」和「共享」狀態都代表 Cache Block 里的數據是干凈的,也就是說,這個時候 Cache Block 里的數據和內存里面的數據是一致性的。
- 「獨占」和「共享」的差別在於,獨占狀態的時候,數據只存儲在一個 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 沒有該數據。這個時候,如果要向獨占的 Cache 寫數據,就可以直接自由地寫入,而不需要通知其他 CPU 核心,因為只有你這有這個數據,就不存在緩存一致性的問題了,於是就可以隨便操作該數據。
- 另外,在「獨占」狀態下的數據,如果有其他核心從內存讀取了相同的數據到各自的 Cache ,那么這個時候,獨占狀態下的數據就會變成共享狀態。
- 「共享」狀態着相同的數據在多個 CPU 核心的 Cache 里都有,所以當我們要更新 Cache 里面的數據的時候,不能直接修改,而是要先向所有的其他 CPU 核心廣播一個請求,要求先把其他核心的 Cache 中對應的 Cache Line 標記為「無效」狀態,然后再更新當前 Cache 里面的數據。
我們舉個具體的例子來看看這四個狀態的轉換:
- 當 A 核心從內存讀取變量 i 的值,數據被緩存在 A 核心自己的 Cache 里面,此時其他 CPU 核心的 Cache 沒有緩存該數據,於是標記 Cache Line 狀態為「獨占」,此時其 Cache 中的數據與內存是一致的;
- 然后 B 核心也從內存讀取了變量 i 的值,此時會發送消息給其他 CPU 核心,由於 A 核心已經緩存了該數據,所以會把數據返回給 B 核心。在這個時候, A 和 B 核心緩存了相同的數據,Cache Line 的狀態就會變成「共享」,並且其 Cache 中的數據與內存也是一致的;
- 當 A 核心要修改 Cache 中 i 變量的值,發現數據對應的 Cache Line 的狀態是共享狀態,則要向所有的其他 CPU 核心廣播一個請求,要求先把其他核心的 Cache 中對應的 Cache Line 標記為「無效」狀態,然后 A 核心才更新 Cache 里面的數據,同時標記 Cache Line 為「已修改」狀態,此時 Cache 中的數據就與內存不一致了;
- 如果 A 核心「繼續」修改 Cache 中 i 變量的值,由於此時的 Cache Line 是「已修改」狀態,因此不需要給其他 CPU 核心發送消息,直接更新數據即可;
- 如果 A 核心的 Cache 里的 i 變量對應的 Cache Line 要被「替換」,發現 Cache Line 狀態是「已修改」狀態,就會在替換前先把數據同步到內存;或者 B 要用 i 這個變量,由於狀態無效,本來要去內存里面重新讀,但如果該數據在其它的核心的 Cache 中也存在(A 核心),並且狀態為「已修改」,那么必須先等 A 核心將該數據從 Cache 寫到內存之后,B 核心才能從內存重新讀取數據。
所以,可以發現當 Cache Line 狀態是「已修改」或者「獨占」狀態時,修改更新其數據不需要發送廣播給其他 CPU 核心,這在一定程度上減少了總線帶寬壓力。事實上,整個 MESI 的狀態可以用一個有限狀態機來表示它的狀態流轉。還有一點,對於不同狀態觸發的事件操作,可能是來自本地 CPU 核心發出的廣播事件,也可以是來自其他 CPU 核心通過總線發出的廣播事件。下圖即是 MESI 協議的狀態圖:
MESI 協議的四種狀態之間的流轉過程,我匯總成了下面的表格,可以更詳細的看到每個狀態轉換的原因:
下面總結一下:
CPU 在讀寫數據的時候,都是在 CPU Cache 讀寫數據的,原因是 Cache 離 CPU 很近,讀寫性能相比內存高出很多。對於 Cache 里沒有緩存 CPU 所需要讀取的數據的這種情況,CPU 則會從內存讀取數據,並將數據緩存到 Cache 里面,最后 CPU 再從 Cache 讀取數據。
而對於數據的寫入,CPU 都會先寫入到 Cache 里面,然后再在找個合適的時機寫入到內存,那就有「寫直達」和「寫回」這兩種策略來保證 Cache 與內存的數據一致性:
寫直達,只要有數據寫入,都會直接把數據寫入到內存里面,這種方式簡單直觀,但是性能就會受限於內存的訪問速度;
寫回,對於已經緩存在 Cache 的數據的寫入,只需要更新其數據就可以,不用寫入到內存,只有在需要把緩存里面的臟數據交換出去的時候,才把數據同步到內存里,這種方式在緩存命中率高的情況,性能會更好;
當今 CPU 都是多核的,每個核心都有各自獨立的 L1/L2 Cache,只有 L3 Cache 是多個核心之間共享的。所以,我們要確保多核緩存是一致性的,否則會出現錯誤的結果。要想實現緩存一致性,關鍵是要滿足兩點:
第一點是寫傳播,也就是當某個 CPU 核心發生寫入操作時,需要把該事件廣播通知給其他核心;
第二點是事物的串行化,這個很重要,只有保證了這個,才能保障我們的數據是真正一致的,我們的程序在各個不同的核心上運行的結果也是一致的;
而基於總線嗅探機制的 MESI 協議,就滿足上面了這兩點,因此它是保障緩存一致性的協議。
MESI 協議,是已修改、獨占、共享、已實現這四個狀態的英文縮寫的組合。整個 MSI 狀態的變更,則是根據來自本地 CPU 核心的請求,或者來自其他 CPU 核心通過總線傳輸過來的請求,從而構成一個流動的狀態機。另外,對於在「已修改」或者「獨占」狀態的 Cache Line,修改更新其數據不需要發送廣播給其他 CPU 核心。
最后推薦一個網站,可以在線體驗 MESI 協議狀態轉換,有興趣的話可以去玩一玩。