記憶體一致性模型(Memory Consistency Models),consistencymodels
譯註:電腦早已進入了多核時代,多核時代要求程式員能夠編寫並行的程式來充分發揮多處理器的功效。而編寫並行/並發程式必須要對記憶體模型有所瞭解。因此本人特翻譯了一篇有關記憶體模型綜述性質的文章。初次翻譯文章,錯誤在所難免,還望指教。原文地址:http://www.cs.nmsu.edu/~pfeiffer/classes/573/notes/consistency.html
註:有一個很好的關於記憶體一致性模型的教程在 ftp://gatekeeper.dec.com/pub/DEC/WRL/research-reports/WRL-TR-95.7.pdf上。本文大量參考了那篇論文。
記憶體一致性模型 (Memory Consistency Models)
本文對最近幾年出現的幾種比較重要的記憶體一致性模型進行了描述。最基本的想法是去闡述清楚:試圖去實現我們頭腦中對“記憶體一致性”概念最直覺的理解,是如此的困難並且特別的昂貴,同時也是沒有必要的(and isn't necessary to get a properly written parallel program to run correctly.)。因此我們試圖給出比直覺理解的“記憶體一致性”要弱化一些的其它的記憶體一致性模型,使得它更容易去實現,同時仍然允許我們來編寫並行的程式,並且會程式按照我們的預期來正確的運行。
記號(Notation)
在描述這些記憶體模型時,我們只對共用記憶體(也就是線程或進程間的共用變數)感興趣 --- 而不是任何其它與程式有關的東西。我們不涉及程式的控制流程、資料的操作、局部變數(非共用的)。我們用一個標準的記號來描述記憶體模型,我們下面將用到它。在這個記號中,用一條直線來表示系統中每一個處理器的運行,時間從左邊開始到右邊。每一個共用記憶體的操作我們把它寫在處理器的直線上。兩個主要的操作為“讀”和“寫”,用下面的運算式表示:W(var) value該運算式的含義為:將值value寫到共用變數var中,而R(var) value該運算式的含義為:讀取共用變數var,擷取它的值value.比如:W(x)1 表示:將1寫到x中,而R(y)3表示:讀取變數y的值3.更多的操作(特別是同步操作)在需要的時候我們再來定義他的記號。為簡單起見,假設所有的變數都初始化為0.我們要特別注意一點,在進階語言中的一條語句(比如x = x + 1;)通常將會涉及到幾次記憶體操作。如果x之前為0,則那條進階語言中的語句將變成(不考慮其它處理器):P1:R(x)0 W(x)1------------------------------在一個RISC類型的處理器中,那條C語句很可能變成3條指令:一條載入指令(譯註:載入變數x到寄存器)、一條加1指令(譯註:將寄存器中的x加1)、一條儲存指令(譯註:將寄存器中的x放回記憶體),對記憶體進行了兩次操作。在一個CISC類型的處理器中,那條語句很可能轉換成一條指令,即記憶體add指令。即使如此,處理器仍然會按照“讀記憶體”、“加1”、“寫記憶體”的方式來執行那條指令。所以它仍然涉及到兩次記憶體操作。
注意到實際的記憶體操作的執行可能很好的等價於一些完全不同的進階語言的代碼的執行;也許一條if-then-else語句將會測試一個標誌flag,然後設定標誌flag。如果我問到記憶體操作,而你的答案中有一些像是轉化或者一些資料什麼的之類的東西,那麼就不太妙了。(Notice that the actual memory operations performed could equally well have been performed by some completely different high level language code; maybe an if-then-else statement that checked and then set a flag. If I ask for memory operations and there is anything in your answer that looks like a transformation or something of the data, then something is wrong!)
嚴格一致性記憶體模型 / 記憶體嚴格一致性模型 (Strict Consistency)
對“記憶體一致性”概念的最直覺的理解,我們得到就是“嚴格一致性的記憶體模型”。在這種嚴格一致性記憶體模式下:任何對一個記憶體位置X的讀操作,將返回最最近的一次對該記憶體位置的寫操作所寫入的值。如果我們有很多處理器,沒有緩衝,通過一條匯流排來訪問記憶體,那麼就我們得到(或者符合)了“嚴格一致性記憶體模型”。這裡最關鍵的是精確地序列化所有對記憶體的訪問。我們用一個例子來說明什麼是“嚴格一致性記憶體模型”,什麼不是,同時也給出一個關於如何用記號來表示記憶體操作的例子。就像我們前面說過的,我們假定所有的變數有一個為0的初始值。下面的例子就是一個符合“嚴格一致性記憶體模型”的情境。P1:W(x)1----------------------------------------------P2: R(x)1 R(x)1這表示,“處理器P1將1寫到變數x中;一段時間之後處理器P2讀取x的值1。然後再讀取一次,獲得相同的值。我們再給出一個符合”記憶體嚴格一致性模型”的情境:P1: W(x)1----------------------------------------------P2:R(x)0 R(x)1這一次,處理器P2先執行,它先讀取x的值0,當它第二次讀取x時卻獲得了處理器P1寫入的x的值1。注意這兩個情境能夠通過將同一個程式在同一個處理器上執行兩次而獲得。我們給出一個不符合“記憶體嚴格一致性模型”的情境:P1: W(x)1--------------------------------------------P2: R(x)0 R(x)1這個例子中,當處理器P2第一次讀取x的值時,它並沒讀到處理器P1在之前對x所寫入的值1,但是它最終還是讀取到了他的值。我們稱這種模型為“記憶體原子一致性”(atomic consistency)。
順序一致性 / 記憶體順序一致性模型 / 順序一致性記憶體模型(Sequential Consistency)http://www.sigma.me/2011/05/06/sequential-consistency.html
順序一致性記憶體模型是一個比嚴格一致性記憶體模型稍微弱化一點的模型。它被Lamport定義為:“(並發程式在多處理器上的)任何一次執行結果都相同,就像所有處理器的操作按照某個順序執行,各個微處理器的操作按照其程式指定的順序進行。”本質上,程式在“嚴格一致性記憶體模型”中產生的任何執行順序在“順序一致性記憶體模型”也都是合法的執行順序,當然不考慮處理器的速度。這裡的想法是,通過從“實際發生的讀寫集合恰好是可能發生的讀寫集合”進行擴充開來,我們能夠更有效對該程式進行各種推理(因為我們能夠詢問更加有用的問題,“這個程式曾經會可能被broken過嗎?”)。我們能夠對程式本身進行推理,而幾乎不受運行我們程式的硬體的細節的幹擾。可以很公平的說,如果我們有一台真正採用嚴格一致性記憶體模型的電腦系統,那麼我們將可以推理出它使用順序一致性記憶體模型是的各種情況。(It's probably fair to say that if we have a computer system that really uses strict consistency, we'll want to reason about it using sequential consistency.)
上面的第三個情境在順序一致性記憶體模型中是合法的。下面是另一個合法的順序一致性記憶體模型的執行情境:P1: W(x)1----------------------- P2: R(x)1 R(x)2 ----------------------- P3: R(x)1 R(x)2----------------------- P4: W(x)2
這個情境是合法的順序一致性記憶體模型的原因是,下面的交替操作在嚴格一致性記憶體模型中將會是合法的:P1: W(x)1 -------------------------------------P2: R(x)1 R(x)2 -------------------------------------P3: R(x)1 R(x)2 -------------------------------------P4: W(x)2下面是一個不符合順序一致性記憶體模型的情境:P1: W(x)1--------------------------------------------------------P2: R(x)1 R(x)2--------------------------------------------------------P3: R(x)2 R(x)1--------------------------------------------------------P4: W(x)2很奇怪,Lamport給出的精確定義,甚至沒有要求維持普通的應果關係的觀念。在一個寫操作發生之前看到該寫操作的結果是可能的,比如:(Oddly enough, the precise definition, as given by Lamport, doesn't even require that ordinary notions of causality be maintained; it's possible to see the result of a write before the write itself takes place, as in:)P1: W(x)1-------------------------------P2: R(x)1這是合法的,因為存在一個在嚴格一致性記憶體模型中的執行順序,可能會掛起(yield)處理器P2直到它的值為1。這並不是該模型的一個缺點;如果你的程式確實可以違背這樣的應果關係,那麼在你的程式中漏掉了一些同步操作(if your program can indeed violate causality like this, you're missing some synchronization operations in your program.)。直到現在我們還沒有談論到同步操作;不過馬上就到了。
緩衝一致性(Cache Coherence)
許多研究者幾乎將緩衝一致性(Cache Coherence)看作是順序一致性的同義字;但是它們不是的,這也許讓人感到驚訝。順序一致性要求一個從全域(也就是所以記憶體)一致性的角度看待記憶體操作,緩衝一致性僅僅要求一個局部的(也就是單個記憶體位址)一致性。這裡有一個例子,它給出的情境符合緩衝一致性,但是不符合順序一致性:P1: W(x)1 W(y)2-----------------------------------------------P2: R(x)0 R(x)2 R(x)1 R(y)1-----------------------------------------------P3: R(y)0 R(y)1 R(x)0 R(x)1-----------------------------------------------P4: W(x)2 W(y)1處理器P2和處理器P3都看到了處理器P1對x的寫操作發生在處理器P4對x的寫操作之後(實際上處理器P3更本沒有看到處理器P4對x的寫操作),看到處理器P4對y的寫操作發生在處理器P1對y的寫操作之後(這一次,處理器P3更本沒有看到處理器P1對y的寫操作。)但是處理器P2看到處理器P4對y的寫操作發生在處理器P1對x的寫操作之後,而處理器P3卻看到處理器P1對x的寫操作發生在處理器P4對y的寫操作之後。這在一個基於匯流排偵聽緩衝一致性協議(snoopy-cache based)的系統中是不可能發生的。但是它確實會發生在一個基於目錄(directory-based)的緩衝一致性協議的系統中。
我們真的需要一個如此強的記憶體模型嗎?(Do We Really Need Such a Strong Model?)
考慮下面的發生在一個共用記憶體的多處理器中的情況:進程運行在兩個處理器上,每一個進程改變共用變數x的值,就像這樣: P1 P2 x = x + 1; x = x + 2;會發生什麼呢?沒有任何附加的資訊,存在4種不同的執行順序,會導致3中不同的結果:P1先執行 --- x獲得的值會是3。P2先執行 --- x獲得的值會是3。P1和P2都讀取了x的值,P1先執行對x的寫操作 --- x獲得的值會是2。P1和P2都讀取了x的值,P2先執行對x的寫操作 --- x獲得的值會是1。我們可以相當容易而簡潔的描述這樣的程式:它有bug。更加準確一點,我們說它有一個“資料競爭”(data race):一個變數被多個進程(線程)修改,而結果決定與那個進程(線程)先執行。為了讓這個程式變得可靠,我們必須使用鎖來保證,其中的一個進程(線程)在另一個進程(線程)開始之前先執行完整個操作過程。
那麼,如果我們的程式存在“資料競爭”,並且程式的行為是不可預測的,但是如果所有的處理器以相同的順序看到所有的修改,這真的有關係嗎(重要嗎)?試圖去達到“嚴格一致性”或者“順序一致性”也許會被看作是試圖去支援有bug的程式的語義 --- 因為程式的結果是隨機的,為什麼我們要關心它是否會獲得正確的隨機值呢?但是它會變得更糟,就像我們下一節所要描述的。(Attempting to achieve strict or sequential consistency might be regarded as trying to support the semantics of buggy programs -- since the result of the program is random anyway, why should we care whether it results in the right random value? But it gets worse, as we consider in the next sections...)
最佳化與一致性(Optimizations and Consistency)
即使我們寫的程式沒有bug,但是編譯器大體上實際並不支援順序一致性(編譯器大體上並不知道其它處理器的存在,更不要說什麼一致性記憶體模型了。我們可以爭論說,也許這說明了語言對並行語義的需要,只要程式員要去用C和Java來寫並行程式,那麼我們將必須去支援它們。)大多數的語言支援一種語義,在這種語義中程式的執行順序是通過每一個記憶體位址來維護的,但是不能跨記憶體位址;這就給了編譯器重排代碼順序的自由。因此,比如,如果一個程式寫兩個變數x和y,這兩個變數相互之間沒有依賴關係,那麼編譯器就有自由(權利)去以任意的順序來執行對這兩個變數的寫操作而不影響程式的正確性。然而,在一個並行的環境中,很可能一個在其它一些處理器上啟動並執行程式確實依賴於x和y變數的寫操作發生的順序。兩個互斥的進程是一個很好的例子。進入臨界區的代碼如下所示:flag[i] = true;trun = 1 - i;while(flag[1 - i] && (turn == (1 - i))) ;如果編譯器決定(不管以什麼理由)交換對flag[i]和trun的寫操作的順序,這在單進程環境中是完全正確的代碼,但是在多進程環境中卻會導致失敗(這是有關係的情況)。更糟糕的是,因為處理器支援亂序執行,就不會保證程式的機器碼將會按照指定的順序來執行對記憶體的訪問!更糟糕的是,因為處理器和緩衝的緊密耦合,所以處理器會進行更加具有進取性(更加嚴重)的指令重排,這種類型的最佳化幾乎沒有辦法阻止或者進行控制(很容易想到,處理器已經完成了對turn的更新,但是它仍然在設定上面的flag[i]的值,因為訪問flag[i]涉及到了對數組的訪問)。但是情況還不至於特別糟糕,因為我們能夠要求我們的編譯器按照程式指定的順序來完成對共用記憶體的訪問(關鍵字volatile專門用於此)。以Intel處理器為例,我們也能夠通過帶有lock首碼的指令來強制指定記憶體的訪問順序。但是要注意到我們只在我們關心代碼的精確執行順序的地方使用這些關鍵字和前置。下面的記憶體模型將擴充這一點。
處理器一致性(Processor Consistency)
該模型也被稱為PRAM(流水線隨機存取記憶體的縮寫,而不是並行隨機存取機器模型可計算理論)一致性。該模型的定義為:在單一一個處理器上完成的所有寫操作,將會被以它實際發生的順序通知給所有其它的處理器,但是在不同處理器上完成的寫操作也許會被其它不同的處理器以不同於它實際執行的順序所看到。基本的想法是“處理器一致性“可以更好的反映真實的網路 --- 網路中不同節點的延遲可能是不相同的。在順序一致性一節中最後的一個情境(如下所示),它不是合法的順序一致性,但是是合法的處理器一致性:P1: W(x)1--------------------------------------------------------P2: R(x)1 R(x)2--------------------------------------------------------P3: R(x)2 R(x)1--------------------------------------------------------P4: W(x)2下面說明它是如何產生的,在一個用比匯流排更複雜的東西串連的多處理器的機器中:1. 處理器以線性數組的方式串連: P1 --- P2 --- P3 --- P42. 在第一次迴圈中,P1和P4進行寫操作,並將它們的操作通知給其它的處理器。3. 在第二次迴圈中,P1寫入的值1通知到了P2,P4寫入的值2通知到了P3。然後P2和P3進行讀操作,所以P2看到x=1, 而P3看到x=2。4.在第三次迴圈中,P1寫入的值1通知到了P3,P4寫入的值2通知到了P2。然後P2和P3進行讀操作,所以P2看到x=2, 而P3看到x=1。(所以我們就得到所示的情況。)因此,你可以看到我們符合了“處理器一致性”定義的關鍵區段(要求所有其它處理器有序的看到某個單一處理器上的寫操作):P1和P4各自進行了一次寫操作,P2和P3分別有序的看到了P1和P4的寫操作(譯註:因為P2離P1更近,所以它先看到P1的寫操作,然後才看到P4的寫操作;同理,P3離P4更近,所以P3先看到P4的寫操作,然後才看到P1的寫操作)。然而這個例子的關鍵點是,它說明了“處理器一致性”的定義違反了我們直覺:P2和P3所看到的P1和P4的寫操作發生的順序是不想同的。(譯註:所以說它違反了“順序一致性”。)
下面給出一個違反“處理器一致性”的情境:P1:W(x)1 W(x)2-------------------------------------------P2: R(x)2 R(x)1處理器P2所看到的P1對x的兩次寫操作的順序,與P1對x的兩次寫操作的實際順序不同!因此我們得到了一個結論:兩個進程(線程)之間的互斥代碼(臨界區代碼)會被“處理器一致性”所破壞掉!
最後關於“處理器一致性”和“PARAM一致性”要說明的一點是:一些研究者試圖通過要求PC同時符合“PARAM一致性“和”緩衝一致性“,來得到一個比“PARAM一致性”稍微強一點的“處理器一致性”。
同步記憶體訪問與普通記憶體訪問(Synchronization Access vs. Ordinary Access)
一種正確編寫具有共用記憶體變數的並行程式的方法是使用互斥的方法來保護要訪問的共用記憶體變數。在上面第一個有bug的例子中,我們通過加鎖能夠得到確定性行為代碼,我們用S表示相關的同步操作。 P1 P2x = x + 1; S; S; x = x + 2;一般來說,在正確的並行程式中,我們先獲得對一系列共用變數的互斥的訪問,然後就可以按照我們的要求來進行處理,之後退出互斥訪問,向系統其它的部分分發修改之後的共用變數的值。其它的處理器沒有必要看到值得中間結果;它們僅僅只要看到最終的結果就可以了。
有了這樣的想法之後,讓我們更加仔細的審視各種不同類型的共用記憶體模型。給出了各種記憶體訪問模型的分類圖[Gharachorloo]: 共用訪問 | ------------------------------------------ 競爭 無競爭 | -------------------------- 同步 非同步 | --------------------- 獲得鎖 釋放鎖
各種記憶體訪問的定義如下:共用訪問(Shared Access) 實際上,除了對變數的共用訪問方式之外,還存在私人訪問方式。但是私人訪問方式與我們要現在討論的問題無關,所以我們僅僅考慮共用訪問。競爭與非競爭(Competing vs. Non-Competing) 如果我們有兩個不同的處理器要訪問同一個變數,其中至少有一個是寫操作,那麼這就是競爭訪問。因為最終的結果取決於哪一個處理器先訪問該變數(如果兩個處理器都是對該變數進行讀操作,那麼誰先訪問,誰後訪問是沒有關係的)。同步與非同步(Synchronizing vs. Non-Synchroning) 普通的競爭訪問,比如變數訪問,都是非同步的訪問。當然在同步進程之間的訪問肯定是同步的訪問。獲得鎖與釋放鎖(Acquire vs. Release) 最後,我們把同步訪問分為兩步:獲得鎖和釋放鎖。
記住比起競爭訪問來,同步訪問應該是很少見的(如果你所有的時間都花在同步訪問上,那麼一定是你的程式有問題!),因此通過區別對待同步訪問和其它的訪問形式,我們可以進一步弱化我們的記憶體訪問模型。
弱一致性(Weak Consistency)
如果我們僅僅將競爭訪問分為同步與非同步訪問,並且同時要求符合下列條件,那麼我們就得到了“弱一致性”:1.對同步變數的訪問是“順序一致性”的2.直到之前對所有同步變數的寫操作完成之後,才允許訪問這些同步變數。3.直到之前對同步變數的訪問完成之後,才允許我們訪問(讀或寫)這些同步變數。
下面給出一個符合“弱一致性”的情境,顯示了“弱一致性”的真正用途:P1: W(x)1 W(x)2------------------------------------------------------P2: R(x)0 R(x)2 S R(x)2------------------------------------------------------P3: R(x)1 S R(x)2
換一句話說,根本沒有要求一個處理器廣播它對變數的修改,直到一個同步訪問的發生。在一個基於網路而不是匯流排的分布式系統中,這能夠極大的減少資訊的互連(注意到,在現實中沒有人會故意寫一個具有這樣行為的程式;你絕對不想去讀一個別人正在更新的變數。讀操作必鬚髮生在S之後。我提到過一些同步演算法,比如鬆弛演算法,它不要求記憶體一致性的概念。這些演算法在“弱一致性”系統中不能工作,因為在“弱一致性”系統中延遲了資料的交流直到同步點。)
釋放一致性(Release Consistency) 單一的同步訪問類型要求做到:當一個同步發生時,我們必須更新所有的記憶體 --- 我們自己局部的對共用變數修改需要通知給其它的處理器,通過複製的方式,同時我們必須要獲得其它處理器的修改。“釋放一致性”僅僅關注被鎖住的共用記憶體記憶體變數,僅僅只需要將對被鎖住的共用變數的修改通知給其它的處理器。它的定義如下:1.在對一個共用變數進行普通訪問之前,進程在之前所有的獲得鎖而進行的操作必須成功的完成。2.在釋放一個鎖操作之前,進程的所有之前的讀和寫操作必須已經完成。3.獲得和釋放鎖的操作必須符合“順序一致性”。
結語(One Last Point)
相當明顯,一個同步訪問是相當重量級的操作,因為它要求整個記憶體同步。但是為什麼會產生這麼多的記憶體模型呢?那是因為一個基本的事實:這些記憶體模型下的同步操作的代價畢竟要比要求每一次記憶體訪問(不管是共用變數還是局部變數,不管是讀還是寫操作)都符合“順序一致性”的代價要小。(But where the strength of these memory models comes is that the cost of these sync operations isn't any worse than the cost of every memory access in a sequentially consistent system.)
參考資料:(References)
Gharachorloo, K., D. Lenoski, J. Ludon, P. Gibbons, A. Gupta, and J. Hennessy, ``Memory consistency and event ordering in scalable shared-memory multiprocessors,'' in Proceedings of the 17th International Symposium on Computer Architecture (1990) 15-26