無鎖資料結構(基礎篇):記憶體柵障

喬永琪發表於2016-06-12

譯者注:記憶體柵障:作為一種同步手段,用於保持執行緒集合在其運算控制流的某個邏輯計算點上的的協調性。運算執行緒集內的執行緒必須等待該集合中所有其他執行緒都執行完某個運算後,才能繼續向下執行。確保在所有執行緒全部到達某個邏輯執行點前,任何執行緒都不能越過該邏輯點。更多內容參見《多核程式設計技術》

記憶體柵障是多核處理器軟體設計者不太瞭解的東西,究竟是什麼原因促使 CPU 設計者引入它呢?

簡單地說,因為記憶體引用重新排序可以很好地提升效能,而記憶體柵障和同步原語相似可以保證記憶體引用的有序性。程式碼能否正確執行取決於記憶體引用是否有序。

要詳盡地回答這個問題,需要從CPU快取的工作機制入手,特別是如何做確保快取很好地工作。這包含以下幾個部分:

  1. 單個快取結構;
  2. 快取一致性協議如何確保多核CPU與記憶體中某個位置上值的一致性;
  3. 如何通過儲存緩衝、失效佇列提升快取效能以及確保快取一致性協議。

如你所見,記憶體柵障是一種必需的羅煞(evil),用來確保系統的高效能和分散式。羅煞根植於這樣一個事實,多核CPU處理速度高出互聯部分(interconnects)好幾個數量級,互聯部分位於多核CPU和其待訪問記憶體之間。(譯者注:CPU週期受制於儲存週期)

1、快取結構

如今,多核CPU處理速度遠快於記憶體系統的存取速度。一個2006年產的CPU每納秒可能執行十個指令,但卻需要幾十納秒獲取記憶體中的某個資料項。這種跨越兩個數量級的速度差異,最終導致現代多核CPU上多個兆位元組快取的出現。通常快取在幾個處理週期就可以被訪問到,快取和多核CPU的關係如下面圖一所示:

現代計算機系統快取結構
圖一 現代計算機系統快取結構

資料在多核CPU快取和記憶體之間以固定大小的模組進行遷移,這種模組稱為“快取行”。該模組的大小通常為2的冪次方,範圍從16到256位元組不等。當某個CPU第一次訪問特定的資料項時,該資料項在CPU快取中是缺失的,這時會出現“快取缺失”。這意味著,資料項是從記憶體中獲取的。此時,在成百上千個處理週期內CPU都在空轉。然而,一旦資料項載入進CPU快取,接下來的訪問就能夠在快取中獲取,同時CPU會全速運轉。(譯者注:第一次拿到的資料後,將其拷貝放一份在快取中,CPU再次訪問時直接從快取中讀取數據)

過不了多久,這些CPU快取就會被資料填滿。接下來的快取缺失,則需要清除快取中已有的某個資料項,為新獲取的資料項騰出空間。此類快取缺失稱之為“容量缺失”,這是由快取容量有限引起的。不過,多數快取在資料還沒有存滿的時候,就可以強行清除舊的資料項,為新的資料項騰出空間。這是因為,大快取是由硬體雜湊表實現,這種雜湊表擁有固定大小的雜湊桶(CPU設計者將雜湊桶稱為“集合”)。雜湊表之間彼此毫無關聯,如下面圖二所示:

CPU快取結構
圖二 CPU快取結構

此快取有兩“道(way)”,每道16個“集合(set)”,總計32“行(line)”。每個入口包含一個大小256位元組的“快取行”,這是一個256位元組對齊的記憶體塊。快取行稍微小了點,不過十六進位制的算術運算會變得更加簡單。用硬體的術語描述,這是一個兩道集合關聯的快取,類似於一個擁有16個桶的軟體雜湊表,每個桶雜湊鏈最多有兩個元素。快取行的大小和關聯性合起來稱之為快取“幾何學(geome-try)”。正是因為快取是在硬體中實現的,雜湊函式相當簡單,從記憶體地址中獲取四個位元組。

在圖二中,每個盒子(譯者注,即每行)對應一個快取入口,包含256個位元組的快取行。像圖中空盒子所展示的那樣,快取入口也可能為空。餘下的盒子由該快取行的記憶體地址表示。快取行必須是256個位元組對齊,因此每個地址的低八位是0。採用硬體雜湊函式意味著,接下來4個高位和雜湊行數是吻合的。

如圖二所示,可能會出現這樣一種情形:當程式程式碼位於0x43210E00和0x43210EFF地址之間,該程式依次獲取0x12345000到0x12345EFF之間的資料。假設該程式正在訪問地址0x12345F00,該地址被雜湊為行0xF,該行的兩道都為空,可提供256位元組的行空間用於儲存。假如程式訪問地址0x1233000,該地址被雜湊為行0x0,因此相應的256個位元組的快取行可儲存在該行一號通道(way 1)中。然而倘若程式的訪問地址為0x1233E00,該地址的雜湊行為0xE;其中一個已有行必須清零,為新的快取行資料騰出空間。假如這個被清理的行隨後被再次訪問,就會發生一次快取缺失。這樣的快取缺失成為為“相關性缺失”。

截止目前,我們僅僅考慮了單個CPU讀取某個資料項的情形,那CPU在寫操作的時候又會發生什麼呢?多核CPU保持某個資料項與值的一致性非常重要。某個CPU寫入資料項之前,必須首先將該資料項從其CPU快取中移除或者“失效(invalidate)”。一旦失效,該CPU或許就能安全地修改此資料項。倘若資料項在該CPU快取中,但設定為只讀,此處理過程稱之為“寫缺失”。一旦某個CPU完成對其它CPU快取中某個資料項的失效運算,該CPU就可以反覆讀寫那個資料項。

稍後,倘若有其它CPU試圖訪問該資料項,很有可能發生一次快取缺失。這一次,第一個CPU失效資料項為了寫入資料。此型別的快取缺失稱之為“通訊缺失”,通常是由多個CPU利用某些資料項通訊引起。比如單個鎖就是一個資料項,由互斥演算法實現的,用於多核CPU通訊。

顯然,小心謹慎是必須的,確保多核CPU維持一個連貫的數據檢視。所有的CPU都在進行獲取、失效以及寫運算。因此不難想象,資料會發生丟失甚至更糟,或者出現同一資料項在多個CPU快取中含有不同的值。諸如此類的問題,都可藉由下一單元中描述的“快取一致性協議”加以解決。

2、快取一致性協議

快取一致性協議管理快取行的狀態,防止數據不一致,或者丟失。這些協議相當複雜,大約存在十幾種狀態,不過我們需要做的是僅僅關注四種狀態的MESI快取一致性協議。

2.1、MESI狀態

MESI表示修改、獨佔、共享、失效,快取行在此本協議中用到這四種狀態。依靠本協議,快取可以維護2個位元的狀態“標籤”,並將此標籤追加到快取行的實體地址和資料後面

處於“修改”狀態的快取行,只屬於某一個最新記憶體儲存所對應的CPU,這就確保該記憶體單元不會出現在其它CPU的快取中。可以說,處在“修改”狀態的快取行被某個CPU“獨有”。此快取僅持有最新的資料副本,所以該快取負責將資料寫回記憶體或者將資料寫入其它快取。同樣地,本快取需要儲存其它資料時也必須同樣操作,將資料寫回記憶體或者寫入其它快取中。

獨佔狀態與修改狀態相似,唯一的區別是快取行還未被相應的CPU修改。這也就意味著快取行中的資料副本是最新的。當然,考慮到該CPU在任何時候均可在本行中儲存資料,無需與其它CPU協商,處於獨佔狀態的快取行依然被該CPU獨有。也就是說,在記憶體中快取對應的值是最新的。因此,快取可以丟棄該資料,無需將其寫回記憶體或者傳遞給其它CPU。

處於共享狀態的快取行資料會在於其它一個或多個CPU的快取中儲存。因此,該CPU需要先與其它CPU協商然後儲存該快取行儲存資料。與佔狀態一樣,快取行對應的記憶體資料為最新的。本快取可以丟棄該資料,無需將其寫回記憶體或傳給其它CPU。

處在“失效”狀態的快取行是空的,換句話說,它不持有任何資料。一旦新資料進入該快取,如果可以,該資料會被放入一個處於失效的快取行中。本方法為一種優先選項,因為替換某個處在任何一種其它狀態的快取行,都會導致巨大的快取缺失開銷。這些被替代的快取行資料,原本在未來某個時間點應該被再次引用。

因為所有CPU必須在快取行中維護一個連貫的資料檢視,快取連貫性協議提供了一組訊息,用來協調系統中快取行之間的資料遷移。

2.2 MESI協議訊息

如前文所述,許多的狀態轉換需要CPU之間通訊。倘若所有CPU共享一條匯流排,以下的訊息足以滿足通訊的需要:

讀訊息:“讀”訊息包含待讀取快取行的實體地址。

讀響應:“讀響應”消息包含了早先“讀”訊息請求的資料。“讀響應”訊息可能來自記憶體或者其它快取。例如,某個處於“修改”狀態的快取持有需要的資料,該快取必然提供“讀響應”訊息。

失效:“失效”訊息包含失效資料快取行的實體地址,其它快取必須移除這些資料,並作出響應。

