內存屏障及其在-JVM 內的應用(上)

做者:LeanCloud 後端高級工程師 郭瑞css

內容分享視頻版本: 內存屏障及其在-JVM-內的應用html

MESI

MESI 的詞條在這裏:MESI protocol - Wikipedia,它是一種緩存一致性維護協議。MESI 表示 Cache Line 的四種狀態,modified, exclusive, shared, invalidlinux

  • modified:CPU 擁有該 Cache Line 且將其作了修改,CPU 須要保證在重用該 Cache Line 存其它數據前,將修改的數據寫入主存,或者將 Cache Line 轉交給其它 CPU 全部;
  • exclusive:跟 modified 相似,也表示 CPU 擁有某個 Cache Line 但還將來得及對它作出修改。CPU 能夠直接將裏面數據丟棄或者轉交給其它 CPU
  • shared:Cache Line 的數據是最新的,能夠丟棄或轉交給其它 CPU,但當前 CPU 不能對其進行修改。要修改的話須要轉爲 exclusive 狀態後再進行;
  • invalid:Cache Line 內的數據爲無效,也至關於沒存數據。CPU 在找空 Cache Line 緩存數據的時候就是找 invalid 狀態的 Cache Line;

有個超級棒的可視化工具,能看到 Cache 是怎麼在這四個狀態之間流轉的:VivioJS MESI animation help。Address Bus 和 Data Bus 都是全部 CPU 都能監聽到變化。好比 CPU 0 要讀數據會把請求先發去 Address Bus,Memory 和其它 CPU 都會收到此次請求。Memory 經過 Data Bus 發數據時候也是全部 CPU 都會收到數據。我理解這就是能實現出來 Bus snooping的緣由。另外這個工具上可使用鼠標滾輪上下滾,看每一個時鐘下數據流轉過程。後端

後續內容以及圖片大多來自 Is Parallel Programming Hard, And, If So, What Can You Do About It? 這本書的附錄 C。由於 MESI 協議自己很是複雜,各類狀態流轉很麻煩,因此這本書裏對協議作了一些精簡,用比較直觀的方式來介紹這個協議,好處是讓理解更容易。若是想知道協議的真實樣貌須要去看上面提到的 WIKI。緩存

協議

  • Read: 讀取一條物理內存地址上的數據;
  • Read Response: 包含 Read 命令請求的數據結果,能夠是主存發來的,也能夠是其它 CPU 發來的。好比被讀的數據在別的 CPU 的 Cache Line 上處於 modified 狀態,這個 CPU 就會響應 Read 命令
  • Invalidate:包含一個物理內存地址,用於告知其它全部 CPU 在本身的 Cache 中將這條地址對應的 Cache Line 清理;
  • Invalidate Acknowledge:收到 Invalidate,在清理完本身的 Cache 後,CPU 須要迴應 Invalidate Acknowledge
  • Read Invalidate:至關於將 ReadInvalidate 合起來發送,一方面收到請求的 CPU 要考慮構造 Read Response 還要清理本身的 Cache,完成後回覆 Invalidate Acknowledge,即要回復兩次;
  • Writeback:包含要寫的數據地址,和要寫的數據,用於讓對應數據寫回主存或寫到某個別的地方。

發起 Writeback 通常是由於某個 CPU 的 Cache 不夠了,好比須要新 Load 數據進來,可是 Cache 已經滿了。就須要找一個 Cache Line 丟棄掉,若是這個被丟棄的 Cache Line 處於 modified 狀態,就須要觸發一次 WriteBack,多是把數據寫去主存,也可能寫入同一個 CPU 的更高級緩存,還有可能直接寫去別的 CPU。好比以前這個 CPU 讀過這個數據,可能對這個數據有興趣,爲了保證數據還在緩存中,可能就觸發一次 Writeback 把數據發到讀過該數據的 CPU 上。安全

舉例:併發

