CPU緩存(CPU Cache)的目的是為了提高訪問內存(RAM)的效率,這雖然已經涉及到硬件的領域,但它仍然與我們息息相關,了解了它的一些原理,能讓我們寫出更高效的程序,另外在多線程程序中,一些不可思議的問題也與緩存有關。
現代多核處理器,一個CPU由多個核組成,每個核又可以有多個硬件線程,比如我們說4核8線程,就是指有4個核,每個核2個線程,這在OS看來就像8個並行處理器一樣。
CPU緩存有多級緩存,比如L1, L2, L3等:
- L1容量最小,速度最快,每個核都有L1緩存,L1又專門針對指令和數據分成L1d(數據緩存),L1i(指令緩存)。
- L2容量比L1大,速度比L1慢,每個核都有L2緩存。
- L3容量最大,速度最慢,多個核共享一個L3緩存。
有些CPU可能還有L4緩存,不過不常見;此外還有其他類型的緩存,比如TLB(translation lookaside buffer),用於物理地址和虛擬地址轉譯,這不是我們關心的緩存。
下圖展示了緩存和CPU的關系:
Linux用下面命令可以查看CPU緩存的信息:
$ getconf -a | grep CACHE
LEVEL1_ICACHE_SIZE 32768
LEVEL1_ICACHE_ASSOC 8
LEVEL1_ICACHE_LINESIZE 64
LEVEL1_DCACHE_SIZE 32768
LEVEL1_DCACHE_ASSOC 8
LEVEL1_DCACHE_LINESIZE 64
LEVEL2_CACHE_SIZE 262144
LEVEL2_CACHE_ASSOC 8
LEVEL2_CACHE_LINESIZE 64
LEVEL3_CACHE_SIZE 31457280
LEVEL3_CACHE_ASSOC 20
LEVEL3_CACHE_LINESIZE 64
LEVEL4_CACHE_SIZE 0
LEVEL4_CACHE_ASSOC 0
LEVEL4_CACHE_LINESIZE 0
- 上面顯示CPU只有3級緩存,L4都為0。
- L1的數據緩存和指令緩存分別是32KB;L2為256KB;L3為30MB。
- 在緩存和主存之間,數據是按固定大小的塊傳輸的 該塊稱為緩存行(cache line),這里顯示每行的大小為64Bytes。
- ASSOC表示主存地址映射到緩存的策略,這里L1,L2是8路組相聯,L3是20路組相聯,等一會兒再說是什么意思。
緩存結構
一塊CPU緩存可以看成是一個數組,數組元素是緩存項(cache entry),一個緩存項的內容大概是這樣的:
+-------------------------------------------+
| tag | data block(cache line) | flag |
+-------------------------------------------+
- data block就是從內存中拷貝過來的數據,也就是我們說的cache line,從上面信息可知大小是64字節。
- tag 保存了內存地址的一部分,是用來驗證是否緩存命中的。
- flag 是一些標志位,比如緩存是否失效,寫dirty等等。
- 實際上LEVEL1_ICACHE_SIZE這個數據,是用data block來算的,並不包括tag和flag占用的大小,比如64 x 512 = 32768,表示LEVEL1_ICACHE_SIZE可以緩存512個cache line。
緩存首先要解決的問題是:怎么映射內存地址和緩存地址?比如CPU要檢查一個內存值是否已經緩存,那么它首先要能算出這個內存地址對應的緩存地址,然后才能檢查。
為了解決這個問題,緩存將一個內存地址分成下面幾個部分:
+-------------------------------------------+
| tag | index | offset |
+-------------------------------------------+
- tag和緩存項中的tag對應,用來驗證是否緩存命中的。
- index 緩存項數組中的索引。
- offset 緩存塊(cache line)中的偏移,因為緩存塊是64字節,而內存值可能只有4個字節,一個緩存塊可以保存多個連續的內存值。這個offset實際上就是指明內存值在cache line中的位置。
直接映射緩存
現在我們舉一個具體的例子,說明內存和緩存是如何映射的:
- 假如緩存的大小是32768B(32KB),緩存塊大小是64B,那么緩存項數組就有 32768/64=512 個。
- CPU要訪問一個內存地址
0x1CAABBDD
,它首先檢查這個內存地址是否在緩存中,檢查過程是這樣的: - 內存地址的二進制形式是(低位在前面):
| tag | index | offset |
0 0 0 1 1 1 0 0 1 0 1 0 1 0 1 0 1 0 1 1 1 0 1 1 1 1 0 1 1 1 0 1
- 先計算內存在cache line中的偏移,因為緩存塊是64字節,那么offset需要占6位(2^6=64),即offset=011101=29。
- 接着要計算緩存項的索引,因為緩存項數組是512個,所以index需要占9位(2^9=512),即index=011101111=239。
- 現在我們通過offset和index已經找到緩存塊的具體位置了,但是因為內存要遠比緩存大很多,所以多個內存塊是可以映射到同一個位置的,怎么判斷這個緩存塊位置存的就是這個內存的值呢?答案就是tag:內存地址去掉index和offset的部分,剩下的就是tag=00011100101010101=0x3955。
- 通過index找到緩存項,比較緩存項中的tag是否與內存地址中的tag相同,如果相同表示命中,就直接取緩存塊中的值;如果不同表示未命中,CPU需要將內存值拷貝到緩存(替換掉老的)。
這種映射方式就稱為直接映射(Direct mapped)
,它的缺點就是多個內存地址會映射到同一個緩存地址,拿上面的內存地址來看,只要offset和index相同的內存地址,就一定會映射到同一個地方,比如:
00011100101010100 011101111 011101
00011100101010110 011101111 011101
00011100101010111 011101111 011101
如果同時訪問上面3個地址,就會一直替換緩存的值,也就是一直出現緩存沖突,這可能比沒有緩存還要慢,因為除了訪問內存外,還多一個拷貝內存值到緩存的操作。
N路組相聯
為了解決上面的問題,我試着把緩存項數組分成2個數組(2路),比如分成2個256的數組,如下圖所示:
查找過程和上面其實一樣的:
- 先通過index找到數組索引,只不過因為是2路,所以存在2個數組。
- 然后通過內存tag依次比較2個緩存頂的tag,如果其中一個tag相等,說明這個數組緩存命中;如果兩個都不相等,說明緩存不命中,CPU會拷貝內存值到緩存中,但是現在有2個位置,要拷貝進哪個呢?我的理解CPU應該是隨機選1路拷貝。
- offset這個其實無關緊要,因為它是cache line中的偏移。
那這個和直接映射相比,好在哪里呢,因為一個內存值會隨機拷貝到2路中的1個,所以緩存沖突(多個內存地址映射到同一個緩存地址)的概率會降低一半;如果把緩存項數組分成4個數組,這就是4路組相聯。
上面LEVEL1_ICACHE_ASSOC
的值等於8,表明是8路組相聯。分組越多,緩存沖突率越低,但是CPU要遍歷的數組就越多,這是一個權衡的問題。
通過觀察也可以發現,其實直接映射就是1路組相聯。如果直接分成512個數組,那每個數組只有1項,這種就是全相聯,CPU直接遍歷512個數組,判斷內存地址在哪1個。
緩存分配策略和更新策略:
- 當CPU從內存讀數據時,如果該數據沒有在緩存中(read miss),CPU會把數據拷貝到緩存。
- 當CPU往內存寫數據時:
- 有多種寫策略:
- Write through 更新緩存的數據,同時更新內存的數據。
- Write back 只更新緩存的數據,同時在緩存項設置一個drity標志位,內存的數據只會在某個時刻更新(比如替換cache line時)。
- 如果在寫的時候數據沒有在緩存中(write miss),也有兩種策略:
- Write allocate 在寫之前先把數據加載到緩存,然后再實施上面的寫策略。
- No-write allocate 不加載緩存,直接把數據寫到內存。數據只有在 read miss 時才會加載到緩存。
雖然上面兩組策略可以任意搭配,但通常情況下是 No-write allocate 和 Write through 一起使用,而 Write allocate 則和 Write back 一起使用,下面是 wikipedia 的兩張流程圖。
No-write allocate
方式的Write through Cache
:
Write allocate
方式的Write back Cache
:
從上面描述我們知道,當我們向一個內存寫數據時,內存中的數據可能不馬上被更新,這個新數據可能還在cache line呆着。因為每個核都有自己的緩存,如果CPU不做處理,可以想象一定會出問題的:比如核1改了數據,核2去讀同一個數據,此時數據還在核1的緩存中,核2讀到的就是老的數據。
三、CPU緩存一致性協議(MESI)
MESI(Modified Exclusive Shared Or Invalid
)(也稱為伊利諾斯協議,是因為該協議由伊利諾斯州立大學提出的)是一種廣泛使用的支持寫回策略的緩存一致性協議。為了保證多個CPU緩存中共享數據的一致性,定義了緩存行(Cache Line)的四種狀態,而CPU對緩存行的四種操作可能會產生不一致的狀態,因此緩存控制器監聽到本地操作和遠程操作的時候,需要對地址一致的緩存行的狀態進行一致性修改,從而保證數據在多個緩存之間保持一致性。
1. 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狀態可能是非一致的。如果一個緩存將處於S狀態的緩存行作廢了,而另一個緩存實際上可能已經獨享了該緩存行,但是該緩存卻不會將該緩存行升遷為E狀態,這是因為其它緩存不會廣播他們作廢掉該緩存行的通知,同樣由於緩存並沒有保存該緩存行的copy的數量,因此(即使有這種通知)也沒有辦法確定自己是否已經獨享了該緩存行。
從上面的意義看來E狀態是一種投機性的優化:如果一個CPU想修改一個處於S狀態的緩存行,總線事務需要將所有該緩存行的copy變成invalid狀態,而修改E狀態的緩存不需要使用總線事務。
MESI狀態轉換圖:

