天天看點

C/C++ Volatile關鍵詞深度剖析背景 Volatile:易變的Volatile:不可優化的 Volatile:順序性Volatile:Java增強Volatile的起源參考資料

轉自:http://hedengcheng.com/?p=725

這篇文章很有收藏價值。

背景

前幾天,發了一條如下的微網誌 (關于C/C++ Volatile關鍵詞的使用建議):

C/C++ Volatile關鍵詞深度剖析背景 Volatile:易變的Volatile:不可優化的 Volatile:順序性Volatile:Java增強Volatile的起源參考資料

此微網誌,引發了朋友們的大量讨論:贊同者有之;批評者有之;當然,更多的朋友,是希望我能更詳細的解讀C/C++ Volatile關鍵詞,來佐證我的微網誌觀點。而這,正是我寫這篇博文的初衷:本文,将詳細分析C/C++ Volatile關鍵詞的功能 (有多種功能)、Volatile關鍵詞在多線程程式設計中存在的問題、Volatile關鍵詞與編譯器/CPU的關系、C/C++ Volatile與Java Volatile的差別,以及Volatile關鍵詞的起源,希望對大家更好的了解、使用C/C++ Volatile,有所幫助。

Volatile,詞典上的解釋為:易失的;易變的;易揮發的。那麼用這個關鍵詞修飾的C/C++變量,應該也能夠展現出”易變”的特征。大部分人認識Volatile,也是從這個特征出發,而這也是本文揭秘的C/C++ Volatile的第一個特征。

 Volatile:易變的

在介紹C/C++ Volatile關鍵詞的”易變”性前,先讓我們看看以下的兩個代碼片段,以及他們對應的彙編指令 (以下用例的彙編代碼,均為VS 2008編譯出來的Release版本):

測試用例一:非Volatile變量

C/C++ Volatile關鍵詞深度剖析背景 Volatile:易變的Volatile:不可優化的 Volatile:順序性Volatile:Java增強Volatile的起源參考資料

b = a + 1;這條語句,對應的彙編指令是:lea ecx, [eax + 1]。由于變量a,在前一條語句a = fn(c)執行時,被緩存在了寄存器eax中,是以b = a + 1;語句,可以直接使用仍舊在寄存器eax中的a,來進行計算,對應的也就是彙編:[eax + 1]。

測試用例二:Volatile變量

C/C++ Volatile關鍵詞深度剖析背景 Volatile:易變的Volatile:不可優化的 Volatile:順序性Volatile:Java增強Volatile的起源參考資料

與測試用例一唯一的不同之處,是變量a被設定為volatile屬性,一個小小的變化,帶來的是彙編代碼上很大的變化。a = fn(c)執行後,寄存器ecx中的a,被寫回記憶體:mov dword ptr [esp+0Ch], ecx。然後,在執行b = a + 1;語句時,變量a有重新被從記憶體中讀取出來:mov eax, dword ptr [esp + 0Ch],而不再直接使用寄存器ecx中的内容。

小結

從以上的兩個用例,就可以看出C/C++ Volatile關鍵詞的第一個特性:易變性。所謂的易變性,在彙編層面反映出來,就是兩條語句,下一條語句不會直接使用上一條語句對應的volatile變量的寄存器内容,而是重新從記憶體中讀取。volatile的這個特性,相信也是大部分朋友所了解的特性。

在了解了C/C++ Volatile關鍵詞的”易變”特性之後,再讓我們接着繼續來剖析Volatile的下一個特性:”不可優化”特性。

Volatile:不可優化的

與前面介紹的”易變”性類似,關于C/C++ Volatile關鍵詞的第二個特性:”不可優化”性,也通過兩個對比的代碼片段來說明:

 測試用例三:非Volatile變量

C/C++ Volatile關鍵詞深度剖析背景 Volatile:易變的Volatile:不可優化的 Volatile:順序性Volatile:Java增強Volatile的起源參考資料

在這個用例中,非volatile變量a,b,c全部被編譯器優化掉了 (optimize out),因為編譯器通過分析,發覺a,b,c三個變量是無用的,可以進行常量替換。最後的彙編代碼相當簡介,高效率。

測試用例四:Volatile變量

C/C++ Volatile關鍵詞深度剖析背景 Volatile:易變的Volatile:不可優化的 Volatile:順序性Volatile:Java增強Volatile的起源參考資料

測試用例四,與測試用例三類似,不同之處在于,a,b,c三個變量,都是volatile變量。這個差別,反映到彙編語言中,就是三個變量仍舊存在,需要将三個變量從記憶體讀入到寄存器之中,然後再調用printf()函數。

小結

