這篇文章詳細(xì)剖析了為什么在多核時(shí)代進(jìn)行多線程編程時(shí)需要慎用volatile關(guān)鍵字。
主要內(nèi)容有:
1. C/C++中的volatile關(guān)鍵字
2. Visual Studio對(duì)C/C++中volatile關(guān)鍵字的擴(kuò)展
3. Java/.NET中的volatile關(guān)鍵字
4. Memory Model(內(nèi)存模型)
5. Volatile使用建議
1. C/C++中的volatile關(guān)鍵字
1.1 傳統(tǒng)用途
C/C++作為系統(tǒng)級(jí)語(yǔ)言,它們與硬件的聯(lián)系是很緊密的。volatile的意思是“易變的”,這個(gè)關(guān)鍵字最早就是為了針對(duì)那些“異常”的內(nèi)存操作 而準(zhǔn)備的。它的效果是讓編譯器不要對(duì)這個(gè)變量的讀寫(xiě)操作做任何優(yōu)化,每次讀的時(shí)候都直接去該變量的內(nèi)存地址中去讀,每次寫(xiě)的時(shí)候都直接寫(xiě)到該變量的內(nèi)存地 址中去,即不做任何緩存優(yōu)化。它經(jīng)常用在需要處理中斷的嵌入式系統(tǒng)中,其典型的應(yīng)用有下面幾種:
a. 避免用通用寄存器對(duì)內(nèi)存讀寫(xiě)的優(yōu)化。編譯器常做的一種優(yōu)化就是:把常用變量的頻繁讀寫(xiě)弄到通用寄存器中,最后不用的時(shí)候再存回內(nèi)存中。但是如果某個(gè)內(nèi)存地址中的值是由片外決定的(例如另一個(gè)線程或是另一個(gè)設(shè)備可能更改它),那就需要volatile關(guān)鍵字了。(感謝Kenny老師指正)
b. 硬件寄存器可能被其他設(shè)備改變的情況。例如一個(gè)嵌入式板子上的某個(gè)寄存器直接與一個(gè)測(cè)試儀器連在一起,這樣在這個(gè)寄存器的值隨時(shí)可能被那個(gè)測(cè)試儀器更改。 在這種情況下如果把該值設(shè)為volatile屬性的,那么編譯器就會(huì)每次都直接從內(nèi)存中去取這個(gè)值的最新值,而不是自作聰明的把這個(gè)值保留在緩存中而導(dǎo)致 讀不到最新的那個(gè)被其他設(shè)備寫(xiě)入的新值。
c. 同一個(gè)物理內(nèi)存地址M有兩個(gè)不同的內(nèi)存地址的情況。例如兩個(gè)程序同時(shí)對(duì)同一個(gè)物理地址進(jìn)行讀寫(xiě),那么編譯器就不能假設(shè)這個(gè)地址只會(huì)有一個(gè)程序訪問(wèn)而做緩存優(yōu)化,所以程序員在這種情況下也需要把它定義為volatile的。
1.2 多線程程序中的錯(cuò)誤用法
看到這里,很多朋友自然會(huì)想到:恩,那么如果是兩個(gè)線程需要同時(shí)訪問(wèn)一個(gè)共享變量,為了讓其中兩個(gè)線程每次都能讀到這個(gè)變量的最新值,我們就把它定 義為volatile的就好了嘛!我想這個(gè)就是多線程程序中volatile之所以引起那么多爭(zhēng)議的最大原因。可惜的是,這個(gè)想法是錯(cuò)誤的。
舉例來(lái)說(shuō),想用volatile變量來(lái)做同步(例如一個(gè)flag)?錯(cuò)!為什么?很簡(jiǎn)單,雖然volatile意味著每次讀和寫(xiě)都是直接去內(nèi)存地址中去操作,但是volatile在C/C++現(xiàn)有標(biāo)準(zhǔn)中即不能保證原子性(Atomicity)也不能保證順序性(Ordering),所以幾乎所有試圖用volatile來(lái)進(jìn)行多線程同步的方案都是錯(cuò)的。我之前一篇文章介紹了Sequential Consistency模型(后 面簡(jiǎn)稱(chēng)SC),它其實(shí)就是我們印象中多線程程序應(yīng)該有的執(zhí)行順序。但是,SC最大的問(wèn)題是性能太低了,因?yàn)镃PU/編譯器完全沒(méi)有必要嚴(yán)格按代碼規(guī)定的順 序(program order)來(lái)執(zhí)行每一條指令。學(xué)過(guò)體系結(jié)構(gòu)的同學(xué)應(yīng)該知道不管是編譯器也好CPU也好,他們最擅長(zhǎng)做的事情就是幫你做亂序優(yōu)化。在串行時(shí)代這些亂序優(yōu)化 對(duì)程序員來(lái)說(shuō)都是透明的,封裝好了的,你不用關(guān)心它們到底給你亂序成啥樣了,因?yàn)樗鼈儠?huì)保證優(yōu)化后的程序的運(yùn)行結(jié)果跟你寫(xiě)程序時(shí)預(yù)期的結(jié)果是一模一樣的。 但是進(jìn)入多核時(shí)代之后,CPU和編譯器還會(huì)繼續(xù)做那些串行時(shí)代的優(yōu)化,更重要的是這些優(yōu)化還會(huì)打破你多線程程序的SC模型語(yǔ)義,從而使得多線程程序的實(shí)際 運(yùn)行結(jié)果與我們所期待的運(yùn)行結(jié)果不一致!
拿X86來(lái)說(shuō),它的多核內(nèi)存模型沒(méi)有嚴(yán)格執(zhí)行SC,即屬于weak ordering(或者叫relax ordering?)。它唯一允許的亂序優(yōu)化是可以把對(duì)不同地址的load操作提到store之前去(即把store x->load y亂序優(yōu)化成load y -> store x)。而store x -> store y、load x -> load y,以及store x -> load y不允許交換執(zhí)行順序。在X86這樣的內(nèi)存模型下,volatile關(guān)鍵字根本就不能保證對(duì)不同volatile變量x和y的store x -> load y的操作不會(huì)被CPU亂序優(yōu)化成load y -> store x。
而對(duì)多線程讀寫(xiě)操作的原子性來(lái)說(shuō),諸如volatile x=1這樣的寫(xiě)操作的原子性其實(shí)是由X86硬件保證的,跟volatile沒(méi)有任何關(guān)系。事實(shí)上,volatile根本不能保證對(duì)沒(méi)有內(nèi)存對(duì)齊的變量(或者超出機(jī)器字長(zhǎng)的變量)的讀寫(xiě)操作的原子性。
為了有個(gè)更直觀的理解,我們來(lái)看看CPU的亂序優(yōu)化是如何讓volatile在多線程程序中顯得如此無(wú)力的。下面這個(gè)著名的Dekker算法是想用 flag1/2和turn來(lái)實(shí)現(xiàn)兩個(gè)線程情況下的臨界區(qū)互斥訪問(wèn)。這個(gè)算法關(guān)鍵就在于對(duì)flag1/2和turn的讀操作(load)是在其寫(xiě)操作 (store)之后的,因此這個(gè)多線程算法能保證dekker1和dekker2中對(duì)gSharedCounter++的操作是互斥的,即等于是把 gSharedCounter++放到臨界區(qū)里去了。但是,多核X86可能會(huì)對(duì)這個(gè)store->load操作做亂序優(yōu)化,例如dekker1中對(duì) flag2的讀操作可能會(huì)被提到對(duì)flag1和turn的寫(xiě)操作之前,這樣就會(huì)最終導(dǎo)致臨界區(qū)的互斥訪問(wèn)失效,而gSharedCounter++也會(huì)因 此產(chǎn)生data race從而出現(xiàn)錯(cuò)誤的計(jì)算結(jié)果。那么為什么多核CPU會(huì)對(duì)多線程程序做這樣的亂序優(yōu)化呢?因?yàn)閺膯尉€程的視角來(lái)看flag2和flag1、turn是沒(méi) 有依賴(lài)關(guān)系的,所以CPU當(dāng)然可以對(duì)他們進(jìn)行亂序優(yōu)化以便充分利用好CPU里面的流水線(想了解更多細(xì)節(jié)請(qǐng)參考計(jì)算機(jī)體系結(jié)構(gòu)相關(guān)書(shū)籍)。這樣的優(yōu)化雖然 從單線程角度來(lái)講沒(méi)有錯(cuò),但是它卻違反了我們?cè)O(shè)計(jì)這個(gè)多線程算法時(shí)所期望的那個(gè)多線程語(yǔ)義。(想要解決這個(gè)bug就需要自己手動(dòng)添加memory barrier,或者干脆別去實(shí)現(xiàn)這樣的算法,而是使用類(lèi)似pthread_mutex_lock這樣的庫(kù)函數(shù),后面我會(huì)再講到這點(diǎn))
當(dāng)然,對(duì)不同的CPU來(lái)說(shuō)他們的內(nèi)存模型是不同的。比如說(shuō),如果這個(gè)程序是在單核上以多線程的方式執(zhí)行那么它肯定不會(huì)出錯(cuò),因?yàn)閱魏薈PU的內(nèi)存模 型是符合SC的。而在例如PowerPC,ARM之類(lèi)的架構(gòu)上運(yùn)行結(jié)果到底如何就得去翻它們的硬件手冊(cè)中內(nèi)存模型是怎么定義的了。
2. Visual Studio對(duì)C/C++中volatile關(guān)鍵字的擴(kuò)展
雖然C/C++中的volatile關(guān)鍵字沒(méi)有對(duì)ordering做任何保證,但是微軟從Visual Studio 2005開(kāi)始就對(duì)volatile關(guān)鍵字添加了同步語(yǔ)義(保證ordering),即:對(duì)volatile變量的讀操作具有acquire語(yǔ)義,對(duì) volatile變量的寫(xiě)操作具有release語(yǔ)義。Acquire和Release語(yǔ)義是來(lái)自data-race-free模型的概念。為了理解這個(gè) acquire語(yǔ)義和release語(yǔ)義有什么作用,我們來(lái)看看MSDN中的一個(gè)例子。
例子中的 while (Sentinel) Sleep(0); // volatile spin lock 是對(duì)volatile變量的讀操作,它具有acquire語(yǔ)義,acquire語(yǔ)義的隱義是當(dāng)前線程在對(duì)sentinel的這個(gè)讀操作之后的所有的對(duì)全局 變量的訪問(wèn)都必須在該操作之后執(zhí)行;同理,例子中的Sentinel = false; // exit critical section 是對(duì)volatile變量的寫(xiě)操作,它具有release語(yǔ)義,release語(yǔ)義的隱義是當(dāng)前線程在對(duì)sentinel這個(gè)寫(xiě)操作之前的所有對(duì)全局變量 的訪問(wèn)都必須在該操作之前執(zhí)行完畢。所以ThreadFunc1()讀CriticalData時(shí)必定已經(jīng)在ThreadFunc2()執(zhí)行完 CriticalData++之后,即CriticalData最后輸出的值必定為1。建議大家用紙畫(huà)一下acquire/release來(lái)加深理解。一個(gè)比較形象的解釋就是把a(bǔ)cquire當(dāng)成lock,把release當(dāng)成unlock,它倆組成了一個(gè)臨界區(qū),所有臨界區(qū)外面的操作都只能往這個(gè)里面移,但是臨界區(qū)里面的操作都不能往外移,簡(jiǎn)單吧?
其實(shí)這個(gè)程序就相當(dāng)于用volatile變量的acquire和release語(yǔ)義實(shí)現(xiàn)了一個(gè)臨界區(qū),在臨界區(qū)內(nèi)部的代碼就是 Sleep(2000); CriticalData++; 或者更貼切點(diǎn)也可以看成是一對(duì)pthread_cond_wait和pthread_cond_signal。
這個(gè)volatile的acquire和release語(yǔ)義是VS自己的擴(kuò)展,C/C++標(biāo)準(zhǔn)里是沒(méi)有的,所以同樣的代碼用gcc編譯執(zhí)行結(jié)果就可 能是錯(cuò)的,因?yàn)榫幾g器/CPU可能做違反正確性的亂序優(yōu)化。Acquire和release語(yǔ)義本質(zhì)上就是為了保證程序執(zhí)行時(shí)memory order的正確性。但是,雖然這個(gè)VS擴(kuò)展使得volatile變量能保證ordering,它還是不能保證對(duì)volatile變量讀寫(xiě)的原子性。事 實(shí)上,如果我們的程序是跑在X86上面的話,內(nèi)存對(duì)齊了的變量的讀寫(xiě)的原子性是由硬件保證的,跟volatile沒(méi)有任何關(guān)系。而像volatile g_nCnt++這樣的語(yǔ)句本身就不是原子操作,想要保證這個(gè)操作是原子的,就必須使用帶LOCK語(yǔ)義的++操作,具體請(qǐng)看我這篇文章。
另外,VS生成的volatile變量的匯編代碼是否真的調(diào)用了memory barrier也得看具體的硬件平臺(tái),例如x86上就不需要使用memory barrier也能保證acquire和release語(yǔ)義,因?yàn)閄86硬件本身就有比較強(qiáng)的memory模型了,但是Itanium上面VS就會(huì)生成帶 memory barrier的匯編代碼。具體可以參考這篇。
但是,雖然VS對(duì)volatile關(guān)鍵字加入了acquire/release語(yǔ)義,有一種情況還是會(huì)出錯(cuò),即我們之前看到的dekker算法的例子。這 個(gè)其實(shí)蠻好理解的,因?yàn)樽x操作的acquire語(yǔ)義不允許在其之后的操作往前移,但是允許在其之前的操作往后移;同理,寫(xiě)操作的release語(yǔ)義允許在 其之后的操作往前移,但是不允許在其之前的操作往后移;這樣的話對(duì)一個(gè)volatile變量的讀操作(acquire)當(dāng)然可以放到對(duì)另一個(gè) volatile變量的寫(xiě)操作(release)之前了!Bug就是這樣產(chǎn)生的!下面這個(gè)程序大家拿Visual Studio跑一下就會(huì)發(fā)現(xiàn)bug了(我試了VS2008和VS2010,都有這個(gè)bug)。多線程編程復(fù)雜吧?希望大家還沒(méi)被弄暈,要是暈了的話也很正 常,仔仔細(xì)細(xì)重新再看一遍吧:)
想解決這個(gè)Bug也很簡(jiǎn)單,直接在dekker1和dekker2中對(duì)flag1/flag2/turn賦值操作之后都分別加入full memory barrier就可以了,即保證load一定是在store之后執(zhí)行即可。具體的我就不詳述了。
3. Java/.NET中的volatile關(guān)鍵字
3.1 多線程語(yǔ)義
Java和.NET分別有JVM和CLR這樣的虛擬機(jī),保證多線程的語(yǔ)義就容易多了。說(shuō)簡(jiǎn)單點(diǎn),Java和.NET中的volatile關(guān)鍵字也是限制虛擬機(jī)做優(yōu)化,都具有acquire和release語(yǔ)義,而且由虛擬機(jī)直接保證了對(duì)volatile變量讀寫(xiě)操作的原子性。 (volatile 只保證可見(jiàn)性,不保證原子性。java中,對(duì)volatile修飾的long和double的讀寫(xiě)就不是原子的 (http://java.sun.com/docs/books/jvms/second_edition/html /Threads.doc.html#22244),除此之外的基本類(lèi)型和引用類(lèi)型都是原子的。– 多謝liuchangit指正) 這 里需要注意的一點(diǎn)是,Java和.NET里面的volatile沒(méi)有對(duì)應(yīng)于我們最開(kāi)始提到的C/C++中對(duì)“異常操作”用volatile修飾的傳統(tǒng)用 法。原因很簡(jiǎn)單,Java和.NET的虛擬機(jī)對(duì)安全性的要求比C/C++高多了,它們才不允許不安全的“異常”訪問(wèn)存在呢。
而且像JVM/.NET這樣的程序可移植性都非常好。雖然現(xiàn)在C++1x正在把多線程模型添加到標(biāo)準(zhǔn)中去,但是因?yàn)镃++本身的性質(zhì)導(dǎo)致它的硬件平 臺(tái)依賴(lài)性很高,可移植性不是特別好,所以在移植C/C++多線程程序時(shí)理解硬件平臺(tái)的內(nèi)存模型是非常重要的一件事情,它直接決定你這個(gè)程序是否會(huì)正確執(zhí) 行。
至于Java和.NET中是否也存在類(lèi)似VS 2005那樣的bug我沒(méi)時(shí)間去測(cè)試,道理其實(shí)是相同的,真有需要的同學(xué)自己應(yīng)該能測(cè)出來(lái)。好像這篇InfoQ的文章中顯示Java運(yùn)行這個(gè)dekker算法沒(méi)有問(wèn)題,因?yàn)镴VM給它添加了mfence。另一個(gè)臭名昭著的例子就應(yīng)該是Double-Checked Locking了。
3.2 volatile int與AtomicInteger區(qū)別
Java和.NET中這兩者還是有些區(qū)別的,主要就是后者提供了類(lèi)似incrementAndGet()這樣的方法可以直接調(diào)用(保證了原子性), 而如果是volatile x進(jìn)行++操作則不是原子的。increaseAndGet()的實(shí)現(xiàn)調(diào)用了類(lèi)似CAS這樣的原子指令,所以能保證原子性,同時(shí)又不會(huì)像使用 synchronized關(guān)鍵字一樣損失很多性能,用來(lái)做全局計(jì)數(shù)器非常合適。
4. Memory Model(內(nèi)存模型)
說(shuō)了這么多,還是順帶介紹一下Memory Model吧。就像前面說(shuō)的,CPU硬件有它自己的內(nèi)存模型,不同的編程語(yǔ)言也有它自己的內(nèi)存模型。如果用一句話來(lái)介紹什么是內(nèi)存模型,我會(huì)說(shuō)它就是程序 員,編程語(yǔ)言和硬件之間的一個(gè)契約,它保證了共享的內(nèi)存地址里的值在需要的時(shí)候是可見(jiàn)的。下次我會(huì)專(zhuān)門(mén)詳細(xì)寫(xiě)一篇關(guān)于它的內(nèi)容。它最大的作用是取得可編程 性與性能優(yōu)化之間的一個(gè)平衡。
5. volatile使用建議
總的來(lái)說(shuō),volatile關(guān)鍵字有兩種用途:一個(gè)是ISO C/C++中用來(lái)處理“異常”內(nèi)存行為(此用途只保證不讓編譯器做任何優(yōu)化,對(duì)多核CPU是否會(huì)進(jìn)行亂序優(yōu)化沒(méi)有任何約束力),另一種是在Java /.NET(包括Visual Studio添加的擴(kuò)展)中用來(lái)實(shí)現(xiàn)高性能并行算法(此種用途通過(guò)使用memory barrier保證了CPU/編譯器的ordering,以及通過(guò)JVM或者CLR保證了對(duì)該volatile變量讀寫(xiě)操作的原子性)。
一句話,volatile對(duì)多線程編程是非常危險(xiǎn)的,使用的時(shí)候千萬(wàn)要小心你的代碼在多核上到底是不是按你所想的方式執(zhí)行的,特別是對(duì)現(xiàn)在暫時(shí)還沒(méi) 有引入內(nèi)存模型的C/C++程序更是如此。安全起見(jiàn),大家還是用Pthreads,Java.util.concurrent,TBB等并行庫(kù)提供的 lock/spinlock,conditional variable, barrier, Atomic Variable之類(lèi)的同步方法來(lái)干活的好,因?yàn)樗鼈兊膬?nèi)部實(shí)現(xiàn)都調(diào)用了相應(yīng)的memory barrier來(lái)保證memory ordering,你只要保證你的多線程程序沒(méi)有data race,那么它們就能幫你保證你的程序是正確的(是的,Pthreads庫(kù)也是有它自己的內(nèi)存模型的,只不過(guò)它的內(nèi)存模型還些缺點(diǎn),所以把多線程內(nèi)存模 型直接集成到C/C++中是更好的辦法,也是將來(lái)的趨勢(shì),但是C++1x中將不會(huì)像Java/.NET一樣給volatile關(guān)鍵字添加acquire和 release語(yǔ)義,而是轉(zhuǎn)而提供另一種具有同步語(yǔ)義的atomic variables,此為后話)。如果你想實(shí)現(xiàn)更高性能的lock free算法,或是使用volatile來(lái)進(jìn)行同步,那么你就需要先把CPU和編程語(yǔ)言的memory model搞清楚,然后再時(shí)刻注意Atomicity和Ordering是否被保證了。(注 意,用沒(méi)有acquire/release語(yǔ)義的volatile變量來(lái)進(jìn)行同步是錯(cuò)誤的,但是你仍然可以在C/C++中用volatile來(lái)修飾一個(gè)不 是用來(lái)做同步(例如一個(gè)event flag)而只是被不同線程讀寫(xiě)的共享變量,只不過(guò)它的新值什么時(shí)候能被另一個(gè)線程讀到是沒(méi)有保證的,需要你自己做相應(yīng)的處理)
Herb Sutter 在他的那篇volatile vs. volatile中對(duì)這兩種用法做了很仔細(xì)的區(qū)分,我把其中兩張表格鏈接貼過(guò)來(lái)供大家參考:
volatile的兩種用途
volatile兩種用途的異同
最后附上《Java Concurrency in Practice》3.1.4節(jié)中對(duì)Java語(yǔ)言的volatile關(guān)鍵字的使用建議(不要被英語(yǔ)嚇到,這些內(nèi)容確實(shí)對(duì)你有用,而且還能順便幫練練英語(yǔ),哈哈):
So from a memory visibility perspective, writing a volatile variable is like exiting a synchronized block and reading a volatile variable is like entering a synchronized block. However, we do not recommend relying too heavily on volatile variables for visibility; code that relies on volatile variables for visibility of arbitrary state is more fragile and harder to understand than code that uses locking.
Use volatile variables only when they simplify implementing and verifying your synchronization policy; avoid using volatile variables when veryfing correctness would require subtle reasoning about visibility. Good uses of volatile variables include ensuring the visibility of their own state, that of the object they refer to, or indicating that an important lifecycle event (such as initialization or shutdown) has occurred.
Locking can guarantee both visibility and atomicity; volatile variables can only guarantee visibility.
You can use volatile variables only when all the following criteria are met:
(1) Writes to the variable do not depend on its current value, or you can ensure that only a single thread ever updates the value;
(2) The variable does not participate in invariants with other state variables; and
(3) Locking is not required for any other reason while the variable is being accessed.
參考資料
1. 《Java Concurrency in Practice》3.1.4節(jié)
2. volatile vs. volatile(Herb Sutter對(duì)volatile的闡述,必看)
3. The “Double-Checked Locking is Broken” Declaration
4. Threading in C#
5. Volatile: Almost Useless for Multi-Threaded Programming
6. Memory Ordering in Modern Microprocessors
7. Memory Ordering @ Wikipedia
8. 內(nèi)存屏障什么的
9. The memory model of x86
10. VC 下 volatile 變量能否建立 Memory Barrier 或并發(fā)鎖
11. Sayonara volatile(Concurrent Programming on Windows作者的文章 跟我觀點(diǎn)幾乎一致)
12. Java 理論與實(shí)踐: 正確使用 Volatile 變量
13. Java中的Volatile關(guān)鍵字
轉(zhuǎn)自:http://www.parallellabs.com/2010/12/04/why-should-we-be-care-of-volatile-keyword-in-multithreaded-applications/