3 CPU緩存一致性協議MESi


之前說了volatile加在全局變量上, 可以保證變量的可見性. 那么volatile到底是怎么保證變量的可見性的呢?

 首先, 我們來說一下, java代碼是怎么執行的. 

一、java代碼從jvm虛擬機到底層cpu等硬件是如何交互運行的?

先來看看程序代碼在jvm虛擬機層面是如何工作的

 

    package com.alibaba.nacos.test;

    /**
     * Description
     * <p>
     * </p>
     * DATE 2020/8/31.
     *
     * @author luoxiaoli.
     */
    public class CodeVisiable {
        private static boolean initFlag = false;


        public static void refresh() {
            System.out.println("refresh data.....");
            initFlag = true;
            System.out.println("refresh data success");
        }

        public static void main(String[] args) {
            // 線程A
            Thread threadA = new Thread(() -> {
                while (!initFlag) {

                }
                System.out.println("線程:" + Thread.currentThread().getName() + "當前線程秀談到initFlag的狀態已經改變");
            }, "threadA");
            threadA.start();

            // 中間休眠500hs
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            // 線程B
            Thread threadB = new Thread(() -> {
                while (!initFlag) {
                    refresh();
                }
            }, "threadB");
            threadB.start();
        }
    }

以這個代碼為例來說明:

第一步. 類CodeVisiable會被類加載器ClassLoader加載, 加載完以后, 將類基本信息放入元數據區-->a.class, 然后會在堆里面創建一個class對象. 

第二步: 程序如果要想運行, 首先要啟動一個線程

       然后加載元數據區的方法, 比如refresh()方法. 啟動線程后, 首先, 會在線程棧開辟一塊棧幀,  然后執行操作數棧  

    操作數棧第一步, 就是獲取一個常量. 將其壓入棧, 

    然后字節碼執行引擎, 調用常量iconst0, 執行字節碼操作

 

 如上面的步驟, 這樣程序代碼在jvm中的流程就結束了

 

 

如果說從jvm的角度來說, jvm的流程是結束了, 但是, 仔細思考, 整個 JVM運行時數據區, 還有開辟的線程0, 都是在那里呢? 都是在內存里的. 

但我們知道如果要想執行iconst0這個變量, 需要誰來調度? 需要cpu來調度. 忘內存里寫入一個值, 需要有cpu來控制.

變量iconst0到底是怎么放進去的呢?

剛開始iconst0是在內存空間的.    

iconst0是jvm字節碼執行引擎才認識的代碼, 如果想要往操作數棧中寫值, 那么它對應的邏輯必須要放虧到cpu上, 而cpu只認0101的二進制. 

jvm字節碼執行引擎, 內置了兩個解釋器,  一個叫做JIT, 以及叫做解釋執行器. 所以consts0這個常量的字節碼, 會被解釋執行器/JIT進行翻譯, 翻譯成匯編指令.

為什么說java慢, 相對於c來說, java很慢, 他慢就慢在這里了.

那么, 匯編指令能夠直接在cpu上執行么?

當然是不可以的, 因為匯編指令是硬件原語. 還需要將匯編指令翻譯成二進制代碼, 也就是cpu能夠識別的語言. 這個過程是很快的. 

 

 

 這過程中, 將字節碼翻譯成匯編指令也就是硬件原語, 是在軟件層面上操作的,速度更慢. 將匯編指令翻譯成二進制, 是在硬件成面的, 速度相對快一些

 

 

所以, 我們看到, 我們的一個java代碼至少要被執行兩次, 才能被放到cpu上執行.

此時. 只是具備了被cpu執行的可能. 還不能被執行. 什么時候才能被執行呢?

這里雖然准備好了指令以及數據, 但是cpu並不是說馬上就會執行, 而是當二進制代碼所在的線程被cpu調用了, cpu才會執行二進制代碼

 

 

cpu怎么知道, 什么時候來調度線程呢?

我們知道cpu的內核又兩種KLT和ULT, jvm使用的是klt