從測試用例三、四,可以總結出C/C++ Volatile關鍵詞的第二個特性:“不可優化”特性。volatile告訴編譯器,不要對我這個變量進行各種激進的優化,甚至将變量直接消除,保證程式員寫在代碼中的指令,一定會被執行。相對于前面提到的第一個特性:”易變”性,”不可優化”特性可能知曉的人會相對少一些。但是,相對于下面提到的C/C++ Volatile的第三個特性,無論是”易變”性,還是”不可優化”性,都是Volatile關鍵詞非常流行的概念。

 Volatile:順序性

C/C++ Volatile關鍵詞前面提到的兩個特性,讓Volatile經常被解讀為一個為多線程而生的關鍵詞:一個全局變量,會被多線程同時通路/修改,那麼線程内部,就不能假設此變量的不變性,并且基于此假設,來做一些程式設計。當然,這樣的假設,本身并沒有什麼問題,多線程程式設計,并發通路/修改的全局變量,通常都會建議加上Volatile關鍵詞修飾,來防止C/C++編譯器進行不必要的優化。但是,很多時候,C/C++ Volatile關鍵詞,在多線程環境下,會被賦予更多的功能,進而導緻問題的出現。

回到本文背景部分我的那篇微網誌,我的這位朋友,正好犯了一個這樣的問題。其對C/C++ Volatile關鍵詞的使用,可以抽象為下面的僞代碼:

C/C++ Volatile關鍵詞深度剖析背景 Volatile:易變的Volatile:不可優化的 Volatile:順序性Volatile:Java增強Volatile的起源參考資料

這段僞代碼,聲明另一個Volatile的flag變量。一個線程(Thread1)在完成一些操作後,會修改這個變量。而另外一個線程(Thread2),則不斷讀取這個flag變量,由于flag變量被聲明了volatile屬性,是以編譯器在編譯時,并不會每次都從寄存器中讀取此變量,同時也不會通過各種激進的優化,直接将if (flag == true)改寫為if (false == true)。隻要flag變量在Thread1中被修改,Thread2中就會讀取到這個變化,進入if條件判斷,然後進入if内部進行處理。在if條件的内部,由于flag == true,那麼假設Thread1中的something操作一定已經完成了,在基于這個假設的基礎上,繼續進行下面的other things操作。

通過将flag變量聲明為volatile屬性,很好的利用了本文前面提到的C/C++ Volatile的兩個特性:”易變”性;”不可優化”性。按理說,這是一個對于volatile關鍵詞的很好應用,而且看到這裡的朋友,也可以去檢查檢查自己的代碼,我相信肯定會有這樣的使用存在。

但是,這個多線程下看似對于C/C++ Volatile關鍵詞完美的應用,實際上卻是有大問題的。問題的關鍵,就在于前面标紅的文字:由于flag = true,那麼假設Thread1中的something操作一定已經完成了。flag == true,為什麼能夠推斷出Thread1中的something一定完成了?其實既然我把這作為一個錯誤的用例,答案是一目了然的:這個推斷不能成立,你不能假設看到flag == true後,flag = true;這條語句前面的something一定已經執行完成了。這就引出了C/C++ Volatile關鍵詞的第三個特性:順序性。

同樣,為了說明C/C++ Volatile關鍵詞的”順序性”特征,下面給出三個簡單的用例 (注:與上面的測試用例不同,下面的三個用例,基于的是Linux系統,使用的是”GCC: (Debian 4.3.2-1.1) 4.3.2″):

測試用例五:非Volatile變量

C/C++ Volatile關鍵詞深度剖析背景 Volatile:易變的Volatile:不可優化的 Volatile:順序性Volatile:Java增強Volatile的起源參考資料

一個簡單的示例,全局變量A,B均為非volatile變量。通過gcc O2優化進行編譯,你可以驚奇的發現,A,B兩個變量的指派順序被調換了!!!在對應的彙編代碼中,B = 0語句先被執行,然後才是A = B + 1語句被執行。

在這裡,我先簡單的介紹一下C/C++編譯器最基本優化原理:保證一段程式的輸出,在優化前後無變化。将此原理應用到上面,可以發現,雖然gcc優化了A,B變量的指派順序,但是foo()函數的執行結果,優化前後沒有發生任何變化,仍舊是A = 1;B = 0。是以這麼做是可行的。

測試用例六:一個Volatile變量

C/C++ Volatile關鍵詞深度剖析背景 Volatile:易變的Volatile:不可優化的 Volatile:順序性Volatile:Java增強Volatile的起源參考資料

此測試,相對于測試用例五,最大的差別在于,變量B被聲明為volatile變量。通過檢視對應的彙編代碼,B仍舊被提前到A之前指派,Volatile變量B,并未阻止編譯器優化的發生,編譯後仍舊發生了亂序現象。

如此看來,C/C++ Volatile變量,與非Volatile變量之間的操作,是可能被編譯器交換順序的。

