概述
由於內存的運行速度和CPU的運行速度相差太多,所以現代計算機CPU都不是直接操作內存,而是直接操作寄存器和高速緩存,如果只有一個CPU這個事情就很簡單,但是如果計算機中有多個核,那每個CPU都從主內存中讀取了同一個變量,如何保證緩存的一致性,就變得非常麻煩,現在常用的解決辦法有兩種。
- 總線鎖定:當某個CPU需要修改某個數據的時候,通過鎖住內存總線,使得別的CPU無法訪問內存中的數據,從而保證緩存的一致性,但這種實現方式會導致CPU執行效率降低,現在很少被使用。
- 緩存鎖:當一個CPU要修改緩存中的變量時,會對緩存加鎖,同時會通過總線通知別的CPU,讓他們的變量副本失效,這樣同樣可以保證一次只有一個CPU修改變量的值,從而保證緩存一致性。
以上兩種方法的實質作用都是為了防止讀取到臟數據和更新的結果無效。
高速緩存結構
在介紹緩存一致性協議之前有必要先介紹一下高速緩存的數據的組織形式。因為把高速緩存的數據結構介紹清楚,有助於理解下面MESI協議。
高速緩存的結構和jdk中的HashMap的結構有點類似,都是采用數組進行分桶,之后采用拉鏈法掛到對應的桶上,具體結構如下:
鏈表中節點名稱叫做cache entry,下面看一下cache entry的結構:
其中tag用來定位cache entry,data block用來保存緩存的數據,flag就是重點了,這個標識就是用來標注當前節點的狀態,對應下面要介紹的MESI協議的四種狀態,分別位M,E,S,I。
MESI協議介紹
有了上面的鋪墊,下面就開始介紹MESI協議,MESI是四個單詞的首字母縮寫,Modified修改,Exclusive獨占,Shared共享,Invalid無效,下面就簡要介紹一下這四種狀態。
M:表示當前CPU的高速緩存中的變量副本是獨占的,而且和主存中的變量值不一致,而且別的CPU的flag不可能是這個狀態。如果別的CPU想要讀取變量的值,不能直接讀主內存中的值,而是需要將處於M狀態的變量刷新回主內存才可以。
E:表示當前CPU的高速緩存中的變量副本是獨占的,別的CPU高速緩存中該變量的副本不能處於該狀態,但是,處於E狀態的高速緩存變量的值和主內存中的變量值是一致的。
S:處於S狀態表示CPU中的變量副本和主存中數據一致,而且多個CPU都可以處於S狀態,舉例,當多個CPU讀取主內存的值的時候高速緩存的flag就處於S狀態。
I:表示當前CPU的高速緩存的變量副本處於不合法狀態,不可以直接使用,需要從主內存重新讀取,flag的初始狀態就是I。
MESI狀態轉換
說明:
- local read(本地讀取):本地cache讀取本地cache
- local write(本地寫入):本地cache寫入本地cache
- remote read(遠端讀取):其他cache讀取本地cache
- remote write(遠端寫入):其他cache寫入本地cache
上圖切換解釋:
狀態 | 觸發本地讀取 | 觸發本地寫入 | 觸發遠端讀取 | 觸發遠端寫入 |
---|---|---|---|---|
M狀態(修改) | 本地cache:M 觸發cache:M 其他cache:I |
本地cache:M 觸發cache:M 其他cache:I |
本地cache:M→E→S 觸發cache:I→S 其他cache:I→S 同步主內存后修改為E獨享,同步觸發、其他cache后本地、觸發、其他cache修改為S共享 |
本地cache:M→E→S→I 觸發cache:I→S→E→M 其他cache:I→S→I 同步和讀取一樣,同步完成后觸發cache改為M,本地、其他cache改為I |
E狀態(獨享) | 本地cache:E 觸發cache:E 其他cache:I |
本地cache:E→M 觸發cache:E→M 其他cache:I 本地cache變更為M,其他cache狀態應當是I(無效) |
本地cache:E→S 觸發cache:I→S 其他cache:I→S 當其他cache要讀取該數據時,其他、觸發、本地cache都被設置為S(共享) |
本地cache:E→S→I 觸發cache:I→S→E→M 其他cache:I→S→I 當觸發cache修改本地cache獨享數據時時,將本地、觸發、其他cache修改為S共享.然后觸發cache修改為獨享,其他、本地cache修改為I(無效),觸發cache再修改為M |
S狀態(共享) | 本地cache:S 觸發cache:S 其他cache:S |
本地cache:S→E→M 觸發cache:S→E→M 其他cache:S→I 當本地cache修改時,將本地cache修改為E,其他cache修改為I,然后再將本地cache為M狀態 |
本地cache:S 觸發cache:S 其他cache:S |
本地cache:S→I 觸發cache:S→E→M 其他cache:S→I 當觸發cache要修改本地共享數據時,觸發cache修改為E(獨享),本地、其他cache修改為I(無效),觸發cache再次修改為M(修改) |
I狀態(無效) | 本地cache:I→S或者I→E 觸發cache:I→S或者I →E 其他cache:E、M、I→S、I 本地、觸發cache將從I無效修改為S共享或者E獨享,其他cache將從E、M、I 變為S或者I |
本地cache:I→S→E→M 觸發cache:I→S→E→M 其他cache:M、E、S→S→I |
既然是本cache是I,其他cache操作與它無關 | 既然是本cache是I,其他cache操作與它無關 |
以上狀態轉換過程,是博客園的一個大佬總結的,這里直接拿過來使用了,抱歉,上面已經注明出處。在原文中作者並沒有做過多的解釋對於上面的轉換過程,這里我就做一下解釋。
上面每個單元格中都有三個cache,介紹一下
- 本地cache,可以認為就是其中的一個CPU中的cache
- 觸發cache,可以認為是觸發了read或者寫操作的CPU的cache,如果觸發cache就是本地cache,那這兩個相同
- 其他cache,除了上面介紹的兩個CPU的cache(如果本地cache就是觸發cache,就只有一個)其他的CPU的cache
最左邊的狀態,可以認為就是本地cache的狀態,上面的表格就是當本地的cache分別處於M、E、S、I 這四種狀態,觸發cache發生讀操作或者寫操作之后,每個cache狀態發生的變化。
MESI消息
上面只是給出了MESI狀態的轉換,但是並沒有給出如果某個CPU發生讀或者寫操作,CPU之間通過總線之間的交互方式是什么樣子的,下面就介紹一個MESI發送的消息總類。
上面圖中都有詳細的解釋,就不過多介紹了,這里說明一下Read Invalidate請求,這個請求和Read請求的不同之處是他不只是要Read的結果,還需要別的CPU的緩存失效。
MESI協議舉例
以上的過程都是理論性的介紹,下面通過一個例子來感受一下。
圖片來源:正確理解MESI協議
說明:圖中有兩個CPU,主存中有一個變量X = 0,下面就介紹一個CPU A和CPU B讀寫X的過程。
- 初始狀態,cache A和cache B中都沒有X的副本,CPU A發起read請求向主內存,主內存會向總線發送ReadResponse,之后CPU A將Cache A的狀態更改位E,表示獨占。
- CPU B發起Read請求,此時CPU A和CPU B同時嗅探總線,發現主內存的變量X不只有一個副本,此時CPU A將Cache A的狀態更改位S,CPU B收到ReadResponse之后,更改狀態位S。
- 假如這時CPU A要修改變量X的值為1,這時CPU A先發起Invalidate請求,當CPU B嗅探該請求之后,會將Cache B的狀態更新為I,之后回復Invalidate Acknowledge,當CPU A收到CPU B發送的ack之后,才會更改變量X的值為1,之后更新Cache A的狀態為M。
- 如果此時CPU B要讀取變量X,發現自己緩存的狀態為I,則會發起Read請求,這時CPU A嗅探到Read請求,會將Cache A中的X的值刷新回主內存,然后會將自己的狀態更新為E,之后,CPU A會將X的值同步給CPU B,在之后兩者的狀態都更新為S。
MESI性能優選
在上面的例子中,有這么一個場景,就是CPU A要修改Cache A的值,這個時候他需要先發送Invalidate請求,等到別的CPU都返回了ack,才可以真正的開始修改緩存中的值。這個過程其實有兩個地方要等待。
- CPU A需要等待別的CPU返回ack,這個過程浪費時間
- CPU B需要先將Cache B的狀態更新為I,之后再返回ack,這個過程也非常浪費時間
所以針對這兩點,從硬件級別就做了兩點優化,引入了store buffer(寫緩沖區)和Invalidate queues(失效隊列),對應於上面例子中的優化具體如下:
- CPU A將X的值寫入到寫緩沖區,之后直接發送Invalidate請求,然后CPU就去做別的事情,當所有的ack都收到之后,再把寫緩沖區中的值更新到高速緩存。
- CPU B收到CPU A發送的invalidate請求之后,並不會直接去修改Cache B的狀態,而是將請求信息放入Invalidate Queues中,等CPU有空閑了再處理。
以上兩個存儲結構的引入的確可以解決MESI協議效率低的問題,但是由於延遲執行卻帶來了新的問題,就是常見的可見性和有序性的問題,下面就舉例分析一下引入上面兩個存儲結構之后導致的可見性和有序性的問題。
可見性問題:
- 如果CPU A修改了X的值,但是並沒有直接刷新回高速緩存,這個時候如果CPU A或者CPU B要使用X的值,對於CPU A來說,他的緩存狀態時S,說明和內存中的狀態一致,所以就直接使用了舊的值。對於CPU B來說,他的狀態也是S,他也會直接使用這個值,但是其實這個時候X的值已經被CPU A修改過了,但是卻沒有生效。
有序性問題:
思考這么一段代碼
public class Test { static int a = 1; static int c = 1; public static void main(String[] args) { new Thread(()->{ a = 2; int b = c; }).start(); } }
在上面的代碼中,有兩個全局變量,在main中有一個線程,這個線程先執行了一個寫操作,對a進行重新賦值,之后執行了一個讀操作,如果在多線程的環境中,第一步寫操作會先放到寫緩沖區,收到ack之后將寫緩沖區的數據刷新回主內存,在別的線程看來,其實是先執行的第二步賦值操作,而不是第一步,這樣順序就出現了問題,這就是所說的有序性問題。
內存屏障
上面提到了使用store buffer和invalidate queues之后會有可見性和有序性的問題,那如何解決這些問題,就是下面要介紹的內存屏障來解決。
內存屏障(memory barrier)是一個CPU指令。其基本作用:
- 阻止屏障兩邊的代碼發生指令重排
- 強制將寫緩沖區/高速緩存的數據刷新回主內存,並使得相應的緩存中的數據失效
在詳細介紹內存屏障之前需要先介紹兩個指令
- store指令:將數據刷新到主內存中
- load指令:從主內存中重新加載最新的數據
內存屏障在不同的硬件有不同的實現,本文介紹一下x86的內存屏障實現
-
Store Barrier:在x86中是sfence指令實現的,強制該屏障之前的store指令都執行完才可以執行sfence指令,然后才可以執行屏障之后的store指令。
- Load Barrier:在x86中是lfence指令實現的,強制該屏障之前的load指令都執行完才可以執行Ifence指令,然后才可以執行屏障之后的load指令。
- Full Barrier:在x86中是mfence指令實現的,該指令相當於sfence和Ifence兩個指令的功能。
jvm為了屏蔽硬件的差異,定義了自己的內存屏障,其底層是使用硬件的內存屏障。
- LoadLoad內存屏障:相當於上面介紹的Load Barrier內存屏障的作用。
- StoreStore內存屏障:相當於上面介紹的Store Barrier內存屏障的作用。
- StoreLoad內存屏障:相當於上面介紹的Full Barrier內存屏障,這個是最全能的,相當於其他三個內存屏障的功能,但是相應的開銷也更大。
- LoadStore內存屏障:這個在上面沒有對應的硬件指令,不清楚jvm如何實現的,不過功能是在LoadStore內存屏障之前Load指令執行完之后才可以執行LoadStore,之后才可以執行后面的Store指令。
介紹完內存屏障,那可見性和有序性如何解決呢?
可見性問題:
public class Test { static int a = 1; static int c = 1; public static void main(String[] args) { new Thread(()->{ a = 2; StoreLoad();//偽代碼 int b = c; }).start(); } }
還是上面介紹的例子,如果在a = 2之后加入StoreLoad指令,就可以保證a的值從寫緩沖區寫入到高速緩存,如果硬件同時要求寫入主內存,還會刷新回主內存,之后才會執行int b = c;這個load操作,這樣就可以保證可見性。
有序性問題:
public class Test { static boolean isRunning = true; public static void main(String[] args) { new Thread(()->{ isRunning = false; StoreLoad(); //偽代碼 while (isRunning){ System.out.println("執行了"); } }).start(); } }
本例中可能會發生指令重排的地方就是isRunning = false;賦值操作還沒有執行,先執行了下面的while,當然CPU可以保證最終結果的正確性,所以這里並沒有出現問題,如果一定保證代碼不發生指令重排,可以在isRunning = false;下面加一個StoreLoad指令,防止指令重排序。其實有序性問題有一個著名的例子,就是單例模式使用double check進行初始化單例的時候,在高並發的場景下依然可能會出問題,這個例子放到介紹volatile的時候再介紹。
總結
本文從硬件說起,提到了現代計算機系統的多核和多級緩存的架構,由這個架構引發了緩存不一致的問題,為了解決緩存不一致的問題引入了MESI協議,之后詳細介紹了MESI協議的工作機制和狀態轉換規則,之后介紹了MESI協議的優化方法,由此方法又引發了可見性和有序性的問題,最后介紹了內存屏障,以及內存屏障是如何解決可見性和有序性問題的。由於本人水平有限,文中有些例子舉的可能不太恰當,如有錯誤或者說法有不當的地方,望大家指正。
推薦大家閱讀:
深入學習緩存一致性問題和緩存一致性協議MESI(一)[轉載]
參考文章
深入學習緩存一致性問題和緩存一致性協議MESI(一)[轉載]