左邊是操做執行順序,CPU 是執行操做的 CPU 編號。Operation 是執行的操做。RMW 表示讀、修改、寫。Memory 那裏 V 表示內存數據是 Valid。ide

  1. 一開始全部緩存都是 Invalid;
  2. CPU 0 經過 Read 消息讀 0 地址數據,0 地址所在 Cache Line 變成 Shared 狀態;
  3. CPU 3 再執行 Read 讀 0 地址,它的 0 地址所在 Cache Line 也變成 Shared;
  4. CPU 0 又從 Memory 讀取 8 地址,替換了以前存 0 地址的 Cache Line。8 地址所在 Cache Line 也標記爲 Shared
  5. CPU 2 由於要讀取並修改 0 地址數據,因此發送 Read Invalidate 請求,首先 Load 0 地址數據到 Cache Line,再讓當前 Cache 了 0 地址數據的 CPU 3 的 Cache 變成 Invalidate;
  6. CPU 2 修改了 0 地址數據,0 地址數據在 Cache Line 上進入 Modified 狀態,而且 Memory 上數據也變成 Invalid 的;
  7. CPU 1 發送 Read Invalidate 請求,從 CPU 2 獲取 0 地址的最新修改,並設置 CPU 2 上 Cache Line 爲 Invalidate。CPU 1 在讀取到 0 地址最新數據後對其進行修改,Cache Line 進入 Modified 狀態。注意這裏 CPU 2 沒有 Writeback 0 地址數據到 Memory
  8. CPU 1 讀取 8 地址數據,由於本身的 Cache Line 滿了,因此 Writeback 修改後的 0 地址數據到 Memory,讀 8 地址數據到 Cache Line 設置爲 Shared 狀態。此時 Memory 上 0 地址數據進入 Valid 狀態

真實的 MESI 協議很是複雜,MESI 由於是緩存之間維護數據一致性的協議,因此它全部請求都分爲兩端,請求來自 CPU 仍是來自 Bus。請求來源不一樣在不一樣狀態下也有不一樣結果。下面圖片來自 wiki MESI protocol - Wikipedia,只是貼一下大概瞧瞧就好。工具

Memory Barrier

Store Buffer

假設 CPU 0 要寫數據到某個地址,有兩種狀況:oop

  1. CPU 0 已經讀取了目標數據所在 Cache Line,處於 Shared 狀態;
  2. CPU 0 的 Cache 中尚未目標數據所在 Cache Line;

第一種狀況下,CPU 0 只要發送 Invalidate 給其它 CPU 便可。收到全部 CPU 的 Invalidate Ack 後,這塊 Cache Line 能夠轉換爲 Exclusive 狀態。第二種狀況下,CPU 0 須要發送 Read Invalidate 到全部 CPU,擁有最新目標數據的 CPU 會把最新數據發給 CPU 0,而且會標記本身的這塊 Cache Line 爲無效。

不管是 Invalidate 仍是 Read Invalidate,CPU 0 都得等其餘全部 CPU 返回 Invalidate Ack 後才能安全操做數據,這個等待時間可能會很長。由於 CPU 0 這裏只是想寫數據到目標內存地址,它根本不關心目標數據在別的 CPU 上當前值是什麼,因此這個等待是能夠優化的,辦法就是用 Store Buffer:

每次寫數據時一方面發送 Invalidate 去其它 CPU,另外一方面是將新寫的數據內容放入 Store Buffer。等到全部 CPU 都回復 Invalidate Ack 後,再將對應 Cache Line 數據從 Store Buffer 移除,寫入 CPU 實際 Cache Line。

除了避免等待 Invalidate Ack 外,Store Buffer 還能優化 Write Miss 的狀況。好比即便只用一個 CPU,若是目標待寫內存不在 Cache,正常來講須要等待數據從 Memory 加載到 Cache 後 CPU 才能開始寫,那有了 Store Buffer 的存在,若是待寫內存如今不在 Cache 裏能夠不用等待數據從 Memory 加載,而是把新寫數據放入 Store Buffer,接着去執行別的操做,等數據加載到 Cache 後再把 Store Buffer 內的新寫數據寫入 Cache。

