CPU緩存的由來
我們知道CPU的處理能力要遠比內存強,主內存執行一次內存讀、寫操作的時間可能足夠處理器執行上百條的指令。為了彌補處理器與內存處理能力之間的鴻溝,在內存和處理器之間引入了高速緩存(Cache)。高速緩存是一種存取速率遠比主內存大而容量遠比主內存小的存儲部件,每個處理器都有其高速緩存。如下圖所示
CPU的讀(load)實質上就是從緩存中讀取數據到寄存器(register)里,在多級緩存的架構中,如果緩存中找不到數據(cache miss),就會層層讀取二級緩存三級緩存,一旦所有的緩存里都找不到對應的數據,就要去內存里尋址了。尋址到的數據首先放到寄存器里,其副本會駐留到CPU的緩存中。
CPU的寫(store)也是針對緩存作寫入。並不會直接和內存打交道,而是通過某種機制實現數據從緩存到內存的寫回(write back)。
CPU緩存的概念
CPU緩存是位於CPU與內存之間的臨時數據交換器,它的容量比內存小的多但是交換速度卻比內存要快得多。CPU緩存一般直接跟CPU芯片集成或位於主板總線互連的獨立芯片上。
為了簡化與內存之間的通信,高速緩存控制器是針對數據塊,而不是字節進行操作的。高速緩存其實就是一組稱之為緩存行(Cache Line)的固定大小的數據塊組成的,典型的一行是64字節。
CPU緩存的意義
CPU往往需要重復處理相同的數據、重復執行相同的指令,如果這部分數據、指令CPU能在CPU緩存中找到,CPU就不需要從內存或硬盤中再讀取數據、指令,從而減少了整機的響應時間。所以,緩存的意義滿足以下兩種局部性原理:
- 時間局部性(Temporal Locality):如果一個信息項正在被訪問,那么在近期它很可能還會被再次訪問。
- 空間局部性(Spatial Locality):如果一個存儲器的位置被引用,那么將來他附近的位置也會被引用。
緩存一致性協議-MESI協議
由於現在一般是多核處理器,每個處理器都有自己的高速緩存,那么會導致一些問題:
當某一個數據在多個處於“運行”狀態的線程中進行讀寫共享時(例如ThreadA、ThreadB和ThreadC),第一個問題是多個線程可能在多個獨立的CPU內核中“同時”修改數據A,導致系統不知應該以哪個數據為准;第二個問題是由於ThreadA進行數據A的修改后沒有即時寫會內存ThreadB和ThreadC也沒有即時拿到新的數據A,導致ThreadB和ThreadC對於修改后的數據不可見。這就是緩存一致性問題。
為了解決這個問題,處理器之間需要一種通信機制----緩存一致性協議。
MESI(Modified-Exclusive-Shared-Invalid)協議是一種廣為使用的緩存一致性協議。MESI協議對內存數據訪問的控制類似於讀寫鎖,它使得針對同一地址的讀內存操作是並發的,而針對同一地址的寫內存操作是獨占的。
之所以叫 MESI,是因為這套方案把一個緩存行(cache line)區分出四種不同的狀態標記,他們分別是 Modified、Exclusive、Shared 和 Invalid。這四種狀態分別具備一定的意義:
狀態 | 描述 | 監聽任務 | 狀態轉換 |
---|---|---|---|
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無效。 | 無 | 無 |
這些狀態本身是靜態的,那么動態來看,又是如何產生狀態變化的呢?
首先不同CPU之間也是需要溝通的,這里的溝通是通過在消息總線上傳遞message實現的。這些在總線上傳遞的消息有如下幾種:
- Read :帶上數據的物理內存地址發起的讀請求消息;
- Read Response:Read 請求的響應信息,內部包含了讀請求指向的數據;
- Invalidate:該消息包含數據的內存物理地址,意思是要讓其他如果持有該數據緩存行的 CPU 直接失效對應的緩存行;
- Invalidate Acknowledge:CPU 對Invalidate 消息的響應,目的是告知發起 Invalidate 消息的CPU,這邊已經失效了這個緩存行啦;
- Read Invalidate:這個消息其實是 Read 和 Invalidate 的組合消息,與之對應的響應自然就是一個Read Response 和 一系列的 Invalidate Acknowledge;
- Writeback:該消息包含一個物理內存地址和數據內容,目的是把這塊數據通過總線寫回內存里。
舉個例子
現在有 cpu0 cpu1 變量a
現在cpu0對a賦值 a=1
假如變量a不在cpu0 緩存中,則需要發送 Read Invalidate 信號,再等待此信號返回Read Response和Invalidate Acknowledge,之后再寫入量到緩存中。
假如變量a在cpu0 緩存中,如果該量的狀態是 Modified 則直接更改發送Writeback 最后修改成Exclusive。而如果是 Shared 則需要發送 Invalidate 消息讓其它 CPU 感知到這一更改后再更改。
- 一般情況下,CPU 在對某個緩存行修改之前務必得讓其他 CPU 持有的相同數據緩存行失效,這是基於 Invalidate Acknowledge 消息反饋來判斷的;
- 緩存行為 M 狀態,意味着該緩存行指向的物理內存里的數據,一定不是最新;
- 在修改變量之前,如果CPU持有該變量的緩存,且為 E 狀態,直接修改;若狀態為 S ,需要在總線上廣播 Invalidate;若CPU不持有該緩存行,則需要廣播 Read Invalidate。
Store Buffers
這個極簡的 CPU 緩存架構存在一定的問題,當相當一部分 CPU 持有相同的數據時(S 狀態),如果其中有一個 CPU 要對其進行修改,則需要等待其他 CPU 將其共同持有的數據失效,那么這里就會有空等期(stall),這對於頻率很高的CPU來說,簡直不能接受!
這里引入了Store buffers
這是一個 CPU 在真正寫入緩存之前的的緩沖區,緩沖區作用在於 CPU 無需等待其他 CPU 的反饋,把要寫入的數據先丟到 Store Buffer 中,自己可以去處理別的事情,避免了CPU的傻等。
Store Forwarding
引入store buffer之后又帶了新的問題,單個 CPU 在順序執行指令的過程中,有可能出現,前面的已經執行寫入變更,但對后面的代碼邏輯不可見
舉個例子
假設 a , b 初始值為0:
a=1
b=a+1
assert(a==2)
cpu對a賦值為1,此時a變量進入到storebuffer,緩存中的a還是等於0,此時執行b=a+1得到的結果是b=1,assert不通過。
解決方案就是采用Store Forwarding
對於同一個 CPU 而言,在讀取 a 變量的時候,如若發現 Store Buffer 中有尚未寫入到緩存的數據 a,則直接從 Store Buffer 中讀取。這就保證了,邏輯上代碼執行順序,也保證了可見性
Memory Barriers
通過 Store Forwarding 解決了單個 CPU 執行順序性和內存可見性問題,但是在全局多 CPU 的環境下,這種內存可見性恐怕就很難保證了。
void foo(void)
{
a = 1;
b = 1;
}
void bar(void)
{
while (b == 0) continue;
assert(a == 1);
}
假設上面的 foo 方法被 CPU 0 執行,bar 方法被 CPU 1 執行,也就是我們常說的多線程環境。試想,即便在多線程環境下,foo 和 bar 如若嚴格按照理想的順序執行,是無論如何都不會出現 assert failed 的情況的。但往往事與願違,這種看似很詭異的且有一定幾率發生的 assert failed ,結合上面所說的 Store Buffer 就一點都不難理解了。
我們來還原 assert failed 的整個過程,假設 a,b 初始值為 0 ,a 被 CPU0 和 CPU1 共同持有,b 被 CPU0 獨占;
CPU0 處理 a=1 之前發送 Invalidate 消息給 CPU1 ,並將其放入 Store Buffer ,尚未及時刷入緩存;
CPU 0 轉而處理 b=1 ,此時 b=1 直接被刷入緩存;
CPU 1 發出 Read 消息讀取 b 的值,發現 b 為 1 ,跳出 while 語句;
CPU 1 發出 Read 消息讀取 a 的值,發現 a 卻為舊值 0,assert failed。
在日常開發過程中也是完全有可能遇到上面的情況,由於 a 的變更對 CPU1 不可見,雖然執行指令的時序沒有真正被打亂,但對於 CPU1 來說,這造成了 b=1 先於 a=1 執行的假象,這種看是亂序的問題,通常稱為 “重排序”。當然上面所說的情況,只是指令重排序的一種可能。
解決辦法就是 Memory Barrier(內存屏障)。借助內存屏障可以很好地保證了順序一致性。
void foo(void)
{
a = 1;
smp_mb();
b = 1;
}
void bar(void)
{
while (b == 0) continue;
assert(a == 1);
}
這個屏障可以理解為兩條指令之間的柵欄(fence),比如在上面的 foo 方法中,a 的賦值和 b 的賦值之間勢必要執行這個柵欄。這個柵欄有什么用呢?
smp_mb 首先會使得 CPU 在后續變量變更寫入之前,把 Store Buffer 的變更寫入 flush 到緩存;CPU 要么就等待 flush 完成后寫入,要么就把后續的寫入變更放到 Store Buffer 中,直到 Store Buffer 數據順序刷到緩存。
CPU 的設計者兼顧性能和指令重排序之間做了權衡,認為其實在大多數場景下,多線程環境下的指令重排序和可見性問題是可以接受的,並且這有助於 CPU 發揮出該有的性能。如果真的有特殊需求,我們可以借助內存屏障來解決,雖然有一定的代碼侵入性,但是這樣的 tradeoff 是相當划算的。
Invalidate Queues
然而從目前設計看,還依然有問題。試想這么一個場景,CPU 寫入一大串數據到 Store Buffer,而這些緩存行均被其他 CPU 持有,那么此時這個 CPU 需要等待一系列的 Invalidate Acknowledge 反饋后才能將這批數據 flush 到緩存行。
這里存在的問題是,Store Buffer 本身很小,如果寫入變更指向的變量在CPU 本地緩存中均是 cache miss 的情況下,變更數量超過了 Store Buffer 能承載的容量,CPU 依然需要等待 Store Buffer 排空后才能繼續處理。尤其是執行 Memory Barrier 以后,無論本地緩存是否 cache miss,只要 Store Buffer 還有數據,所有的寫入變更都要進入 Store Buffer。這就導致 CPU 依然存在的空等(stall)現象。
CPU 設計者的思路是,盡可能減少 invalidate ack 的時延,以減少CPU的無謂等待。目前的方案是,CPU 一旦收到 Invalidate 消息,先是會去緩存中標記該緩存狀態為 I ,標記完畢后發送 invalidate ack 到消息總線。那如果 CPU 接收到 invalidate 消息,立馬反饋 invalidate ack,而cache line 此時也並非強制要求馬上失效,只要確保最終會失效即可,這樣的思路是否可以呢?
可以,基於這個思路, Invalidate Queues 應運而生。
每個 CPU 都有一個 Invalidate Queue,用以把需要失效的數據物理地址存儲起來,根據這個物理地址,我們可以對緩存行的失效行為 “延后執行” 。這樣做的好處上面也說過,又一次釋放了 CPU 的發揮空間,但依然有額外的副作用。繼續來看上面的例子:
void foo(void)
{
a = 1;
smp_mb();
b = 1;
}
void bar(void)
{
while (b == 0) continue;
assert(a == 1);
}
引入 Invalidate Queue 后,assert failed 死灰復燃。我們來重現下:
假設 a,b 初始值為0,CPU0 執行 foo 方法,CPU1 執行 bar 方法,b 被CPU0 獨占,a 則被 CPU0 和 CPU1 共同持有。
- CPU0 執行 a=1,由於緩存行狀態為 S ,需要發送 Invalidate 消息到總線;
- CPU1 接收到 Invalidate 消息,將數據內存地址放入 Invalidate Queue 后立馬反饋 Invalidate Acknowledge;
- CPU 0 收到反饋后,把 a=1 從 Store Buffer 刷到緩存后,執行 b=1,b 的新值 1 直接被寫入到了 CPU 0 的緩存中;
- CPU1 執行 while 語句,通過發送 Read 指令查詢 b 的值,此時 b 為 1,跳出 while;
- CPU1 執行 assert(a==1) ,a 的 invalidate 信息還在 Invalidate Queue 中,CPU1 緩存中的 a 仍然是舊值 0,assert failed。
表象上看依然是指令執行順序被打亂了,這似乎用 Memory Barrier 也有問題呀,解決方案就是要使用更多的 Memory Barrier 。
void foo(void)
{
a = 1;
smp_mb();
b = 1;
}
void bar(void)
{
while (b == 0) continue;
smp_mb();
assert(a == 1);
}
不過這里的 smp_mb 有更豐富的語義,除了與 Store Buffer 的交互外,一旦執行到 smp_mb 指令,CPU 首先將本地 Invalidate Queue 的條目全部標記,並且強制要求 CPU 隨后的所有讀操作,務必等待 Invalidate Queue 中被標記的條目真正應用到緩存后方能執行。這就很好解決了上面的重排序問題,但同理,會帶來一定程度的性能損耗。
讀內存屏障 vs 寫內存屏障
還有一個小問題,smp_mb 包含的語義有些“重”,既包含了 Store Buffer 的 flush,又包含了 Invalidate Queue 的等待環節,但現實場景下,我們可能只需要與其中一個數據結構打交道即可。於是,CPU 的設計者把 smp_mb 屏障進一步拆分,一分為二, smp_rmb 稱之為讀內存屏障,smp_wmb 稱之為寫內存屏障。他們分別的語義也相應做了簡化:
- smp_wmb(StoreStore):執行后需等待 Store Buffer 中的寫入變更 flush 完全到緩存后,后續的寫操作才能繼續執行,保證執行前后的寫操作對其他 CPU 而言是順序執行的;
- smp_rmb(LoadLoad):執行后需等待 Invalidate Queue 完全應用到緩存后,后續的讀操作才能繼續執行,保證執行前后的讀操作對其他 CPU 而言是順序執行的;
回到 Java 語言,JVM 是如何實現自己的內存屏障的?抽象上看 JVM 涉及到的內存屏障有四種:
屏障類型 | 指令示例 | 說明 |
---|---|---|
LoadLoad Barriers | Load1;LoadLoad;Load2 | 該屏障確保Load1數據的裝載先於Load2及其后所有裝載指令的的操作 |
StoreStore Barriers | Store1;StoreStore;Store2 | 該屏障確保Store1立刻刷新數據到內存(使其對其他處理器可見)的操作先於Store2及其后所有存儲指令的操作 |
LoadStore Barriers | Load1;LoadStore;Store2 | 確保Load1的數據裝載先於Store2及其后所有的存儲指令刷新數據到內存的操作 |
StoreLoad Barriers | Store1;StoreLoad;Load2 | 該屏障確保Store1立刻刷新數據到內存的操作先於Load2及其后所有裝載裝載指令的操作。它會使該屏障之前的所有內存訪問指令(存儲指令和訪問指令)完成之后,才執行該屏障之后的內存訪問指令 |
JVM 是如何分別插入上面四種內存屏障到指令序列之中的呢?這里的設計相當巧妙。
對於 volatile 讀 or monitor enter
int t = x; // x 是 volatile 變量
[LoadLoad]
[LoadStore]
<other ops>
對於 volatile 寫 or monitor exit
<other ops>
[StoreStore]
[LoadStore]
x = 1; // x 是 volatile 變量
[StoreLoad] // 這里帶了個尾巴
不同架構下的實現
不同的處理器平台,本身的內存模型有強(Strong)弱(Weak)之分。實際實現的時候由於某些指令集之間的關系使得 memory barrier 的實現不可能做到最優,很多常見的平台都使用了簡單粗暴的 bus 鎖(x86、amd64、armv7)
- Weak Memory Model: 如DEC Alpha是弱內存模型,它可能經歷所有的四種內存亂序(LoadLoad, LoadStore, StoreLoad, StoreStore),任何Load和Store操作都能與任何其它的Load或Store操作亂序,只要其不改變單線程的行為。
- Weak With Date Dependency Ordering: 如ARM, PowerPC, Itanium,在Aplpha的基礎上,支持數據依賴排序,如C/C++中的A->B,它能保證加載B時,必定已經加載最新的A
- Strong Memory Model: 如X86/64,強內存模型能夠保證每條指令acquire and release語義,換句話說,它使用了LoadLoad/LoadStore/StoreStore三種內存屏障,即避免了四種亂序中的三種,仍然保留StoreLoad的重排,
- Sequential Consistency: 最強的一致性,理想中的模型,在這種內存模型中,沒有亂序的存在。如今很難找到一個硬件體系結構支持順序一致性,因為它會嚴重限制硬件對CPU執行效率的優化(對寄存器/Cache/流水線的使用)。
對於x86架構來說,store buffer是FIFO,因此不會存在亂序,寫入順序就是刷入cache的順序。但是對於ARM/Power架構來說,store buffer並未保證FIFO,因此先寫入store buffer的數據,是有可能比后寫入store buffer的數據晚刷入cache的。從這點上來說,store buffer的存在會讓ARM/Power架構出現亂序的可能。store barrier存在的意義就是將store buffer中的數據,刷入cache。
在某些cpu中,存在invalid queue。invalid queue用於緩存cache line的失效消息,也就是說,當cpu0寫入W0(x, 1),並從store buffer將修改刷入cache,此時cpu1讀取R1(x, 0)仍是允許的。因為使cache line失效的消息被緩沖在了invalid queue中,還未被應用到cache line上。這也是一種會使得指令亂序的可能。load barrier存在的意義就是將invalid queue緩沖刷新。
對於x86架構的cpu來說,在單核上來看,其保證了Sequential consistency,因此對於開發者,我們可以完全不用擔心單核上的亂序優化會給我們的程序帶來正確性問題。在多核上來看,其保證了x86-tso模型,使用mfence就可以將store buffer中的數據,寫入到cache中。而且,由於x86架構下,store buffer是FIFO的和不存在invalid queue,mfence能夠保證多核間的數據可見性,以及順序性。
對於arm和power架構的cpu來說,編程就變得危險多了。除了存在數據依賴,控制依賴以及地址依賴等的前后指令不能被亂序之外,其余指令間都有可能存在亂序。而且,它們的store buffer並不是FIFO的,而且還可能存在invalid queue,這些也同樣讓並發編程變得困難重重。因此需要引入不同類型的barrier來完成不同的需求。
我們接下來着重討論 X86 平台下,Java volatile 關鍵字是如何實現防止指令重排序的。
根據上面表格我們可以看到 x86 平台下,只有 StoreLoad 才有具體的指令對應,而其他三個屏障均是 no-op (空操作)。
關於 StoreLoad 又有三個具體的指令對應,分別是 mfence、cpuid、以及 locked insn,他們都能很好地實現 StoreLoad 的屏障效果。但畢竟不可能同時用三種指令,這里可能意思是,三種均能達到效果,具體實現交由 JVM 設計者決斷。
我們隨便寫一段代碼,查看下JVM采用的是哪一種命令(需要下載 hsdis-amd64.dylib 然后移動到 jre lib 目錄)
javac VolatileTest.java && java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly
public class VolatileTest {
volatile static int a = 1;
public static void main(String[] args) {
test();
}
public static void test(){
a++;
}
}
可以看到這里的 StoreLoad 用到的具體指令是lock
0x000000010dfedef8: lock addl $0x0,(%rsp)
0x000000010dfedefd: cmpl $0x0,-0x32f1197(%rip) # 0x000000010acfcd70
; {external_word}
0x000000010dfedf07: jne 0x000000010dfedf1b
lock用於在多處理器中執行指令時對共享內存的獨占使用。它的副作用是能夠將當前處理器對應緩存的內容刷新到內存,並使其他處理器對應的緩存失效。另外還提供了有序的指令無法越過這個內存屏障的作用。
簡單來說,這句指令的作用就是保證了可見性以及內存屏障。
- 執行 a 的寫操作后執行到 StoreLoad 內存屏障;
- 發出 Lock 指令,鎖總線 或 a 的緩存行,那么其他 CPU 不能對已上鎖的緩存行有任何操作;
- 讓其他 CPU 持有的 a 的緩存行失效;
- 將 a 的變更寫回主內存,保證全局可見;
上面執行完后,該 CPU 方可執行后續操作。
volatile與原子性
我們看到jvm通過lock實現了volatile的內存屏障,但是volatile並不具有原子性。原因很簡單,不同 CPU 依舊可以對同一個緩存行持有,一個 CPU 對同一個緩存行的修改不能讓另一個 CPU 及時感知,因此出現並發沖突。線程安全還是需要用鎖來保障,鎖能有效的讓 CPU 在同一個時刻獨占某個緩存行,執行完並釋放鎖后,其他CPU才能訪問該緩存行。
MESI和volatile的聯系
本文我們通過cpu的緩存介紹了cpu的緩存一致性協議,同時又引出了內存屏障。在多核cpu中通過緩存一致性協議保證了每個緩存中使用的共享變量的副本是一致的。
當一個CPU進行寫入時,首先會給其它CPU發送Invalid消息,然后把當前寫入的數據寫入到Store Buffer中。然后異步在某個時刻真正的寫入到Cache中。當前CPU核如果要讀Cache中的數據,需要先掃描Store Buffer之后再讀取Cache。但是此時其它CPU核是看不到當前核的Store Buffer中的數據的,要等到Store Buffer中的數據被刷到了Cache之后才會觸發失效操作。而當一個CPU核收到Invalid消息時,會把消息寫入自身的Invalidate Queue中,隨后異步將其設為Invalid狀態。和Store Buffer不同的是,當前CPU核心使用Cache時並不掃描Invalidate Queue部分,所以可能會有極短時間的臟讀問題。MESI協議,可以保證緩存的一致性,但是無法保證實時性。所以我們需要通過內存屏障在執行到某些指令的時候強制刷新緩存來達到一致性。
但是MESI只是一種抽象的協議規范,在不同的cpu上都會有不同的實現,對於x86架構來說,store buffer是FIFO,寫入順序就是刷入cache的順序。但是對於ARM/Power架構來說,store buffer並未保證FIFO,因此先寫入store buffer的數據,是有可能比后寫入store buffer的數據晚刷入cache的
而對於JAVA而言,他必須要屏蔽各個處理器的差異,所以才有了java內存模型(JMM),volatile只是內存模型的一小部分,實現了變量的可見性和禁止指令重排序優化的功能。整個內存模型必須要實現可見性,原子性,和有序性。而volatile實現了其中的可見性和有序性。
參考資料:
《Memory Barriers: a Hardware View for Software Hackers》