程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> 關於C++ >> C++內存模型

C++內存模型

編輯:關於C++

關於亂序

說到內存模型,首先需要明確一個普遍存在,但卻未必人人都注意到的事實:程序通常並不是總按著照源碼中的順序一一執行,此謂之亂序,亂序產生的原因可能有好幾種:

  1. 編譯器出於優化的目的,在編譯階段將源碼的順序進行交換。
  2. 程序執行期間,指令流水被 cpu 亂序執行。
  3. inherent cache 的分層及刷新策略使得有時候某些寫讀操作的從效果上看,順序被重排。

以上亂序現象雖然來源不同,但從源碼的角度,對上層應用程序來說,他們的效果其實相同:寫出來的代碼與最後被執行的代碼是不一致的。這個事實可能會讓人很驚訝:有這樣嚴重的問題,還怎麼寫得出正確的代碼?這擔憂是多余的了,亂序的現象雖然普遍存在,但它們都有很重要的一個共同點:在單線程執行的情況下,亂序執行與不亂序執行,最後都會得出相同的結果 (both end up with the same observable result), 這是亂序被允許出現所需要遵循的首要原則,也是為什麼亂序雖然一直存在但卻多數程序員大部分時間都感覺不到的根本原因。

亂序的出現說到底是編譯器,CPU 等為了讓你程序跑得更快而作出無限努力的結果,程序員們應該為它們的良苦用心抹一把淚。

從亂序的種類來看,亂序主要可以分為如下4種:

寫寫亂序(store store), 前面的寫操作被放到了後面的操作之後,比如:

a = 3;
b = 4;
被亂序為:
b = 4;
a = 3;

寫讀亂序(store load),前面的寫操作被放到了後面的讀操作之後,比如:

a = 3;
load(b);
被亂序為
load(b);
a = 3;

讀讀亂序(load load), 前面的讀操作被放到了後一個讀操作之後,比如:

load(a);
load(b);
被亂序為:
load(b);
load(a);

讀寫亂序(load store), 前面的讀操作被放到了後一個寫操作之後,比如:

load(a);
b = 4;
被亂序為:
b = 4;
load(a);

程序的亂序在單線程的世界裡多數時候並沒有引起太多引人注意的問題,但在多線程的世界裡,這些亂序就制造了特別的麻煩,究其原因,最主要的有2個:

  1. 並發不能保證修改和訪問共享變量的操作原子性,使得一些中間狀態暴露了出去,因此像 mutex,各種 lock 之類的東西在寫多線程時被頻繁地使用。
  2. 變量被修改後,該修改未必能被另一個線程及時觀察到,因此需要“同步”。

解決同步問題就需要確定內存模型,也就是需要確定線程間應該怎麼通過共享內存來進行交互(查看維基百科).

 

內存模型

1. 順序一致性模型(SEQUENTIAL CONSISTENCY)

在介紹C++多線程模型之前,讓我們先介紹一下最基本的順序一致性模型。對多線程程序來說,最直觀,最容易被理解的執行方式就是順序一致性模型。順序一致性的提出者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)從單個線程的角度來看,每個線程內部的指令都是按照程序規定的順序(program order)來執行的;
(2)從整個多線程程序的角度來看,整個多線程程序的執行順序是按照某種交錯順序來執行的,且是全局一致的;

下面我們通過一個例子來理解順序一致性。假設我們有兩個線程(線程1和線程2),它們分別運行在兩個CPU核上,有兩個初始值為0的全局共享變量x和y,兩個線程分別執行下面兩條指令:
初始條件: x = y = 0;

線程 1 線程 2 x = 1; y=1; r1 = y; r2 = x;

因為多線程程序交錯執行的順序是不確定的,所以該程序可能有如下幾種執行順序:

順序 1 順序 2 順序 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

順序一致性模型的第一個約定要求每個線程內部的語句都是按照程序規定的順序執行,例如,線程1裡面的兩條語句在該線程中一定是x=1先執行,r1=y後執行。順序一致性的第二個約定要求多線程程序按照某種順序執行,且所有線程看見的整體執行順序必須一致,即該多線程程序可以按照順序1、順序2或者順序3(以及其他可能的執行順序)執行,且線程1和線程2所觀察到的整個程序的執行順序是一致的(例如,如果線程1“看見”整個程序的執行順序是順序 1,那麼線程2“看見”的整個程序的執行順序也必須是順序1,而不能是順序2或者順序3)。依照順序一致性模型,雖然這個程序還可能按其他的交錯順序執行,但是r1和r2的值卻只可能出現上面三種結果,而不可能出現r1和r2同時為0的情況。

然而,盡管順序一致性模型非常易於理解,但是它卻對CPU和編譯器的性能優化做出了很大的限制,所以常見的多核CPU和編譯器大都沒有實現順序一致性模型。例如,編譯器可能會為了隱藏一部分讀操作的延遲而做如下優化,把線程1中對y的讀操作(即r1=y)調換到x=1之前執行:

初始條件:x=y=0;

線程 1 線程 2 r1 = y; y=1; x = 1; r2 = x;