其實,在OS底層,有線程變量池, 線程變量池里的線程和我們的線程棧是1對1的關系. 

當cpu調度到線程變量池的某個線程的時候, 就會去執行這個線程的二進制代碼. 

 

二. volatile的可見性問題: volatile是如何保證可見性的呢?

就是依賴硬件原語(匯編語言) 給我們提供的這個功能.

 下面看看一個變量加了volatile以后, 底層到底做了什么. 想要看到底層的源碼,

第一步: 我們需要下載一個額外的插件

 

這兩個插件包, 第一個對應的是64位操作系統, 第二個對應的是32位操作系統.

第二步: 解壓后, 有兩個文件 ,將這兩個文件放到如下目錄下

 

 第三步: 再啟動配置上增加啟動參數. 

 

 

-server
-Xcomp
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintAssembly
-XX:CompileCommand=compileonly.*Jmm03_CodeVisibility.refresh

其中,標紅的那段代碼, 就是會把匯編指令打印出來

mac版本下載地址和使用方法參考: https://blog.csdn.net/iter_zc/article/details/41897137?readlog

 

運行程序, 我們看到, 會打印出來一個匯編指令碼

 

 

其中, 加了volatile關鍵字的變量, 在執行到第31行, 寫volatile的時候, 加了一個鎖. 加鎖的那一個行代碼是第31行. 剛好就是initFlag=true這一行

我們來看看這個鎖是什么意思呢?

 

 查找手冊, 我們發現, LOCK的含義是, 加了一個總線鎖. 

 

 

 lock會觸發硬件緩存鎖定機制, 鎖定機制有兩種: 總線鎖和緩存一致性協議

為什么會有兩種鎖呢? 這就和cpu的發展有關系了.

早期的cpu技術比較落后, 才使用的總線鎖, 來保存緩存的一致性. 

總線: cpu想要訪問內存條, 必須要通過總線去訪問, 如下圖. 如果有多個cpu想要同時訪問內存條, 就需要獲取總線的鎖, 誰獲取到鎖了, 誰就能訪問內存條.

 

可以看到這種方法的缺點, 一旦搶到鎖, 那么只有這個cpu可以執行,其他cpu就沒有辦法在訪問內存里的這個變量了. 沒有辦法發揮多核並發的能力.

因此發展出來了緩存一致性協議. 現在使用最普遍的是mesi協議,

 

三. mesi協議的工作原理

 四個字母分別代表在緩存里不同的四個狀態: M:已修改  E:獨占  S:共享  I:已失效 

MESI 是4種狀態的首字母。每個Cache line有4個狀態,可用2個bit表示,它們分別是:

緩存行(Cache line):緩存存儲數據的單元。
狀態
描述
監聽任務
M 修改 (Modified)
該Cache line有效,數據被修改了,和內存中的數據不一致,數據只存在於本Cache中。
緩存行必須時刻監聽所有試圖讀該緩存行相對就主存的操作,這種操作必須在緩存將該緩存行寫回主存並將狀態變成S(共享)狀態之前被延遲執行。
E 獨享、互斥 (Exclusive)
該Cache line有效,數據和內存中的數據一致,數據只存在於本Cache中。
緩存行也必須監聽其它緩存讀主存中該緩存行的操作,一旦有這種操作,該緩存行需要變成S(共享)狀態。
S 共享 (Shared)
該Cache line有效,數據和內存中的數據一致,數據存在於很多Cache中。
緩存行也必須監聽其它緩存使該緩存行無效或者獨享該緩存行的請求,並將該緩存行變成無效(Invalid)。
I 無效 (Invalid)
該Cache line無效。

 

