緩存一致性(Cache Coherency)入門

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

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

緩存(Cache)

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

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

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

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

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

若是咱們只處理讀操做,那麼事情會很簡單,由於全部級別的緩存都遵照如下規律,我稱之爲: spa

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

一旦咱們容許寫操做,事情就變得複雜一點了。這裏有兩種基本的寫模式:直寫(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處理器爲內存操做提供了很強的一致性保證——雖然沒有達到完美的順序一致性。

不管如何,一篇文章講這麼多已經夠了。我把它放在個人博客上。個人想法是未來的文章只要引用它就好了。咱們看效果吧。感謝閱讀!

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

相關文章
相關標籤/搜索