失效確認:接收“失效”訊息的CPU,移除快取中特定資料後,必須以“失效確認”訊息作出響應。

讀失效:“讀失效”訊息包含待讀取緩存行的實體地址,與此同時,指導其它快取移除資料。因此可以說,它是“讀”和“失效”的某個聯合。一個“讀失效”訊息同時需要一個“讀響應”和一組“失效確認”訊息作為迴應。

回寫:“回寫”訊息包含待寫回的記憶體地址和資料(或者寫入其它CPU快取中)。本訊息允許快取在必要的時候,剔除處在“修改”狀態的快取行,為其它資料騰出空間。

有趣的是,單個共享記憶體多核系統的底層其實就是一個訊息傳送計算機。這就意味著採用分散式共享記憶體的SMP機群,利用訊息傳遞機制在系統架構的兩個不同層次實現記憶體共享。

快速問答1:倘若兩個CPU同時讓某個快取行失效會發生什麼?

快速問答2:一旦某個“失效”訊息出現在一個大的多核處理器中,每個CPU必須對此作出一個“失效確認”迴應。那“失效確認”迴應導致的匯流排“風暴”會不會致使系統匯流排完全飽和?

快速問答 3:倘若SMP機器真的利用訊息傳輸機制,會給SMP帶來哪些困惑呢?

2.3 MESI 狀態圖

如下面圖三所示,協議訊息不斷進行傳送和接收時,某個既定快取行狀態會不斷髮生變化。

1
圖三 MESI快取一致性狀態圖

圖中的弧度轉換如下:

轉換(a):某個快取行資料項被寫回記憶體,不過CPU依然保留此快取行,並保留進一步修改它的許可權。本轉換需要一個“回寫”訊息。
轉換(b):CPU獲得快取行獨有訪問許可權後會寫入資料項,本轉換無需接收或傳送任何訊息。

轉換(c):針對某個已修改狀態的快取行,CPU接收一個“讀失效”訊息。CPU必須讓本地副本失效,接著用一個“讀響應”和一個“失效確認”訊息作出迴應,併傳送資料給請求CPU,表明此資料項不再是一個本地副本

轉換(d):CPU在一個資料項上執行一個原子性讀-修改-寫(read-modify-write)運算,但該資料項並不在該快取中。此時CPU需要傳送一個“讀失效”,並通過一個“讀響應”接受資料。一旦CPU完成快取行的狀態轉換,便會接受一組“失效確認”響應。

轉換(e):CPU在一個資料項上執行原子性讀-修改-寫運算,該資料項存在於只讀快取中。本CPU必須發出“失效”訊息,在完成轉換之前,必須等待一組“失效確認”響應。

轉換(f):其它CPU讀取本CPU的快取行,該CPU快取行持有一個只讀副本,也可能將快取行副本寫回記憶體中。在接收一個“讀”訊息時發起轉換,本CPU會回覆一個包含請求資料的“讀響應”。

轉換(g):其它CPU讀取本快取行的某個資料項,此資料來自本CPU快取或記憶體。同樣,本CPU持有一個只讀副本。轉換是在接收一個“讀”訊息時發起的,本CPU會回覆一個包含請求資料的“讀響應”。
轉換(h):隨後,本CPU將某些資料項寫入本快取行中,因此發出一個“失效”訊息。CPU在完成轉換之前,需要獲得一組完整的“失效確認”響應。另外,其它CPU通過“回寫”訊息將此快取行從它們的快取中剔除出去(假設為其它快取行騰出空間),因此本CPU是最後一個快取該資料的CPU。
轉換(i):其它CPU在一個由本CPU持有的快取行資料項上,執行一個原子性讀-修改-寫運算,因此本CPU使快取失效。本轉換是在接收一個“讀失效”訊息後發起的,本CPU會回覆一個“讀響應“和一個“失效確認”訊息。

轉換(j):本CPU將單個資料項存入某個快取行中,然而在此之前,此快取行並不在本CPU的快取中,因此需要傳送一個“讀失效”訊息。CPU完成轉換前,需要接收“讀響應”和一組完整的“失效確認”訊息。一旦儲存完成,通過轉換(b),快取行就能轉換為修改狀態。

轉換(k):本CPU載入資料項到某個快取行,然而在此之前,該快取行並不在本快取中。本CPU傳送一個“讀”訊息,並依據接收到的“訊息響應”完成轉換。

轉換(l):一些其它的CPU在本快取行執行一個資料項儲存運算,持有處於只讀狀態的本快取行,這樣該快取行亦可以被其它CPU快取所持有(比如當前CPU快取)。本轉換在接收一個“失效”訊息開始,同時本CPU回覆一個“失效確認”訊息。

快速問答 4:那麼硬體如何處理如上所述的延遲轉換?

2.4 MESI協議示例

讓我們從快取行有利於資料運算的角度來審視該協議。最初,在四個 CPU的系統中,資料儲存在記憶體地址0,隨後該資料穿越不同的單行直接對映快取。表格1展示了資料流向,第一列為運算編號,第二列為執行該運算的CPU,第三列為執行的運算,接下來的四列為每個CPU快取行的狀態(緊跟MESI狀態的是記憶體地址),最後兩列表示對應的記憶體內容是否為最新(“V”)或者無效(“I”)。

2

表一 快取一致性示例

開始,儲存資料的快取行處於失效狀態,它在記憶體中是有效資料。當CPU0載入地址0的資料時,CPU0快取行便進入“共享”狀態,該資料在記憶體中依舊有效。同樣,CPU3載入0地址資料,因此該資料處於“共享”狀態,同時存在於兩個CPU快取中,而且在記憶體中依舊有效。接下來,CPU0載入其它快取行(地址8),通過一個無效運算將0地址資料強制剔除出本快取,即以地址8的資料替代地址0中的資料。此刻CPU2從地址0載入資料。該CPU很快意識到它馬上要儲存此資料,因此利用一個“讀無效”訊息獲取一個獨佔副本,同時使CPU3快取中該副本失效。接著CPU2執行預期的儲存運算,並將狀態改為修改。此時記憶體中的資料備份已經過期失效。CPU1執行一個原子性增加的運算,利用一個讀無效”從CPU2快取中取值,並將其判為失效。於是CPU1快取中的資料備份處在“修改”狀態(與此同時,記憶體中的備份依舊過期)。最終,CPU1讀取地址為8快取行,利用“回寫”訊息將0地址資料寫回到記憶體。

需要注意的是,操作完成後一些CPU快取中依舊存有資料。

快速問答 5 :按照什麼的順序去執行運算,所有CPU緩存會回到“無效”狀態?

3 儲存導致的非必需延滯

圖一中的快取結構可以提高單個CPU反覆讀寫某個資料項效能,但首次寫入某個快取行的效能卻是非常糟糕。為了一探究竟,請檢視圖四。該時間列表顯示CPU0一個寫資料到CPU1快取所持有的快取行中。CPU0必須等待直到快取行寫入,因此CPU0會有一個額外延滯的時間週期。

1

圖四 非必要寫阻塞

但是,沒有理由強制CPU0滯後那麼長時間。畢竟,不管CPU1輸入快取行資料有怎樣變化,CPU0都可以無條件重寫該快取行。

3.1 儲存緩衝(Store Buffers)

如下面圖五所示,預防這種非必須寫延滯的第一種方式是在每個CPU和其快取行之間新增“儲存緩衝”。有了這些額外的儲存緩衝,CPU 0可以簡單地將它的寫記入儲存緩衝並繼續執行。一旦快取行最終從CPU 1遷入CPU 0,資料必會從儲存緩衝遷入快取行中。
1

圖五 帶有儲存緩衝的緩衝

然而,這種機制過於複雜,必須簡化。請看接下來的兩個章節。

3.2 儲存指向(store forward)

先來看第一種複雜情況,違反自我一致性原則。考慮以下程式碼兩個初始值均為零的變數“a”和“b”,由CPU 1持有包含變數a的快取行,以及由CPU 0持有包含變數b的快取行。

大家肯定認為上面的斷言不會失敗。然而,如果程式愚蠢到採用圖五所示的簡單架構,那結果可能會讓大家失算。這樣的系統可能會遇到以下一系列事件:

1.CPU 0 開始執行a=1;
2.CPU 0 在快取中查詢a,發現它是缺失的;
3.CPU 0 因此傳送一個“讀無效”訊息,以便獨佔這個包含a的快取行;
4.CPU 0 將儲存a命令記錄在該儲存緩衝中;
5.CPU 1 接收到“讀無效”訊息,傳送本快取行,並在本快取中清除該行;
6.CPU 0 開始執行b=a+1;
7.CPU 0 接收來自CPU 1的快取行,依舊持有一個值為零的變數a;
8.CPU 0 CPU記載來自快取的a,同時發現其值為零;
9.CPU 0 申請進入,從該儲存佇列到最新快取行,並將該快取中a的值設定為1;
10.CPU 0 為上述已載入的值為零的a加1,並將其存入包含b的快取行中。(假定它已為CPU 0 所有)
11.CPU 0 執行assert(b==2),失敗。

問題是我們有兩個a的副本,一個在快取中,一個在儲存緩衝中。

