緩存一致性

參考原文:http://fgiesen.wordpress.com/2014/07/07/cache-coherency/

  本文是RAD Game Tools程序員Fabian “ryg” Giesen在其博客上發表的《Cache coherency primer》一文的翻譯,經作者許可分享至InfoQ中文站。該系列共有兩篇,本文系第一篇。

  我計劃寫一些關於多核場景下數據組織的文章。寫了第一篇,但我很快意識到有大量的基礎知識我首先需要講一下。在本文中,我就嘗試闡述這些知識。

  緩存(Cache)

  本文是關於CPU緩存的快速入門。我假設你已經有了基本概念,但你可能不熟悉其中的一些細節。(如果你已經熟悉了,你可以忽略這部分。)

  在現代的CPU(大多數)上,所有的內存訪問都需要通過層層的緩存來進行。也有些例外,比如,對映射成內存地址的I/O口、寫合併(Write-combined)內存,這些訪問至少會繞開這個流程的一部分。但這兩者都是罕見的場景(意味着絕大多數的用戶態代碼都不會遇到這兩種情況),所以在本文中,我將忽略這兩者。

  CPU的讀/寫(以及取指令)單元正常情況下甚至都不能直接訪問內存——這是物理結構決定的;CPU都沒有管腳直接連到內存。相反,CPU和一級緩存(L1 Cache)通訊,而一級緩存才能和內存通訊。大約二十年前,一級緩存可以直接和內存傳輸數據。如今,更多級別的緩存加入到設計中,一級緩存已經不能直接和內存通訊了,它和二級緩存通訊——而二級緩存才能和內存通訊。或者還可能有三級緩存。你明白這個意思就行。

  緩存是分“段”(line)的,一個段對應一塊存儲空間,大小是32(較早的ARM、90年代/2000年代早期的x86和PowerPC)、64(較新的ARM和x86)或128(較新的Power ISA機器)字節。每個緩存段知道自己對應什麼範圍的物理內存地址,並且在本文中,我不打算區分物理上的緩存段和它所代表的內存,這聽起來有點草率,但是爲了方便起見,還是請熟悉這種提法。具體地說,當我提到“緩存段”的時候,我就是指一段和緩存大小對齊的內存,不關心裏面的內容是否真正被緩存進去(就是說保存在任何級別的緩存中)了。

  當CPU看到一條讀內存的指令時,它會把內存地址傳遞給一級數據緩存(或可戲稱爲L1D$,因爲英語中“緩存(cache)”和“現金(cash)”的發音相同)。一級數據緩存會檢查它是否有這個內存地址對應的緩存段。如果沒有,它會把整個緩存段從內存(或者從更高一級的緩存,如果有的話)中加載進來。是的,一次加載整個緩存段,這是基於這樣一個假設:內存訪問傾向於本地化(localized),如果我們當前需要某個地址的數據,那麼很可能我們馬上要訪問它的鄰近地址。一旦緩存段被加載到緩存中,讀指令就可以正常進行讀取。

  如果我們只處理讀操作,那麼事情會很簡單,因爲所有級別的緩存都遵守以下規律,我稱之爲:

基本定律:在任意時刻,任意級別緩存中的緩存段的內容,等同於它對應的內存中的內容。

  一旦我們允許寫操作,事情就變得複雜一點了。這裏有兩種基本的寫模式:直寫(write-through)和回寫(write-back)。直寫更簡單一點:我們透過本級緩存,直接把數據寫到下一級緩存(或直接到內存)中,如果對應的段被緩存了,我們同時更新緩存中的內容(甚至直接丟棄),就這麼簡單。這也遵守前面的定律:緩存中的段永遠和它對應的內存內容匹配。

  回寫模式就有點複雜了。緩存不會立即把寫操作傳遞到下一級,而是僅修改本級緩存中的數據,並且把對應的緩存段標記爲“髒”段。髒段會觸發回寫,也就是把裏面的內容寫到對應的內存或下一級緩存中。回寫後,髒段又變“乾淨”了。當一個髒段被丟棄的時候,總是先要進行一次回寫。回寫所遵循的規律有點不同。

回寫定律:當所有的髒段被回寫後,任意級別緩存中的緩存段的內容,等同於它對應的內存中的內容。

  換句話說,回寫模式的定律中,我們去掉了“在任意時刻”這個修飾語,代之以弱化一點的條件:要麼緩存段的內容和內存一致(如果緩存段是乾淨的話),要麼緩存段中的內容最終要回寫到內存中(對於髒緩存段來說)。

  直接模式更簡單,但是回寫模式有它的優勢:它能過濾掉對同一地址的反覆寫操作,並且,如果大多數緩存段都在回寫模式下工作,那麼系統經常可以一下子寫一大片內存,而不是分成小塊來寫,前者的效率更高。

  有些(大多數是比較老的)CPU只使用直寫模式,有些只使用回寫模式,還有一些,一級緩存使用直寫而二級緩存使用回寫。這樣做雖然在一級和二級緩存之間產生了不必要的數據流量,但二級緩存和更低級緩存或內存之間依然保留了回寫的優勢。我想說的是,這裏涉及到一系列的取捨問題,且不同的設計有不同的解決方案。沒有人規定各級緩存的大小必須一致。舉個例子,我們會看到有CPU的一級緩存是32字節,而二級緩存卻有128字節。

  爲了簡化問題,我省略了一些內容:緩存關聯性(cache associativity),緩存組(cache sets),使用分配寫(write-allocate)還是非分配寫(上面我描述的直寫是和分配寫相結合的,而回寫是和非分配寫相結合的),非對齊的訪問(unaligned access),基於虛擬地址的緩存。如果你感興趣,所有這些內容都可以去查查資料,但我不準備在這裏講了。

  一致性協議(Coherency protocols)

  只要系統只有一個CPU核在工作,一切都沒問題。如果有多個核,每個核又都有自己的緩存,那麼我們就遇到問題了:如果某個CPU緩存段中對應的內存內容被另外一個CPU偷偷改了,會發生什麼?

  好吧,答案很簡單:什麼也不會發生。這很糟糕。因爲如果一個CPU緩存了某塊內存,那麼在其他CPU修改這塊內存的時候,我們希望得到通知。我們擁有多組緩存的時候,真的需要它們保持同步。或者說,系統的內存在各個CPU之間無法做到與生俱來的同步,我們實際上是需要一個大家都能遵守的方法來達到同步的目的。

  注意,這個問題的根源是我們擁有多組緩存,而不是多個CPU核。我們也可以這樣解決問題,讓多個CPU核共用一組緩存:也就是說只有一塊一級緩存,所有處理器都必須共用它。在每一個指令週期,只有一個幸運的CPU能通過一級緩存做內存操作,運行它的指令。

  這本身沒問題。唯一的問題就是太慢了,因爲這下處理器的時間都花在排隊等待使用一級緩存了(並且處理器會做大量的這種操作,至少每個讀寫指令都要做一次)。我指出這一點是因爲它表明了問題不是由多核引起的,而是由多緩存引起的。我們知道了只有一組緩存也能工作,只是太慢了,接下來最好就是能做到:使用多組緩存,但使它們的行爲看起來就像只有一組緩存那樣。緩存一致性協議就是爲了做到這一點而設計的。就像名稱所暗示的那樣,這類協議就是要使多組緩存的內容保持一致。

  緩存一致性協議有多種,但是你日常處理的大多數計算機設備使用的都屬於“窺探(snooping)”協議,這也是我這裏要講的。(還有一種叫“基於目錄的(directory-based)”協議,這種協議的延遲性較大,但是在擁有很多個處理器的系統中,它有更好的可擴展性。)

  “窺探”背後的基本思想是,所有內存傳輸都發生在一條共享的總線上,而所有的處理器都能看到這條總線:緩存本身是獨立的,但是內存是共享資源,所有的內存訪問都要經過仲裁(arbitrate):同一個指令週期中,只有一個緩存可以讀寫內存。窺探協議的思想是,緩存不僅僅在做內存傳輸的時候才和總線打交道,而是不停地在窺探總線上發生的數據交換,跟蹤其他緩存在做什麼。所以當一個緩存代表它所屬的處理器去讀寫內存時,其他處理器都會得到通知,它們以此來使自己的緩存保持同步。只要某個處理器一寫內存,其他處理器馬上就知道這塊內存在它們自己的緩存中對應的段已經失效。

  在直寫模式下,這是很直接的,因爲寫操作一旦發生,它的效果馬上會被“公佈”出去。但是如果混着回寫模式,就有問題了。因爲有可能在寫指令執行過後很久,數據纔會被真正回寫到物理內存中——在這段時間內,其他處理器的緩存也可能會傻乎乎地去寫同一塊內存地址,導致衝突。在回寫模型中,簡單把內存寫操作的信息廣播給其他處理器是不夠的,我們需要做的是,在修改本地緩存之前,就要告知其他處理器。搞懂了細節,就找到了處理回寫模式這個問題的最簡單方案,我們通常叫做MESI協議(譯者注:MESI是Modified、Exclusive、Shared、Invalid的首字母縮寫,代表四種緩存狀態,下面的譯文中可能會以單個字母指代相應的狀態)。

  MESI以及衍生協議

  本節叫做“MESI以及衍生協議”,是因爲MESI衍生了一系列緊密相關的一致性協議。我們先從原生的MESI協議開始:MESI是四種緩存段狀態的首字母縮寫,任何多核系統中的緩存段都處於這四種狀態之一。我將以相反的順序逐個講解,因爲這個順序更合理:

失效(Invalid)緩存段,要麼已經不在緩存中,要麼它的內容已經過時。爲了達到緩存的目的,這種狀態的段將會被忽略。一旦緩存段被標記爲失效,那效果就等同於它從來沒被加載到緩存中。
共享(Shared)緩存段,它是和主內存內容保持一致的一份拷貝,在這種狀態下的緩存段只能被讀取,不能被寫入。多組緩存可以同時擁有針對同一內存地址的共享緩存段,這就是名稱的由來。
獨佔(Exclusive)緩存段,和S狀態一樣,也是和主內存內容保持一致的一份拷貝。區別在於,如果一個處理器持有了某個E狀態的緩存段,那其他處理器就不能同時持有它,所以叫“獨佔”。這意味着,如果其他處理器原本也持有同一緩存段,那麼它會馬上變成“失效”狀態。
已修改(Modified)緩存段,屬於髒段,它們已經被所屬的處理器修改了。如果一個段處於已修改狀態,那麼它在其他處理器緩存中的拷貝馬上會變成失效狀態,這個規律和E狀態一樣。此外,已修改緩存段如果被丟棄或標記爲失效,那麼先要把它的內容回寫到內存中——這和回寫模式下常規的髒段處理方式一樣。
  如果把以上這些狀態和單核系統中回寫模式的緩存做對比,你會發現I、S和M狀態已經有對應的概念:失效/未載入、乾淨以及髒的緩存段。所以這裏的新知識只有E狀態,代表獨佔式訪問。這個狀態解決了“在我們開始修改某塊內存之前,我們需要告訴其他處理器”這一問題:只有當緩存段處於E或M狀態時,處理器才能去寫它,也就是說只有這兩種狀態下,處理器是獨佔這個緩存段的。當處理器想寫某個緩存段時,如果它沒有獨佔權,它必須先發送一條“我要獨佔權”的請求給總線,這會通知其他處理器,把它們擁有的同一緩存段的拷貝失效(如果它們有的話)。只有在獲得獨佔權後,處理器才能開始修改數據——並且此時,這個處理器知道,這個緩存段只有一份拷貝,在我自己的緩存裏,所以不會有任何衝突。

  反之,如果有其他處理器想讀取這個緩存段(我們馬上能知道,因爲我們一直在窺探總線),獨佔或已修改的緩存段必須先回到“共享”狀態。如果是已修改的緩存段,那麼還要先把內容回寫到內存中。

  MESI協議是一個合適的狀態機,既能處理來自本地處理器的請求,也能把信息廣播到總線上。我不打算講更多關於狀態圖的細節以及不同的狀態轉換類型。如果你感興趣的話,可以在關於硬件架構的書中找到更多的深度內容,但對於本文來說,講這些東西有點過了。作爲一個軟件開發者,你只要理解以下兩點,就大有可爲:

  第一,在多核系統中,讀取某個緩存段,實際上會牽涉到和其他處理器的通訊,並且可能導致它們發生內存傳輸。寫某個緩存段需要多個步驟:在你寫任何東西之前,你首先要獲得獨佔權,以及所請求的緩存段的當前內容的拷貝(所謂的“帶權限獲取的讀(Read For Ownership)”請求)。

  第二,儘管我們爲了一致性問題做了額外的工作,但是最終結果還是非常有保證的。即它遵守以下定理,我稱之爲:

MESI定律:在所有的髒緩存段(M狀態)被回寫後,任意緩存級別的所有緩存段中的內容,和它們對應的內存中的內容一致。此外,在任意時刻,當某個位置的內存被一個處理器加載入獨佔緩存段時(E狀態),那它就不會再出現在其他任何處理器的緩存中。

  注意,這其實就是我們已經講過的回寫定律加上獨佔規則而已。我認爲MESI協議或多核系統的存在根本沒有弱化我們現有的內存模型。

  好了,至此我們(粗略)講了原生MESI協議(以及使用它的CPU,比如ARM)。其他處理器使用MESI擴展後的變種。常見的擴展包括“O”(Owned)狀態,它和E狀態類似,也是保證緩存間一致性的手段,但它直接共享髒段的內容,而不需要先把它們回寫到內存中(“髒段共享”),由此產生了MOSEI協議。還有MERSI和MESIF,這兩個名字代表同一種思想,即指定某個處理器專門處理針對某個緩存段的讀操作。當多個處理器同時擁有某個S狀態的緩存段的時候,只有被指定的那個處理器(對應的緩存段爲R或F狀態)才能對讀操作做出迴應,而不是每個處理器都能這麼做。這種設計可以降低總線的數據流量。當然你可以同時加入R/F狀態和O狀態,或者更多的狀態。這些都屬於優化,沒有一種會改變基本定律,也沒有一種會改變MESI協議所確保的結果。

  我不是這方面的專家,很有可能有系統在使用其他協議,這些協議並不能完全保證一致性,不過如果有,我沒有注意到它們,或者沒有看到有什麼流行的處理器在使用它們。所以爲了達到我們的目的,我們真的就可以假設一致性協議能保證緩存的一致性。不是基本一致,不是“寫入一會兒後才能保持一致”——而是完全的一致。從這個層面上說,除非硬件有問題,內存的狀態總是一致的。用技術術語來說,MESI以及它的衍生協議,至少在原理上,提供了完整的順序一致性(sequential consistency),在C++ 11的內存模型中,這是最強的一種確保內存順序的模型。這也引出了問題,爲什麼我們需要弱一點的內存模型,以及“什麼時候會用到它們”?

  內存模型

  不同的體系結構提供不同的內存模型。到本文寫作的時候爲止,ARM和POWER體系結構的機器擁有相對較弱的內存模型:這類CPU在讀寫指令重排序(reordering)方面有相當大的自由度,這種重排序有可能會改變程序在多核環境下的語義。通過“內存屏障(memory barrier)”,程序可以對此加以限制:“重排序操作不允許越過這條邊界”。相反,x86則擁有較強的內存模型。

  我不打算在這裏深入到內存模型的細節中,這很容易陷入堆砌技術術語中,而且也超出了本文的範圍。但是我想說一點關於“他們如何發生”的內容——也就是,弱內存模型如何保證正確性(相比較於MESI協議給緩存帶來的順序一致性),以及爲什麼。當然,一切都歸結於性能。

  規則是這樣的:如果滿足下面的條件,你就可以得到完全的順序一致性:第一,緩存一收到總線事件,就可以在當前指令週期中迅速做出響應。第二,處理器如實地按程序的順序,把內存操作指令送到緩存,並且等前一條執行完後才能發送下一條。當然,實際上現代處理器一般都無法滿足以上條件:

緩存不會及時響應總線事件。如果總線上發來一條消息,要使某個緩存段失效,但是如果此時緩存正在處理其他事情(比如和CPU傳輸數據),那這個消息可能無法在當前的指令週期中得到處理,而會進入所謂的“失效隊列(invalidation queue)”,這個消息等在隊列中直到緩存有空爲止。
處理器一般不會嚴格按照程序的順序向緩存發送內存操作指令。當然,有亂序執行(Out-of-Order execution)功能的處理器肯定是這樣的。順序執行(in-order execution)的處理器有時候也無法完全保證內存操作的順序(比如想要的內存不在緩存中時,CPU就不能爲了載入緩存而停止工作)。
寫操作尤其特殊,因爲它分爲兩階段操作:在寫之前我們先要得到緩存段的獨佔權。如果我們當前沒有獨佔權,我們先要和其他處理器協商,這也需要一些時間。同理,在這種場景下讓處理器閒着無所事事是一種資源浪費。實際上,寫操作首先發起獲得獨佔權的請求,然後就進入所謂的由“寫緩衝(store buffer)”組成的隊列(有些地方使用“寫緩衝”指代整個隊列,我這裏使用它指代隊列的一條入口)。寫操作在隊列中等待,直到緩存準備好處理它,此時寫緩衝就被“清空(drained)”了,緩衝區被回收用於處理新的寫操作。
  這些特性意味着,默認情況下,讀操作有可能會讀到過時的數據(如果對應失效請求還等在隊列中沒執行),寫操作真正完成的時間有可能比它們在代碼中的位置晚,一旦牽涉到亂序執行,一切都變得模棱兩可。回到內存模型,本質上只有兩大陣營:

  在弱內存模型的體系結構中,處理器爲了開發者能寫出正確的代碼而做的工作是最小化的,指令重排序和各種緩衝的步驟都是被正式允許的,也就是說沒有任何保證。如果你需要確保某種結果,你需要自己插入合適的內存屏障——它能防止重排序,並且等待隊列中的操作全部完成。

  使用強一點的內存模型的體系結構則會在內部做很多記錄工作。比如,x86會跟蹤所有在等待中的內存操作,這些操作都還沒有完全完成(稱爲“退休(retired)”)。它會把它們的信息保存在芯片內部的MOB(“memory ordering buffer”,內存排序緩衝)。x86作爲部分支持亂序執行的體系結構,在出問題的時候能把尚未“退休”的指令撤銷掉——比如發生頁錯誤(page fault),或者分支預測失敗(branch mispredict)的時候。我已經在我以前的文章“好奇地說”中提到過一些細節,以及和內存子系統的一些交互。主旨是x86處理器會主動地監控外部事件(比如緩存失效),有些已經執行完的操作會因爲這些事件而被撤銷,但不算“退休”。這就是說,x86知道自己的內存模型應該是什麼樣子的,當發生了一件和這個模型衝突的事,處理器會回退到上一個與內存模型兼容的狀態。這就是我在以前另一篇文章中提到的“清除內存排序機(memory ordering machine clear)”。最後的結果是,x86處理器爲內存操作提供了很強的一致性保證——雖然沒有達到完美的順序一致性

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章