1. 程式人生 > >為什麼程式設計師需要關心順序一致性(Sequential Consistency)而不是Cache一致性(Cache Coherence?)

為什麼程式設計師需要關心順序一致性(Sequential Consistency)而不是Cache一致性(Cache Coherence?)

本文所討論的計算機模型是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等相關概念的文章,敬請期待。

題外話:

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