下圖表示了當一個緩存行(Cache line)的調整的狀態的時候,另外一個緩存行(Cache line)需要調整的狀態。
狀態 | M | E | S | I |
---|---|---|---|---|
M | × | × | × | √ |
E | × | × | × | √ |
S | × | × | √ | √ |
I | √ | √ | √ | √ |
舉個示例:
假設cache 1 中有一個變量
x = 0
的 Cache line 處於S狀態(共享)。
那么其他擁有x變量的 cache 2、cache 3 等x
的 Cache line調整為S
狀態(共享)或者調整為I
狀態(無效)。
2. 多核緩存協同操作
(1) 內存變量
假設有三個CPU A、B、C,對應三個緩存分別是cache a、b、c。在主內存中定義了x
的引用值為0。

(2) 單核讀取
執行流程是:
- CPU A發出了一條指令,從主內存中讀取
x
。 - 從主內存通過 bus 讀取到 CPU A 的緩存中(遠端讀取 Remote read),這時該 Cache line 修改為 E 狀態(獨享)。

(3) 雙核讀取
執行流程是:
- CPU A發出了一條指令,從主內存中讀取
x
。 - CPU A從主內存通過bus讀取到 cache a 中並將該 Cache line 設置為E狀態。
- CPU B發出了一條指令,從主內存中讀取
x
。 - CPU B試圖從主內存中讀取
x
時,CPU A檢測到了地址沖突。這時CPU A對相關數據做出響應。此時x
存儲於 cache a 和 cache b 中,x
在 chche a 和 cache b 中都被設置為S狀態(共享)。

(4) 修改數據
執行流程是:
- CPU A 計算完成后發指令需要修改
x
. - CPU A 將
x
設置為M狀態(修改)並通知緩存了x
的 CPU B, CPU B 將本地 cache b 中的x
設置為I
狀態(無效) - CPU A 對
x
進行賦值。

(5) 同步數據
那么執行流程是:
- CPU B 發出了要讀取x的指令。
- CPU B 通知CPU A,CPU A將修改后的數據同步到主內存時cache a 修改為E(獨享)
- CPU A同步CPU B的x,將cache a和同步后cache b中的x設置為S狀態(共享)。

3. CPU 存儲模型簡介
MESI協議為了保證多個 CPU cache 中共享數據的一致性,定義了 Cache line 的四種狀態,而 CPU 對 cache 的4
種操作可能會產生不一致狀態,因此 cache 控制器監聽到本地操作和遠程操作的時候,需要對地址一致的 Cache line 狀態做出一定的修改,從而保證數據在多個cache之間流轉的一致性。
但是,緩存的一致性消息傳遞是要時間的,這就使得狀態切換會有更多的延遲。某些狀態的切換需要特殊的處理,可能會阻塞處理器。這些都將會導致各種各樣的穩定性和性能問題。比如你需要修改本地緩存中的一條信息,那么你必須將I
(無效)狀態通知到其他擁有該緩存數據的CPU緩存中,並且等待確認。等待確認的過程會阻塞處理器,這會降低處理器的性能。因為這個等待遠遠比一個指令的執行時間長的多。所以,為了為了避免這種阻塞導致時間的浪費,引入了存儲緩存(Store Buffer
)和無效隊列(Invalidate Queue
)。
(1) 存儲緩存
在沒有存儲緩存時,CPU 要寫入一個量,有以下情況:
- 量不在該 CPU 緩存中,則需要發送 Read Invalidate 信號,再等待此信號返回,之后再寫入量到緩存中。
- 量在該 CPU 緩存中,如果該量的狀態是 Exclusive 則直接更改。而如果是 Shared 則需要發送 Invalidate 消息讓其它 CPU 感知到這一更改后再更改。
這些情況中,很有可能會觸發該 CPU 與其它 CPU 進行通訊,接着需要等待它們回復。這會浪費大量的時鍾周期!為了提高效率,可以使用異步的方式去處理:先將值寫入到一個 Buffer 中,再發送通訊的信號,等到信號被響應,再應用到 cache 中。並且此 Buffer 能夠接受該 CPU 讀值。這個 Buffer 就是 Store Buffer。而不須要等待對某個量的賦值指令的完成才繼續執行下一條指令,直接去 Store Buffer 中讀該量的值,這種優化叫Store Forwarding。
(2) 無效隊列
同理,解決了主動發送信號端的效率問題,那么,接受端 CPU 接受到 Invalidate 信號后如果立即采取相應行動(去其它 CPU 同步值),再返回響應信號,則時鍾周期也太長了,此處也可優化。接受端 CPU 接受到信號后不是立即采取行動,而是將 Invalidate 信號插入到一個隊列 Queue 中,立即作出響應。等到合適的時機,再去處理這個 Queue 中的 Invalidate 信號,並作相應處理。這個 Queue 就是Invalidate Queue。
四、亂序執行
亂序執行(out-of-orderexecution
):是指CPU允許將多條指令不按程序規定的順序分開發送給各相應電路單元處理的技術。這樣將根據各電路單元的狀態和各指令能否提前執行的具體情況分析后,將能提前執行的指令立即發送給相應電路。
這好比請A、B、C三個名人為晚會題寫橫幅“春節聯歡晚會”六個大字,每人各寫兩個字。如果這時在一張大紙上按順序由A寫好”春節”后再交給B寫”聯歡”,然后再由C寫”晚會”,那么這樣在A寫的時候,B和C必須等待,而在B寫的時候C仍然要等待而A已經沒事了。
但如果采用三個人分別用三張紙同時寫的做法, 那么B和C都不必須等待就可以同時各寫各的了,甚至C和B還可以比A先寫好也沒關系(就象亂序執行),但當他們都寫完后就必須重新在橫幅上(自然可以由別人做,就象CPU中亂序執行后的重新排列單元)按”春節聯歡晚會”的順序排好才能掛出去。
所以,CPU 為什么會有亂序執行優化?本質原因是CPU為了效率,將長費時的操作“異步”執行,排在后面的指令不等前面的指令執行完畢就開始執行后面的指令。而且允許排在前面的長費時指令后於排在后面的指令執行完。
CPU 執行亂序主要有以下幾種:
- 寫寫亂序(store store):
a=1;b=2; -> b=2;a=1;
- 寫讀亂序(store load):
a=1;load(b); -> load(b);a=1;
- 讀讀亂序(load load):
load(a);load(b); -> load(b);load(a);
- 讀寫亂序(load store):
load(a);b=2; -> b=2;load(a);
總而言之,CPU的亂序執行優化指的是處理器為提高運算速度而做出違背代碼原有順序的優化。