第 64 講:多線程(六):volatile 關(guān)鍵字
今天我們來說一個(gè)新的 C# 的關(guān)鍵字:volatile
。這個(gè)關(guān)鍵字在 C 語言里就有,不過因?yàn)?C 語言基本上也遇不到這個(gè)關(guān)鍵字的使用,所以即使是有,在課堂上也沒有辦法學(xué)到這里。
C# 的 volatile
關(guān)鍵字和 C 語言的這個(gè)關(guān)鍵字的使用場景基本一致,但因?yàn)槭敲嫦驅(qū)ο蟮恼Z言,所以也有一點(diǎn)不同。
Part 1 引例
我們來看一個(gè)超級簡單的程序。
首先,因?yàn)?x
位于類里作為字段出現(xiàn),所以它會(huì)被系統(tǒng)自動(dòng)初始化為 0。在運(yùn)行 Main
里的時(shí)候,x
會(huì)被賦值為 1。我現(xiàn)在更新后的數(shù)值會(huì)直接被 Console.WriteLine
方法調(diào)用并且輸出,所以預(yù)期結(jié)果肯定是 1。是的,編譯器也是這么想的。所以編譯器會(huì)認(rèn)為預(yù)期結(jié)果就一定是輸出 1,于是編譯器會(huì)對其進(jìn)行優(yōu)化。正因?yàn)槿绱?,編譯器會(huì)因?yàn)槟氵@個(gè)預(yù)期結(jié)果而直接去假設(shè) x
的最終結(jié)果一定是 1,然后在優(yōu)化代碼后,直接改寫代碼,改成這樣:
即直接輸出 1。這一點(diǎn)沒有問題,很好理解,對吧。
注意代碼。優(yōu)化后的代碼甚至你看不到
x
的賦值,而直接是把 1 當(dāng)成結(jié)果直接給輸出的。這個(gè)是優(yōu)化后的情況。
你可能會(huì)覺得,編譯器這么做雖然沒用 x
了,但也沒問題,對吧。與其我直接計(jì)算出結(jié)果后顯示結(jié)果,還不如優(yōu)化掉代碼,直接在編譯期間就把這個(gè)運(yùn)行結(jié)果給算出來,然后直接用常量替換掉一大堆的復(fù)雜表達(dá)式??蓡栴}在于,這種優(yōu)化代碼的過程顯然是不適用于多線程的。假設(shè)我在別的線程里更改了 x
的數(shù)值,比如我在多線程里,在 x = 1
和輸出的中間,主線程時(shí)間片到了,然后開始執(zhí)行我開的線程,并變更了 x
的數(shù)值。這種“詭變”使得時(shí)間片回到主線程的時(shí)候,x
已經(jīng)不再是 1,然后輸出的結(jié)果就不應(yīng)該是 1。在多線程的世界里,這種情況是完全可以出現(xiàn)的;而由于編譯器自身的優(yōu)化效果,好家伙,x
變量都給我們優(yōu)化掉了,直接輸出個(gè) 1,于是,程序就出現(xiàn)問題了。
倘若我已經(jīng)編譯好了一個(gè)程序,它已經(jīng)優(yōu)化過了。然后我直接上去就運(yùn)行它。結(jié)果我照著代碼看,“欸,我代碼沒錯(cuò)啊,怎么結(jié)果不按常理出牌”,就會(huì)出現(xiàn)這種復(fù)雜的、還基本上沒辦法找的 bug。
Part 2 規(guī)避編譯器無法識(shí)別的賦值優(yōu)化:volatile
關(guān)鍵字
我們試著直接給字段加上 volatile
關(guān)鍵字:
這個(gè)時(shí)候,程序就不再給 x
執(zhí)行代碼優(yōu)化了。我不管是不是正常的只有一句 x = 1
,還是有多線程更改 x
的數(shù)值,還是只有單線程的情況在不斷計(jì)算 x
的數(shù)值,編譯器都不再按優(yōu)化處理這個(gè)變量了。這樣的話,雖然有些時(shí)候代碼不會(huì)增加運(yùn)行效率,但保證了程序執(zhí)行的正確性和安全性,畢竟數(shù)據(jù)沒有被優(yōu)化而規(guī)避了產(chǎn)生潛在 bug 的隱蔽的錯(cuò)誤現(xiàn)象。
Part 3 volatile
關(guān)鍵字可以用在哪些地方?
一般而言,多線程為了給方法執(zhí)行的時(shí)候傳值,我們大多數(shù)情況都會(huì)考慮使用字段來解決問題。這種情況下,字段成了多線程引用變量的常客。因此,volatile
用于字段。
第二。volatile
的變量一般都非常容易操作。所謂的“容易操作”,指的是這個(gè)變量非常方便去修改它的數(shù)值。之前我們簡單說到過一個(gè)叫做 torn read 的現(xiàn)象,當(dāng)電腦位數(shù)不夠的時(shí)候,數(shù)據(jù)類型過大就會(huì)導(dǎo)致這個(gè)對象多線程拷貝的過程之中變得不再是原子的。在 64 位數(shù)的電腦上可以拷貝 long
類型大小的數(shù)據(jù)類型的數(shù)據(jù),但現(xiàn)在仍有 32 位的電腦。C# 因?yàn)榭紤]兼容性和一些安全性層面的原因,所以多線程環(huán)境下,不讓我們對于 long
和 ulong
類型有 volatile
的修飾。當(dāng)然,別的和 long
這樣都是大于等于 8 個(gè)字節(jié)的對象也都不會(huì)讓你使用 volatile
修飾,比如 double
。
所以,你在官方文獻(xiàn)上查找對應(yīng)的 volatile
的資料的時(shí)候,都會(huì)告訴你有效的數(shù)據(jù)類型都有哪些。例如,在微軟官方的 C# 語言文檔里,的解釋是這樣的:

大概直接翻譯一下:
引用類型(因?yàn)橐妙愋蛡鬟f引用,也就是底層的地址數(shù)值。它們傳參剛好不超過本機(jī)位數(shù)大?。?;
指針類型(指針類型和引用類型傳參的地址在底層基本上可以說是一個(gè)東西);
內(nèi)置值類型,但不包含
long
、ulong
、double
和decimal
(因?yàn)樗鼈円呀?jīng) 8 個(gè)字節(jié)甚至于 16 個(gè)字節(jié)了);枚舉類型,它的特征數(shù)值為非
long
和ulong
類型;泛型類型,能夠從上下文暗示出它是引用類型的情況(這個(gè)屬于泛型,這個(gè)我們現(xiàn)在不講);
IntPtr
和UIntPtr
(就是安全地封裝了的void*
,所以也是地址數(shù)值)。
所以,兩點(diǎn):
必須是字段;
該字段的類型在多線程里的賦值過程不得發(fā)生 torn read 現(xiàn)象。
Part 4 volatile
是為了避免優(yōu)化存在,那為毛要放在線程同步里說?
當(dāng)然是為了契合線程同步才會(huì)放在這里說啊。下面我們來說一個(gè) volatile
廣泛使用的、也是 volatile
最正確的使用場景——模擬用戶點(diǎn)擊(或者別的什么操作)激活程序繼續(xù)運(yùn)行的模式。用戶點(diǎn)擊之后程序才會(huì)繼續(xù)運(yùn)行,否則程序會(huì)主動(dòng)卡住在這里。這個(gè)現(xiàn)象我們是不是需要線程同步?而這個(gè)模式也剛好我們可以這里說一下,它也是 lock
的一種替代方案。
我們來看這段代碼。
刪除這里面的注釋,其實(shí)代碼并不多。我使用了一個(gè)叫做 Console.ReadKey
的方法模擬線程卡?。哼@個(gè)方法的作用原本是等待用戶按下任意按鍵以繼續(xù)執(zhí)行后面的步驟的。
請仔細(xì)查看里面的注釋文字,特別是 AnotherMethod
里的。注釋文字是我自己寫的,如果你有一點(diǎn)英語水平可以看英文(我也建議你看英文,以后查資料就不費(fèi)勁了)。大概翻譯一下是這樣的:
這段代碼用來模擬一個(gè)現(xiàn)象,模擬用戶按下任何一個(gè)按鍵才會(huì)繼續(xù)執(zhí)行后續(xù)的代碼。如果任何一個(gè)人按下了按鍵,這個(gè)程序就會(huì)繼續(xù)執(zhí)行后續(xù)的代碼,然后更改
ShouldExit
的變量的數(shù)值,改成true
。我們這個(gè)程序的目的就是為了終止它(先運(yùn)行然后按按鍵后結(jié)束程序運(yùn)行),所以我們總會(huì)發(fā)現(xiàn)一點(diǎn),就是最終情況下的
ShouldExit
變量一定是在結(jié)束的時(shí)候?yàn)?true
的。正因?yàn)槿绱?,一個(gè)叫做 JIT 的東西(JIT 是 C# 運(yùn)行環(huán)境的優(yōu)化代碼的工具,JIT 全稱叫 Just In Time)會(huì)優(yōu)化代碼,并改寫這個(gè)ShouldExit
變量的行為,并直接在任何地方都把這個(gè)變量改成true
,就免得我任何時(shí)候讀取它的時(shí)候還去算一下結(jié)果,然后等了漫長的結(jié)果才得到這個(gè)結(jié)果true
。換句話說,你會(huì)在優(yōu)化代碼之后發(fā)現(xiàn),這個(gè)
Main
方法里的while (!ShouldExit)
會(huì)被優(yōu)化成while (true)
,顯然它已經(jīng)改寫了代碼的邏輯使得程序已經(jīng)面目全非了。所以我們?yōu)榱俗柚咕幾g器優(yōu)化它,我們要添加volatile
關(guān)鍵字修飾這個(gè)字段。
你仔細(xì)看看這個(gè)程序的執(zhí)行流程:首先調(diào)取了線程池的線程,執(zhí)行無限期等待用戶按按鍵的過程,然后用戶按下按鍵后,直接 ShouldExit
改成 true
,副線程結(jié)束;而主線程則是一直做 while
循環(huán)。很明顯主線程做循環(huán)是一直在獲取 ShouldExit
的變量數(shù)值。因?yàn)橛脩暨€沒有點(diǎn)擊繼續(xù),ShouldExit
就永遠(yuǎn)為 false
,因此我使用 while (!ShouldExit)
和一個(gè)空的循環(huán)體用來占位,表示程序一直在主線程這里卡住,讓它不繼續(xù)往下執(zhí)行。
這是線程同步的第二個(gè)慣用手法:用一個(gè)無意義的死循環(huán)(無限循環(huán),或者叫近乎無限的循環(huán))來卡住線程本身,然后等待別的線程執(zhí)行運(yùn)行完成后,起到橋梁的變量 ShouldExit
改變數(shù)值后,while
循環(huán)立馬因?yàn)闂l件不成立而退出循環(huán),接著主線程就可以繼續(xù)執(zhí)行后面的代碼了。
在 UI 程序里,你可以在 while (!ShouldExit)
這樣的循環(huán)里寫上一些等待語句,比如調(diào)整控件的顯示信息,告知用戶“程序正在運(yùn)行中”,然后更改一些控件的狀態(tài)之類的??偟膩碚f,這是一種慣用法。
Part 5 它和 lock
和 Interlocked
類型有啥關(guān)系呢?
你有沒有發(fā)現(xiàn),這個(gè)死循環(huán)有點(diǎn)像 lock
的底層邏輯:lock
的原理是開一個(gè)同步鎖來監(jiān)控線程行為。如果線程正在執(zhí)行這段代碼,別的線程就無法繼續(xù)進(jìn)行下去,直到這個(gè)線程退出關(guān)鍵代碼(同步代碼)。
我上面的 while
這個(gè)無意義的循環(huán),看起來無意義,但實(shí)際上也是在等待和卡住線程,當(dāng)這個(gè)線程運(yùn)行完畢后,其它的線程恢復(fù)執(zhí)行。是的,lock
是一個(gè)固定的語句形式,而我們有些時(shí)候也不需要一定寫成 lock
的形式,比如上面舉的這個(gè)例子。這種狀態(tài)下,我們就會(huì)覺得 while
循環(huán)更適合讀懂邏輯;與此同時(shí),這樣的代碼也有類似 lock
的效果,所以上面這種現(xiàn)象也被稱為弱同步鎖(Weak Synchronized Lock),暗示它沒有用鎖也達(dá)到了類似的效果。
Interlocked
是隱藏式的鎖,不用寫出來,所以它跟這個(gè)更加類似。大概是這么一個(gè)關(guān)系。
Part 6 道理我懂了,那么單詞為啥用 volatile?
嗯,原因很簡單。這個(gè)單詞可能你沒學(xué)過,它比較生僻,但它其實(shí)也算是比較常見的(用于日常生活的)單詞。它的意思是“易變的”、“不穩(wěn)定的”,是一個(gè)形容詞,比如說:
Global markets are volatile: the country's current-account surplus has fallen by more than half from a mighty 8% of GDP in just a year.
全球的市場經(jīng)濟(jì)都呈現(xiàn)出變化無常的態(tài)勢:這個(gè)國家自己的賬戶只在一年以內(nèi)就從 GDP 的 8% 的賺,減少了足足一半以上。
好吧。好像不是英語課,扯得好像有點(diǎn)多了?;氐竭@里。由于這種變量用于多線程,經(jīng)常被多個(gè)線程訪問(甚至賦值修改數(shù)據(jù)),因此這樣的變量經(jīng)常被呈現(xiàn)出不定狀態(tài),也就是說它的數(shù)值是隨時(shí)隨地都可能改變,因此翻譯成“易變的”是沒有任何問題的。但是你想想,要是用編程的視角來想這個(gè)問題,使用編程多線程的角度給這樣的特殊變量取名關(guān)鍵字的話,你會(huì)發(fā)現(xiàn)不好取名:“表示一個(gè)變量在多個(gè)線程都能訪問取值,并禁止編譯器優(yōu)化的變量”,就拿一個(gè)詞來解釋這個(gè)現(xiàn)象,換誰誰都詞窮。所以,干脆用它的現(xiàn)象“易變”作為變量的特征,定義這個(gè)關(guān)鍵字的名字的話,比較契合的同時(shí)也比較精簡,所以就用了這個(gè)詞語。