另外對於 Invalidate 操做,有沒有可能兩個 CPU 併發的去 Invalidate 某個相同的 Cache Line?

這種衝突主要靠 Bus 解決,能夠從前面 MESI 的可視化工具看到,全部操做都得先訪問 Address Bus,訪問時會鎖住 Address Bus,因此一段時間內只有一個 CPU 會操做 Bus,會操做某個 Cache Line。可是兩個 CPU 能夠不斷相互修改同一個內存數據,致使同一個 Cache Line 在兩個 CPU 上來回切換。

Store Forwarding

前面圖上畫的 Store Buffer 結構還有問題,主要是讀數據的時候還須要讀 Store Buffer 裏的數據,而不是寫完 Store Buffer 就結束了。

好比如今有這個代碼,a 一開始不在 CPU 0 內,在 CPU 1 內,值爲 0。b 在 CPU 0 內:

a = 1;
b = a + 1;
assert(b == 2); 
複製代碼

CPU 0 由於沒緩存 a,寫 a 爲 1 的操做要放入 Store Buffer,以後須要發送 Read Invalidate 去 CPU 1。等 CPU 1 發來 a 的數據後 a 的值爲 0,若是 CPU 0 在執行 a + 1 的時候不去讀取 Store Buffer,則執行完 b 的值會是 1,而不是 2,致使 assert 出錯。

因此更正常的結構是下圖:

另一開始 CPU 0 雖然就是想寫 a 的值 爲 1,根本不關心它如今值是什麼,但也不能直接發送 Invalidate 給其它 CPU。由於 a 所在 Cache Line 上可能不僅 a 在,可能還有別的數據在,若是直接發送 Invalidate 會致使 Cache Line 上不屬於 a 的數據丟失。因此 Invalidate 只有在 Cache Line 處於 Shared 狀態,準備向 Exclusive 轉變時纔會使用。

Write Barrier

// CPU 0 執行 foo(), 擁有 b 的 Cache Line
void foo(void) { 
    a = 1; 
    b = 1; 
} 
// CPU 1 執行 bar(),擁有 a 的 Cache Line
void bar(void) {
    while (b == 0) continue; 
    assert(a == 1);
} 
複製代碼

對 CPU 0 來講,一開始 Cache 內沒有 a,因而發送 Read Invalidate 去獲取 a 所在 Cache Line 的修改權。a 寫入的新值存在 Store Buffer。以後 CPU 0 就能夠當即寫 b = 1 由於 b 的 Cache Line 就在 CPU 0 上,處於 Exclusive 狀態。

對 CPU 1 來講,它沒有 b 的 Cache Line 因此須要先發送 Read 讀 b 的值,若是此時 CPU 0 恰好寫完了 b = 1,CPU 1 讀到的 b 的值就是 1,就能跳出循環,此時若是還未收到 CPU 0 發來的 Read Invalidate,或者說收到了 CPU 0 的 Read Invalidate 可是隻處理完 Read 部分給 CPU 0 發回去 a 的值即 Read Response 但還未處理完 Invalidate,也即 CPU 1 還擁有 a 的 Cache Line,CPU 0 仍是不能將 a 的寫入從 Store Buffer 寫到 CPU 0 的 Cache Line 上。這樣 CPU 1 上 a 讀到的值就是 0,從而觸發 assert 失敗。

上述問題緣由就是 Store Buffer 的存在,若是沒有 Write Barrier,寫入操做可能會亂序,致使後一個寫入提早被其它 CPU 看到。