在這種情況下,該程序如果按下面的順序執行就可能就會出現r1和r2都為0這樣的違反順序一致性的結果:

順序 4 r1 = y;
y = 1;
r2 = x;
x = 1;

那麼為什麼編譯器會做這樣的亂序優化呢?因為讀一個在內存中而不是在cache中的共享變量需要較長的時鐘周期,所以編譯器就“自作聰明”的讓讀操作先執行,從而隱藏掉一些指令執行的延遲,從而提高程序的性能。實際上,這種優化是串行時代非常普遍的,因為它對單線程程序的語義是沒有影響的。但是在進入多核時代後,編譯器缺少語言級的內存模型的約束,導致其可能做出違法順序一致性規定的多線程語義的錯誤優化。同樣的,多核CPU中的寫緩沖區(store buffer)也可能實施亂序優化:它會把要寫入內存的值先在緩沖區中緩存起來,以便讓該寫操作之後的指令先執行,進而出現違反順序一致性的執行順序。

因為現有的多核CPU和編譯器都沒有遵守順序一致模型,而且C/C++的現有標准中都沒有把多線程考慮在內,所以給編寫多線程程序帶來了一些問題。例如,為了正確地用C++實現Double-Checked Locking,我們需要使用非常底層的內存柵欄(Memory Barrier)指令來顯式地規定代碼的內存順序性(memory ordering)[5]。然而,這種方案依賴於具體的硬件,因此可移植性很差;而且它過於底層,不方便使用。

2. C++多線程內存模型

為了更容易的進行多線程編程,程序員希望程序能按照順序一致性模型執行;但是順序一致性對性能的損失太大了,CPU和編譯器為了提高性能就必須要做優化。為了在易編程性和性能間取得一個平衡,一個新的模型出爐了:sequential consistency for data race free programs,它就是即將到來的C++1x標准中多線程內存模型的基礎。對C++程序員來說,隨著C++1x標准的到來,我們終於可以依賴高級語言內建的多線程內存模型來編寫正確的、高性能的多線程程序。

C++內存模型可以被看作是C++程序和計算機系統(包括編譯器,多核CPU等可能對程序進行亂序優化的軟硬件)之間的契約,它規定了多個線程訪問同一個內存地址時的語義,以及某個線程對內存地址的更新何時能被其它線程看見。這個模型約定:沒有數據競跑的程序是遵循順序一致性的。該模型的核心思想就是由程序員用同步原語(例如鎖或者C++1x中新引入的atomic類型的共享變量)來保證你程序是沒有數據競跑的,這樣CPU和編譯器就會保證程序是按程序員所想的那樣執行的(即順序一致性)。換句話說,程序員只需要恰當地使用具有同步語義的指令來標記那些真正需要同步的變量和操作,就相當於告訴CPU和編譯器不要對這些標記好的同步操作和變量做違反順序一致性的優化,而其它未被標記的地方可以做原有的優化。編譯器和CPU的大部分優化手段都可以繼續實施,只是在同步原語處需要對優化做出相應的限制;而且程序員只需要保證正確地使用同步原語即可,因為它們最終表現出來的執行效果與順序一致性模型一致。由此,C++多線程內存模型幫助我們在易編程性和性能之間取得了一個平衡。

在C++1x標准之前,C++是在建立在單線程語義上的。為了進行多線程編程,C++程序員通過使用諸如Pthreads,Windows Thread等C++語言標准之外的線程庫來完成代碼設計。以Pthreads為例,它提供了類似pthread_mutex_lock這樣的函數來保證對共享變量的互斥訪問,以防止數據競跑。人們不禁會問,Pthreads這樣的線程庫我用的好好的,干嘛需要C++引入的多線程,這不是多此一舉麼?其實,以線程庫的形式進行多線程編程在絕大多數應用場景下都是沒有問題的。然而,線程庫的解決方案也有其先天缺陷。第一,如果沒有在編程語言中定義內存模型的話,我們就不能清楚的定義到底什麼樣的編譯器/CPU優化是合法的,而程序員也不能確定程序到底會怎麼樣被優化執行。例如,Pthreads標准中並未對什麼是數據競跑(Data Race)做出精確定義,因此C++編譯器可能會進行一些錯誤優化從而導致數據競跑[3]。第二,絕大多數情況下線程庫能正確的完成任務,而在極少數對性能有更高要求的情況下(尤其是需要利用底層的硬件特性來實現高性能Lock Free算法時)需要更精確的內存模型以規定好程序的行為。簡而言之,把內存模型集成到編程語言中去是比線程庫更好的選擇。

內存模型所要表達的內容主要是怎麼描述一個內存操作的效果,在各個線程間的可見性的問題。修改操作的效果不能及時被別的線程看見的原因有很多,比較明顯的一個是,對計算機來說,通常內存的寫操作相對於讀操作是昂貴很多很多的,因此對寫操作的優化是提升性能的關鍵,而這些對寫操作的種種優化,導致了一個很普遍的現象出現:寫操作通常會在 CPU 內部的 cache 中緩存起來。這就導致了在一個 CPU 裡執行一個寫操作之後,該操作導致的內存變化卻不一定會馬上就被另一個 CPU 所看到,這從另一個角度講,效果上其實就是讀寫亂序了。

cpu1 執行如下:
a = 3;
cpu2 執行如下:
load(a);

對如上代碼,假設 a 的初始值是 0, 然後 cpu1 先執行,之後 cpu2 再執行,假設其中讀寫都是原子的,那麼最後 cpu2 如果讀到 a = 0 也其實不是什麼奇怪事情。很顯然,這種在某個線程裡成功修改了全局變量,居然在另一個線程裡看不到效果的後果是很嚴重的。

因此必須要有必要的手段對這種修改公共變量的行為進行同步。

c++11 中的 atomic library 中定義了以下6種語義來對內存操作的行為進行約定,這些語義分別規定了不同的內存操作在其它線程中的可見性問題:

enum memory_order {
    memory_order_relaxed,
    memory_order_consume,
    memory_order_acquire,
    memory_order_release,
    memory_order_acq_rel,
    memory_order_seq_cst
};

我們主要討論其中的幾個:relaxed, acquire, release, seq_cst(sequential consistency).

relaxed 語義

首先是 relaxed 語義,這表示一種最寬松的內存操作約定,該約定其實就是不進行約定,以這種方式修改內存時,不需要保證該修改會不會及時被其它線程看到,也不對亂序做任何要求,因此當對公共變量以 relaxed 方式進行讀寫時,編譯器,cpu 等是被允許按照任意它們認為合適的方式來加以優化處理的。

release-acquire 語義

如果你曾經去看過別的介紹內存模型相關的文章,你一定會發現 release 總是和 acquire 放到一起來講,這並不是偶然。事實上,release 和 acquire 是相輔相承的,它們必須配合起來使用,這倆是一個 “package deal”, 分開使用則完全沒有意義。具體到其中, release 用於進行寫操作,acquire 則用於進行讀操作,它們結合起來表示這樣一個約定:

如果一個線程A對一塊內存 m 以 release 的方式進行修改,那麼在線程 A 中,所有在該 release 操作之前進行的內存操作,都在另一個線程 B 對內存 m 以 acquire 的方式進行讀取之後,變得可見。

舉個粟子,假設線程 A 執行如下指令:

a.store(3);
b.store(4);
m.store(5, release);

線程 B 執行如下:

e.load();
f.load();
m.load(acquire);
g.load();
h.load();

如上,假設線程 A 先執行,線程 B 後執行, 因為線程 A 中對 m 以 release 的方式進行修改, 而線程 B 中以 acquire 的方式對 m 進行讀取,所以當線程 B 執行完m.load(acquire)之後, 線程 B 必須已經能看到a == 3, b == 4. 以上死板的描述事實上還傳達了額外的不那麼明顯的信息:

  • release 和 acquire 是相對兩個線程來說的,它約定的是兩個線程間的相對行為:如果其中一個線程 A 以 release 的方式修改公共變量 m, 另一個線程 B 以 acquire 的方式時讀取該 m 時,要有什麼樣的後果,但它並不保證,此時如果還有另一個線程 C 以非 acquire 的方式來讀取 m 時,會有什麼後果。

  • 一定程度阻止了亂序的發生,因為要求 release 操作之前的所有操作都在另一個線程 acquire 之後可見,那麼:
  • release 操作之前的所有內存操作不允許被亂序到 release 之後。
  • acquire 操作之後的所有內存操作不允許被亂序到 acquire 之前。

而在對它們的使用上,有幾點是特別需要注意和強調的:

  1. release 和 acquire 必須配合使用,分開單獨使用是沒有意義。
  2. release 只對寫操作(store) 有效,對讀 (load) 是沒有意義的。
  3. acquire 則只對讀操作有效,對寫操作是沒有意義的。

現代的處理器通常都支持一些 read-modify-write 之類的指令,對這種指令,有時我們可能既想對該操作 執行 release 又要對該操作執行 acquire,因此 c++11 中還定義了 memory_order_acq_rel,該類型的操作就是 release 與 acquire 的結合,除前面提到的作用外,還起到了 memory barrier 的功能。

sequential consistency

sequential consistency 相當於 release + acquire 之外,還加上了一個對該操作加上全局順序的要求,這是什麼意思呢?

簡單來說就是,對所有以 memory_order_seq_cst 方式進行的內存操作,不管它們是不是分散在不同的 cpu 中同時進行,這些操作所產生的效果最終都要求有一個全局的順序,而且這個順序在各個相關的線程看起來是一致的。

舉個粟子,假設 a, b 的初始值都是0:

線程 A 執行:

a.store(3, seq_cst);

線程 B 執行:

b.store(4, seq_cst);

如上對 a 與 b 的修改雖然分別放在兩個線程裡同時進行,但是這多個動作畢竟是非原子的,因此這些操作地進行在全局上必須要有一個先後順序:

  1. 先修改a, 後修改 b,或
  2. 先修改b, 把整個a。

而且這個順序是固定的,必須在其它任意線程看起來都是一樣,因此 a == 0 && b == 4 與 a == 3 && b == 0 不允許同時成立。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved