Memory Consistency Models -- 內存一致性模型


zz http://blog.sina.com.cn/s/blog_6472c4cc0102dvel.html


本章出現的Coherency指Cache Coherency,Consistency指Memory Consistency。許多工程師經常混淆這兩個概念,沒有建立足夠準確的Memory Consistency概念。Consistency與Coherency之間有一定的聯繫,所關注的對象並不等同。

MemoryConsistency的實現需要考慮處理器系統Cache Coherency使用的協議。除了狹義Cache之外,在處理器系統中存在的廣義Cache依然會對Memory Consistency模型產生重大影響。Memory Consistency和Cache Coherency有一定的聯繫,但是並不對等。這兩部分內容相對較爲複雜,可以獨立成篇。有些學者認爲Cache Coherency是Memory Consistency的一部分[55],更爲準確的說是Memory Coherency的一部分。

我們首先給出MemoryCoherency的定義。Memory Coherency指處理器系統保證對其存儲器子系統訪問Correctness。我們並不關注對處理器私有空間的存儲器訪問,僅考慮共享空間的這種情況,即便在這種情況下定義Correctness依然很困難。

在一個DistributedSystem中,共享存儲器空間可能分佈在不同的位置,由於廣義和狹義Cache的存在,這些數據單元存在多個副本;在Distributed System中,不同處理器訪問存儲器子系統可以併發進行,使得Memory Coherency層面的Correctness並不容易保證。

我們假設在一個DistributedSystem中含有n個處理器分別爲P1~Pn,Pi中有Si個存儲器操作,此時從全局上看可能的存儲器訪問序列有(S1+S2+…+Sn)! /(S1! ×S2!×…×Sn)種組合[56]。爲保證Memory Coherency的Correctness,需要按照某種規則選出合適的組合。這個規則被稱爲Memory Consistency Model,也決定了處理器存儲器訪問的Correctness。這個規則需要在Correctness的前提下,保證操作友好度的同時,保證多處理器存儲器訪問較高的並行度。

在不同規則定義之下,Correctness的含義並不相同,這個Correctness是有條件的。在傳統的單處理器環境下,Correctness指每次存儲器讀操作所獲得的結果是Most Recent寫入的結果。在Distributed System中,單處理器環境下定義的Correctness,因爲多個處理器併發的存儲器訪問而很難保證。在這種環境下,即便定義什麼是Most Recent也很困難。

在一個DistributedSystem中,最容易想到的是使用一個Global Time Scale決定存儲器訪問次序,從而判斷Most Recent,這種Memory Consistency Model即爲Strict Consistency,也被稱爲Atomic Consistency。Global Time Scale不容易以較小的代價實現,退而求其次採用每一個處理器的Local Time Scale確定Most Recent的方法被稱爲Sequential Consistency[56]。

與SequentialConsistency要求不同處理器的寫操作對於所有處理器具有一致的Order不同,Causal Consistency要求具有Inter-Process Order的寫操作具有一致的Order,是Sequential Consistency的一種弱化形式。Processor Consistency進一步弱化,要求來自同一個處理器的寫操作具有一致的Order即可。Slow Memory是最弱化的模型,僅要求同一個處理器對同一地址的寫操作具有一致的Order[56]。

以上這些ConsistencyModel針對存儲器讀寫指令展開,還有一類目前使用更爲廣闊的Model。這些Model需要使用Synchronization指令,這類指令也被稱爲Barrier指令。在這種模型之下,存儲器訪問指令被分爲Data和Synchronization指令兩大類。其中Synchronization指令能夠Issue的必要條件是之前的Data指令執行完畢,其他指令在Synchronization指令執行完畢前不能進行Issue。在Synchronization指令之間的存儲器訪問需要依照處理器的約束,可以Reordered也可以Overlapped。

在這種Model中,Data指令的Order並沒有受到關注,所有規則僅針對Synchronization指令起作用,也因此產生了Weak Consistency,Release Consistency和Entry Consistency[55][56]三個主要模型。這些模型將在下文做進一步的說明。

對於不支持NetworkPartitions的Distributed System,可以在實現Strict Data Consistency的同時實現Availability[1]。而對於一個較大規模的Distributed System中,Network Partitions是一個先決條件。例如在一個大型系統中,所使用的Web服務器和數據庫系統已經分佈在世界上的很多角落,Network Partitions已經是一個事實。

Consistency,Availability和Partition-Tolerance三者不可兼得[57],這使得在一個Network Partitions的Distributed System中,必須在Consistency和Availability之間進行Trade-Off,也引出了Eventually Consistent模型[58]。這種模型是另一種Weak Consistency Model,基於一個數據在較長時間內沒有發生更新操作,所有數據副本將最終一致的假設。

EventuallyConsistent在DNS(Domain Name System)系統中得到了較爲廣泛的應用,也是Distributed Storage領域的用武之地。這些內容在Cloud嶄露頭角之後迅速成爲熱點,卻不是本篇重點。我依然相信在Cloud相關領域工作的人必然可以在處理器存儲器子系統的精彩中獲得進一步前進的動力,可能是源動力。

這些內容超出了本書的覆蓋範圍,我們需要對Cache Coherency做進一步說明。從上文中的描述可以發現Memory Consistency關注對多個地址進行的存儲器訪問序列;Cache Coherency單純一些,關注同一個地址多個數據備份的一致性。不難發現Cache Coherency是Memory Coherency的基礎。

CacheCoherency要求寫操作必須最終廣播到參與Cache Coherency的全部處理器中,即Write Propagation;同時要求參與Cache Coherency的處理器所觀察到的對同一個地址的寫操作,必須按照相同的順序進行,即Write Serialization。

WritePropagation有Invalidate-Based和Update-Based兩種實現策略。Invalidate-Based策略的實現首先是確定一次存儲器訪問是否在本地Cache Hit,如果Hit而且當前Cache Block狀態爲廣義的Exclusive/Ownership,不需要做進一步的操作;否則或者在Cache Miss時需要獲得所訪問地址的Exclusive/Ownership。此時進行存儲器訪問的CPU向參與Coherency的所有CPU發送RFO(Read for Ownership)廣播報文,這些CPU需要監聽RFO報文並作出迴應。

如果RFO報文所攜帶的地址命中了其他CPU的Cache Block,需要進一步觀察這個Cache Block所處的狀態,如果這個Cache Block沒有被修改,則可以直接Invalidate;否則需要向發出請求的CPU迴應當前Cache Block的內容,在多數情況下,被修改的Cache Block只有一個數據副本。這種方法在Share-Bus的處理器系統中得到了最廣泛的應用,如果存儲器訪問連續命中本地Cache,命中的Cache Block多處於Exclusive狀態,不需要使用RFO報文,因此不會頻繁地向處理器系統發出廣播操作,適合Write-Back方式。

Update-based策略的實現通常使用Central Directory維護Cache Block的Ownership,在Cache Block Miss時,需要Write Update其他CPU Cache Block存在的副本,可以視網絡拓撲結構同時進行多個副本的同步,即便如此所帶來的Bus Traffic仍較嚴重,適用於使用Directory進行一致性操作的大型系統。如果進一步考慮實現細節中的各類Race Condition,完成這種方式的設計並不容易。

除了Invalidate和Update-Based策略之外,Cache Coherency可以使用Read Snarfing策略。在這種實現方式中,可以在一定程度上避免再次Read被Write-Invalidate的Cache Block時,引發的Miss。當一個CPU讀取一個Data Block時,這個讀迴應除了需要發給這個CPU之外,還需要更新其他CPU剛剛Invalidate的Cache Block。在實現中,其他CPU可以監控這個讀迴應的地址與數據信息,主動更新剛剛Invalidate的數據拷貝[59]。

在參考文獻[59]的模式中,使用Read Snarfing策略可以減少36~60%的Bus Traffic。但是這種方式的實現較爲複雜,目前尚不知在商業處理器是否採用過這樣的實現方式。在學術領域,Wisconsin Multicube模型機曾經使用過Read Snarfing策略[61]。

WriteSerialization的實現需要使用Cache Coherent Protocol和Bus Transaction。類似RFO這樣的廣播報文必不可少。在使用Share Bus和Ring-Bus互連時,較易實現Write Serialization。Directory方式在對同一個地址Cache Block的併發寫時需要使用額外的邏輯處理ACK Conflict,這些邏輯大多設置在Home Agent/Node中。



