“你不瞭解的”CC++ volatile

1. 令人困惑的volatile

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

很多人以爲自己精通CC++,但是被問起volatile的時候卻無法清晰、果斷地表明態度,那隻能說明還是處在“從入門到精通”的路上,如果瞭解一門語言常見特性的使用、能夠寫健壯高效的程序就算精通的話,那實在是太藐視“大師”的存在了。從一個volatile關鍵字折射出了對CC++標準、編譯器、操作系統、處理器、MMU各個方面的掌握程度。

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

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

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/users/734069/nicol-bolas)回答中提到:

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, lock a std::mutex, etc.

最後看了標準委員會對volatile的討論:http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2006/n2016.html
簡而言之,就是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的“能”與“不能”以及背後的原因。由於個人認識的侷限性,難免會出現錯誤,也請大家指正。

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