Cache一致性協議
在說偽共享問題之前,有必要聊一聊什么是Cache一致性協議
局部性原理
時間局部性:如果一個信息項正在被訪問,那么在近期它很可能還會被再次訪問
比如循環、方法的反復調用等
空間局部性:如果一個存儲器的位置被引用,那么將來他附近的位置也會被引用
比如順序結構、數組
Cache的作用
CPU在摩爾定律的指導下以每18個月翻一番的速度在發展,然而內存和硬盤的發展速度遠遠不及CPU。為了解決這個問題,CPU廠商在CPU中內置了少量的高速緩存Cache,以解決訪存速度和CPU運算速度之間不匹配的問題
帶Cache的CPU訪存過程
CPU和Cache交換數據以字為單位。Cache與主存以塊為單位,一個緩存行(Cache Line)對應一個主存塊

讀:
- Cache命中,則直接從Cache中讀取數據
- Cache不命中,則訪問主存,並將一個主存塊調入Cache中,存入為一個緩存行。這個過程中可能由於Cache滿而發生替換,替換算法包括RAND、FIFO、LRU、LFU
寫:
- Cache命中時
- 寫回法(write back):CPU只將數據寫入Cache,只有當數據調出Cache時,才寫入主存
- 寫穿法(write through):CPU同時將數據寫入Cache和主存
- Cache不命中時
- 寫分配法:從主存中將數據塊調入Cache,並修改Cache,和寫回法配合使用
- 非寫分配法:只寫入主存,不調入Cache,和寫穿法配合使用
Cache和主存的映射方式(三種):直接映射、全相聯映射、組相聯映射
如果是單CPU結構,這么執行沒有其他問題。但是現代系統往往包含多個CPU,每個CPU都有各自的Cache。多核CPU的情況下有多個一級緩存,如何保證緩存內部數據的一致性,本質上就是為了防止數據的臟讀,這里就引出了Cache一致性協議——MESI。注意,這並不是唯一的緩存一致性協議,還有其他協議如MOSEI(相對於MESI多引入了一個Owned狀態,並重新定義了S狀態)、MESIF(配合NUMA架構)等,這里不多介紹
MESI協議詳解
MESI(Modified Exclusive Shared Or Invalid),也稱伊利諾斯協議,是一種廣泛使用的、支持寫回策略的緩存一致性協議。MESI協議其實就是使用4種狀態來標記各個緩存行(Cache Line)的狀態,而這些狀態英文首字母縮寫就構成了“MESI”
MESI協議中的各種狀態
每個緩存行都使用一個狀態來標記,該狀態總共有4種,使用2bit進行存儲:
- M(Modified):相應的數據只被緩存在該CPU的Cache中,但數據是被修改過的(臟數據),即與主存中的數據不一致。該緩存行中的內存需要在未來的某個時間點,但必須是其它CPU讀取主存中相應內存之前,將數據寫回主存
- E(Exclusive):相應的數據只被緩存在該CPU的緩存中,數據是未被修改過的,與主存中的數據一致
- S(Shared):相應的數據被多個CPU緩存,且各個CPU的Cache中的數據和主存都是一致的
- I(Invalid):該緩存行中的數據是無效的,因為有其他CPU修改了數據
總線嗅探機制(監聽)
每個CPU都可以感知其他CPU的行為,比如讀、寫某個緩存行,這就是嗅探機制,也稱監聽。所有的緩存行(除了Invalid狀態)都需要監聽自己和其他CPU對相應的緩存行的讀寫操作,也稱觸發事件,從而根據觸發事件和自身狀態,進行狀態的轉換
各種觸發事件
觸發事件 | 描述 |
---|---|
本地讀取(Local Read) | 本CPU讀取本Cache的數據 |
本地寫入(Local Write) | 本CPU向本Cache寫入數據 |
遠端讀取(Remote Read) | 其他CPU讀取它們各自Cache的數據 |
遠端寫入(Remote Write) | 其他CPU向它們各自Cache寫入數據 |
MESI中各個狀態之間的轉換
下圖描述了當前緩存行在不同觸發事件下的狀態切換:

下表是對上圖的一個詳細解釋:

舉例
假設CPU0、CPU1、CPU2、CPU3中有一個緩存行(包含變量x)都是S狀態
此時CPU1要對變量x進行寫操作,這時候通過總線嗅探機制,CPU0、CPU2、CPU3中的緩存行會置為I狀態(無效),然后給CPU1發響應,收到全部響應后CPU1會完成對變量x的寫操作,並更新CPU1內的緩存行為M狀態,但不會將數據x同步到主存中
接着CPU0想要對變量x執行讀操作,卻發現本地緩存行是I狀態,就會觸發CPU1去把緩存行寫回到主存中,然后CPU0再去主存中同步最新的值
MESI協議解決了緩存一致性的問題,但其中有一個問題,那就是需要在等待其他處理器全部回復后才能進行下一步操作,這種等待明顯是不能接受的,下面就繼續來看看如何解決處理器等待的問題
寫緩沖和無效化隊列
1、寫緩沖(Store Buffer)
實際CPU1在執行寫操作要更新緩存行時,其實並不會乖乖地等待其他CPU的緩存行狀態都置為I狀態,才去執行寫操作,這樣效率很低。實際做法是:每個CPU都引入一個寫緩存器,相當於在CPU和Cache之間又加了一層buffer,在CPU執行寫操作時直接寫入寫緩沖,然后去忙其他事,等其他CPU的緩存行都置為I后,CPU1才把buffer中的數據寫入到Cache中
2、無效化隊列(Invalidate Queue)
引入寫緩沖后,CPU1就可以不用等待其他CPU中對應的緩存行失效而去忙別的。不過其他CPU也不傻,實際上他們也不會真的把緩存行置為I后,才給CPU1發響應。他們會寫入一個無效化隊列,還沒把緩存置為I狀態就發送響應了
之后CPU會異步掃描無效化隊列,將緩存行置為I狀態。和寫緩沖不同的是,CPU1之后讀變量x時,會先查寫緩沖,再查Cache。而CPU0要讀變量x時,不會先去檢查無效化隊列,所以存在臟讀的可能
總之,寫緩沖器解決了寫數據時要等待其他處理器響應的問題,無效化隊列解決了刪除數據的等待問題。不管是寫緩沖器還是無效化隊列,其實都是為了減少處理器的等待時間,采用了空間換時間的方式來實現命令的異步處理
不過既然是異步處理,就又會引發新的問題——內存重排序和內存可見性問題,這里不過多贅述,請參考網上其他文章,之后博主也會跟進
多級緩存架構對MESI的影響

現代系統都會采用多級緩存架構,L1-L3級緩存,其中L3緩存是所有CPU共享的一個緩存,但MESI的描述中並沒有涉及L3緩存。其實上文提到的所有跟“主存”交換數據的地方,在L3緩存存在的情況下,都應該替換為L3緩存。比如我上一節舉的例子中,CPU0中某緩存行是I,CPU1 中是M。當CPU0想到執行local read操作時,就會觸發CPU1中的緩存寫入到主存中,然后CPU0從主存中取最新的緩存行。其實這里的描述是不准確的,因為由於L3緩存的存在,這里其實是直接從L3緩存讀取緩存行,而不直接訪問主存
個人認為是如果在描述MESI的狀態流轉時,如果引入L3緩存,會使得描述過於復雜,因此一般的描述都會刻意忽略L3緩存
版權:本文版權歸作者和博客園共有
轉載:歡迎轉載,但未經作者同意,必須保留此段聲明;必須在文章中給出原文連接;否則 必究法律責任
偽共享
由於內存和Cache之間的交換單位是內存塊/緩存行,因此如果訪問一個變量,會將一整個內存塊讀入一個緩存行。但是如果多個線程訪問的變量不相同,且這些變量在內存中的位置臨近,那么很可能在同一個緩存行中。在Cache一致性協議(如MESI協議)的約束下,多個線程(CPU)在並行讀寫相對應的緩存行會有限制,因此其他線程不得不去訪問低級別的Cache甚至是主存,這會導致cache沒有起到真正的作用,程序性能下降
偽共享示例

如圖,線程1訪問變量x,而線程2訪問變量y,而這兩個變量在內存中的位置臨近(在同一個內存塊中),雖然線程都將該內存塊讀入到各自的工作內存(Cache)中,但是在Cache一致性協議的約束下,同一時間兩個線程很難自由地讀寫相同位置的緩存行,那么可能就會讓其中一個線程去低級別的內存中讀寫數據,性能因此降低,這就是偽共享
一般地址連續的多個變量更可能被放在同一個緩存行中,例如創建數組時,數組中的多個元素更可能被放入同一個緩存行中
如何避免偽共享問題
JDK8之前
JDK8之前,使用字節填充的方式,即創建一個變量時,使用填充字段填充該變量所在的緩存行,從而避免多個變量被放入同一個緩存行中,如下:
public final static class FilledLong {
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6;
}
一般來說,緩存行為64 Byte,而經過填充的FilledLong
對象有7*8 Byte=56 Byte,而FilledLong
對象的對象頭也有8 Byte,正好填滿一個緩存行
JDK8及之后
JDK8提供了一個注解——sun.misc.Contended
,用於解決偽共享問題,上述代碼可以修改為如下:
@sun.misc.Contended
public final static class FilledLong {
public volatile long value = 0L;
}
在Thread
類中,也有這樣的字段,如下:
@sun.misc.Contended("tlr")
long threadLocalRandomSeed;
/** Probe hash value; nonzero if threadLocalRandomSeed initialized */
@sun.misc.Contended("tlr")
int threadLocalRandomProbe;
/** Secondary seed isolated from public ThreadLocalRandom sequence */
@sun.misc.Contended("tlr")
int threadLocalRandomSecondarySeed;
但是,@Contended
注解只用於Java核心類,而用戶類路徑下的類使用該注解,需要添加JVM參數-XX:-RestrictContended
。填充寬度默認為128 Byte,也可以自定義寬度,通過JVM參數-XX:ContendedPaddingWidth
來設定