1. 程式人生 > >C++11 併發指南七(C++11 記憶體模型一:介紹)

C++11 併發指南七(C++11 記憶體模型一:介紹)

第六章主要介紹了 C++11 中的原子型別及其相關的API,原子型別的大多數 API 都需要程式設計師提供一個 std::memory_order(可譯為記憶體序,訪存順序) 的列舉型別值作為引數,比如:atomic_storeatomic_loadatomic_exchangeatomic_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 上維護了一個頁面,主要是與記憶體模型相關資料的連結,感興趣的同學可以參考裡面的資料自己閱讀。