注意:
對於M和E狀態而言總是精確的,他們在和該緩存行的真正狀態是一致的,而S狀態可能是非一致的。如果一個緩存將處於S狀態的緩存行作廢了,而另一個緩存實際上可能已經獨享了該緩存行,但是該緩存卻不會將該緩存行升遷為E狀態,這是因為其它緩存不會廣播他們作廢掉該緩存行的通知,同樣由於緩存並沒有保存該緩存行的copy的數量,因此(即使有這種通知)也沒有辦法確定自己是否已經獨享了該緩存行。
從上面的意義看來E狀態是一種投機性的優化:如果一個CPU想修改一個處於S狀態的緩存行,總線事務需要將所有該緩存行的copy變成invalid狀態,而修改E狀態的緩存不需要使用總線事務。

 

 

 

 如上圖, 一共有4個狀態, 那么, 這四個狀態之間是如何轉換的呢?

 

 

計算機啟動的時候, cpu會啟動一個監控程序, 監控總線中被lock標記的變量. 我們知道加了volatile的變量, 就被lock標記了. 那么被lock標記后的變量是如何工作的呢? 一initFlag為例說明

1. 計算器啟動, 有兩個cpu, 那么兩個cpu都會監聽bus總線上帶有lock標記的變量

2. 內存中有一個變量initFlag=false.

3. cpu core0 要調用initFlag, 這時候 ,首先拷貝一份initFlag 放入到bus總線, bus總線監控到initFlag帶有lock標記, 於是所有cpu都監控到這個變量. 

4. 然后將initFlag 拷貝到L3 cache-->L2 cache ,此時, 只有一個cpu使用到這個變量, 所以, initFlag此時的狀態是獨享的狀態.

5. 另一個cpu core1 也要調用initFlag變量, 通過bus總線監控到已經有線程在使用這個變量了, 於是, cpu core0也監控到cpu core1 使用這個變量了, 此時, 將initFlag的狀態由獨占變為共享狀態. 同時cpu core1的中initFlag的狀態也是共享狀態

6. 接下來, cpu core1和cpu core2都想要去修改這個變量, 是如何操作的呢? 我們知道, 在緩存中, 有一個緩存行, 變量保存在緩存行里, 每個cpu需要搶占鎖, 然后鎖住緩存行, 並告訴bus總線, 我搶到鎖了, 監聽bus總線的所有cpu都將得知, 當前已經有一個線程獲取的鎖, 我們要將這個變量丟棄, 於是變量從共享狀態變為丟棄狀態. 

7. 獲得鎖的cpu, 修改變量, 這時變量的狀態從共享狀態變為修改狀態. 然后重新協會到主內存, 在經過bus總線的時候, 所有cpu都被告知initFlag變臉已經被修改, 需要重新獲取新的initFlag變量.

8. 當兩個線程同時修改initFlag, 並同時搶到鎖, 怎么辦呢? 他們會同時告訴bus總線, 我搶到鎖了, 由bus總線裁決, 到底有誰來執行. 

 

 這就是volatile為何能夠保證可見性的原因. 原因就是加了lock標記, 

 

問題1: 一個緩存行64個字節, 那如果有個對象是128個字節, 怎么辦呢?

 

緩存行本身是可以保證原子性的, 但是如果一個變量是128字節, 那怎么辦呢? 跨緩存行就不是原子的了, 不是原子的, 緩存一致性協議就搞不定了, 緩存一致性協議就升級為總線鎖了 ,誰搶到誰贏.

 

問題2: 既然最終都可以總線鎖解決問題, 為什么還要用總線裁決呢?

因為: 總線裁決速度快, 效率高, 只需要裁決一下. 但總線鎖要鎖很久, 效率低. 總線裁決比總線鎖快的多得多. 多數情況下, 總線裁決是可以解決問題的. 很少會遇到超過64字節的變量

 

 

四. volatile為什么不能保證原子性呢?

緩存一致性協議, 不能對寄存器生效. 

上面那句話是什么意思呢?

 

比如: cpu core0 從內存里讀取了一個volatile變量 counter = 0, 然后將其從L1緩存總將變量加載到寄存器進行計算. 計算完寫回到L1 緩存, 此時, 變量的狀態是修改, 然后通知bus總線, 所有的cpu都會監測到counter變量已經被修改, 丟棄自己現有的變量. 比如 cpu core1 此時會丟棄counter = 0, 但是如果counter已經被讀取到寄存器進行計算了. 即使在L1內存中的數據被丟棄, 獲取到了新的counter值, 當寄存器計算完以后, 會重新回寫到L1緩存, 此時會覆蓋剛剛讀取到的counter=1, 將自己計算的counter=1寫入內存中. 

L1緩存中的變量有兩種賦值方式, 一種是從內存加載進來, 另一種是從寄存器回寫過來的. 

因為緩存一致性協議只能失效緩存行的數據, 而不能失效寄存器的數據, 導致volatile不能做到原子性. 

 

 

--------------------------------------以下是課件內容----------------------------------------------

1.1 MESI協議緩存狀態

MESI 是4種狀態的首字母。每個Cache line有4個狀態,可用2個bit表示,它們分別是:
 
緩存行(Cache line):緩存存儲數據的單元。
狀態
描述
監聽任務
M 修改 (Modified)
該Cache line有效,數據被修改了,和內存中的數據不一致,數據只存在於本Cache中。
緩存行必須時刻監聽所有試圖讀該緩存行相對就主存的操作,這種操作必須在緩存將該緩存行寫回主存並將狀態變成S(共享)狀態之前被延遲執行。
E 獨享、互斥 (Exclusive)
該Cache line有效,數據和內存中的數據一致,數據只存在於本Cache中。
緩存行也必須監聽其它緩存讀主存中該緩存行的操作,一旦有這種操作,該緩存行需要變成S(共享)狀態。
S 共享 (Shared)
該Cache line有效,數據和內存中的數據一致,數據存在於很多Cache中。
緩存行也必須監聽其它緩存使該緩存行無效或者獨享該緩存行的請求,並將該緩存行變成無效(Invalid)。
I 無效 (Invalid)
該Cache line無效。

 

注意:
對於M和E狀態而言總是精確的,他們在和該緩存行的真正狀態是一致的,而S狀態可能是非一致的。如果一個緩存將處於S狀態的緩存行作廢了,而另一個緩存實際上可能已經獨享了該緩存行,但是該緩存卻不會將該緩存行升遷為E狀態,這是因為其它緩存不會廣播他們作廢掉該緩存行的通知,同樣由於緩存並沒有保存該緩存行的copy的數量,因此(即使有這種通知)也沒有辦法確定自己是否已經獨享了該緩存行。
從上面的意義看來E狀態是一種投機性的優化:如果一個CPU想修改一個處於S狀態的緩存行,總線事務需要將所有該緩存行的copy變成invalid狀態,而修改E狀態的緩存不需要使用總線事務。

1.2 MESI狀態轉換

 

 

 

理解該圖的前置說明:

1.觸發事件

觸發事件
描述
本地讀取(Local read)
本地cache讀取本地cache數據
本地寫入(Local write)
本地cache寫入本地cache數據
遠端讀取(Remote read)
其他cache讀取本地cache數據
遠端寫入(Remote write)
其他cache寫入本地cache數據

 

 

 

 

 

 

2.cache分類:

前提:所有的cache共同緩存了主內存中的某一條數據。
本地cache:指當前cpu的cache。
觸發cache:觸發讀寫事件的cache。
其他cache:指既除了以上兩種之外的cache。
注意:本地的事件觸發 本地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 line的調整的狀態的時候,另外一個cache line 需要調整的狀態。

 

下圖示意了,當一個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 狀態(無效)。
多核緩存協同操作
假設有三個CPU A、B、C,對應三個緩存分別是cache a、b、 c。在主內存中定義了x的引用值為0。

 

 

 

雙核讀取
那么執行流程是:
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狀態(共享)。

 

 

 

 

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

 

 

 

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

 

 

 

緩存行偽共享
什么是偽共享?
CPU緩存系統中是以緩存行(cache line)為單位存儲的。目前主流的CPU Cache 的 Cache Line 大小都是64Bytes。在多線程情況下,如果需要修改“共享同一個緩存行的變量”,就會無意中影響彼此的性能,這就是偽共享(False Sharing)。

 舉個例子: 現在有2個long 型變量 a 、b,如果有t1在訪問a,t2在訪問b,而a與b剛好在同一個cache line中,此時t1先修改a,將導致b被刷新!

 怎么解決偽共享?

 Java8中新增了一個注解:@sun.misc.Contended。加上這個注解的類會自動補齊緩存行,需要注意的是此注解默認是無效的,需要在jvm啟動時設置 -XX:-RestrictContended 才會生效。

 