[1] Availbility指來自任何一個處理器的讀寫請求一定可以獲得Response。


××××××××××××××××××××××××××××××××××××

zz 爲什麼程序員需要關心順序一致性(Sequential Consistency)而不是Cache一致性(Cache Coherence?)

最後一次修改:2010年11月11日

本文所討論的計算機模型是Shared Memory Multiprocessor,即我們現在常見的共享內存的多核CPU。本文適合的對象是想用C++或者Java進行多線程編程的程序員。本文主要包括對Sequential Consistency和Cache Coherence的概念性介紹並給出了一些相關例子,目的是幫助程序員明白爲什麼需要在並行編程時關注Sequential Consistency。

Sequential Consistency(下文簡稱SC)是Java內存模型和即將到來的C++0x內存模型的一個關鍵概念,它是一個最直觀最易理解的多線程程序執行順序的模型。Cache Coherence(下文簡稱CC)是多核CPU在硬件中已經實現的一種機制,簡單的說,它確保了對在多核CPU的Cache中一個地址的讀操作一定會返回那個地址最新的(被寫入)的值。

那麼爲什麼程序員需要關心SC呢?因爲現在的硬件和編譯器出於性能的考慮會對程序作出違反SC的優化,而這種優化會影響多線程程序的正確性,也就是說你用C++編寫的多線程程序可能會得到的不是你想要的錯誤的運行結果。Java從JDK1.5開始加入SC支持,所以Java程序員在進行多線程編程時需要注意使用Java提供的相關機制來確保你程序的SC。程序員之所以不需要關心CC的細節是因爲現在它已經被硬件給自動幫你保證了(不是說程序員完全不需要關心CC,實際上對程序員來說理解CC的大致工作原理也是很有幫助的,典型的如避免多線程程序的僞共享問題,即False Sharing)。

那麼什麼是SC,什麼是CC呢?

1. Sequential Consistency (順序一致性)

SC的作者Lamport給的嚴格定義是:
“… the result of any execution is the same as if the operations of all the processors were executed in some sequential order, and the operations of each individual processor appear in this sequence in the order specified by its program.”

這個概念初次理解起來拗口,不過不要緊,下面我會給出個很直觀的例子幫助理解。

假設我們有兩個線程(線程1和線程2)分別運行在兩個CPU上,有兩個初始值爲0的全局共享變量x和y,兩個線程分別執行下面兩條指令:

初始條件: x = y = 0;

線程 1 線程 2
x = 1; y=1;
r1 = y; r2 = x;

因爲多線程程序是交錯執行的,所以程序可能有如下幾種執行順序:

Execution 1 Execution 2 Execution 3
x = 1;
r1 = y;
y = 1;
r2 = x;
結果:r1==0 and r2 == 1
y = 1;
r2 = x;
x = 1;
r1 = y;
結果: r1 == 1 and r2 == 0
x = 1;
y = 1;
r1 = y;
r2 = x;
結果: r1 == 1 and r2 == 1

當然上面三種情況並沒包括所有可能的執行順序,但是它們已經包括所有可能出現的結果了,所以我們只舉上面三個例子。我們注意到這個程序只可能出現上面三種結果,但是不可能出現r1==0 and r2==0的情況。

SC其實就是規定了兩件事情:
(1)每個線程內部的指令都是按照程序規定的順序(program order)執行的(單個線程的視角)
(2)線程執行的交錯順序可以是任意的,但是所有線程所看見的整個程序的總體執行順序都是一樣的(整個程序的視角)

第一點很容易理解,就是說線程1裏面的兩條語句一定在該線程中一定是x=1先執行,r1=y後執行。第二點就是說線程1和線程2所看見的整個程序的執行順序都是一樣的,舉例子就是假設線程1看見整個程序的執行順序是我們上面例子中的Execution 1,那麼線程2看見的整個程序的執行順序也是Execution 1,不能是Execution 2或者Execution 3。