通過此用例,已經能夠很好的說明,本章節前面,通過flag == true,來假設something一定完成是不成立的。在多線程下,如此使用volatile,會産生很嚴重的問題。但是,這不是終點,請繼續看下面的測試用例七。

測試用例七:兩個Volatile變量

C/C++ Volatile關鍵詞深度剖析背景 Volatile:易變的Volatile:不可優化的 Volatile:順序性Volatile:Java增強Volatile的起源參考資料

同時将A,B兩個變量都聲明為volatile變量,再來看看對應的彙編。奇迹發生了,A,B指派亂序的現象消失。此時的彙編代碼,與使用者代碼順序高度一直,先指派變量A,然後指派變量B。

如此看來,C/C++ Volatile變量間的操作,是不會被編譯器交換順序的。

happens-before

通過測試用例六,可以總結出:C/C++ Volatile變量與非Volatile變量間的操作順序,有可能被編譯器交換。是以,上面多線程操作的僞代碼,在實際運作的過程中,就有可能變成下面的順序:

C/C++ Volatile關鍵詞深度剖析背景 Volatile:易變的Volatile:不可優化的 Volatile:順序性Volatile:Java增強Volatile的起源參考資料

由于Thread1中的代碼執行順序發生變化,flag = true被提前到something之前進行,那麼整個Thread2的假設全部失效。由于something未執行,但是Thread2進入了if代碼段,整個多線程代碼邏輯出現問題,導緻多線程完全錯誤。

細心的讀者看到這裡,可能要提問,根據測試用例七,C/C++ Volatile變量間,編譯器是能夠保證不交換順序的,那麼能不能将something中所有的變量全部設定為volatile呢?這樣就阻止了編譯器的亂序優化,進而也就保證了這個多線程程式的正确性。

針對此問題,很不幸,仍舊不行。将所有的變量都設定為volatile,首先能夠阻止編譯器的亂序優化,這一點是可以肯定的。但是,别忘了,編譯器編譯出來的代碼,最終是要通過CPU來執行的。目前,市場上有各種不同體系架構的CPU産品,CPU本身為了提高代碼運作的效率,也會對代碼的執行順序進行調整,這就是所謂的CPU Memory Model (CPU記憶體模型)。關于CPU的記憶體模型,可以參考這些資料:Memory Ordering From Wiki;Memory Barriers Are Like Source Control Operations From Jeff Preshing;CPU Cache and Memory Ordering From 何登成。下面,是截取自Wiki上的一幅圖,列舉了不同CPU架構,可能存在的指令亂序。

C/C++ Volatile關鍵詞深度剖析背景 Volatile:易變的Volatile:不可優化的 Volatile:順序性Volatile:Java增強Volatile的起源參考資料

從圖中可以看到,X86體系(X86,AMD64),也就是我們目前使用最廣的CPU,也會存在指令亂序執行的行為:StoreLoad亂序,讀操作可以提前到寫操作之前進行。

是以,回到上面的例子,哪怕将所有的變量全部都聲明為volatile,哪怕杜絕了編譯器的亂序優化,但是針對生成的彙編代碼,CPU有可能仍舊會亂序執行指令,導緻程式依賴的邏輯出錯,volatile對此無能為力。

其實,針對這個多線程的應用,真正正确的做法,是建構一個happens-before語義。關于happens-before語義的定義,可參考文章:The Happens-Before Relation。下面,用圖的形式,來展示happens-before語義:

C/C++ Volatile關鍵詞深度剖析背景 Volatile:易變的Volatile:不可優化的 Volatile:順序性Volatile:Java增強Volatile的起源參考資料

如圖所示,所謂的happens-before語義,就是保證Thread1代碼塊中的所有代碼,一定在Thread2代碼塊的第一條代碼之前完成。當然,建構這樣的語義有很多方法,我們常用的Mutex、Spinlock、RWLock,都能保證這個語義 (關于happens-before語義的建構,以及為什麼鎖能保證happens-before語義,以後專門寫一篇文章進行讨論)。但是,C/C++ Volatile關鍵詞不能保證這個語義,也就意味着C/C++ Volatile關鍵詞,在多線程環境下,如果使用的不夠細心,就會産生如同我這裡提到的錯誤。

小結

C/C++ Volatile關鍵詞的第三個特性:”順序性”,能夠保證Volatile變量間的順序性,編譯器不會進行亂序優化。Volatile變量與非Volatile變量的順序,編譯器不保證順序,可能會進行亂序優化。同時,C/C++ Volatile關鍵詞,并不能用于建構happens-before語義,是以在進行多線程程式設計時,要小心使用volatile,不要掉入volatile變量的使用陷阱之中。

Volatile:Java增強

在介紹了C/C++ Volatile關鍵詞之後,再簡單介紹一下Java的Volatile。與C/C++的Volatile關鍵詞類似,Java的Volatile也有這三個特性,但最大的不同在于:第三個特性,”順序性”,Java的Volatile有很極大的增強,Java Volatile變量的操作,附帶了Acquire與Release語義。所謂的Acquire與Release語義,可參考文章:Acquire and Release Semantics。(這一點,後續有必要的話,可以寫一篇文章專門讨論)。Java Volatile所支援的Acquire、Release語義,如下:

對于Java Volatile變量的寫操作,帶有Release語義,所有Volatile變量寫操作之前的針對其他任何變量的讀寫操作,都不會被編譯器、CPU優化後,亂序到Volatile變量的寫操作之後執行。

對于Java Volatile變量的讀操作,帶有Acquire語義,所有Volatile變量讀操作之後的針對其他任何變量的讀寫操作,都不會被編譯器、CPU優化後,亂序到Volatile變量的讀操作之前進行。

通過Java Volatile的Acquire、Release語義,對比C/C++ Volatile,可以看出,Java Volatile對于編譯器、CPU的亂序優化,限制的更加嚴格了。Java Volatile變量與非Volatile變量的一些亂序操作,也同樣被禁止。

由于Java Volatile支援Acquire、Release語義,是以Java Volatile,能夠用來建構happens-before語義。也就是說,前面提到的C/C++ Volatile在多線程下錯誤的使用場景,在Java語言下,恰好就是正确的。如下圖所示:

C/C++ Volatile關鍵詞深度剖析背景 Volatile:易變的Volatile:不可優化的 Volatile:順序性Volatile:Java增強Volatile的起源參考資料

Volatile的起源

C/C++的Volatile關鍵詞,有三個特性:易變性;不可優化性;順序性。那麼,為什麼Volatile被設計成這樣呢?要回答這個問題,就需要從Volatile關鍵詞的産生說起。(注:這一小節的内容,參考自C++ and the Perils of Double-Checked Locking論文的第10章節:volatile:A Brief History。這是一篇頂頂好的論文,值得多次閱讀,強烈推薦!)

Volatile關鍵詞,最早出現于19世紀70年代,被用于處理memory-mapeed I/O (MMIO)帶來的問題。在引入MMIO之後,一塊記憶體位址,既有可能是真正的記憶體,也有可能被映射到一個I/O端口。相對的,讀寫一個記憶體位址,既有可能操作記憶體,也有可能讀寫的是一個I/O裝置。MMIO為什麼需要引入Volatile關鍵詞?考慮如下的一個代碼片段:

C/C++ Volatile關鍵詞深度剖析背景 Volatile:易變的Volatile:不可優化的 Volatile:順序性Volatile:Java增強Volatile的起源參考資料

在此代碼片段中,指針p既有可能指向一個記憶體位址,也有可能指向一個I/O裝置。如果指針p指向的是I/O裝置,那麼(1),(2)中的a,b,就會接收到I/O裝置的連續兩個位元組。但是,p也有可能指向記憶體,此時,編譯器的優化政策,就可能會判斷出a,b同時從同一記憶體位址讀取資料,在做完(1)之後,直接将a指派給b。對于I/O裝置,需要防止編譯器做這個優化,不能假設指針b指向的内容不變——易變性。

同樣,代碼(3),(4)也有類似的問題,編譯器發現将a,b同時指派給指針p是無意義的,是以可能會優化代碼(3)中的指派操作,僅僅保留代碼(4)。對于I/O裝置,需要防止編譯器将寫操作給徹底優化消失了——”不可優化”性。

對于I/O裝置,編譯器不能随意互動指令的順序,因為順序一變,寫入I/O裝置的内容也就發生變化了——”順序性”。

基于MMIO的這三個需求,設計出來的C/C++ Volatile關鍵詞,所含有的特性,也就是本文前面分析的三個特性:易變性;不可優化性;順序性。

參考資料

[1] Wiki. Volatile variable.

[2] Wiki. Memory ordering.

[3] Scott Meyers; Andrei Alexandrescu. C++ and the Perils of Double-Checked Locking.

[4] Jeff Preshing. Memory Barriers Are Like Source Control Operations.

[5] Jeff Preshing. The Happens-Before Relation.

[6] Jeff Preshing. Acquire and Release Semantics.

[7] 何登成. CPU Cache and Memory Ordering——并發程式設計入門.

[8] Bartosz Milewski. Who ordered sequential consistency?

[9] Andrew Haley. What are we going to do about volatile?

[10] Java Glossary. volatile.

[11] stackoverflow. Why is volatile not considered useful in multithreaded C or C++ programming?

[12] msdn. Volatile fields.

[13] msdn. volatile (C++).

[14] 劉未鵬. 《C++0x漫談》系列之:多線程記憶體模型.

繼續閱讀