上面的例子打破了一個很重要的保證,即每個CPU都會一直掌握自己的運算,如同該運算髮生在程式順序(program
order)中的那樣。從軟體開發者的角度,這種保證是嚴重違反常理的;與此同時,硬體開發人員也會覺得可惜並且為此實現了“儲存轉發”。這樣每個CPU在執行載入時,像下面圖六中展示的引用快取一樣引用它的儲存緩衝。換句話說,某個CPU儲存運算可以不通過快取,直接執行後續載入。
1

圖六 帶有儲存指向的快取

這裡利用儲存指向,上面序列中第八條能夠在儲存緩衝a種找到正確的值“1”,因此b最終的值是2,與預期一致。

3.3 儲存緩衝和記憶體柵障

接著看第二種複雜情況,這種情況違反全域性記憶體排序原則。假定以下程式碼序列變數“a”和“b”初始值為零:

假定CPU 0執行foo(),接著CPU 1執行bar()。進一步假設包含a的快取行僅存在於CPU 1快取中,包含b的快取行由CPU 0 持有。那麼,運算運算順序或許是這樣的:

1.CPU 0 執行a=1,快取行不在CPU 0 的快取中。因此CPU 0會將a的新值放入儲存緩衝中,併傳送一個“讀無效”訊息。

2.CPU 1 執行while(b==0) continue,但包含b快取行不在快取中,因此傳送一個“讀”訊息。
3.CPU 0 執行b=1,此刻它已經擁有這個快取行(換句話說,快取行已經處在修改或者獨佔狀態),因此它會儲存b的新值於快取行中。
4.CPU 0 接收“讀”訊息,傳送包含更新後b的快取行給CPU 1,同時將該行標記為“共享”狀態。
5.CPU 1 接收包含b快取行,並將其放入自己的快取中。
6.CPU 1 此刻可以完成while(b==0),因為它找到了b的值是1。接著處理接下來的執行語句。
7.CPU 1 執行assert(a==1),因為CPU 1 正作用於a的舊值,因此此斷言失敗。
8.CPU 1 接收“讀無效”訊息,然後將包含a快取行傳送給CPU 0 ,並將本快取的快取行失效,但為時已晚。
9.CPU 0 接收包含a的快取行,申請緩衝中儲存,從而免於陷入CPU 1的失敗斷言中。

快速問答 6: 在步驟1中,為何CPU 0 需要傳送一個“讀無效” 而非一個簡單的“無效”?

對此硬體設計者深感無力,CPU無法判斷哪些是相關變數,更不用說它們之間的關聯關係。因此,硬體設計者為軟體開發人員提供了記憶體柵障指令,告訴CPU變數之間的關聯關係。必須更新程式片段(program fragment)加入記憶體柵障:

記憶體柵障smp_mb()會觸發CPU刷入儲存緩衝中,在這之前它會申請後續儲存(subsequent store)到快取行中。CPU既可以一直等待直到儲存緩衝為空。或者利用儲存緩衝持有後續儲存,直到儲存緩衝前面所有的入口執行完畢為止。

用這種辦法,運算流程會像下面這樣:

  1. CPU 0 執行a=1,快取行不在CPU 0 的快取中,因此CPU 0 會將a的新值放入儲存緩衝中,併傳送一個“讀無效”訊息;
  2. CPU 1執行while(b==0) continue,但包含b的快取行不在本快取中。因此,它傳送一個“讀”訊息;
  3. CPU 0 執行smp_mb(),完成當前所有的儲存緩衝准入(store-buffer entries)(即a=1);
  4. CPU 0 執行 b=1,它已擁有本快取行(快取行已處於修改或者獨佔狀態),不過儲存緩衝中存在一個標記了的入口。因此不用將新值b放入快取行中,相反它將其放入儲存緩衝中(一個沒有標記的入口中);
  5. CPU 0 接收CPU 1 的“讀”訊息,傳送一個包含原始值的變數b到CPU 1,同時將本快取行副本設定為“共享”;
  6. CPU 1 接收包含“b”的快取行,然後把它放入自己的快取中;
  7. CPU 1 此刻可以執行完while(b==0) continue。但由於它發現b值仍然是0,會重複while語句。b的新值此刻安全的藏匿於CPU 0的儲存緩衝中;
  8. CPU 1 收到CPU0的“讀無效”訊息,發生包含a的快取行給CPU 0,並將本快取中的快取行設無效;
  9. CPU 0 接收包含a的快取行,申請快取中儲存,並快取行設為修改狀態;
  10. 由於儲存a是儲存緩衝中唯一的入口,被smp_mb()標記。所以CPU 0 同樣也可以儲存b的新值,包含b並處於共享狀態的快取行除外;
  11. 接著CPU 0 傳送一個“無效”訊息到CPU 1;
  12. CPU 1 接收CPU 0的“無效”訊息,設定包含b的快取行為無效,併傳送一個“確認”訊息給CPU 0;
  13. CPU 1執行while(b==0) continue,但包含“b”快取行不在本快取中。因此它會傳送一個“讀”訊息給CPU 0;
  14. CPU 0接收“確認”訊息,並將包含b的快取行設定為獨佔狀態。CPU 0將帶有最新值的b放入快取行;
  15. CPU 0接收“讀”訊息,傳送這個包含帶有新值的b的快取行給CPU 1。同樣它會標記本快取行的副本為“共享”;
  16. CPU 1接收包含b的快取行,並將其放入自己的快取中;
  17. CPU 1此刻可以完成while(b==0) continue,因此它可以發現b的值為1,接著執行接下來的語句;
  18. CPU 1執行assert(a==1),但包含a的快取行不再快取中。一旦它得到來自CPU 0的快取行,便採用a變數最新的值,斷言會通過。

如你所見,本程式的記賬薄(bookkeeping)資料不會很少。即使直觀上覺得簡單,但諸如“載入a的值”卻可能涉及處理器中許多複雜的步驟。

4、儲存序列導致的非必需延滯

不幸的是,每個儲存緩衝規模必須保持相對較小,這樣單個CPU執行的一個最適中的儲存序列才能放入它的儲存緩衝中(這可能導致快取缺失)。這種情況下,CPU必須再次等待無效命令完成以便清掉該儲存緩衝的資料,然後CPU才能繼續往下執行。在記憶體柵障之後,此情景依然會快速出現,因為所以後續儲存指令必須等待所有無效執行完畢,無論這些儲存是否會導致快取缺失。

這款情形可以通過加速無效確認訊息得到改善。其中一種實現方式可以利用每個CPU無效訊息佇列或“無效佇列”。

4.1 無效佇列

無效確認訊息傳遞時間過長的一個原因,它們必須確保相應的快取行確實已被置為失效。假如快取很忙,那這個失效可能被延遲,比如CPU正在進行密集的資料載入和儲存,而所有這些資料就在快取中。另外,如果大規模的無效訊息在短時間抵達,該CPU陷入無效訊息的處理中,並且有可能阻塞其它CPU。

然而,本CPU無需在傳送確認之前,使快取行變得無效。相反,它收集這些無效訊息表明這些訊息會在未來處理。在此之前CPU就該快取行還可以傳送任何其它訊息。

4.2 無效佇列和無效確認

下面圖七種展示的是一個帶有無效佇列系統。單個CPU帶有單個無效佇列,在該無效訊息放入佇列後或許就能被確認,而非等到相應的快取行無效。當然,CPU必須指向它的無效佇列,準備傳送無效訊息。倘若相應的快取行入口在無效佇列中,CPU不能立即傳送無效訊息;相反,它必須等待直到無效佇列入口已被處理。


1
圖七 帶有無效佇列的快取

將某個入口放入無效佇列一個很重要的承諾是,CPU處理完該入口後,就可以傳送該快取行各種型別的MESI協議訊息。只要相應的資料結構沒有大的競爭,諸如此類的承諾對CPU而言還是很便捷的。

事實上,無效訊息可以緩衝到無效佇列中,可能會造成記憶體無序。接下來我們會探討這些。

4.3 無效佇列和記憶體柵障

假定CPU收集無效響應,實時作出迴應。本方法儘可能減小CPU儲存帶來的快取無效延遲,但這缺破壞了記憶體柵障,具體請看下面的例子。

假定a和b的初始值為0,a處在只讀狀態(MESI共享狀態);b為CPU0所有(MESI獨佔或修改狀態)。接著假定CPU 0 執行foo(),同時CPU 1執行函式bar(),程式碼片段如下面所示:

接下來的運算,操作的執行順序如下:

  1. CPU 0 執行a=1,其對應的快取行在CPU 0中,且為只讀。因此CPU 0將帶有新值a放入本儲存緩衝中,併傳送一個無效訊息以便清除CPU 1快取中對應的快取行;
  2. CPU 1執行while(b==0) continue,但b快取行不在本快取中,因此傳送一個“讀”訊息;
  3. CPU 1接收CPU 0的“無效”訊息,收入佇列中,並快速做出響應;
  4. CPU 0接收來自CPU 1的響應,因此它可以繼續處理 smp_mb()在第4行以上的程式碼,將本儲存緩衝中的a放入快取行中;
  5. CPU 0 執行b=1,它早已擁有本快取(處在修改或者獨佔狀態),因此它會將b的新值存入本快取行中;
  6. CPU 0接收一個“讀”訊息,傳送包含此刻已更新(now-updated)b的快取行到CPU 1,同樣也將本行標記為共享;
  7. CPU 1接收包含b的快取行,並放入本快取中;
  8. CPU 1 此刻可以執行完while(b==0) contiune,發現b的值為1,因此可以處理下面的語句;
  9. CPU 1 執行assert(a==1),a的舊值依然存於CPU 1 的快取中,因此本斷言失敗;
  10. 儘管斷言失敗,CPU 1處理了佇列中的“無效”訊息,(延遲)使得包含a快取行在本快取中失效。