@sun.misc.Contended
public final static class TulingVolatileLong {
    public volatile long value = 0L;
    //public long p1, p2, p3, p4, p5, p6;
}

MESI優化和他們引入的問題

 

緩存的一致性消息傳遞是要時間的,這就使其切換時會產生延遲。當一個緩存被切換狀態時其他緩存收到消息完成各自的切換並且發出回應消息這么一長串的時間中CPU都會等待所有緩存響應完成。可能出現的阻塞都會導致各種各樣的性能問題和穩定性問題。
CPU切換狀態阻塞解決-存儲緩存(Store Bufferes)
比如你需要修改本地緩存中的一條信息,那么你必須將I(無效)狀態通知到其他擁有該緩存數據的CPU緩存中,並且等待確認。等待確認的過程會阻塞處理器,這會降低處理器的性能。應為這個等待遠遠比一個指令的執行時間長的多。
Store Bufferes
為了避免這種CPU運算能力的浪費,Store Bufferes被引入使用。處理器把它想要寫入到主存的值寫到緩存,然后繼續去處理其他事情。當所有失效確認(Invalidate Acknowledge)都接收到時,數據才會最終被提交。
這么做有兩個風險
Store Bufferes的風險
第一、就是處理器會嘗試從存儲緩存(Store buffer)中讀取值,但它還沒有進行提交。這個的解決方案稱為Store Forwarding,它使得加載的時候,如果存儲緩存中存在,則進行返回。
第二、保存什么時候會完成,這個並沒有任何保證。
value = 3
void exeToCPUA(){
  value = 10;
  isFinsh = true;
}
void exeToCPUB(){
  if(isFinsh){
    //value一定等於10?!
    assert value == 10;
  }
}
試想一下開始執行時,CPU A保存着finished在E(獨享)狀態,而value並沒有保存在它的緩存中。(例如,Invalid)。在這種情況下,value會比finished更遲地拋棄存儲緩存。完全有可能CPU B讀取finished的值為true,而value的值不等於10。
即isFinsh的賦值在value賦值之前。
這種在可識別的行為中發生的變化稱為重排序(reordings)。注意,這不意味着你的指令的位置被惡意(或者好意)地更改。
它只是意味着其他的CPU會讀到跟程序中寫入的順序不一樣的結果。
順便提一下NIO的設計和Store Bufferes的設計是非常相像的。
硬件內存模型
執行失效也不是一個簡單的操作,它需要處理器去處理。另外,存儲緩存(Store Buffers)並不是無窮大的,所以處理器有時需要等待失效確認的返回。這兩個操作都會使得性能大幅降低。為了應付這種情況,引入了失效隊列。它們的約定如下:
  • 對於所有的收到的Invalidate請求,Invalidate Acknowlege消息必須立刻發送
  • Invalidate並不真正執行,而是被放在一個特殊的隊列中,在方便的時候才會去執行。
  • 處理器不會發送任何消息給所處理的緩存條目,直到它處理Invalidate。
即便是這樣處理器已然不知道什么時候優化是允許的,而什么時候並不允許。
干脆處理器將這個任務丟給了寫代碼的人。這就是內存屏障(Memory Barriers)。
寫屏障 Store Memory Barrier(a.k.a. ST, SMB, smp_wmb)是一條告訴處理器在執行這之后的指令之前,應用所有已經在存儲緩存(store buffer)中的保存的指令。
讀屏障Load Memory Barrier (a.k.a. LD, RMB, smp_rmb)是一條告訴處理器在執行任何的加載前,先應用所有已經在失效隊列中的失效操作的指令。

 

 

 

 

 

 

 

 

 

asw


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM