內存模型 & C++11的memory order

 

  做個矯正  直到我滿意。。。


《深入理解c++ 11》6.3.3節, by. Michael Wong

鞭辟入裏,耐心學習.... ::


如果只是簡單地想在線程間進行數據的同步的話,原子類型已經爲程序員已經提供了一些同步的保障。不過這樣做的安全性卻是建築於一個假設之上,即所謂的順序一致性(sequentialconsistent)的內存模型(memorymodel)。要了解順序一致性以及內存模型,我們不妨看看如下代碼清單6-21所示的例子:

在代碼清單6-21中,我們創建了兩個線程t1和t2,分別執行ValueSet和Observer函數。在ValueSet中,爲a和b分別賦值1和2。
而在Observer中,只是打印出a和b的值。可以想象,由於Observer打印a和b的時間與ValueSet設置a和b的時間可能有多種組合方式,因此Obsever可能打印出(0,0),或者(1,2),(1,0)這樣的結果。不過無論Observer打印了什麼,在兩個線程結束後再打印a和b的值,總會得到(1,2)這樣的結果。

雖然Observer可能打印出a、b的3種組合,但這裏如果Observer打印出(0,2)這樣的值是否合理呢?按照通常的程序是順序執行的理解,(0,2)應該不是合理的輸出。這從圖6-4中也可以直觀地看到,a的賦值語句a=t總是先於b的賦值語句b=2執行的,這是一個合乎情理的假設,但對於本例卻並不重要。

Observer的編寫者只是試圖一窺線程ValueSet的執行狀況,不過這種窺看相比於結果——線程結束後a和b的值總是(1,2)而言,
並不是必須的。也就是說,在本例的假定下,a、b的賦值語句在ValueSet中誰先執行誰後執行並不會對程序的執行產生影響,因此說執行順序是不重要的。

這一點假設雖然看似並不起眼,但對於編譯器(甚至是處理器,下面我們會解釋)來說非常重要。通常情況下,如果編譯器認定a、b的賦值語句的執行先後順序對輸出結果有任何的影響的話,則可以依情況將指令重排序(reorder)以提高性能。而如果a、b賦值語句的執行順序必須是a先b後,則編譯器則不會執行這樣的優化。

 

如果我們假定,所有的原子類型的執行順序都無關緊要,那麼在多線程情況下就可能發生嚴重的錯誤。我們來看看代碼清單6-22所示的例子:

在代碼清單6-22中,Thread2函數所在線程一開始總是在自旋等待,直到b的值被賦值爲2,它纔會繼續執行打印a的指令。如果這裏,我們假設Thread1中a的賦值語句的執行被重排序到b的賦值語句之後的話,那麼Thread2則可能打印出a的值爲0。這與程序員的看見的代碼執行順序完全背離,而一旦發生這樣的情況,程序員也很難想象居然這是編譯器(或者處理器)改變了代碼的執行順序而導致錯誤。
因此爲了避免這樣的錯誤,在多線程情況下,非常有必要保證原子變量a的賦值語句先於原子變量b的賦值語句發生。

 

實際上默認情況下,在C++11中的原子類型的變量在線程中總是保持着順序執行的特性(非原子類型則沒有必要,因爲不需要在線程間進行同步) 我們稱這樣的特性爲“順序一致”的,即代碼在線程中運行的順序與程序員看到的代碼順序一致,a的賦值語句永遠發生於b的賦值語句之前。這樣的“順序一致”能夠最大限度地保證程序的正確性

如同我們在代碼清單6-22中看到的一樣,a的賦值語句先於b的賦值語句發生,這樣的“先於發生”(happens-before)關係必須得到遵守,否則可能導致嚴重的錯誤。

不過偏偏在代碼清單6-21中我們又看到了相反的例子,ValueSet中的a、b賦值語句的執行順序並不重要。如果我們能夠允許編譯器(處理器)在單個線程中打亂指令的運行順序,即不遵守先於發生的關係的話,則有可能進一步並行程序的性能。

那麼有沒有辦法讓一些代碼遵守先於發生的關係,而另外一部分的代碼不遵守呢?在C++11中,這是完全可能呢。
不過語言的設計者的考量遠遠多過於這一點。更爲確切地,他們對各種平臺、處理器、編程方式都進行了考量,總結出了不同的“內存模型”。事實上,順序一致只是屬於C++11中多種內存模型中的一種。而在C++11中,並不是只支持順序一致單個內存模型的原子變量,
因爲順序一致往往意味着最低效的同步方式


 

要使用C++11中更爲高效的原子類型變量的同步方式,我們先要了解一些處理器和編譯器相關的知識。

通常情況下,內存模型 通常是一個硬件上的概念,表示的是機器指令(或者讀者將其視爲彙編語言指令也可以)是以什麼樣的順序被處理器執行的。現代的處理器並不是逐條處理機器指令的,我們可以看看下面這個段僞彙編碼:

這裏我們演示了“t=1;a=t;b=2;”這段C++語言代碼的僞彙編表示。按照通常的理解,指令總是按照1->2->3->4->5這樣順序執行,如果處理器的執行順序是這樣的話,我們通常稱這樣的內存模型爲強順序的(strongordered)。可以看到,在這種執行方式下,指令3的執行(a的賦值)總是先於指令5(b的賦值)發生。

不過這裏我們看到,指令1、2、3和指令4、5運行順序上毫無影響(使用了不同的寄存器,以及不同的內存地址),一些處理器就有可能將指令執行的順序打亂,比如按照1->4->2->5->3這樣順序(通常這樣的執行順序都是超標量的流水線,即一個時鐘週期裏發射多條指令而產生的)。如果指令是按照這個順序被處理器執行的話,我們通常稱之爲 弱順序的(weakordered)。而在這種情況下,指令5(b的賦值)的執行可能就被提前到指令3(a的賦值)完成之前完成。

注意: 事實上,一些弱內存模型的構架比如PowerPC,其寫回操作是不能夠被亂序的,這裏只是一個幫助讀者理解的示例,並非事實

 


那麼在多線程情況下,強順序和弱順序又意味着什麼呢?我們知道,多線程的程序總是共享代碼的那麼強順序意味着:對於多個線程而言,其看到的指令執行順序是一致的。具體地,對於共享內存的處理器而言,需要看到內存中的數據被改變的順序與機器指令中的一致。反之,如果線程間看到的內存數據被改變的順序與機器指令中聲明的不一致的話,則是弱順序的。

比如在我們的僞彙編中,假設運行的平臺遵從的是一個弱順序的內存模型的話,那麼可能線程A所在的處理器看到指令執行順序是先3後5,而線程B以爲指令執行的順序依然是先5後3,那麼反饋到代碼清單6-22的源代碼中,我們就有可能看Thread2打印出的a的值是0了。

 

在現實中,x86以及SPARC(TSO模式)都被看作是採用強順序內存模型的平臺。對於任何一個線程而言,其看到原子操作(這裏都是指數據的讀寫)都是順序的。而對於是採用弱順序內存模型的平臺,比如Alpha、PowerPC、Itanlium、ArmV7這樣的平臺而言,如果要保證指令執行的順序,通常需要由在彙編指令中加入一條所謂的內存柵欄(memorybarrier)指令。

比如在PowerPC上,就有一條名爲sync的內存柵欄指令。該指令迫使已經進入流水線中的指令都完成後處理器才執行sync以後指令(排空流水線)。這樣一來,sync之前運行的指令總是先於sync之後的指令完成的。比如我們可以這樣來保證我們僞彙編中的指令3的執行先於指令5:

sync 指令對高度流水化的 PowerPC 處理器的性能影響很大, 因此, 如果可以不順序提交語句的運行結果的話, 則可以保證弱順序內存 模型的處理器保持 較高的 流水線吞吐率( throughput)和 運行時 性能。

 

Note:爲什麼會有弱順序的內存模型?
簡單地說,弱順序的內存模型可以使得處理器進一步發掘指令中的並行性,使得指令執行的性能更高。

 

Note:爲什麼我們只關心讀寫操作的執行順序問題?
這是由處理器的設計決定的,通常情況下,處理器總是從內存中讀出數據進行運算,再將運行結果又返回內存,因此內存中的數據是一個“準繩”,相對的,寄存器中的內容則是“臨時量”。所以在多核心處理器上,核心往往都有全套的寄存器來分別存儲臨時量,而數據交流總是以內存中的數據爲準。這麼一來,一些寄存器中的運算(比如僞彙編中的指令2)就不會被多處理器關注,處理器只關心讀寫等原子操作指令的順序。



以上都是硬件上一些可能的內存模型的描述。而C++11中定義的內存模型和順序一致性跟硬件的內存模型的強順序、弱順序之間有着什麼樣的聯繫呢?


事實上,在高級語言和機器指令間還有一層隔離,這層隔離是由編譯器來完成的。如我們之前描述的,編譯器出於代碼優化的考慮,會將指令前後移動,以獲得最佳的機器指令的排列及產生最佳的運行時性能。

那麼對於C++11中的內存模型而言,要保證代碼的順序一致性,就必須同時做到以下幾點:
1) 編譯器保證原子操作的指令間順序不變,即保證產生的讀寫原子類型的變量的機器指令與代碼編寫者看到的是一致的。
2) 處理器原子操作的彙編指令的執行順序不變。
 

這對於x86這樣的強順序的體系結構而言,並沒有任何的問題;而對於PowerPC這樣的弱順序的體系結構而言,則要求編譯器在每次原子操作後加入內存柵欄。

如前文所述,在C++11中,原子類型的成員函數(原子操作)總是保證了順序一致性。這對於x86這樣的平臺來說,禁止了編譯器對原子類型變量間的重排序優化;  而對於PowerPC這樣的平臺來說,則不僅禁止了編譯器的優化,插入了大量的內存柵欄。這對於意圖是提高性能的多線程程序而言,無疑是一種性能傷害。

具體而言,對於代碼清單6-21中ValueSet這樣的不需要遵守a、b賦值語句“先於發生”關係的程序而言,由於atomic默認的順序一致性則會在對a、b的賦值語句間加入內存柵欄,並阻止編譯器優化,這無疑會增加並行開銷(內存柵欄尤其如此)。那麼解除這樣的性能約束也勢在必行。
 

在C++11中,設計者給出的解決方式是讓程序員爲原子操作指定所謂的內存順序:memory_order。比如在代碼清單6-21(不需要遵守先於發生關係)中,就可以採用一種鬆散的內存模型(relaxed memory model)來放鬆對原子操作的執行順序的要求。我們來看看代碼清單6-23對代碼清單6-21的所作的改進。



C++ 11 中的 memory_ order 枚舉值

memory_order_seq_cst表示該原子操作必須是順序一致的,這是C++11中所有atomic原子操作的默認值,不帶memory_order參數的原子操作就是使用該值。而memorey_order_relaxed則表示該原子操作是鬆散的,可以被任意重排序的。
 

值得注意的是,並非每種memory_order都可以被atomic的成員使用。通常情況下,我們可以把atomic成員函數可使用的memory_order值分爲以下3組:
1) 原子存儲操作(store)可以使用memorey_order_relaxed、memory_order_release、memory_order_seq_cst。

2) 原子讀取操作(load)可以使用memorey_order_relaxed、memory_order_consume、memory_order_acquire、memory_order_seq_cst。

3) RMW操作(read-modify-write),即一些需要同時讀寫的操作,比如之前提過的atomic_flag類型的test_and_set()操作。又比如atomic類模板的atomic_compare_exchange()操作等都是需要同時讀寫的。RMW操作可以使用memorey_order_relaxed、memory_order_consume、memory_order_acquire、memory_order_release、memory_order_acq_rel、memory_order_seq_cst。

 

一些形如“operator=”、“operator+=”的操作符函數,事實上都是memory_order_seq_cst作爲memory_order參數的原子操作的簡單封裝。也即是說,之前小節中的代碼都是採用順序一致性的內存模型。如果讀者需要的正是順序一致性的內存模型的話,那麼這些操作符都是可以直接使用的。而如果讀者是要指定內存順序的話,則應該採用形如load、atomic_fetch_add這樣的版本

 


使用例子來加深對memory order的用法的理解:

 

如之前提到的,memory_order_seq_cst這種memory_order對於atomic類型數據的內存順序要求過高,容易阻礙系統發揮線程應有的性能。而memorey_order_relaxed對內存順序毫無要求,這在代碼清單6-21中滿足了我們解除“先於發生”順序的需求。但在另外一些情況下,
則還是可能無法滿足真正的需求。我們可以看看由代碼清單6-22改造而來的代碼清單6-24的例子。

線程1中對原子類型的操作使用了memory_order_relaxed,因此,兩個賦值語句的先後順序得不到保證,因此,線程2中打印的a的值可能是0或1。 因此,需要考慮使用其他的memory order:

仔細地分析的話,我們所需要的只是a.store先於b.store發生,b.load先於a.load發生的順序。這要這兩個“先於發生”關係得到了遵守,對於整個程序而言來說,就不會發生線程間的錯誤。建立這種“先於發生”關係,即原子操作間的順序則需要利用其他的memory_order枚舉值。我們可以看看代碼清單6-25中修改的代碼:

這裏代碼清單6-25對代碼清單6-24做了兩處改動,(限制的還是一個線程內代碼的執行的先後順序)

一是b.store採用了memory_order_release內存順序,這保證了本原子操作前所有的寫原子操作必須完成,也即a.store操作必鬚髮生於b.store之前。

二是b.load採用了memory_order_acquire作爲內存順序,這保證了本原子操作必須完成才能執行之後所有的讀原子操作。即b.load必鬚髮生在a.load操作之前。

這樣一來,通過確立“先於發生”關係的,我們就完全保證了代碼運行的正確性,即當b的值爲2的時候,a的值也確定地爲1。
而打印語句也不會在自旋等待之前打印a的值。Thread1和Thread2的執行順如圖6-5所示。


 

簡單介紹memory_ order_ consume的使用:

 

 


順序一致、鬆散、release-acquire和 release-consume通常是最爲典型的4種內存順序。其他的如memory_order_acq_rel,則是常用於實現一種叫做CAS(compareandswap)的基本同步元語,對應到atomic的原子操作compare_exchange_strong成員函數上。我們也稱之爲acquire-release內存順序。


Finally:

雖然在C++11中,我們看到了大量的內存順序相關的設計。不過這樣的設計主要還是爲了從各種繁雜不同的平臺上抽象出獨立於硬件平臺的並行操作。如果讀者不太願意瞭解內存模型等相關概念,那麼簡單地使用C++11原子操作的順序一致性就可以進行並行程序的編寫了。而如果讀者想讓自己的程序在多線程情況下獲得更好的性能的話,尤其當使用的是一些弱內存順序的平臺,比如PowerPC的話,建立原子操作間內存順序則很有必要,因爲這可會帶來極大的性能提升(弱一致性內存模型平臺的優勢)。

 

但對於並行編程來說,可能最根本的(這是本書沒有涉及的話題)還是思考如何將大量計算的問題,按需分解成多個獨立的、能夠同時運行的部分,並找出真正需要在線程間共享的數據,實現爲C++11的原子類型。雖然有了原子類型的良好設計,實現這些都可以非常的便捷,但並不是所有的問題或者計算都適合用並行計算來解決,對於不適用的問題,強行用並行計算來解決會收效甚微,甚至起到相反效果。因此在決定使用並行計算解決問題之前,程序員必須要有清晰的設計規劃。而在實現了代碼並行後,進一步使用一些性能調試工具來提高並行程序的性能也是非常必要的。

 

 




網絡資源:


C++ atomic操作數中有一個選項可以指定對應的memory_order。C++11中提供了六種不同memory_order選項,不同的選項會定義不同的memory consistency類型。

 

什麼是原子操作?原子操作就是對一個內存上變量(或者叫左值)的讀取-變更-存儲(load-add-store)作爲一個整體一次完成。

例如普通的非原子操作:

x++

這個表達式如果編譯成彙編,對應的是3條指令:

mov(從內存到寄存器),add,mov(從寄存器到內存)

那麼在多線程環境下,就存在這樣的可能:當線程A剛剛執行完第二條指令的時候,線程B開始執行第一條指令。那麼就會導致線程B沒有看到線程A執行的結果。如果這個變量初始值是0,那麼線程A和線程B的結果都是1。

如果我們想要避免這種情況,就可以使用原子操作。使用了原子操作之後,你可以認爲這3條指令變成了一個整體,從而別的線程無法在其執行的期間當中訪問x。也就是起到了鎖的作用。


 

所謂的memory order,其實就是限制編譯器以及CPU對單線程當中的指令執行順序進行重排的程度(此外還包括對cache的控制方法)。這種限制,決定了以atom操作爲基準點(邊界),對其之前的內存訪問命令,以及之後的內存訪問命令,能夠在多大的範圍內自由重排(或者反過來,需要施加多大的保序限制)。從而形成了6種模式。它本身與多線程無關,是限制的單一線程當中指令執行順序。

 


https://blog.csdn.net/netyeaxi/article/details/80718781

https://preshing.com/20140709/the-purpose-of-memory_order_consume-in-cpp11/


Ref:

如何理解 C++11 的六種 memory order: https://www.zhihu.com/question/24301047/answer/1193956492

https://en.cppreference.com/w/cpp/atomic/memory_order

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