「你不認識的」CC++ volatile

1. 使人困惑的volatile

volatile字面意思是「不穩定的、易失的」,很多編程語言中存在volatile關鍵字,也有共同之處,如「表示程序執行期間數據可能會被外部操做修改」,如被外設修改或者被其餘線程修改等。這只是字面上給咱們的通常性認識,然而具體到不一樣的編程語言中volatile的語義可能相差甚遠。css

不少人覺得本身精通CC++,可是被問起volatile的時候卻沒法清晰、果斷地代表態度,那隻能說明仍是處在「從入門到精通」的路上,若是瞭解一門語言常見特性的使用、可以寫健壯高效的程序就算精通的話,那實在是太藐視「大師」的存在了。從一個volatile關鍵字折射出了對CC++標準、編譯器、操做系統、處理器、MMU各個方面的掌握程度。html

幾十年的發展,不少開發者由於本身的偏見、誤解,或者對某些語言特性(如Java中的volatile語義)的根深蒂固的認識,賦予了CC++ volatile本不屬於它的能力,本身卻渾然不知本身犯了多大的一個錯誤。java

我曾經覺得CC++中volatile能夠保證保證線程可見性,由於Java中是這樣的,直到後來閱讀Linux內核看到Linus Torvards的一篇文檔,他強調了volatile可能帶來的壞處「任何使用volatile的地方,均可能潛藏了一個bug」,我爲他的「危言聳聽」感到吃驚,因此我當時搜索了很多資料來求證CC++ volatile的能力,過後我認爲CC++ volatile不能保證線程可見性。可是後來部門內一次分享,分享中提到了volatile來保證線程可見性,我當時心存疑慮,過後驗證時犯了一個錯誤致使我錯誤地認爲volatile能夠保證線程可見性。直到我最近翻閱之前的筆記,翻到了幾年前對volatile的疑慮……我決定深刻研究下這個問題,以便能順利入眠。c++

2. 從規範認識volatile

以常見的編程語言C、C++、Java爲例,它們都有一個關鍵字volatile,可是對volatile的定義卻並不是徹底相同。算法

  • Java中對volatile的定義:編程

    8.3.1.4. volatile Fields

    The Java programming language allows threads to access shared variables (§17.1). As a rule, to ensure that shared variables are consistently and reliably updated, a thread should ensure that it has exclusive use of such variables by obtaining a lock that, conventionally, enforces mutual exclusion for those shared variables.緩存

    The Java programming language provides a second mechanism, volatile fields, that is more convenient than locking for some purposes.網絡

    A field may be declared volatile, in which case the Java Memory Model ensures that all threads see a consistent value for the variable (§17.4).多線程

    Java清晰地表達了這樣一個觀點,Java內存模型中會保證volatile變量的線程可見性,接觸過Java併發編程的開發者應該都清楚,這是一個不爭的事實。架構

  • CC++中對volatile的定義:

    6.7.3 Type qualifiers

    volatile: No cacheing through this lvalue: each operation in the abstract semantics must be performed (that is, no cacheing assumptions may be made, since the location is not guaranteed to contain any previous value). In the absence of this qualifier, the contents of the designated location may be assumed to be unchanged except for possible aliasing.

    C99中也清晰地表名了volatile的語義,不要作cache之類的優化。這裏的cache指的是software cacheing,即編譯器生成指令將內存數據緩存到cpu寄存器,後續訪問內存變量使用寄存器中的值;須要與之做出區分的是hardware cacheing,即cpu訪問內存時將內存數據緩存到cpu cache,硬件操做徹底對上層應用程序透明。你們請將這兩個點銘記在心,要想搞清楚CC++ volatile必需要先理解這裏cache的區別。

    C99清晰嗎?上述解釋看上去很清晰,可是要想完全理解volatile的語義,絕非上述一句話就能夠講得清的,C99中定義了abstract machine以及sequence points,與volatile相關的描述有多處,篇幅緣由這裏就不一一列舉了,其中與volatile相關的abstract machine行爲描述共同肯定了volatile的語義。

3. 對volatile持何觀點

爲了引發你們對CC++ volatile的重視並及時代表觀點,先貼一個頁面「Is-Volatile-Useful-with-Threads」,網站中簡明扼要的告知你們,「Friends don’t let friends use volatile for inter-thread communication in C and C++」。But why?

is-volatile-useful-with-threads

isocpp專門掛了這麼個頁面來強調volatile在不一樣編程語言中的差別,可見它是一個多麼難纏的問題。即使是有這麼個頁面,要完全搞清楚volatile,也不是說讀完上面列出的幾個技術博客就能解決,那也過輕描淡寫了,因此我搜索、整理、討論,但願能將學到的內容總結下來供其餘開發者參考,我也不想再由於這個問題而困擾。

結合CC++ volatile qualifier以及abstract machine中對volatile相關sequence points的描述,能夠肯定volatile的語義:

  • 不可優化性:不要作任何軟件cache之類的優化,即屢次訪問內存對象時,編譯器不能優化爲cache內存對象到寄存器、後續訪問內存對象轉爲訪問寄存器 [6.7.3 Type qualifiers - volatile];
  • 順序性:對volatile變量的屢次讀寫操做,編譯器不能以預測數據不變爲藉口優化掉讀寫操做,而且要保證前面的讀寫操做先於後面的讀寫操做完成 [5.1.2.3 Program execution];
  • 易變性:從不可優化性、順序性語義要求,不難體會出其隱含着數據「易變性」,這也是volatile字面上的意思,也是很多開發者學習volatile時最熟知的語義;

CC++規範沒有顯示要求volatile支持線程可見性,gcc也沒有在標準容許的空間內作什麼「發揮」去安插什麼保證線程可見性的處理器指令(Java中volatile會使用lock指令使其餘處理器cache失效強制讀內存保證線程可見性)。而關於CPU cache一致性協議,x86原先採用MESI協議,後改用效率更高的MESIF,都是強一致性協議,在x86這等支持強一致的CPU上,CC++中結合volatile是能夠「得到」線程可見性的,在非強一致CPU上則否則。

可是CC++ volatile確實是有價值的,不少地方都要使用它,並且很多場景下彷佛沒有比它更簡單的替代方法,下面首先列舉CC++ volatile的通用適用場景,方便你們認識volatile,而後咱們再研究爲何CC++ volatile不能保證線程可見性。CC++標準中確實沒有說volatile要支持線程可見性,你們能夠選擇就此打住,可是我懷疑的是gcc在標準容許的空間內是怎麼作的?操做系統、MMU、處理器是怎麼作的?「標準中沒有顯示列出」,這樣的理由還不足以讓我停下探索的腳步。

4. CC++ need volatile

CC++ volatile語義「不可優化型」、「順序性」、「易變性」,如何直觀感覺它的價值呢?看C99中給出的適用場景吧。

  • setjmp、longjmp用於實現函數內、函數間跳轉(goto只能在函數內跳轉),C Spec規定longjmp以後但願跳到的棧幀中的局部變量的值是最新值,而不是setjmp時的值,考慮編譯器可能做出一些優化,將auto變量cache到寄存器中,假如setjmp保存硬件上下文的時候恰巧保存了存有該局部變量值的寄存器信息,等longjmp回來的時候就用了舊值。這違背了C Spec的規定,因此這個時候可使用volatile來避免編譯器優化,知足C Spec!
  • signal handler用於處理進程捕獲到的信號,與setjmp、longjmp相似,進程捕獲、處理信號時須要保存當前上下文再去處理信號,信號處理完成再恢復上下文繼續執行。信號處理函數中也可能會修改某些共享變量,假如共享變量在收到信號時加載到了寄存器,而且保存硬件上下文時也保存起來了,那麼信號處理函數執行完畢返回(可能會修改該變量)恢復上下文後,訪問到的仍是舊值。所以將信號處理函數中要修改的共享變量聲明爲volatile是必要的。
  • 設備驅動、Memory-Mapped IO、DMA。
    咱們先看一個示例,假如不使用volatile,編譯器會作什麼。編譯器生成代碼可能會將內存變量sum、i放在寄存器中,循環執行過程當中,編譯器可能認爲這個循環能夠直接優化掉,sum直接獲得了最終的a[0]+a[1]+…a[N]的值,循環體執行次數大大減小。

    sum  = 0;
    for (i=0; i<N; ++i)
        sum += a[i];

    這種優化對於面向硬件的程序開發(如設備驅動開發、內存映射IO)來講有點過頭了,並且會致使錯誤的行爲。下面的代碼使用了volatile qualifer,其餘與上述代碼基本相同。若是不存在volatile修飾,編譯器會認爲最終*ttyport的值就是a[N-1],前N-1次賦值都是不必的,因此直接優化成*ttyport = a[N-1]。可是ttyport是外設的設備端口經過內存映射IO獲得的虛擬內存地址,編譯器發現存在volatile修飾,便不會對循環體中*ttyport = a[i]進行優化,循環體會執行N次賦值,且保證每次賦值操做都與前一次、後一次賦值存在嚴格的順序性保證。

    volatile short *ttyport;
    for (i=0; i<N; ++i)
        *ttyport = a[i];

    可能你們會有疑問,volatile只是避免編譯器將內存變量存儲到寄存器,對cpu cache卻一籌莫展,誰能保證每次對*ttyport的寫操做都肯定寫回內存了呢?這裏就涉及到cpu cache policy問題了。

    對於外設IO而言,有兩種經常使用方式:

    • Memory-Mapped IO,簡稱MMIO,將設備端口(寄存器)映射到進程地址空間。以x86爲例,對映射內存區域的讀寫操做經過普通的load、store訪存指令來完成,處理器經過內存類型範圍寄存器(MTRR,Memory Type Range Regsiters)和頁面屬性表(PAT,Page Attribute Table)對不一樣的內存範圍設置不一樣的CPU cache policy,內核設置MMIO類型範圍的cpu cache策略爲uncacheable,其餘RAM類型範圍的cpu cache策略爲write-back!即直接繞過cpu cache讀寫內存,但實際上並無物理內存參與,而是將讀寫操做轉發到外設,上述代碼中*ttyport = a[i]這個賦值操做繞過CPU cache直達外設。
    • Port IO,此時外設端口(寄存器)採用獨立編址,而非Memory-Mapped IO這種統一編址方式,須要經過專門的cpu指令來對設備端口進行讀寫,如x86上採用的是指令in、out來完成設備端口的讀寫。

而若是是DMA(Direct Memory Access)操做模式的話,它繞過cpu直接對內存進行操做,期間不中斷cpu執行,DMA操做內存方式上與cpu相似,都會考慮cpu cache一致性問題。假如DMA對內存進行讀寫操做,總線上也會對事件進行廣播,cpu cache也會觀測到並採起相應的動做。如DMA對內存進行寫操做,cpu cache也會將相同內存地址的cache line設置爲invalidate,後續讀取時就能夠從新從內存加載最新數據;假如DMA進行內存讀操做,數據可能從其餘cpu cache中直接獲取而非從內存中。這種狀況下DMA操做的內存區域,對應的內存變量也應該使用volatile修飾,避免編譯器優化從寄存器中讀到舊值。

以上示例摘自C99規範,經過上述示例、解釋,能夠體會到volatile的語義特色:「不可優化型、易變性、順序性」。

下面這個示例摘自網絡,也比較容易表現volatile的語義特色:

// 應爲 volatile unsigned int *p = ....
unsigned int *p = GetMagicAddress();
unsigned int a, b;

a = *p;
b = *p;

*p = a;
*p = b;

GetMagicAddress()返回一個外設的內存映射IO地址,因爲unsigned int *p指針沒有volatile修飾,編譯器認爲*p中的內容不是「易變的」所以可能會做出以下優化。首先從p讀取一個字節到寄存器,而後將其賦值給a,而後認爲*p內容不變,就直接將寄存器中內容再賦值給b。寫*p的時候認爲a == b,寫兩次不必就只寫了一次。

而若是經過volatile對*p進行修飾,則就是另外一個結果了,編譯器會認爲*p中內容是易變的,每次讀取操做都不會沿用上次加載到寄存器中的舊值,而內存映射IO內存區域對應的cpu cache模式又是被uncacheable的,因此會保證從內存讀取到最新寫入的數據,成功連續讀取兩個字節a、b,也保證按順序寫入兩個字節a、b。

相信讀到這裏你們對CC++ volatile的適用場景有所瞭解了,它確實是有用的。那接下來咱們針對開發者誤解很嚴重的一個問題「volatile可否支持線程可見性」再探索一番,不能!不能!不能!

5. CC++ thread visibility

5.1. 線程可見性問題

多線程編程中常常會經過修改共享變量的方式來通知另外一個線程發生了某種狀態的變化,但願線程能及時感知到這種變化,所以咱們關心「線程可見性問題」。

在對稱多處理器架構中(SMP),多處理器、核心經過總線共享相同的內存,可是各個處理器核心有本身的cache,線程執行過程當中,通常會將內存數據加載到cache中,也可能會加載到寄存器中,以便實現訪問效率的提高,但這也帶來了問題,好比咱們提到的線程可見性問題。某個線程對共享變量作了修改,線程可能只是修改了寄存器中的值或者cpu cache中的值,修改並不會當即同步回內存。即使同步回內存,運行在其餘處理器核心上的線程,訪問該共享數據時也不會當即去內存中讀取最新的數據,沒法感知到共享數據的變化。

5.2. diff volatile in java、cc++

有些編程語言中定義了關鍵字volatile,如Java、C、C++等,對比下Java volatile和CC++ volatile,差別簡直是太大了,咱們只討論線程可見性相關的部分。

Java中語言規範明確指出volatile保證內存可見性,JMM存在「本地內存」的概念,線程對「主存」變量的訪問都是先加載到本地內存,後續寫操做再同步回主存。volatile能夠保證一個線程的寫操做對其餘線程當即可見,首先是保證volatile變量寫操做必需要更新到主存,而後還要保證其餘線程volatile變量讀取必須從主存中讀取。處理器中提供了MFENCE指令來建立一個屏障,能夠保證MFENCE以前的操做對後續操做可見,用MFENCE能夠實現volatile,可是考慮到AMD處理器中耗時問題以及Intel處理器中流水線問題,JVM從MFENCE修改爲了LOCK: ADD 0。

可是在C、C++規範裏面沒有要求volatile具有線程可見性語義,只要求其保證「不可優化性、順序性、易變性」。

5.3. how gcc handle volatile

這裏作個簡單的測試:

#include <stdio.h>
int main() {
    // volatile int a = 0;
    int a = 0;
    while(1) {
        a++;
        printf("%d\n", a);
    }
    return 0;
}

不開優化的話,有沒有volatile gcc生成的彙編指令基本是一致的,volatile變量讀寫都是針對內存進行,而非寄存器。開gcc -O2優化時,不加volatile狀況下讀寫操做經過寄存器,加了volatile則經過內存。

1)不加volatile :gcc -g -O2 -o main main.c

without-volatile

這裏重點看下對變量a的操做,xor %ebx,%ebx將寄存器%ebx設爲0,也就是將變量a=0存儲到了%ebx,nopl不作任何操做,而後循環體裏面每次讀取a的值都是直接在%ebx+1,加完以後也沒有寫回內存。假若有個共享變量是多個線程共享的,而且沒有加volatile,多個線程訪問這個變量的時候就是用的物理線程跑的處理器核心寄存器中的數據,是沒法保證內存可見性的。

2)加volatile:gcc -g -O2 -o main main.c

with-volatile

這裏變量a的值首先被設置到了0xc(%rsp)中,nopl空操做,而後a++時是將內存中的值移動到了寄存器%eax中,而後執行%eax+1再寫回內存0xc(%rsp)中,while循環中每次循環執行都是先從內存裏面取值,更新後再寫回內存。可是這樣就能夠保證線程可見性了嗎?No!

5.4. how cpu cache works

是否有這樣的疑問?CC++中對volatile變量讀寫,發出的內存讀寫指令不會被CPU轉換成讀寫CPU cache嗎?這個屬於硬件層面內容,對上層透明,編譯器生成的彙編指令也沒法反映實際執行狀況!所以,只看上述反彙編示例是不能肯定CC++ volatile支持線程可見性的,固然也不能排除這種可能性?

Stack Overflow上Dietmar Kühl提到,‘volatile’阻止了對變量的優化,例如對於頻繁訪問的變量,會阻止編譯器對其進行編譯時優化,避免將其放入寄存器中(注意是寄存器而不是cpu的cache)。編譯器優化內存訪問時,會生成將內存數據緩存到寄存器、後續訪問內存操做轉換爲訪問寄存器,這稱爲「software cacheing」;而CPU實際執行時硬件層面將內存數據緩存到CPU cache中,這稱爲「hardware cacheing」,是對上層徹底透明的。如今已經肯定CC++ volatile不會再做出「將內存數據緩存到CPU寄存器」這樣的優化,那上述CPU hardware caching技術就成了咱們下一個懷疑的對象。

保證CPU cache一致性的方法,主要包括write-through(寫直達)或者write-back(寫回),write-back並非當cache中數據更新時當即寫回,而是在稍後的某個時機再寫回。寫直達會嚴重下降cpu吞吐量,因此現現在的主流處理器中一般採用寫回法,而寫回法又包括了write-invalidate和write-update兩種方式,可先跳過。

write-back:

  • write-invalidate,當某個core(如core 1)的cache被修改成最新數據後,總線觀測到更新,將寫事件同步到其餘core(如core n),將其餘core對應相同內存地址的cache entry標記爲invalidate,後續core n繼續讀取相同內存地址數據時,發現已經invalidate,會再次請求內存中最新數據。
  • write-update,當某個core(如core 1)的cache被修改成最新數據後,將寫事件同步到其餘core,此時其餘core(如core n)當即讀取最新數據(如更新爲core 1中數據)。

write-back(寫回法)中很是有名的cache一致性算法MESI,它是典型的強一致CPU,intel就憑藉MESI優雅地實現了強一致CPU,如今intel優化了下MESI,獲得了MESIF,它有效減小了廣播中req/rsp數量,減小了帶寬佔用,提升了處理器處理的吞吐量。關於MESI,這裏有個可視化的MESI交互演示程序能夠幫助理解其工做原理,查看MESI可視化交互程序

咱們就先結合簡單的MESI這個強一致性協議來試着理解下x86下爲何就能夠保證強一致,結合多線程場景分析:

  • 一個volatile共享變量被多個線程讀取,假定這幾個線程跑在不一樣的cpu核心上,每一個核心有本身的cache,線程1跑在core1上,線程2跑在core2上。
  • 如今線程1準備修改變量值,這個時候會先修改cache中的值而後稍後某個時刻寫回主存或者被其餘core讀取。cache同步策略「write-back」,MESI就是其中的一種。處理器全部的讀寫操做都能被總線觀測到,snoop based cache coherency,當線程2準備讀取這個變量時:
  • 假定以前沒讀取過,發現本身的cache裏面沒有,就經過總線向內存請求,爲了保證cpu cache高吞吐量,總線上全部的事務都能被其餘core觀測到,core1發現core2要讀取內存值,這個數據恰好在個人cache裏面,可是處於dirty狀態。core1可能灰採起兩種動做,一種是將dirty數據直接丟給core2(至少是最新的),或者告知core2延遲read,等我先寫回主存,而後core2再嘗試read內存。
  • 假定以前讀取過了,core1對變量的修改也會被core2觀測到,core1應該將其cache line標記爲modified,將core2 cache line標記爲invalidate使其失效,下次core2讀取時從core1獲取或內存獲取(觸發core1將dirty數據寫回主存)。

這麼看來只要處理器的cache一致性算法支持,而且結合volatile避免寄存器相關優化,就能輕鬆保證線程可見行。可是不一樣的處理器設計不同,咱們只是以MESI協議來粗略瞭解了x86的處理方式,對於其餘非強一致性CPU,即使使用了volatile也不必定能保證線程可見性,但如果對volatile變量讀寫時安插了相似MFENCE、LOCK指令也是能夠的?如何進一步判斷呢?

還須要判斷編譯器(如gcc)是否有對volatile來作特殊處理,如安插MFENCE、LOCK指令之類的。上面編寫的反彙編測試示例中,gcc生成的彙編沒有看到lock相關的指令,可是由於我是在x86上測試的,而x86恰好是強一致CPU,我也不肯定是否是由於這個緣由,gcc直接圖省事略掉了lock指令?因此如今要驗證下,在其餘非x86平臺上,gcc -O2優化時作了何種處理。若是安插了相似指令,問題就解決了,咱們也能夠得出結論,c、c++中volatile在gcc處理下能夠保證線程可見性,反之則不能獲得這樣的結論!

我在網站godbolt.org交叉編譯測試了一下上面gcc處理的代碼,換了幾個不一樣的硬件平臺也沒發現有生成特定的相似MFENCE或者LOCK相關的導致處理器cache失效後從新從內存加載的指令。

備註:在某些處理器架構下,gcc確實有提供一些特殊的編譯選項容許繞過CPU cache直接對內存進行讀寫,可參考gcc man手冊「-mcache-volatile」、「-mcache-bypass」選項的描述。

想了解下CC++中volatile的真實設計「意圖」,而後,在stack overflow上我又找到了這樣一個回答:https://stackoverflow.com/a/12878500,重點內容已加粗顯示。

[Nicol Bolas](https://stackoverflow.com/use...

What volatile tells the compiler is that it can't optimize memory reads from that variable. However, CPU cores have different caches, and most memory writes do not immediately go out to main memory. They get stored in that core's local cache, and may be written... eventually.**

CPUs have ways to force cache lines out into memory and to synchronize memory access among different cores. These memory barriers allow two threads to communicate effectively. Merely reading from memory in one core that was written in another core isn't enough; the core that wrote the memory needs to issue a barrier, and the core that's reading it needs to have had that barrier complete before reading it to actually get the data.

volatile guarantees none of this. Volatile works with "hardware, mapped memory and stuff" because the hardware that writes that memory makes sure that the cache issue is taken care of. If CPU cores issued a memory barrier after every write, you can basically kiss any hope of performance goodbye. So C++11 has specific language saying when constructs are required to issue a barrier.

Dietmar Kühl回答中提到:

The volatile keyword has nothing to do with concurrency in C++ at all! It is used to have the compiler prevented from making use of the previous value, i.e., the compiler will generate code accessing a volatile value every time is accessed in the code. The main purpose are things like memory mapped I/O. However, use of volatile has no affect on what the CPU does when reading normal memory: If the CPU has no reason to believe that the value changed in memory, e.g., because there is no synchronization directive, it can just use the value from its cache. To communicate between threads you need some synchronization, e.g., an std::atomic<T>, lock a std::mutex, etc.

最後看了標準委員會對volatile的討論:http://www.open-std.org/jtc1/...
簡而言之,就是CC++中固然也想提供java中volatile同樣的線程可見性、阻止指令重排序,可是考慮到現有代碼已經那麼多了,忽然改變volatile的語義,可能會致使現有代碼的諸多問題,因此必需要再權衡一下,到底值不值得爲volatile增長上述語義,當前C++標準委員會建議不改變volatile語義,而是經過新的std::atmoic等來支持上述語義。

結合本身的實際操做、他人的回答以及CC++相關標準的描述,我認爲CC++ volatile確實不能保證線程可見性。可是因爲歷史的緣由、其餘語言的影響、開發者本身的誤解,這些共同致使開發者賦予了CC++ volatile不少本不屬於它的能力,甚至大錯特錯,就連Linus Torvards也在內核文檔中描述volatile時說,建議儘可能用memory barrier替換掉volatile,他認爲幾乎全部可能出現volatile的地方均可能會潛藏着一個bug,並提醒開發者必定當心謹慎。

6. 實踐中如何操做

  • 開發者應該儘可能編寫可移植的代碼,像x86這種強一致CPU,雖然結合volatile也能夠保證線程可見性,可是既然提供了相似memory barrier()、std::atomic等更加靠譜的用法,爲何要編寫這種兼顧volatile、x86特性的代碼呢?
  • 開發者應該編寫可維護的代碼,對於這種容易引發開發者誤會的代碼、特性,應該儘可能少用,這雖然不能說成是語言設計上的缺陷,可是確實也不能算是一個優點。

凡事都沒有絕對的,用不用volatile、怎麼用volatile須要開發者本身權衡,本文的目的主要是想總結CC++ volatile的「能」與「不能」以及背後的緣由。因爲我的認識的侷限性,不免會出現錯誤,也請你們指正。

相關文章
相關標籤/搜索