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

來源:http://www.parallellabs.com/2010/03/06/why-should-programmer-care-about-sequential-consistency-rather-than-cache-coherence/

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


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

閱讀全文>>


最後一次修改: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等相關概念的文章,敬請期待。

題外話:

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


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