C++11 并发指南七(C++11 内存模型一:介绍)
第六章主要介紹了 C++11 中的原子類型及其相關的API,原子類型的大多數 API 都需要程序員提供一個 std::memory_order(可譯為內存序,訪存順序) 的枚舉類型值作為參數,比如:atomic_store,atomic_load,atomic_exchange,atomic_compare_exchange 等 API 的最后一個形參為 std::memory_order order,默認值是 std::memory_order_seq_cst(順序一致性)。那么究竟什么是 std::memory_order 呢,為了解答這個問題,我們先來討論 C++11 的內存模型。
一般來講,內存模型可分為靜態內存模型和動態內存模型,靜態內存模型主要涉及類的對象在內存中是如何存放的,即從結構(structural)方面來看一個對象在內存中的布局,以一個簡單的例子為例(截圖參考《C++? Concurrency In Action》 P105 ):
上面是一個簡單的 C++ 類(又稱POD: Plain Old Data,它沒有虛函數,沒有繼承),它在內存中的布局如圖右邊所示(對于復雜類對象的內存布局,請參考《深度探索C++對象模型》一書)。
動態內存模型可理解為存儲一致性模型,主要是從行為(behavioral)方面來看多個線程對同一個對象同時(讀寫)操作時(concurrency)所做的約束,動態內存模型理解起來稍微復雜一些,涉及了內存,Cache,CPU 各個層次的交互,尤其是在共享存儲系統中,為了保證程序執行的正確性,就需要對訪存事件施加嚴格的限制。
文獻中常見的存儲一致性模型包括順序一致性模型,處理器一致性模型,弱一致性模型,釋放一致性模型,急切更新釋放一致性模型、懶惰更新釋放一致性模型,域一致性模型以及單項一致性模型。不同的存儲一致性模型對訪存事件次序的限制不同,因而對程序員的要求和所得到的的性能也不一樣。存儲一致性模型對訪存事件次序施加的限制越弱,我們就越有利于提高程序的性能,但編程實現上更困難。
順序一致性模型由 Lamport 于 1979 年提出。順序一致性模型最好理解但代價太大,原文指出:
... 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 亂序執行和 Memory Reorder)。
我們以一個具體的例子來理解順序一致性:
假設存在兩個共享變量a, b,初始值均為 0,兩個線程運行不同的指令,如下表格所示,線程 1 設置 a 的值為 1,然后設置 R1 的值為 b,線程 2 設置 b 的值為 2,并設置 R2 的值為 a,請問在不加任何鎖或者其他同步措施的情況下,R1,R2 的最終結果會是多少?
?
| 線程 1 | 線程 2 |
| a = 1; | b = 2; |
| R1 = b; | R2 = a; |
?
由于沒有施加任何同步限制,兩個線程將會交織執行,但交織執行時指令不發生重排,即線程 1 中的 a = 1 始終在 R1 = b 之前執行,而線程 2 中的 b = 2 始終在 R2 = a 之前執行 ,因此可能的執行序列共有 4!/(2!*2!) = 6 種:
?
| 情況 1 | 情況 2 | 情況 3 | 情況 4 | 情況 5 | 情況 6 |
| a = 1; | b = 2; | a = 1; | a = 1; | b = 2; | b = 2; |
| R1 = b; | R2 = a; | b = 2; | b = 2; | a = 1; | a = 1; |
| b = 2; | a = 1; | R1 = b; | R2 = a; | R1 = b; | R2 = b; |
| R2 = a; | R1 = b; | R2 = a; | R1 = b; | R2 = a; | R1 = b; |
| R1 == 0, R2 == 1 | R1 == 2, R2 == 0 | R1 == 2, R2 == 1 | R1 == 2, R2 == 1 | R1 == 2, R2 == 1 | R1 == 2, R2 == 1 |
?
上面的表格列舉了兩個線程交織執行時所有可能的執行序列,我們發現,R1,R2 最終結果只有 3 種情況,分別是 R1 == 0, R2 == 1(情況 1),R1 == 2, R2 == 0(情況2) 和 R1 == 2, R2 == 1(情況 3, 4, 5,6)。結合上面的例子,我想大家應該理解了什么是順序一致性。
因此,多線程環境下順序一致性包括兩個方面,(1). 從多個線程平行角度來看,程序最終的執行結果相當于多個線程某種交織執行的結果,(2)從單個線程內部執行順序來看,該線程中的指令是按照程序事先已規定的順序執行的(即不考慮運行時 CPU 亂序執行和 Memory Reorder)。
當然,順序一致性代價太大,不利于程序的優化,現在的編譯器在編譯程序時通常將指令重新排序(當然前提是保證程序的執行結果是正確的),例如,如果兩個變量讀寫互不相關,編譯器有可能將讀操作提前(暫且稱為預讀prefetch 吧),或者盡可能延遲寫操作,假設如下面的代碼段:
int a = 1, b = 2;void func() {a = b + 22;b = 22; }?在GCC 4.4 (X86-64)編譯條件下,優化選項為 -O0 時,匯編后關鍵代碼如下:
movl b(%rip), %eax ; 將 b 讀入 %eax addl $22, %eax ; %eax 加 22, 即 b + 22 movl %eax, a(%rip) ; % 將 %eax 寫回至 a, 即 a = b + 22 movl $22, b(%rip) ; 設置 b = 22而在設置 -O2 選項時,匯編后的關鍵代碼如下:
movl b(%rip), %eax ; 將 b 讀入 %eax movl $22, b(%rip) ; b = 22 addl $22, %eax ; %eax 加 22 movl %eax, a(%rip) ; 將 b + 22 的值寫入 a,即 a = b + 2由上面的例子可以看出,編譯器在不同的優化級別下確實對指令進行了不同程度重排,在 -O0(不作優化)的情況下,匯編指令和 C 源代碼的邏輯相同,但是在 -O2 優化級別下,匯編指令和原始代碼的執行邏輯不同,由匯編代碼可以觀察出,b = 22 首先執行,最后才是 a = b + 2, 由此看出,編譯器會根據不同的優化等級來適當地對指令進行重排。在單線程條件下上述指令重排不會對執行結果帶來任何影響,但是在多線程環境下就不一定了。如果另外一個線程依賴 a,b的值來選擇它的執行邏輯,那么上述重排將會產生嚴重問題。編譯器優化是一門深奧的技術,但是無論編譯器怎么優化,都需要對優化條件作出約束,尤其是在多線程條件下,不能無理由地優化,更不能錯誤地優化。
另外,現代的 CPU 大都支持多發射和亂序執行,在亂序執行時,指令被執行的邏輯可能和程序匯編指令的邏輯不一致,在單線程條件下,CPU 的亂序執行不會帶來大問題,但是在多核多線程時代,當多線程共享某一變量時,不同線程對共享變量的讀寫就應該格外小心,不適當的亂序執行可能導致程序運行錯誤。因此,CPU 的亂序執行也需要作出適當的約束。
綜上所述,我們必須對編譯器和 CPU 作出一定的約束才能合理正確地優化你的程序,那么這個約束是什么呢?答曰:內存模型。C++程序員要想寫出高性能的多線程程序必須理解內存模型,編譯器會給你的程序做優化(靜態),CPU為了提升性能也有亂序執行(動態),總之,程序在最終執行時并不會按照你之前的原始代碼順序來執行,因此內存模型是程序員、編譯器,CPU 之間的契約,遵守契約后大家就各自做優化,從而盡可能提高程序的性能。
C++11 中規定了 6 中訪存次序(Memory Order),如下:
enum memory_order {memory_order_relaxed,memory_order_consume,memory_order_acquire,memory_order_release,memory_order_acq_rel,memory_order_seq_cst };std::memory_order 規定了普通訪存操作和相鄰的原子訪存操作之間的次序是如何安排的,在多核系統中,當多個線程同時讀寫多個變量時,其中的某個線程所看到的變量值的改變順序可能和其他線程寫入變量值的次序不相同。同時,不同的線程所觀察到的某變量被修改次序也可能不相同。然而,如果保證所有對原子變量的操作都是順序的話,可能對程序的性能影響很大,因此,我們可以通過std::memory_order 來指定編譯器對訪存次序所做的限制。因此,在原子類型的 API 中,我們可以通過額外的參數指定該原子操作的訪存次序(內存序),默認的內存序是std::memory_order_seq_cst。
我們可以把上述 6 中訪存次序(內存序)分為 3 類,順序一致性模型(std::memory_order_seq_cst),Acquire-Release 模型(std::memory_order_consume, std::memory_order_acquire, std::memory_order_release, std::memory_order_acq_rel,) 和 Relax 模型(std::memory_order_relaxed)。三種不同的內存模型在不同類型的 CPU上(如 X86,ARM,PowerPC等)所帶來的代價也不一樣。例如,在 X86 或者 X86-64平臺下,Acquire-Release 類型的訪存序不需要額外的指令來保證原子性,即使順序一致性類型操作也只需要在寫操作(Store)時施加少量的限制,而在讀操作(Load)則不需要花費額外的代價來保證原子性。
=====================================?TL;DR =====================================
附:本文剩余部分將介紹其他的存儲器一致模型中的其他幾種較常見的模型:處理器一致性(Processor Consistency)模型,弱一致性(Weak Consistency)模型,釋放一致性(Release Consistency)模型。[注:以下內容來自中國科學院計算技術研究所胡偉武老師寫的《計算機體系結構》(清華大學出版社),該書是胡偉武老師給研究生講課所用的教材,本文略有刪改]
處理器一致性(Processor Consistency)模型:處理器一致性(Processor Consistency)模型比順序一致性模型弱,因此對于某些在順序一致性模型下能夠正確執行的程序在處理器一致性條件下執行時可能會導致錯誤的結果,處理器一致性模型對訪存事件發生次序施加的限制是:(1). 在任意讀操作(Load)被允許執行之前,所有在同一處理器中先于這一 Load 的讀操作都已完成;(2). 在任意寫操作(Store)被允許執行之前,所有在同一處理器中先于這一 Store 的訪存操作(包括 Load 和 Store操作)都已完成。上述條件允許 Store 之后的 Load 越過 Store 操作而有限執行。
弱一致性(Weak Consistency)模型:弱一致性(Weak Consistency)模型的主要思想是將同步操作和普通的訪存操作區分開來,程序員必須用硬件可識別的同步操作把對可寫共享單元的訪存保護起來,以保證多個處理器對可寫單元的訪問是互斥的。弱一致性對訪存事件發生次序的限制如下:(1). 同步操作的執行滿足順序一致性條件; (2). 在任一普通訪存操作被允許執行之前,所有在同一處理器中先于這一訪存操作的同步操作都已完成; (3). 在任一同步操作被允許執行之前,所有在同一處理器中先于這一同步操作的普通操作都已完成。上述條件允許在同步操作之間的普通訪存操作執行時不用考慮進程之間的相關,雖然弱一致性增加了程序員的負擔,但是它能有效地提高系統的性能。
釋放一致性(Release Consistency)模型:釋放一致性(Release Consistency)模型是對弱一致性(Weak Consistency)模型的改進,它把同步操作進一步分成了獲取操作(Acquire)和釋放操作(Release)。Acquire 用于獲取對某些共享變量的獨占訪問權,而 Release 則用于釋放這種訪問權,釋放一致性(Release Consistency)模型訪存事件發生次序的限制如下:(1).?同步操作的執行滿足順序一致性條件; (2). 在任一普通訪存操作被允許執行之前,所有在同一處理器中先于這一訪存操作的 Acquire 操作都已完成; (3). 在任一 Release 操作被允許執行之前,所有在同一處理器中先于這一 Release 操作的普通操作都已完成。
在硬件實現的釋放一致性模型中,對共享單元的訪存是及時進行的,并在執行獲取操作(Acquire)和釋放操作(Release)時對齊。在共享虛擬存儲系統或者在由軟件維護的數據一致性的共享存儲系統中,由于通信和數據交換的開銷很大,有必要減少通信和數據交換的次數。為此,人們在釋放一致性(Release Consistency)模型的基礎上提出了急切更新釋放一致性模型(Eager Release Consistency)和懶惰更新釋放一致性模型(Lazy Release Consistency)。在急切更新釋放一致性模型中,在臨界區內的多個存數操作對共享內存的更新不是及時進行的,而是在執行 Release 操作之前(即退出臨界區之前)集中進行,把多個存數操作合并在一起統一執行,從而減少了通信次數。而在懶惰更新釋放一致性模型中,由一個處理器對某單元的存數操作并不是由此處理器主動傳播到所有共享該單元的其他處理器,而是在其他處理器要用到此處理器所寫的數據時(即其他處理器執行 Acquire 操作時)再向此處理器索取該單元的最新備份,這樣可以進一步減少通信量。
===============================================================================
好了,本文主要介紹了內存模型的相關概念,并重點介紹了順序一致性模型(附帶介紹了幾種常見的存儲一致性模型),并以一個實際的小例子向大家介紹了為什么程序員需要理解內存模型,總之,C++ 程序員要想寫出高性能的多線程程序必須理解內存模型,因為編譯器會給你的程序做優化(如指令重排等),CPU 為了提升性能也有多發射和亂序執行,因此程序在最終執行時并不會按照你之前的原始代碼順序來執行,所以內存模型是程序員、編譯器,CPU 之間的契約,遵守契約后大家就各自做優化,從而盡可能提高程序的性能。
下一節我將給大家介紹 C++11 內存模型中的 6 種訪存次序(或內存序)(std::memory_order_relaxed, std::memory_order_consume, std::memory_order_acquire, std::memory_order_release, std::memory_order_acq_rel, std::memory_order_seq_cst)各自的意義以及常見的用法,希望感興趣的同學繼續關注,如果您發現文中的錯誤,一定盡快告訴我 ;-)
另外,后續的幾篇博客我會給大家介紹更多的與內存模型相關的知識,我在 Github 上維護了一個頁面,主要是與內存模型相關資料的鏈接,感興趣的同學可以參考里面的資料自己閱讀。
總結
以上是生活随笔為你收集整理的C++11 并发指南七(C++11 内存模型一:介绍)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: ArcGIS字段计算器 Field Ca
- 下一篇: Android实例-ImageList与