這裏可能的一個疑問是,上述問題能出現意味着 CPU 1 在收到 Read Invalidate 後還未處理完就能發 Read 請求給 CPU 0 讀 b 變量的 Cache Line,感受上彷佛不合理,由於彷佛 Cache 應該是收到一個請求處理一個請求才對。這裏可能有理解的盲區,我猜想是由於 Read Invalidate 實際分爲兩個操做,一個 Read 一個 InvalidateRead 能夠快速返回,可是 Invalidate 操做可能比較重,好比須要寫回主存,那 Cache 可能有什麼優化能容許等待執行完 Invalidate 返回 Invalidate Ack 前再收到 CPU 發來的輕量級的 Read 操做時能夠把 Read 先丟出去,畢竟 CPU 讀操做對 Cache 來講只須要轉發,Invalidate 則是真的要 Cache 去操做本身的標誌之類的,作的事情更多。

上面問題解決辦法就是 Write Barrier,其做用是將 Write Barrier 以前全部操做的 Cache Line 都打上標記,Barrier 以後的寫入操做不能直接操做 Cache Line 而也要先寫 Store Buffer 去,只是這種擁有 Cache Line 但由於 Barrier 關係也寫入 Store Buffer 的 Cache Line 不用打特殊標記。等 Store Buffer 內帶着標記的寫入由於收到 Invalidate Ack 而能寫 Cache Line 後,這些沒有打標記的寫入操做才能寫入 Cache Line。

相同代碼帶着 Write Barrier:

// CPU 0 執行 foo(), 擁有 b 的 Cache Line
void foo(void) { 
    a = 1; 
    smp_wmb();
    b = 1; 
} 
// CPU 1 執行 bar(),擁有 a 的 Cache Line
void bar(void) {
    while (b == 0) continue; 
    assert(a == 1);
} 
複製代碼

此時對 CPU 0 來講,a 寫入 Store Buffer 後帶着特殊標記,b 的寫入也得放入 Store Buffer。這樣若是 CPU 1 還未返回 Invalidate Ack,CPU 0 對 b 的寫入在 CPU 1 上就不可見。CPU 1 發來的 Read 讀取 b 拿到的一直是 0。等 CPU 1 回覆 Invalidate Ack 後,Ack 的是 a 所在 Cache Line,因而 CPU 0 將 Store Buffer 內 a = 1 的寫入寫到本身的 Cache Line,在從 Store Buffer 內找到全部排在 a 後面不帶特殊標記的寫入,即 b = 1 寫入本身的 Cache Line。這樣 CPU 1 再讀 b 就會拿到新值 1,而此時 a 在 CPU 1 上由於回覆過 Invalidate Ack,因此 a 會是 Invalidate 狀態,從新讀 a 後獲得 a 值爲 1。assert 成功。

Invalidate Queue

每一個 CPU 上 Store Buffer 都是有限的,當 Store Buffer 被寫滿以後,後續寫入就必須等 Store Buffer 有位置後才能再寫。就致使了性能問題。特別是 Write Barrier 的存在,一旦有 Write Barrier,後續全部寫入都得放入 Store Buffer 會讓 Store Buffer 排隊寫入數量大幅度增長。因此須要縮短寫入請求在 Store Buffer 的排隊時間。

以前提到 Store Buffer 存在緣由就是等待 Invalidate Ack 可能較長,那縮短 Store Buffer 排隊時間辦法就是儘快回覆 Invalidate AckInvalidate 操做時間長來自兩方面:

  1. 若是 Cache 特別繁忙,好比 CPU 有大量的在 Cache 上的讀取、寫入操做,可能致使 Cache 錯過 Invalidate 消息,致使 Invalidate 延遲 (我認爲是收到總線上信號後若是來不及處理能夠丟掉,等信號發送方待會重試)
  2. 可能短期到來大量的 Invalidate,致使 Cache 來不及處理這麼多 Invalidate 請求。每一個還得回覆 Invalidate Ack,也會佔用總線通訊時間

因而解決辦法就是爲每一個 CPU 再增長一個 Invalidate Queue。收到 Invalidate 請求後將請求放入隊列,並當即回覆 Ack

這麼作致使的問題也是顯而易見的。一個被 Invalidate 的 Cache Line 原本應該處於 Invalidate 狀態,CPU 不應讀、寫裏面數據的,但由於 Invalidate 請求被放入隊列,CPU 還認爲本身能夠讀寫這個 Cache Line 而在操做老舊數據。從上圖能看到 CPU 和 Invalidate Queue 在 Cache 兩端,因此跟 Store Buffer 不一樣,CPU 不能去 Invalidate Queue 裏查一個 Cache Line 是否被 Invalidate,這也是爲何 CPU 會讀到無效數據的緣由。

另外一方面,Invalidate Queue 的存在致使若是要 Invalidate 一個 Cache Line,得先把 CPU 本身的 Invalidate Queue 清理乾淨,或者至少有辦法讓 Cache 確認一個 Cache Line 在本身這裏狀態是非 Invalidate 的。

Read Barrier

由於 Invalidate Queue 的存在,CPU 可能讀到舊值,場景以下:

// CPU 0 執行 foo(), a 處於 Shared,b 處於 Exclusive
void foo(void) { 
    a = 1; 
    smp_wmb();
    b = 1; 
} 
// CPU 1 執行 bar(),a 處於 Shared 狀態
void bar(void) {
    while (b == 0) continue; 
    assert(a == 1);
} 
複製代碼

CPU 0 將 a = 1寫入 Store Buffer,發送 Invalidate (不是 Read Invalidate,由於 a 是 Shared 狀態) 給 CPU 1。CPU 1 將 Invalidate 請求放入隊列後當即返回了,因此 CPU 0 很快能將 1 寫入 a、b 所在 Cache Line。CPU 1 再去讀 b 的時候拿到 b 的新值 0,讀 a 的時候認爲 a 處於 Shared 狀態因而直接讀 a,拿到 a 的舊值好比 0,致使 assert 失敗。最後,即便程序運行失敗了,CPU 1 還須要繼續處理 Invalidate Queue,把 a 的 Cache Line 設置爲無效。

解決辦法是加 Read Barrier。Read Barrier 起做用不是說 CPU 看到 Read Barrier 後就當即去處理 Invalidate Queue,把它處理完了再接着執行剩下東西,而只是標記 Invalidate Queue 上的 Cache Line,以後繼續執行別的指令,直到看到下一個 Load 操做要從 Cache Line 裏讀數據了,CPU 纔會等待 Invalidate Queue 內全部剛纔被標記的 Cache Line 都處理完才繼續執行下一個 Load。好比標記完 Cache Line 後,又有新的 Invalidate 請求進來,由於這些請求沒有標記,因此下一次 Load 操做是不會等他們的。

// CPU 0 執行 foo(), a 處於 Shared,b 處於 Exclusive
void foo(void) { 
    a = 1; 
    smp_wmb();
    b = 1; 
} 
// CPU 1 執行 bar(),a 處於 Shared 狀態
void bar(void) {
    while (b == 0) continue; 
    smp_rmb();
    assert(a == 1);
} 
複製代碼

有了 Read Barrier 後,CPU 1 讀到 b 爲 0後,標記全部 Invalidate Queue 上的 Cache Line 繼續運行。下一個操做是讀 a 當前值,因而開始等全部被標記的 Cache Line 真的被 Invalidate 掉,此時再讀 a 發現 a 是 Invalidate 狀態,因而發送 Read 到 CPU 0,拿到 a 所在 Cache Line 最新值,assert 成功。

除了 Read Barrier 和 Write Barrier 外還有合二爲一的 Barrier。做用是讓後續寫操做所有先去 Store Buffer 排隊,讓後續讀操做都得先等 Invalidate Queue 處理完。

其它參考

相關文章
相關標籤/搜索