有一個更形象點的例子。伸出你的雙手,掌心面向你,兩個手分別代表兩個線程,從食指到小拇指的四根手指頭分別代表每個線程要依次執行的四條指令。SC的意思就是說:
(1)對每個手來說,它的四條指令的執行順序必須是從食指執行到小拇指
(2)你兩個手的八條指令(八根手指頭)可以在滿足(1)的條件下任意交錯執行(例如可以是左1,左2,右1,右2,右3,左3,左4,右4,也可以是左1,左2,左3,左4,右1,右2,右3,右4,也可以是右1,右2,右3,左1,左2,右4,左3,左4等等等等)

其實說簡單點,SC就是我們最容易理解的那個多線程程序執行順序的模型。

2. Cache Conherence (緩存一致性)

那麼CC是幹什麼用的呢?這個要詳細說的話就複雜了,寫一本書綽綽有餘。簡單來說,我們知道現在的多核CPU的Cache是多層結構,一般每個CPU核心都會有一個私有的L1級和L2級Cache,然後多個CPU核心共享一個L3級緩存,這樣的設計是出於提高內存訪問性能的考慮。但是這樣就有一個問題了,每個CPU核心之間的私有L1,L2級緩存之間需要同步啊。比如說,CPU核心1上的線程A對一個共享變量global_counter進行了加1操作,這個被寫入的新值存到CPU核心1的L1緩存裏了;此時另一個CPU核心2上的線程B要讀global_counter了,但是CPU核心2的L1緩存裏的global_counter的值還是舊值,最新被寫入的值現在還在CPU核心1上呢!怎麼把?這個任務就交給CC來完成了!

CC是Cache之間的一種同步協議,它其實保證的就是對某一個地址的讀操作返回的值一定是那個地址的最新值,而這個最新值可能是該線程所處的CPU核心剛剛寫進去的那個最新值,也可能是另一個CPU核心上的線程剛剛寫進去的最新值。舉例來說,上例的Execution 3中,r1 = y是對y進行讀操作,該讀操作一定會返回在它之前已經執行的那條指令y=1對y寫入的最新值。可能程序員會說這個不是顯而意見的麼?r1肯定是1啊,因爲y=1已經執行了。其實這個看似簡單的”顯而易見“在多核processor的硬件實現上是有很多文章的,因爲y=1是在另一個CPU上發生的事情,你怎麼確保你這個讀操作能立刻讀到別的CPU核心剛剛寫入的值?不過對程序員來講你不需要關心CC,因爲CPU已經幫你搞定這些事情了,不用擔心多核CPU上不同Cache之間的同步的問題了(感興趣的朋友可以看看體系結構的相關書籍,現在的多核CPU一般是以MESI protocol爲原型來實現CC)。總結一下,CC和SC其實是相輔相承的,前者保證對單個地址的讀寫正確性,後者保證整個程序對多個地址讀寫的正確性,兩者共同保證多線程程序執行的正確性。

3. 爲什麼要關心SC?

好,回到SC的話題。爲什麼說程序員需要關心SC?因爲現在的CPU和編譯器會對代碼做各種各樣的優化,有時候它們可能會爲了優化性能而把程序員在寫程序時規定的代碼執行順序(program order)打亂,導致程序執行結果是錯誤的。

例如編譯器可能會做如下優化,即把線程1的兩條語序調換執行順序:
初始條件: x=y=0;

線程 1 線程 2
r1 = y; y=1;
x = 1; r2 = x;

那麼這個時候程序如果按如下順序執行就可能就會出現r1==r2==0這樣程序員認爲”不正確“的結果:

Execution 4
r1 = y;
y = 1;
r2 = x;
x = 1;

爲什麼編譯器會做這樣的優化呢?因爲讀一個在內存中而不是在cache中的共享變量需要很多週期,所以編譯器就”自作聰明“的讓讀操作先執行,從而隱藏掉一些指令執行的latency,提高程序的性能。實際上這種類似的技術是在單核時代非常普遍的優化方法,但是在進入多核時代後編譯器沒跟上發展,導致了對多線程程序進行了違反SC的錯誤優化。爲什麼編譯器很難保證SC?因爲對編譯器來講它很難知道多個線程在執行時會按照什麼樣的交錯順序執行,因爲這需要一個整個程序運行時的視角,而只對一份靜態的代碼做優化的編譯器是很難得到這種運行時的上下文的。那麼爲什麼硬件也保證不了呢?因爲CPU硬件中的寫緩衝區(store buffer)會把要寫入memory的值緩存起來,然後當前線程繼續往下執行,而這個被緩存的值可能要很晚纔會被其他線程“看見”,從而導致多線程程序邏輯出錯。其實硬件也提供了一些例如Memory Barrier等解決方案,但是開銷是一個比較大的問題,而且很多需要程序員手動添加memory barrier,現在還不能指望CPU或者編譯器自動幫你搞定這個問題。(感興趣的朋友可以在本文的參考文獻中發現很多硬件優化造成SC被違反的例子以及Memory Barrier等解決方案)

好了,我們發現爲了保證多線程的正確性,我們希望程序能按照SC模型執行;但是SC的對性能的損失太大了,CPU硬件和編譯器爲了提高性能就必須要做優化啊!爲了既保證正確性又保證性能,在經過十幾年的研究後一個新的新的模型出爐了:sequential consistency for data race free programs。簡單地說這個模型的原理就是對沒有data race的程序可以保證它是遵循SC的,這個模型在多線程程序的正確性和性能間找到了一個平衡點。對廣大程序員來說,我們依賴高級語言內建的內存模型來幫我們保證多線程程序的正確性。例如,從JDK1.5開始引入的Java內存模型中已經支持data race free的SC了(例如使用volatile關鍵字,atomic變量等),但是C++程序員就需要等待C++0x中新的內存模型的atomic類型等來幫助保證SC了(因爲atomic類型的值具有acquire和release語義,它隱式地調用了memory barrier指令)。什麼意思呢?說簡單點,就是由程序員用同步原語(例如鎖或者atomic的同步變量)來保證你程序是沒有data race的,這樣CPU和編譯器就會保證你程序是按你所想的那樣執行的(即SC),是正確的。換句話說,程序員只需要恰當地使用具有acquire和release語義的同步原語標記那些真正需要同步的變量和操作,就等於告訴CPU和編譯器你們不要對這些標記出來的操作和變量做違反SC的優化,而其它未被標記的地方你們可以隨便優化,這樣既保證了正確性又保證了CPU和編譯器可以做盡可能多的性能優化。來告訴編譯器和CPU這裏這裏你不能做違反SC的優化,那裏那裏你不能做違反SC的優化,然後你寫的程序就會得到正確的執行結果了。

從根源上來講,在串行時代,編譯器和CPU對代碼所進行的亂序執行的優化對程序員都是封裝好了的,無痛的,所以程序員不需要關心這些代碼在執行時被亂序成什麼樣子,因爲這些都被編譯器和CPU封裝起來了,你不用擔心內部細節,它最終表現出來的行爲就是按你想要的那種方式執行的。但是進入多核時代,程序員、編譯器、CPU三者之間未能達成一致(例如諸如C/C++之類的編程語言沒有引入多線程),所以CPU、編譯器就會時不時地給你搗蛋,故作聰明的做一些優化,讓你的程序不會按照你想要的方式執行,是錯誤的。Java作爲引入多線程的先驅從1.5開始支持內存模型,等於是幫助程序員達成了與編譯器、CPU(以及JVM)之間的契約,程序員只要正確的使用同步原語就可以保證程序最終表現出來的行爲跟你所想的一樣(即我們最容易理解的SC模型),是正確的。

本文並未詳細介紹所有針對SC問題的解決方案(例如X86對SC的支持,Java對它的支持,C++對它的支持等等),如果想了解更多,可以參考本文所指出的參考文獻。下一次我會寫一篇關於data race free model, weak ordering, x86 memory model等相關概念的文章,敬請期待。

題外話:

並行編程是非常困難的,在多核時代的程序員不能指望硬件和編譯器來幫你搞定所有的事情,努力學習多核多線程編程的一些基礎知識是很有必要的,至少你應該知道你的程序到底會以什麼樣的方式被執行。

參考文獻:
[1] Hans Boehm: C++ Memory Model
[2] Bill Pugh: The Java Memory Model
[3] Wiki: Cache Coherence
[4] Wiki: Sequential Consistency
[5] The Memory Model of X86 (中文,從硬件角度講SC問題)
[6] 《C++0x漫談》系列之:多線程內存模型

Memory Barriers and JVM Concurrency


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