快速問答7:在4.3節情景1的第1步中,為何傳送“無效”而非“讀無效”訊息?CPU 0 無需其它變數的值共享本快取行?

這裡倘若加速無效響應,就會忽視記憶體柵障的有效性。然而,記憶體柵障指令可以和無效佇列互動。因此一旦某個CPU執行記憶體柵障,就便會標記無效佇列中當前所有入口,強制後續的任何載入等待,直到所有標記的入口應用於CPU快取中。因此,可以給bar方法新增如下一個記憶體柵障:

快速問答8 :這說明了什麼?假設while迴圈完成之後,CPU才能執行assert()方法,為何我們還要新增這個記憶體柵障?

這樣一來,運算序列或許就變成下面這樣:

  1. CPU 0 執行a=1,對應的快取行在CPU 0快取中,且為只讀,因此CPU 0將a的新值放入此儲存緩衝中,並且傳送一個“無效”訊息,以便刷掉CPU 1快取中對應的快取行;
  2. CPU 1執行while(b==0)continue,但包含b的快取行不在本快取中,因此它傳送一個“讀”訊息;
  3. CPU 1接收CPU 0的“無效”訊息,放入佇列並立即給出響應;
  4. CPU 0接收來自CPU 1的訊息,因此接著可以處理 smp_mb()所在第四行以上的程式碼,將a值從儲存緩衝放入快取行;
  5. CPU 0 執行b=1,它已擁有該快取行(換言之,本快取行已處在修改或獨佔狀態),因此它將b的新值放入本快取行中;
  6. CPU 0 接收“讀”訊息,傳送一個包含b的此刻最新值(now-updated value)快取行給CPU 1,同時,將本快取中的該行設定為共享狀態;
  7. CPU 1接收包含b的快取行,並放入本快取中;
  8. CPU 1此刻可以完成while(b==0) continue,因為它發現b的值為1,可以繼續執行下面的語句,即一個記憶體柵障;
  9. CPU 1 阻塞,直到處理完本無效佇列先前存在的訊息;
  10. CPU 1此刻執行佇列中的“無效”訊息,並使本快取中包含a的快取行失效;
  11. CPU 1 執行assert(a==1),因為包含a的快取行不在CPU 1 快取中,傳送一個“讀”訊息;
  12. CPU 0 響應該“讀”訊息,傳送包含a新值的快取行;
  13. CPU 1接收該快取行,此a的值為1,因此沒有引發該斷言。

即使傳輸非常多的MESI訊息,所有CPU依然能作出準確的效應。本節解釋了為什麼CPU設計者為何必須謹慎對待快取一致性優化。

5、記憶體柵障讀寫

在前一節裡,記憶體柵障被用來標記儲存緩衝以及無效佇列入口,但在我們的程式碼片段中,foo()沒有理由做任何與無效佇列有關的事情。相似地,bar()沒有理由做任何與儲存佇列相關的事情。

因此,很多核CPU架構提供了一種很弱的記憶體柵障指令,只作用於其中一種或兩種都做。簡單地說,一個“讀記憶體柵障”僅僅標記無效佇列,一個“寫記憶體柵障”僅標記儲存緩衝,還有一種兩者都做的全功能記憶體柵障。

這樣做的目的是一個讀記憶體柵障僅在執行它的CPU中執行載入運算。這樣一來,“讀記憶體柵障”之前的所有載入項處理完成後,該記憶體柵障之後的所有載入項才會繼續往下進行。類似地,一個“寫記憶體柵障”僅在執行它的CPU中反覆地執行儲存運算,該記憶體柵障之前的所有儲存完成之後,所有儲存才會繼續往下進行。一個全功能記憶體柵障同時執行載入和儲存運算,只是該記憶體柵障的CPU上反覆執行。

倘若我們用讀和寫記憶體柵障更新foo和bar,會像下面這樣:

一些計算機甚至傾向使用更多的記憶體柵障,但理解這3種變形就可以大體上很好地理解記憶體柵障。

6、記憶體柵障序列化示例

本節展示了一些令人又愛又恨的記憶體柵障應用,儘管它們多數大多時候都在執行,甚至某些一直執行在特定的CPU上。但諸如此類都必須避免,產生的程式碼應穩定地執行在所有CPU上。為了更好的理解這種細微缺陷,我們首先需要關注一種對有序不利的架構。

6.1 對有序不利的架構

Paul(本文作者)遭遇了許多會造成無序的計算機系統,但這種問題一直十分微妙,要理解它需要對特定硬體有一個全面瞭解。相對了解某個硬體供應商,閱讀詳細的技術說明文件想必更有吸引力。讓我們虛構一個把這種無序最大化的計算機架構。

本硬體必須遵循以下執行順序約束:

1.CPU會一直注意自身的記憶體訪問,就像發生在程式序列中。
2.當且僅當兩個運算運算引用指向不同的地址,多個CPU會重排單個儲存的某個既定運算。
3.位於某個讀記憶體柵障(smp_rmb())前面的單個CPU所有載入,會被所有CPU發現。它們優於讀記憶體柵障後面的所有載入。
4.位於某個寫記憶體柵障(smp_rmb())前面單個CPU所有儲存,會被所有CPU發現,它們優於寫記憶體柵障後面的所有儲存。
5.位於某個全能記憶體柵障(smp_rmb())前面的單個CPU所有訪問(載入和儲存),會被所有CPU發現,它們優於全能記憶體柵障後面的所有訪問。

快速問答 9:確保每個CPU有序地檢視本記憶體的訪問,也是為了確保每個使用者級別的執行緒能夠有序檢視本記憶體訪問?為什麼是這樣,或者說為什麼不是這樣?

如下面圖八所示,試想一個巨大的非統一的快取架構(NUCA)系統,為了給某個節點上的CPU提供一個公平的互連分配頻寬,每個節點互連線口都為單個CPU提供佇列。儘管單個既定CPU訪問的序列,由其執行的記憶體柵障指定。但是,正如我們所看到的,一組既定的CPU訪問,其相對的序列可能導致大量重排。
1

圖八 不利於有序的架構例子

6.2 例一

下面的表格二展示了3個程式碼片段,由CPU 0、1、,2併發執行。a、b、c的初始值均為0。

1
表格二 記憶體柵障示例1

假定CPU 0剛經歷許多快取缺失,因此它的訊息佇列是滿的。而此時CPU 1執行獨佔這個快取,它的訊息佇列是空的。接著CPU 0給a、b的賦值會立即出現在節點 0的快取中(因此對CPU 1可見),但會阻塞在其優先傳輸之後。相反,CPU 1給c賦值會通過CPU 1優先的空佇列。因此,儘管有記憶體柵障,CPU 2或許會先看到CPU 1給c賦值,然後看到CPU 0給a賦值,從而造成斷言失敗。

理論上說,移植程式碼不能依賴示例程式碼中的序列。然而在實踐中,所有主流計算機系統實際上都這麼做。

快速問答10:在CPU 1 “while”和賦值給c之間插入一個記憶體柵障能否修正本程式碼?為什麼能或為什麼不能?

6.3 例二

下面的表格三展示了3個程式碼片段,由CPU 0、1、2併發執行,a和b的初始值均為0。
1

表格三 記憶體柵障示例2

再次,假定CPU 0剛經歷許多快取缺失,因此它的訊息佇列是滿的。此時CPU 1獨佔本快取,因此它的訊息佇列是空的。接著,CPU 0給a賦值會立即出現在節點 0 的快取中(也因此對CPU 1可見),但CPU 0 會被阻塞在其優先傳輸的命令之後。相反,CPU 1賦值給b會通過CPU 1優先的空佇列。因此,儘管有記憶體柵障,CPU 2可能會先看到CPU 1賦值給b,然後才看到CPU 0賦值給a,引起斷言失敗。

理論上,便攜的程式碼不應該依賴本例程式碼段。然而像前面提到的那樣,在實踐中所有主流計算機系統實際上都這麼做。

6.4 例三

下面的表格四展示了3段程式碼,由CPU 0、1、2併發執行,所有變數初始化值均為0。

2

表格四 記憶體柵障示例3

  1. CPU 0 執行a=1,對應的快取行在CPU 0快取中,且為只讀,因此CPU 0將a的新值放入此儲存緩衝中,並且傳送一個“無效”訊息,以便刷掉CPU 1快取中對應的快取行;
  2. CPU 1執行while(b==0)continue,但包含b的快取行不在本快取中,因此它傳送一個“讀”訊息;
  3. CPU 1接收CPU 0的“無效”訊息,放入佇列中,並立即給出響應;
  4. CPU 0接收來自CPU 1的訊息,因此接著可以處理smp_mb()所在第4行以上的程式碼,將a值從儲存緩衝中放入快取行;
  5. CPU 0 執行b=1,它已擁有該快取行(換言之,本快取行已處在修改或獨佔狀態),因此它將b的新值放入本快取行中;
  6. CPU 0 接收“讀”訊息,傳送一個包含b的此刻最新值(now-updated value)快取行給CPU 1,同時,將本快取中的該行設定為“共享”狀態;
  7. CPU 1接收包含b的快取行,並放入本快取中;
  8. CPU 1此刻可以完成while(b==0) continue,因為它發現b的值是1,可以繼續執行下面的語句,即一個記憶體柵障;
  9. CPU 1 阻塞,直到處理完本無效佇列先前存在的訊息;
  10. CPU 1此刻執行佇列中的“無效”訊息,並使本快取中包含a的快取行失效;
  11. CPU 1 執行assert(a==1),因為包含a的快取行不在CPU 1 快取中,傳送一個“讀”訊息;
  12. CPU 0 響應該“讀”訊息,傳送包含a新值的快取行;
  13. CPU 1接收該快取行,此a的值為1,因此沒有引發該斷言。

注意,只有當CPU 1 和 CPU 2看到第3行CPU 0賦值給b才會往下執行第4行。一旦CPU 1和 2執行了第4行的記憶體柵障,都可以看到CPU 0在第二行記憶體柵障前面的所有賦值運算。相似的,CPU 0第八行記憶體柵障和CPU 1和2第4行的記憶體柵障是一對。因此,直到CPU 0賦值a時才會對其它CPU可見,這時會執行第9行賦值e的運算。這樣一來,CPU 2在第9行的斷言就不會失敗了。

快速問答11:假定CPU 1和2的第3到5行處於一箇中斷處理中,CPU 2第9行會在程式級執行。什麼樣的改變可以保證程式碼正確執行,換言之,防止斷言失敗?

Linux核心synchronize_rcu() 原生型別採用了跟本例相似的一個演算法。

7、特定CPU的記憶體柵障指令

每個CPU都有其特定的記憶體柵障指令,這會給移植帶來挑戰,如下面表格五所示。事實上,許多軟體環境,包括pthread和Java,簡單阻止了對記憶體柵障的直接呼叫,將程式設計人員限定在互斥原語(mutual-exclusion primitive)中,這些原語包含記憶體柵障,某種程度上可以滿足程式設計人員開發所需。表格前四列展示某種CPU是否允許4種可能的載入和儲存組合重排。接下來的兩列展示某種CPU在有原子指令的情況下是否允許載入和儲存重排。

123表格五 記憶體序列化總結

第7列執行取決於讀重排,需要對此做一些解釋。這部分會在Alpha CPU相關的那節進行介紹。簡易版本是Alpha的讀取器(reader)需要記憶體柵障,鏈資料結構的更新器也是如此。這就意味著,Alpha可以取到它指向的資料,然後獲取指標本身,看起來很奇怪但實際確實如此。如果你覺得我是在胡編亂造,那麼請參見 http://www.openvmscompaq.com/wizard/wiz_2637.html。這種極度弱化的記憶體模型好處在於,Alpha可以簡化快取硬體,Alpha最繁忙的日子可以承受更高的時鐘頻次。

最後一列顯示某種CPU是否擁有一個連貫的快取和管道指令。這樣的CPU針對自修改程式碼(self-modifying code)提供特定的指令。

CPU名字括號包起來的是為了說明,此模式是架構所允許的,不過實踐中極少使用。

通常對記憶體柵障“說不”的方式有非常的說服力。不過在很多環境中,比如Linux核心,直接使用記憶體柵障卻是必需的。因此,Linux基於最小公分母思想,精挑細選了一組記憶體柵障原語,如下:

  • smp mb():“記憶體柵障”對載入和儲存排序。這意味著記憶體柵障之前的載入和儲存提交到記憶體,才會執行該記憶體柵障之後的載入和儲存。
  • smp rmb():“讀記憶體柵障”只對載入進行排序。
  • smp wmb():“寫記憶體柵障”只對儲存進行排序。
  • smp read barrier depends()後續運算依賴於前面已排序的運算。除了Alpha外,本原語在其它所有平臺上都是空指令。
  • mmiowb()強制記憶體對映IO(MMIO)寫的排序,該寫由全域性自旋鎖控制。該原語在所有平臺上都是一個空指令,自旋鎖中的記憶體柵障已經強制MMIO重排。擁有非運算mmiowb()方法的平臺包括IA64、FRV、MIPS和SH等系統。該原語相對最新,因此有一些相對較少的驅動會使用到它。

smp mb()、smp rmb()和smp wmb()原語同樣可以強制編譯器,避免作出任何優化,該優化可以越過柵障重排記憶體程式碼。smp read barrier depends()原語擁有相似的功能,不過僅作用於Alpha CPU。

這些原語產生的程式碼只存在於對稱多處理器(SMP)核心中。同樣地,它們都有一個相應的單處理器(UP)版本(mb()、rmb()、wmb()和read barrier depends()),它們會在單處理器核心中產生一個記憶體柵障。smp_版本用在多種場合下。然而,後面的原語在實現驅動時非常有用,因為MMIO訪問必須保持有序,即使在單處理器核心中亦如此。不存在記憶體柵障指令,CPU和編譯器樂於重排這些訪問,這會讓裝置表現的很奇怪,比如出現核心崩潰,在某些情況下甚至會損壞你的硬碟。(譯者注:核心為作業系統核心,維護著一個用於程式和執行緒追蹤的表格)

因此,多數核心程式設計人員無需擔心CPU記憶體柵障的新奇特性,只要遵循這些介面即可。當然如果你的工作深入到了某種CPU特定的架構級程式碼中,那就另當別論了。

進一步講,所有Linux鎖的原語(spinlocks、reader-writer locks、semaphores、RCU等)包含了任何我們需要的柵障原語。倘若你編寫的程式碼利用這些原語,甚至無需擔心Linux記憶體序列化原語。

也就是說,深入瞭解每種CPU記憶體一致性模型,在程式碼除錯的時候是很有幫助的,更別說在寫架構級的特定程式碼或者同步原語了。

況且,懂一點皮毛是很危險的。試想你用很多知識可以做出的破壞。對於那些不滿足於僅僅知曉單個CPU記憶體一致性模型的人,接下來的章節描述了許多優秀的主流CPU記憶體一致性模型。儘管不如閱讀某種型別CPU說明文件那樣好,但這些章節卻也給我們提供了一個不錯的視角。

7.1 Alpha

過多宣稱已不再使用CPU似乎有些奇怪,不過Alpha確實很有趣。鑑於非常弱的記憶體序列模型,該CPU可以最大限度地重排記憶體運算,因此,定義Linux核心的記憶體序列化原語,必須工作在所有型別的CPU上,包括Alpha。因此,理解Alpha對Linux核心黑客很重要的。

Alpha和其它CPU的差別如下面程式碼所示,第九行smp wmb()確保六到八行的元素初始化以後,該元素才會加入到第十行的列表中,因此,無鎖查詢是沒有問題的。事實確實如此,除了Alpha,其它CPU都能保證這一點。

圖九 插入和無鎖查詢

Alpha擁有一個極弱的記憶體序列。在上面的程式碼中,第20行程式碼能看到舊的廢棄值,然後從第6至8行開始初始化。

下面圖十展示了它是如何發生在這個擁有分割槽化快取,最大化並行的機器中。因此,間隔的快取行交由不同的快取分割槽處理。假設列表頭head由快取庫0(cache bank)執行,而每個新元素由快取庫1執行。對於Alpha,圖九中smp_wmb()會確保第6到8行的失效快取先於第10行到達互動區(interconnect),但不能確保序列中的新值是否抵達讀CPU核心。比如,讀CPU快取庫1可能很忙,而快取庫 0卻處於閒置狀態。這會導致新元素的失效快取延遲。因此,讀CPU會得到該新值的指標,但它會看到新元素中舊的快取值。當然,如果你認為我是在胡編亂造,可以網上查詢更多的資訊。
1

圖十 smp_read_barrier_depends()為什麼是必需的

將一個smp_rmb()原語在獲取指標(pointer fetch)和間接引用(dereference)之間。然而,這會給系統帶來不必要的開銷(比如i386、IA64、PPC、SPARC),代表資料依賴於讀取的一方。smp read barrier depends()原語加入Linux 2.6核心中以減少這些系統開銷。該型別見圖十一的第19行。

圖十一 安全插入和無鎖查詢

同樣,軟體柵障的另一種可能的實現形式就是加入smp wmb(),它會強制所有“讀CPU”去檢視“寫CPU”中的寫序列。然而,Linux社群認為該方法會極大增加極端微弱序列化CPU的開銷,比如Alpha。本軟體柵障通過傳送處理器間中斷(IPI)給其它CPU。收到IPI後,某個CPU會執行一個記憶體柵障指令,實現一次記憶體柵障命中(shootdown)。需要額外考慮的是如何避免死鎖。當然,代表資料依賴的CPU定義一個柵障來簡化smp wmb()。或許隨著Alpha褪去,未來還需重新審視這個決定。

Linux記憶體柵障原語名字取自Alpha指令,smp_mb即mb,smp_rmb即rmb,smp_wmb即wmb。Alpha是唯一個smp read barrier depends()是smp_mb而非空指令的CPU。

關於Alpha更多細節,請參考手冊。

7.2 AMD64

AMD64與x86相容,最近更新了自己的記憶體模型使其序列更加緊湊,實際的實現還需時日。Linux原語在AMD64中,smp_mb即mfence,smp_rmb即lfence、smp_wmb即sfence。理論上講,這些指令可能很相對靈活,但不管如何靈活,都會考慮SSE和3DNOW指令。

7.3 ARMv7-A/R

CPU的ARM家族在嵌入式應用方面廣受歡迎,特別是諸如手機這樣的低功率應用。然而ARM在多處理器的實現已經做了五年多,記憶體模型和Power的相似(參看7.6小節),但ARM採用的是一組不同的記憶體柵障指令。

1、DMB(資料記憶體柵障)會在特定的運算型別完成之後再進行相同型別的後續運算。該類運算型別可以各種運算,或者僅限於寫(這與Alpha wmb和POWER eieio指令相似)。另外,ARM的快取一致性有三個範圍,單個處理器、一個亞組處理器(內部)和全域性(外部)。

2、DSB(資料一致性柵障)會促使特定的運算型別完成之後,再進行相同型別的後續運算。該運算型別與DMB相同,在早期的ARM架構中,DSB指令稱之為DWB(你可以選擇清空寫緩衝或者資料寫柵障)。

3、ISB(指令一致性柵障)會flush CPU管道,因此所有在ISB之後的指令僅是在ISB完成執行後獲取。比如你寫一個自修改程式(比如JIT),就應該在產生程式碼後執行程式碼之前執行ISB。

沒有一種指令可以與Linux rmb()原語語法完美匹配,因此必須實現一個完整的DMB。DMB和DSB指令在柵障前後,將一個遞迴訪問序列化,其的作用與POWER積累(cumulativity)相似。(譯者注:關於POWER累積性cumulativity請參考Understanding POWER Multiprocessors

ARMv7和POWER記憶體模型的不同在於POWER代表資料以及控制依賴,而ARMv7僅表示資料依賴。這兩種CPU家族的差異可以在下面的程式碼片段中找到,此程式碼在早前的4.3節就已討論過:

在上面的例子中,第10和11行之間存在一個控制依賴,該控制依賴使得POWER在這兩行間插入一個隱式的記憶體柵障,而ARM則是允許第11行啟發式執行( speculatively execute)完後,再執行第10行。另外,這兩款CPU在以下程式碼中均代表資料依賴:

這兩款CPU都會在第5行和第6行之間,放入一個隱式的記憶體柵障,從而避免第六行看到p->a預初始化的值。當然,前提是編譯器被禁止對這兩行進行重排。

7.4 IA64

IA64提供一個弱一致性模型,因此沒有顯式記憶體柵障指令,IA64其內部有權隨意的重排記憶體引用。IA64有一個名為mf的記憶體柵障(memory-fence)指令,數個“半記憶體柵欄”修飾器(modifier)作用於載入、儲存以及一些其它原子性指令。如圖12所示,acq修飾器阻止acq之後的後續記憶體引用指令重排。相似的,rel修飾器阻止rel之前的優先記憶體引用指令發生重排,但允許rel後面的後續記憶體引用指令重排。
無鎖資料結構(基礎篇):記憶體柵障

圖十二 半記憶體柵障

這些半記憶體柵欄(half-memory fences)對臨界段(critical sections)是很有用的,可以安全的將運算壓入某個臨界段中,但致命的是允許它們洩漏。然而,作為僅有的一個有如此屬性的CPU,IA64定義了Linux記憶體序列相關鎖的獲取以及釋放的語法。

在Linux核心中,IA64 mf指令用於smp_rmb()、smp_mb()以及smp_wmb()原語。儘管有很多相反的傳聞,但“mf”確實代表的是“記憶體柵欄”(memory fence)。

最後,IA64為“釋放”運算提供一個全域性總的序列,包括“mf”指令。涉及一個傳播的概念,如果某個程式碼片段看到某個訪問所發生的事情,那任何後續的程式碼片段都會看到早前該訪問所發生的各種情況。前提是涉及到的所有程式碼片段都能正確的使用記憶體柵障。(譯者注:記憶體柵欄用來確儲存儲操作的一致性,進而確保軟體儲存模型到硬體儲存模型的正確對映)

7.5 PA—RISC

儘管PA-RISC架構執行所有的載入和儲存重排,但實際中卻只執行有序的指令。這就意味著Linux核心記憶體序列化原語不產生程式碼。然而,它們可以利用gcc記憶體屬性解除編譯器優化,該優化會越過記憶體柵障進行重排。

7.6 POWER / Power PC

POWER和Power PC CPU家族擁有大量多元記憶體柵障指令:

1、sync前面的運算完成之後,後面的運算才會開始。因此該指令的開銷非常大:

2、lwsync(輕量級sync)採用後續的載入和儲存來序列化載入,序列化儲存亦如此。然而,它卻不能借助後續載入序列化儲存。非常有意思的是,lwsync指令跟zSeries有相同的序列化。巧合的是,SPARC TSO亦如此。

3、eieio(enforce in-order execution of I/O)促使所有前面可快取的儲存完成之後,才會進行後續的儲存。然而,儲存於可快取記憶體的序列和儲存於非快取記憶體中的序列是分開的,這就意味著eieio不會將某個MMIO儲存於之前的某個自旋鎖釋放中。

4、isync會強制所有前面的指令出現並執行完畢,後續的指令才會執行。這意味著前面的指令必須遠離任何由此已發生或未發生的陷阱,這些指令的任何副作用,例如頁-表的改變,均可以被後續指令所看到。

不幸的是,它們當中沒有一個指令可以組成Linux wmb()原語,要求所有儲存都序列化,但不會要求sync這種開銷很大的指令。然而沒有選擇,ppc64版本的wmb()和mb()定義在重量級的sync指令中。Linux smp_wmb()指令從來不用於MMIO(不論是單處理器還是對稱多核處理器核心中,驅動都必須謹慎序列化MMIO)。因此,它定義一個輕量級的eieio指令或許該指令獨一無二之處在於一個五個母音記憶體。

同樣,smp_mb()指令定義於sync指令,但smp_rmb()和rmb()定義於輕量級的lwsync指令中。

Power 獨特的“積累”,可以用來獲取傳播性。運用得當,任何程式碼能看到早期程式碼片段的結果,同樣也會看到早期程式碼片段所能看到的訪問。更多的細節參看McKenney和Silvera[MS09]。

很多POWER架構成員都有一致性指令快取。因此,某個儲存記憶體沒有必要反射(超程式設計)在指令快取中。辛虧那會很少有人寫自修改程式碼,不過JIT和編譯器卻會一直這麼做。(譯者注:JIT執行時編譯器其作用是將熱程式碼編譯為本地碼)進一步講,重編譯一個新近的執行程式,從CPU的角度看就像是自修改程式碼。icbi指令(指令快取塊失效)使得來自指令快取中的特定快取行失效,該指令或許可以應用在這些情景中。

7.7 SPArc RMO、PSO和TSO

在SPARC之上的Solaris運算系統利用TSO(total-store order)。一旦構建“sparc”32位的架構,效果就如同Linux。然而,64位Linux核心(sparc64架構)執行SPARC在RMO(relaxed-memory order)模型中。SPARC架構同樣提供一箇中間的PSO(partial store order)。任何執行在RMO程式,同樣會執行在PSO或者TSO。類似的,一個執行在PSO上的程式亦可以執行在TSO。移入一個共享記憶體並行程式到在其它指令中,或許你需要細心插入記憶體柵障。由於程式制定一致性原語使用標準,無需再擔心記憶體柵障。

SPARC擁有一個極其靈活的記憶體柵障指令,可以對序列化進行細顆粒度控制:

  • StoreStore:序列化其前面的儲存,接著是其後面的儲存。(此選項可以為Linux smp_wmb()原語所用)
  • LoadStore:序列化其前面的載入,接著是其後面的儲存。
  • StoreLoad:序列化其前面的儲存,接著是其後面的載入。
  • LoadLoad:序列化其前面的載入,接著是後面的載入(本選項可以為Linuxsmp_r4mb()原語所用)
  • Sync:完成其前面所有的運算,接著才開始其後面的運算。
  • MemIssue:完成其前面的記憶體運算,接著是其後面的記憶體運算,特別是記憶體對映I/O方面。
  • Lookaside:和MemIssue一樣,唯一的不同是應用於其前面的儲存和後面的載入,甚至僅作用於訪問同一記憶體地址的儲存和載入。

Linux smp_mb()原語採用前四個選擇,membar #LoadLoad | #LoadStore | #StoreStore | #StoreLoad。因此,可以完整地序列化記憶體運算。

那麼,為何我們還需要membar #MemIssue?membar #StoreLoad允許一個後續載入從某個寫快取中取值,這會是場災難。寫入某個MMIO暫存器,會導致待讀的值產生副作用。與之相反的是,membar #MemIssue會一直等待,直到寫快取寫出才會執行載入,因此可以確保載入從MMIO暫存器中取值。而驅動則採用membar #Sync,不過相對輕量級的membar #MemIssue更受歡迎,可能並不需要開銷更大的membar #Sync指令。

membar #Lookaside是一個更加輕量級的membar #MemIssue版本。特別是向某個MMIO暫存器寫值的時候非常有用,這會影響接下來該暫存器中資料的讀取。然而,相對重量級的membar #MemIssue必需的,特別是當寫入某個MMIO暫存器時,會影響接下來其它一些暫存器中的資料讀取。

將smb_wmb()定義於membar #StoreStore,當前的定義對某些驅動的bug看似很致命。SPARC為什麼不將wmb()定義於membar #MemIssue不得而知。很有可能,所有執行了Linux的SPARC CPU實現了一個頗具爭議的記憶體序列化模型,這遠遠超出了架構架構所能允許的範圍。

SPARC需要一個flush指令用在指令儲存和其執行的之間的時間段,用來寫出位於SPARC指令快取中的值。注意寫出指令獲取一個地址,並且僅僅會寫出來自指令快取中的地址。在對稱多核處理器系統中會flush所有CPU快取,儘管有相關實現的參考指南,但還是無法很好地判斷flush結果是否完全。

7.8 x86

x86 CPU提供“程式序列化”,因此所有CPU與某個CPU寫回記憶體的序列是一致的。smp_wmb()原語是一個空指令。然而一個編譯指令確實很有必要,可以防止編譯器優化,防止其優化越過smp_wmb()原語的對程式碼進行重排。

另一方面,x86 CPU傳統上是沒有提供有序載入保障,因此smp_mb()和smp_rmb()原語擴充套件為lock;addl。該原子指令對載入和儲存作用就與柵障類似。

前不久,Intel公佈了一個x86記憶體模型。相比早期的文件宣告,Intel CPU確實在有序性上進行強化。不過本模型還是在有效地簡單遵循早期約定。最近,Intel公開了一個新的x86記憶體模型為儲存提供一個總體全域性序列。不過如早前那樣而非總體全域性序列所闡述的,單個CPU依舊可以看到自身的儲存。總體序列化與其它形式唯一的區別是允許儲存緩衝相關的重要硬體優化。軟體或許利用原子性運算重寫硬體優化,這是原子性運算較非原子性運算開銷巨大的一個原因。總體儲存序列化不適用於舊的處理器。

然而,注意一些SSE指令是弱序列化的(clflush及無時態move)指令。擁有SSE指令集的CPU,其mfence為smp_mb()服務,lfence為smp_rmb()服務,sfence為smp_wmb()服務。

x86 CPU極個別版本有少量的模式允許無序儲存。對於此類CPU,smp_wmb()必須定義在lock;add1中。

許多早期的x86實現滿足自修改程式碼要求,無需任何22個特殊指令。新修訂的x86架構無需x86 CPU作出改變。有意思的是,這種相對寬鬆的記憶體模型出現,恰巧使得JIT實現起來不再那麼容易。

7.9 zSeries

zSeries機器組成IBM大型主機家族,早期為人所知的有360、370和390。在zSeries中並行操作出現的比較遲,考慮到這些大型主機首次使用是在二十世紀六十年代中期,完全可以理解。bcr 15,0指令用於Linux smp_mb()、smp_rmb()、smp_wmb()原語。同樣,它擁有相對強大的記憶體序列化語法,參見上文中的表格五,該語法應允許smp_wmb()原語為一個空指令(當你讀到本文時或許已經實現了)。表格五確實能體現這個情景,正如zSeries記憶體模型是連續一致的,意味著所有CPU會與來自其它不同CPU的不相關儲存序列保持一致。

與多數CPU一樣,zSeries架構不能提供一個快取一致性指令流。因此,自修改程式碼必須在更新指令後執行該指令前執行一個連續的指令。也就是說,許多zSeries機器實際上是滿足自修改程式碼,無需連續指令。zSeries指令集提供一個龐大的連續化指令集,包括compare-and-swap,某些分支型別(比如前面提到的bcr 15,0指令),test-and-set等等。

8、記憶體柵障是否會一直存在?

很多新系統在總體上無序執行以及特定重排記憶體引用方面,表現的不再那麼激進。這種趨勢是否會持續下去,記憶體柵障成為過去?

支援的觀點引用備受推崇的多執行緒硬體架構,因此每個執行緒會等待,直到記憶體就緒,同時有成百上千甚至上萬的執行緒在執行。在這樣的架構中不需要記憶體柵障,因為某個執行緒會簡單的等待所有未完成的運算執行完畢,才會進行接下來的指令。因為有成千上萬潛在的執行緒,CPU會被充分利用,這樣才不會有CPU週期被浪費。

(譯者注:所謂執行緒,就是一條與其它硬體執行緒執行路徑相互獨立的執行路徑,作業系統將軟體執行緒對映其上得以執行)

相反的觀點認為應用的場合極為有限,可能擴充套件成千的執行緒,急速增加惡性實時請求,處理起來需要幾十微秒。這對實時響應要求太高不容易,考慮到由眾多執行緒造成的單執行緒吞吐量極低的情況,這種要求更是難上加難。

另一個支援的觀點認為日益成熟的延遲隱藏(latency-hiding)硬體技術,給人一種錯覺。感覺上是完整的連續執行,而事實上CPU依舊提供幾乎所有無序化執行所帶來的效能優勢。而反方的觀點認為,使用電池裝置和環境問題都必然對功耗效率提出日益嚴苛的要求。

到底誰對誰錯?我們無從知曉,就讓我積極面對這些場景吧。

9、給硬體設計者的一些建議

硬體設計者所做的任何事情就是使軟體從業者日子變得更加艱難。這裡是過去我們遇到的一些問題,下面列舉的是希望能夠在未來避免的事項:

1、I/O裝置忽視快取一致性

這個明顯的錯誤可能導致來自記憶體的DMA錯過輸出快取新近的變化, 或者在DMA完成之後,使得輸入快取被CPU快取所重寫。為了使系統能夠應對此問題,向任何來自DMA快取中的CPU快取做寫操作都必須小心,之後再將此快取遞給I/O裝置。甚至你需要非常小心才能避免指標漏洞,因為即使一個不當的輸入快取讀取也能汙染輸出的資料。

2、裝置中斷忽視快取一致性

這聽起來很無辜——畢竟中斷不是記憶體引用,對吧?但試想單個CPU擁有一個分裂快取(split cache),其中一個快取庫(bank)極度的繁忙,因此會牢牢持有輸入緩衝的最後一行快取行。倘若對應的I/O完成中斷抵達該CPU,接著該CPU記憶體引用快取的最後一行快取行可能返回舊資料,再次導致資料汙染,但在形式上在稍後的故障轉儲中是不可見的。就在系統開始收集不恰當的輸入快取時,DMA很可能已經完成。

3. 處理器之間的中斷(IPI)忽視快取一致性

倘若IPI抵達目標後,其對應訊息快取中的所有快取行才提交到記憶體中,這樣是有問題的。

4. 上下文切換打破了快取一致性

倘若記憶體訪問過於雜亂無序,其上下文切換將難以控制。倘若某個任務從一個CPU轉入另一個CPU,轉入後所有記憶體訪問對源CPU可見,才能確保任務能成功由源CPU轉入目標CPU中,接著任務可能很容易看見對應的變數回復以前的值,這對多數演算法會造成致命的困惑。

5. 對模擬器和模擬器過於友善

遵循記憶體重排的模擬器或者模擬器實現起來是困難的。因為軟體在這些環境下執行沒有問題,一旦在真實硬體上執行就會出現令人厭煩的問題。不幸的是,至今依舊存在這樣一個觀點,真實硬體遠比模擬器和模擬器複雜多變,不過我們希望這種情形能得到改觀。

再次,我們鼓勵硬體設計者能夠規避這些問題。

致謝
I own thanks to many CPU architects for patiently explaining the instruction- and memory-reordering features of their CPUs, particularly Wayne Cardoza,Ed Silha, Anton Blanchard, Brad Frey, Cathy May,Derek Williams, Tim Slegel, Juergen Probst, Ingo
Adlung, and Ravi Arimilli. Wayne deserves special thanks for his patience in explaining Alphas reorder-ing of dependent loads, a lesson that I resisted quite strenuously! We all owe a debt of gratitute to Dave Keck and Artem Bityutskiy for helping to render this material human-readable.
免責宣告

This work represents the view of the author and does not necessarily represent the view of IBM.
IBM, zSeries, and Power PC are trademarks or registered trademarks of International Business Machines Corporation in th
e United States, other countries, or both.Linux is a registered trademark of Linus Torvalds.i386 is a trademarks of Intel Corporation or its subsidiaries in the United States, other countries, or both.Other company, product, and service names may be trademarks or service marks of such companies.
10 快速測驗及答案

快速問答1:倘若兩個CPU同時去廢除某個快取行會發生什麼?

答案:首先獲得共享匯流排訪問的CPU“贏了”,而另一個CPU必須失效該快取行中的副本,併傳送一個“無效確認”訊息給其它CPU。當然,輸的CPU會立即傳送一個“讀失效”業務,這樣贏了的CPU就會取得短暫的勝利。

快速問答2:一旦某個“無效”訊息出現在一個大型多核處理器中,每個CPU必須對此作出一個“無效確認”迴應。那“無效確認”迴應導致的匯流排“風暴”會不會完全飽和系統匯流排?

答案:有可能,如果大規模多核處理器確實這麼實現的。大型多核處理器,特別是NUMA計算機,傾向採用一種“目錄為基礎”快取一致性協議來避免這樣或那樣的問題。

快速問答3:倘若SMP計算機真的利用訊息傳輸機制,會給SMP帶來哪些困惑呢?

答案:在過去的幾十年裡,這一直是一個有爭議的話題。一種觀點認為,快取一致性協議相當簡單,因此可以直接應用於硬體,通過軟體訊息傳遞無法獲取令人滿意的頻寬和延遲。另一種觀點則認為從經濟的角度找到答案,可以比較大型SMP計算機和小型SMP計算機叢集的相對價格。第三種觀點認為SMP程式設計模型相對於分散式系統更好用,但一個反觀點會引用HPC叢集和MPI。因此這類爭議還會持續。

快速問答4:那麼硬體如何處理如上所述的延遲轉換(delayed transitions)?

答案:雖然通常新增額外的狀態,但這些額外狀態無需存入快取行中,因為僅僅有少數快取行在同一時間發生轉換。需要延遲的轉換不過是一個問題,在現實世界,快取一致性協議遠比附錄中過於簡單的MESI協議複雜。Hennessy & Patterson經典的計算機架構入門解答了其中的許多議題。

快速問答5:按照什麼的順序去執行運算,所有CPU快取會置回“無效”狀態?

答案:不存在這樣的順序,至少在缺少“flush my cache”指令的CPU指令集中,多數CPU確實有這樣的指令。

快速問答6: 在步驟1中,為何CPU 0 需要傳送一個“讀無效” 而非一個簡單的“無效”?

答案:因為有爭議的快取行包含不止a一個變數

快速問答7:在4.3第一個情景的第一個步驟中,為何傳送“無效”而非“讀無效”訊息?CPU 0 無需其它變數的值共享本快取行?

答案:CPU 0已經擁有這些變數的值,假定該CPU擁有一個包含“a”的快取行只讀副本。因此,CPU 0需要做的就是告知其它CPU丟棄本快取行的副本。顯然,必須提供一個“無效”訊息

快速問答8 :什麼?假設while迴圈完成之後,CPU才能執行assert()方法,為何我們還要新增這個記憶體柵障?

答案:CPU可以預測執行,可以執行完斷言之後,while迴圈才結束。也就是說,一些弱序列化CPU反應出對“控制依賴”。這樣的CPU執行一個隱式記憶體柵障,在它前面是某個條件分支( conditional branch),比如終止while迴圈的分支。然而本例中用到的顯式記憶體柵障,在DEC Alpha亦是必須的。

快速問答9:每個CPU能夠保證有序地檢視本記憶體的訪問,是否可以確保每個使用者級別的執行緒有序地檢視其記憶體訪問?是否可以保證?

答案:不可以。考慮這樣的場景,執行緒從一個CPU遷移到另一個CPU,目標CPU看到的源CPU近期記憶體運算是無序的。為了確保使用者態(user-mode)正常,核心黑客必須在上下文切換路徑上加入記憶體柵障。然而,已被要求加鎖確保可以安全的上下文切換,應該自動地加入必需的記憶體柵障。這樣可以確保使用者級別的任務能夠有序地看到自身的訪問。也就是說,如果你設計一個超級優化排程器,不論是核心級,亦或是使用者級別的,都請牢記這一點!

快速問答10:在CPU 1 “while”和賦值給“c”之間插入一個記憶體柵障能否修正程式碼?是否可行?

答案:不可以。此記憶體柵障僅會對CPU 1強制序列化,不會影響CPU 0和CPU 1訪問的相對序列,因此斷言會失敗。然而,所有大型主機其計算機系統均有這樣那樣的機制確保傳遞性,即提供一種本能的因果序列化(intuitive causal ordering):倘若B看到A的訪問,C看到了B訪問,那麼C同樣必須能看到A的訪問。

快速問答11:假定CPU 1和2的第3至5行處於一箇中斷處理者中,CPU 2第九行執行在程式級。什麼樣的改變可以保證程式碼正確執行,換言之防止斷言失敗?

答案:斷言必須確保“e”在“a”的前面被載入。在Linux核心中,barrier()原語可以用來完成這個任務,方式與前面例子中記憶體柵障在斷言中作用的方式如出一轍。

參考文獻
[Adv02] Advanced Micro Devices.AMD x86-64 Architecture Programmer’s Manual Volumes 1-5, 2002.
[Adv07] Advanced Micro Devices.AMD x86-64 Architecture Programmer’s Manual Volume 2:System Programming, 2007.

[ARM10] ARM Limited.ARM Architecture Reference Manual: ARMv7-A and ARMv7-R Edition, 2010.
[CSG99] David E. Culler, Jaswinder Pal Singh, and Anoop Gupta.Parallel Computer Architecture: a Hardware/Software Approach
. Morgan Kaufman, 1999.
[Gha95] Kourosh Gharachorloo. Memory consistency models for shared-memory multiprocessors. Technical Report CSL-TR-95-685,Computer Systems Laboratory, Departments of Electrical Engineering and Computer Science, Stanford University,
Stanford, CA, December 1995. Available:
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-9.pdf
[Viewed:October 11, 2004].
[HP95] John L. Hennessy and David A. Patterson.Computer Architecture: A Quantitative Approach
. Morgan Kaufman, 1995.
[IBM94] IBM Microelectronics and Motorola.PowerPC Microprocessor Family: The Programming Environments, 1994.
[Int02a] Intel Corporation.Intel Itanium Architecture Software Developer’s Manual Volume 3: Instruction Set Reference, 2002.
[Int02b] Intel Corporation.Intel Itanium Architecture Software Developer’s Manual Volume 3: System Architecture, 2002.
[Int04a] Intel Corporation.IA-32 Intel Architecture Software Developer’s Manual Volume 2B:Instruction Set Reference, N-Z, 2004.
Available: ftp://download.intel.com/design/Pentium4/manuals/25366714.pdf
[Viewed: February 16, 2005].
[Int04b] Intel Corporation.
IA-32 Intel Architecture Software Developer’s Manual Volume 3: System Programming Guide, 2004.
Available: ftp://download.intel.com/design/Pentium4/manuals/25366814.pdf
[Viewed: February 16, 2005].
[Int04c] International Business Machines Corporation. z/Architecture principles of operation.
Available: http://publibz.boulder.ibm.com/epubs/pdf/dz9zr003.pdf  [Viewed:February 16, 2005], May 2004.

[Int07] Intel Corporation.Intel 64 Architecture Memory Ordering WhitePaper, 2007.
Available:http://developer.intel.com/products/processor/manuals/318147.pdf
[Viewed:September 7, 2007].
[Int09] Intel Corporation.Intel 64 and IA-32 Architectures Software Developers Manual, Volume 3A: System Programming Guide, Part 1, 2009.
Available: http://download.intel.com/design/processor/manuals/253668.pdf  [Viewed:November 8, 2009].
[Kan96] Gerry Kane.PA-RISC 2.0 Architecture.Hewlett-Packard Professional Books, 1996.
[LSH02] Michael Lyons, Ed Silha, and Bill Hay.PowerPC storage model and AIX programming.
Available: http://www-106.ibm.com/developerworks/eserver/articles/powerpc.html
[Viewed: January 31, 2005], August 2002.
[McK05a] Paul E. McKenney.Memory ordering in modern microprocessors,part I.
Linux Journal, 1(136):52–57, August 2005.
Available:http://www.linuxjournal.com/article/8211
http://www.rdrop.com/users/paulmck/scalability/paper/ordering.2007.09.19a.pdf
[Viewed November 30, 2007].
[McK05b] Paul E. McKenney.Memory ordering in modern microprocessors,part II.
Linux Journal, 1(137):78–82,September 2005.
Available:
http://www.linuxjournal.com/article/8212
http://www.rdrop.com/users/paulmck/scalability/paper/ordering.2007.09.19a.pdf
[Viewed November 30, 2007].

[MS09] Paul E. McKenney and Raul Silvera. Example power implementation for c/c++ memory model.
Available:http://www.rdrop.com/users/paulmck/scalability/paper/N2745r.2009.02.27a.html
[Viewed: April 5, 2009], February 2009.
[Sew] Peter Sewell. The semantics of multiprocessor programs.
Available:http://www.cl.cam.ac.uk/~pes20/weakmemory/
[Viewed:June 7, 2010].
[SPA94] SPARC International.The SPARC Architecture Manual, 1994.
[SW95] Richard L. Sites and Richard T. Witek.Alpha AXP Architecture. Digital Press, second edition, 1995.

打賞支援我翻譯更多好文章,謝謝!

打賞譯者

打賞支援我翻譯更多好文章,謝謝!

任選一種支付方式

無鎖資料結構(基礎篇):記憶體柵障 無鎖資料結構(基礎篇):記憶體柵障

相關文章