C++和雙重檢查鎖定模式(DCLP)的風險

多線程其實就是指兩個任務一前一後或者同時發生。

1 簡介

當你在網上搜索設計模式的相關資料時,你一定會找到最常被提及的一個模式:單例模式(Singleton)。然而,當你嘗試在項目中使用單例模式時,一定會遇到一個很重要的限制:若使用傳統的實現方法(我們會在下文解釋如何實現),單例模式是非線程安全的。

程序員們爲了解決這一問題付出了很多努力,其中最流行的一種解決方法是使用一個新的設計模式:雙重檢查鎖定模式(DCLP)[13, 14]。設計DCLP的目的在於在共享資源(如單例)初始化時添加有效的線程安全檢查功能。但DCLP也存在一個問題:它是不可靠的。此外,在本質上不改變傳統設計模式實現的基礎上,幾乎找不到一種簡便方法能夠使DCLP在C/C++程序中變得可靠。更有趣的是,DCLP無論在單處理器還是多處理器架構中,都可能由不同的原因導致失效。

本文將爲大家解釋以下幾個問題:

1. 爲什麼單例模式是非線程安全的?
2. DCLP是如何處理這個問題的?
3. 爲什麼DCLP在單處理器和多處理器架構下都可能失效?
4. 爲什麼我們很難爲這個問題找到簡便的解決辦法?

在這個過程中,我們將澄清以下四個概念之間的關係:語句在源代碼中的順序、序列點(sequence points, 譯者注[1])、 編譯器和硬件優化,以及語句的實際執行順序。最後,我們會總結一些關於如何給單例模式(或其他相似設計)添加線程安全機制的建議,使你的代碼變得既可靠又高效。

2 單例模式與多線程

單例模式[7]傳統的實現方法是,當對象第一次使用時將其實例化,並用一個指針指向該對象,實現代碼如下:

在單線程環境下,雖然中斷會引起某些問題,但大體上這段代碼可以運行得很好。如果代碼運行到Singleton::instance()內部時發生中斷,而中斷處理程序調用的也是Singleton::instance(),可以想象你將會遇到什麼樣的麻煩。因此,如果撇開中斷不考慮,那麼這個實現在單線程環境下可以運行得很好。

很不幸,這個實現在多線程環境下不可靠。假設線程A進入instance()函數,執行第14行代碼,此時線程被掛起(suspended)。在A被掛起時,它剛判斷出pInstance是空值,也就是說Singleton的對象還未被創建。

現在線程B開始運行,進入instance()函數,並執行第14行代碼。線程B也判斷出pInstance爲空,因此它繼續執行第15行,創建出一個Singleton對象,並將pInstance指向該對象,然後把pInstance返回給instance()函數的調用者。

之後的某一時刻,線程A恢復執行,它接着做的第一件事就是執行第15行:創建出另一個Singleton對象,並讓pInstance指向新對象。這顯然違反了“單例(singleton)”的本意,因爲現在我們有了兩個Singleton對象。

從技術上說,第11行纔是pInstance初始化的地方,但實際上,我們到第15行纔將pInstance指向我們所希望它指向的內容,因此本文在提及pInstance初始化的地方,都指的是第15行。

將經典的單例實現成支持線程安全性是很容易的事,只需要在判斷pInstance之前加鎖(lock)即可:

這個解決辦法的缺點在於可能會導致昂貴的程序執行代價:每次訪問該函數都需要進行一次加鎖操作。但實際中,我們只有pInstance初始化時需要加鎖。也就是說加鎖操作只有instance()第一次被調用時纔是必要的。如果在程序運行過程中,intance()被調用了n次,那麼只有第一次調用鎖起了作用。既然另外的n-1次鎖操作都是沒必要的,那麼我們爲什麼還要付出n次鎖操作的代價呢?DCLP就是設計來解決這個問題的。

3 雙重檢查鎖定模式

DCLP的關鍵之處在於我們觀察到的這一現象:調用者在調用instance()時,pInstance在大部分時候都是非空的,因此沒必要再次初始化。所以,DCLP在加鎖之前先做了一次pInstance是否爲空的檢查。只有判斷結果爲真(即pInstance還未初始化),加鎖操作纔會進行,然後再次檢查pInstance是否爲空(這就是該模式被命名爲雙重檢查的原因)。第二次檢查是必不可少的,因爲,正如我們之前的分析,在第一次檢驗pInstance和加鎖之間,可能有另一個線程對pInstance進行初始化。

以下是DCLP經典的實現代碼[13, 14]:

定義DCLP的文章中討論了一些實現中的問題(例如,對單例指針加上volatile限定(譯者注[3])的重要性,以及多處理器系統上獨立緩存的影響,這兩點我們將在下文討論;但關於某些讀寫操作需要確保原子性這一點本文不予討論),但他們都沒有考慮到一個更基本的問題:DCLP的執行過程中必須確保機器指令是按一個可接受的順序執行的。本文將着重討論這個基本問題。

4 DCLP與指令執行順序

我們再來思考一下初始化pInstance的這行代碼:

這條語句實際做了三件事情:

第一步:爲Singleton對象分配一片內存
第二步:構造一個Singleton對象,存入已分配的內存區
第三步:將pInstance指向這片內存區

這裏至關重要的一點是:我們發現編譯器並不會被強制按照以上順序執行!實際上,編譯器有時會交換步驟2和步驟3的執行順序。編譯器爲什麼要這麼做?這個問題我們留待下文解決。當前,讓我們先專注於如果編譯這麼做了,會發生些什麼。

請看下面這段代碼。我們將pInstance初始化的那行代碼分解成我們上文提及的三個步驟來完成,把步驟1(內存分配)和步驟3(指針賦值)寫成一條語句,接着寫步驟2(構造Singleton對象)。正常人當然不會這麼寫代碼,可是編譯器卻有可能將我們上文寫出的DCLP源碼生成出以下形式的等價代碼。

一般情況下,將DCLP源碼轉化成這種代碼是不正確的,因爲在步驟2調用Singleton的構造函數時,有可能拋出異常(exception)。如果異常拋出,很重要的一點在於pInstance的值還沒發生改變。這就是爲什麼一般來說編譯器不會把步驟2和步驟3的位置對調。然而,在某些條件下,生成的這種代碼是合法的。最簡單的一種情況是編譯器可以保證Singleton構造函數不會拋出異常(例如通過內聯化後的流分析(post-inlining flow analysis),當然這不是唯一情況。有些拋出異常的構造函數會自行調整指令順序,因此纔會出現這個問題。

根據上述轉化後的等價代碼,我們來考慮以下場景:

1. 線程A進入instance(),檢查出pInstance爲空,請求加鎖,而後執行由步驟1和步驟3組成的語句。之後線程A被掛起。此時,pInstance已爲非空指針,但pInstance指向的內存裏的Singleton對象還未被構造出來。
2. 線程B進入instance(), 檢查出pInstance非空,直接將pInstance返回(return)給調用者。之後,調用者使用該返回指針去訪問Singleton對象————啊哦,顯然這個Singleton對象實際上還未被構造出來呢!

只有步驟1和步驟2在步驟3之前執行,DCLP纔有效,但在C/C++中我們沒有辦法表達這種限制。這就像一把尖刀插進DCLP的心臟:我們必須爲相關指令順序定義一些限制,但我們所使用的語言卻無法表達這種限制。

是的,C/C++標準[16, 15]確實爲語句的求值順序定義了相應的限制,即序列點(sequence points)。例如,C++標準中1.9章節的第7段有句激動人心的描述:

“序列點(sequence point)是指程序運行到某個特別的時間點,在此之前的所有求值的副作用(side effect,譯者注[2])應已結束,且後續求值的副作用應還未發生。”

此外,標準中還聲明瞭序列點在每條語句之後發生。看來只要你小心謹慎地將你的語句排好序,一切就都有條不紊了。

噢,奧德修斯(Odysseus, 譯者注[4]), 千萬別被她動人的歌聲所迷惑,前方還要許多艱難困苦等着你和你的弟兄們呢!

C/C++標準根據抽象機器的可見行爲(observable behavior)定義了正確的程序行爲。但抽象機器裏並非所有行爲都可見。例如下文中這個簡單的函數:

這個函數看起來挺傻,但它可能是Foo調用其它內聯函數展開後的結果。

C和C++的標準都保證了Foo()函數的輸出是”5, 10″,因此我們也知道是這樣的結果。但我們只是根據c/c++標準中給出的保證,得出我們的結論。我們其實根本不知道語句1-3是否真的會被執行。事實上,一個好的優化器會丟棄這三條語句。如果語句1-3被執行了,那麼我們可以肯定語句1的執行先於語句2-4(假設調用的printf()不是個內聯函數,並且結果沒有被進一步優化),我們也可以肯定語句4的執行晚於語句1-3,但我們並不知道語句2和語句3的執行順序。編譯器可能讓語句2先執行,也可能讓語句3先執行,如果硬件支持,它甚至有可能讓兩條語句並行執行。這種可能性很大,因爲現代處理器支持大字長以及多執行單元,兩個或更多的運算器也很常見(例如,奔騰4處理器有三個整形運算器,PowerPC G4e處理器有四個,Itanium處理器有6個)。這些機器都允許編譯器生成可並行執行的代碼,使得處理器在一個時鐘週期內能夠處理兩條甚至更多指令。

優化編譯器會仔細地分析並重新排序你的代碼,使得程序執行時,在可見行爲的限制下,同一時間能做儘可能多的事情。在串行代碼中發現並利用這種並行性是重新排列代碼並引入亂序執行最重要的原因,但並不是唯一原因,以下幾個原因也可能使編譯器(和鏈接器)將指令重新排序:

1. 避免寄存器數據溢出;
2. 保持指令流水線連續;
3. 公共子表達式消除;
4. 降低生成的可執行文件的大小[4]。

C/C++的編譯器和鏈接器執行這些優化操作時,只會受到C/C++標準文檔中定義的抽象機器上可見行爲的原則這唯一的限制。有一點很重要:這些抽象機器默認是單線程的。C/C++作爲一種語言,二者都不存在線程這一概念,因此編譯器在優化過程中無需考慮是否會破壞多線程程序。如果它們這麼做了,請別驚訝。

既然如此,程序員怎樣才能用C/C++寫出能正常工作的多線程程序呢?通過使用操作系統特定的庫來解決。例如Posix線程庫(pthreads)[6],這些線程庫爲各種同步原語的執行語義提供了嚴格的規範。由於編譯器生成代碼時需要依賴這些線程庫,因此編譯器不得不按線程庫所約束的執行順序生成代碼。這也是爲什麼多線程庫有一部分需要用直接用彙編語言實現,或者調用由彙編實現的系統調用(或者使用一些不可移植的語言)。換句話說,你必須跳出標準C/C++語言在你的多線程程序中實現這種執行順序的約束。DCLP試圖只使用一種語言來達到目的,所以DCLP不可靠。

通常,程序員不願意受編譯器擺佈。或許你也是這類程序員之一。如果是,那你可能會忍不住調整你的代碼,讓pInstance的值在Singleton構造完成之前決不發生任何改變,以此巧勝編譯器。你可能會試着加入一個臨時變量,如下:

本質上,你已經挑起了一場代碼優化之戰。編譯器想優化代碼,可你不希望它這麼做,至少你不希望它對這段代碼這麼做。但這決不是一場你想參與的戰爭。因爲你的敵人老奸巨滑,滿肚子詭計,要知道它可是由一羣幾十年來成天啥也不做一心只想着如何進行編譯優化的人實現的。除非你能自己寫優化編譯器,否則它們總是領先於你。以上段代碼爲例,編譯器能很輕易地通過相關性分析得出temp只是一個無關緊要的變量,因此它會直接刪除該變量,將你精心寫下的“不可優化”代碼視爲如同用傳統 DCLP 方式寫就的一樣。遊戲結束了,你輸啦!

如果你使用殺傷力大點的武器,試圖擴大temp的作用域(例如將temp設成static),編譯器照樣能用相同的分析法得出相同的結論。遊戲結束了,你輸啦!

於是你請求支援,將temp聲明成extern,並將其定義到單獨的編譯單元中,想以此讓編譯器不知道你的意圖。真爲你感到難過啊!因爲有些編譯器似乎帶有“優化夜視鏡”,它們能利用過程間分析來發現你對temp動的小腦筋,再一次優化了temp。永遠記住,它們是”優化”編譯器。它們的目的就是追蹤不必要的代碼並優化之。遊戲結束了,你輸啦!

再換一種辦法,你試着在另一個文件中定義一個輔助函數來消除內聯,這樣可以強迫編譯器假設構造函數可能會拋出異常,從而延遲pInstance的賦值。好辦法,值得一試!但有一些構建環境會採取鏈接時內聯,隨之而來的是更多的代碼優化。好了,遊!戲!結!束!你!又!輸!啦!

一個基本的問題,你沒有能力改變:你希望利用約束條件讓指令按順序執行,但你所使用的語言不提供任何實現方法。

5 volatile關鍵字的成名之路

我們非常希望某些特定的指令可以按順序執行,這引發了許多關volatile關鍵字的思考:volatile是否能夠從總體上幫助多線程程序,特別是對DCLP有所幫助?這一節中,我們將注意力集中在C++裏volatile關鍵字的語義上,然後進一步集中討論volatile對DCLP的影響。關於volatile更深入的討論,可以參考本文結尾附上的補充說明。

C++標準文檔[15] 1.9節中有如下信息(斜體爲本文作者所加):

“C++抽象機器上的可見行爲包括:volatile數據的讀寫順序,以及對輸入輸出(I/O)庫函數的調用。將聲明成volatile的數據作爲左值來訪問對象,修改對象,調用輸入輸出庫函數,抑或調用其他有以上相似操作的函數,都會產生副作用(side effects)(譯者注[2]),即執行環境狀態發生的改變。”

我們早前的觀察如下:

(1) C/C++標準文檔中保證所有的副作用(side effects)將在程序運行到序列點時完成,
(2) 並且,序列點發生在每個c++語句結束之時,

結合上述事實,如果我們想確保正確指令執行順序,那麼我們所要做的就是將合適的數據聲明成volatile,並謹慎安排語句順序。

早期分析顯示我們需要將pInstance聲明成volatile,DCLP[13,14]的相關論文中也給出了這一結論。然而,福爾摩斯大偵探,你一定注意到:爲了確保正確的指令順序,Singleton對象本身也必須聲明成volatile。原版的DCLP論文中並沒有指出這一點,這很重要,但他們疏忽了。

爲了讓大家理解爲什麼僅將pInstance聲明爲volatile是不夠的,我們考慮以下代碼:

將構造函數內聯化後,代碼展開如下:

雖然temp是volatile的,但*temp卻不是,這就意味着temp->x也不是。我們現在已經明白非volatile數據在賦值時執行順序可能會發生變化,因此我們也很容易得知編譯器可以改變temp->x賦值與pInstance賦值的順序。如果編譯器這麼做了,那麼pInstance就將賦值爲還未完全初始化的temp,因爲它的成員變量x還未初始化,這就可能導致另一個線程讀取到這個未初始化的x。

一種比較吸引人的解決辦法是將*pInstance與pInstance一樣限定成volatile,該方法的美化版是將Singleton聲明成volatile,這樣所有Singleton變量都將是volatile的。

(至此,讀者可能會提出一個合理的疑問:爲什麼不將Lock也聲明成volatile的?畢竟在我們試圖給pInstance或temp賦值前將lock初始化至關重要。因爲Lock由多線程庫提供,所以我們可以假設Lock的說明文檔已經給出了足夠的限制,使其在執行過程中無需聲明爲volatile即可保證執行順序。我們所知的所有線程庫都是這麼做的。從本質上說,使用線程庫中的實體,如對象、函數等,都會導致給程序強行加入“硬序列點(hard sequence points)”,即適用於所有線程的序列點。爲了達到本文的目的,我們假設這類“硬序列點”可以阻止編譯器在代碼優化時對指令進行重新排序:源代碼中使用庫實體之前的語句所對應的指令,不會被移到使用庫實體的語句指令之後,反之,使用庫實體之後的語句指令也不會被移到使用庫實體的語句指令之前。真正的線程庫不會有如此嚴格的限制,但這些細節對本文的討論並不重要。)

現在我們希望我們上述完全加入了volatile限定的代碼已經滿足標準文檔的說明,能夠保證該段代碼在多線程環境中正確運行,然而我們還有可能面臨失敗,原因有二。

第一,標準文檔中對可見行爲的約束僅針對標準中定義的抽象機器,而所謂的抽象機器對執行過程中的多線程毫無概念。因此,雖然標準文檔避免編譯器在一個線程中重新排列volatile數據的讀寫順序,但它對跨線程的重新排序沒有任何約束。至少大部分編譯器的實現者是這麼解釋的。因此,現實中,許多編譯器都可以將上述源代碼生成非線程安全的代碼。如果你的多線程程序在加上volatile聲明時可以正確運行,但不加聲明卻發生錯誤,那麼,要麼是你的編譯器小心地實現對volatile的處理使其在多線程時正確運行(這種可能性較少),要麼就是你運氣挺好(這種可能性挺大)。但無論是哪種原因,你的代碼都不可移植。

第二,正如聲明爲const的對象要成爲const得等它的構造函數執行完成後一樣,限制成volatile的對象也要等到其構造函數退出。請看以下語句:

創建的temp對象要直到以下表達式執行完成之後才能成爲volatile的

這意味着我們又回到了之前的境況:內存分配指令與對象初始化指令可能被任意調換順序。

儘管有些尷尬,但這個問題我們能夠解決。在Singleton構造函數中,當它的每個數據成員初始化時,我們都使用cast將其強制轉換爲volatile,這樣可以避免改變初始化指令的執行位置。以下代碼就是Singleton構造函數實現代碼的例子。(爲了簡化代碼,我們依然沿用之前的代碼,使用賦值語句代替初始化列表。這對我們解決當前問題沒有任何影響。

對pInstance加入適當的volatile限定,並將內聯函數展開,我們可以得到如下代碼:

現在,x的賦值必須先於pInstance的賦值,因爲它們都是volatile的。

不幸的是,所有這一切都無法解決我們的第一個問題:C++的抽象機器是單線程的,C++編譯器無論如何都可能爲上述代碼生成非線程安全的代碼。否則,不優化這些代碼會導致很大的效率問題。進行了這麼多討論之後,我們又回到了原點。可等一等,還有另一個問題:多處理器。

6 多處理器上的DCLP

假設你的機器有多個處理器,每個都有各自的高速緩存,但所有處理器共享內存空間。這種架構需要設計者精確定義一個處理器該如何向共享內存執行寫操作,又該何時執行這樣的操作,並使其對其他處理器可見。我們很容易想象這樣的場景:當某一個處理器在自己的高速緩存中更新的某個共享變量的值,但它並沒有將該值更新至共享主存中,更不用說將該值更新到其他處理器的緩存中了。這種緩存間共享變量值不一致的情況被稱爲緩存一致性問題(cache coherency problem)。

假設處理器A改變了共享變量x的值,之後又改變了共享變量y的值,那麼這些新值必須更新至主存中,這樣其他處理器才能看到這些改變。然而,由於按地址順序遞增刷新緩存更高效,所以如果y的地址小於x的地址,那麼y很有可能先於x更新至主存中。這樣就導致其他處理器認爲y值的改變是先於x值的。

對DCLP而言,這種可能性將是一個嚴重的問題。正確的Singleton初始化要求先初始化Singleton對象,再初始化pInstance。如果在處理器A上運行的線程是按正確順序執行,但處理器B上的線程卻將兩個步驟調換順序,那麼處理器B上的線程又會導致pInstance被賦值爲未完成初始化的Singleton對象。

解決緩存一致性問題的一般方法是使用內存屏障(memory barriers), 例如使用柵欄(fences, 譯者注[6]):即在共享內存的多處理器系統中,用以限制對某些可能會對共享內存進行讀寫的指令進行重新排序,能被編譯器、鏈接器,或者其他優化實體識別的指令。對DCLP而言,我們需要使用內存關卡以保證pInstance的賦值在Singleton初始化完成之後。下面這段僞代碼與參考文獻[1]中的一個例子非常相似,我們只在需要加入內存關卡之處加入相應的註釋,因爲實際的代碼是平臺相關的(通常使用匯編)。

Arch Robison指出這種解決辦法殺傷力過大了(他是參考文獻[12]的作者,但這些觀點是私下與他交流時提及的):從技術上說,我們並不需要完整的雙向屏障。第一道屏障可以防止另一個線程先執行Singleton構造函數之後的代碼,第二道屏障可以防止pInstance初始化的代碼先於Singleton對象的初始化。有一組稱作“請求”和“釋放”操作可以比單純用硬件支持內存關卡(如Itainum處理器)具有更高的效率。

但無論如何,只要你的機器支持內存屏障,這是DCLP一種可靠的實現方法。所有可以重新排列共享內存的寫入操作指令順序的處理器都支持各種不同的內存屏障。有趣的是,在單處理器系統中,同樣的方法也適用。因爲內存關卡本質上是“硬序列點”,即從硬件層面防止可能引發麻煩的指令重排序。

7 結論以及DCLP的替代方法

從以上討論中我們可以得出許多經驗。首先,請記住一點:基於分時的單處理機並行機制與真正跨多處理器的並行是完全不同的。這就是爲什麼在單處理器架構下針對某個編譯器的線程安全的解決辦法,在多處理器架構下就不可用了。即使你使用相同的編譯器,也可能導致這個問題(這是個一般性結論,不僅僅存在於DCLP中)。

第二,儘管從本質上講DCLP並不侷限於單例模式,但以DCLP的方式使用單例模式往往會導致編譯器去優化跟線程安全有關的語句。因此,你必須避免用DCLP實現Singleton模式。由於DCLP每次調用instance()時都需要加一個同步鎖,如果你(或者你的客戶)很在意加鎖引起的性能問題,你可以建議你的客戶將instance()返回的指針緩存起來,以達到最小化調用instance()的目的。例如,你可以建議他不要這麼寫代碼:

而應該將上述代碼改寫成:

要實現這個想法有個有趣的辦法,就是鼓勵用戶儘量在每個需要使用singleton對象的線程開始時,只調用一次instance(),之後該線程就可直接使用緩存在局部變量中的指針。使用該方法的代碼,對每個線程都只需要承擔一次instance()調用的代價即可。

在採用“緩存調用結果”這一建議之前,我們最好先驗證一下這樣是否真的能夠顯著地提高性能。加入一個線程庫提供的鎖,以確保Singleton初始化時的線程安全性,然後計時,看看這樣的代價是否真正值得我們擔心。

第三,請不要使用延遲初始化(lazily-initialized)的方式,除非你必須這麼做。單例模式的經典實現方法就是基於這種方式:除非有需求,否則不進行初始化。替代方法是採用提前初始化(eager initialization)方式,即在程序運行之初就對所需資源進行初始化。因爲多線程程序在運行之初通常只有一個線程,我們可以將某些對象的初始化語句寫在程序只存在一個線程之時,這樣就不用擔心多線程所引起的初始化問題了。在很多情況下,將singleton對象的初始化放在程序運行之初的單線程模式下(例如,在進入main函數之前初始化),是最簡便最高效且線程安全的singleton實現方法。

採用提前初始化的另一種方法是用單狀態模式(Monostate模式)[2]代替單例模式。不過,Monostate模式屬於另一個話題,特別是關於構成它的狀態的非局部靜態對象初始化順序的控制,是一個完全不同的問題。Effective C++[9]一書中對Monostate的這個問題給出了介紹,很諷刺的是,關於這一問題,書中給出的方案是使用Singleton變量來避免(這個變量並不能保證線程安全[17])。

另一種可能的方法是每個線程使用一個局部singleton來替代全局singleton,在線程內部存儲singleton數據。延遲初始化可以在這種方法下使用而無需考慮線程問題,但這同時也帶來了新的問題:一個多線程程序中竟然有多個“單例”。

最後,DCLP以及它在C/C++語言中的問題證實了這麼一個結論:想使用一種沒有線程概念的語言來實現具有線程安全性的代碼(或者其他形式的併發式代碼)有着固有的困難。編程中對多線程的考慮很普遍,因爲它們是代碼生成中的核心問題。正如Peter Buhr的觀點,指望脫離語言,只靠庫函數來實現多線程簡直就是癡心妄想。如果你這麼做了,要麼

(1) 庫最終會在編譯器生成代碼時加入各種約束(pthreads庫正是如此)

要麼

(2) 編譯器以及其它代碼生成工具的優化功能將被禁用,即使針對單線程代碼也不得不如此。
多線程、無線程概念的編程語言、優化後的代碼,這三者你只能挑選兩個。例如,Java和.net CLI解決矛盾的辦法是將線程概念加入其語言結構中[8, 12]。

8 致謝

本文發表前曾邀請Doug Lea, Kevlin Henney,Doug Schmidt, Chuck Allison, Petru Marginean, Hendrik Schober, David Brownell, Arch Robison, Bruce Leasure, and James Kanze審閱及校稿。他們的點評建議爲本文的發表做了很大的貢獻,並使我們對DCLP、多線程、指令重排序、編譯器優化這些概念又有了進一步的理解。出版後,我們還加入了Fedor Pikus, Al Stevens, Herb Sutter, and John Hicken幾人的點評建議。

9 關於作者

Scott Meyers曾出版了三本《Effective C++》的書,並出任Addison-Wesley所著的《有效的軟件開發系統叢書(Effective Software Developement Series)》的顧問編輯。目前他專注於研究提高軟件質量的基本原則。他的主頁是:http://aristeia.com.

Andrei Alexandrescu是《Modern C++ Design》一書的作者,(譯者注[5]),他還寫過大量文章,其中大部分都是作爲專欄作家爲CUJ所寫。目前他正在華盛頓大學攻讀博士學位,專攻編程語言方向。他的主頁是:http://moderncppdesign.com.

10 [補充說明] volatile的發展簡史

volatile產生的根源得追溯到20世紀70年代,那時Gordon Bell(PDP-11架構的設計者)提出了內存映射I/O(MMIO)的概念。在這之前,處理器爲I/O端口分配針腳,並定義專門的指令訪問。MMIO讓I/O與內存共用相同的處理器針腳和指令集。處理器外部的硬件將某些特定的內存地址轉換成I/O請求,因此對I/O端口的讀寫變得與訪問內存一樣簡單。

真是個好主意!減少針腳數量是個好辦法,因爲針腳會降低信號傳輸速度、增加出錯率,並使封裝複雜化。而且MMIO不需要爲I/O使用專門的指令集,程序只需像訪問內存一樣即可,剩下的工作都由硬件去完成。

或者我們應該說:“幾乎”都由硬件去完成。

讓我們通過以下代碼來看看爲什麼MMIO需要引入volatile變量:

如果p指向一個端口,a和b應該能夠從該端口讀取到兩個連續的字長(words)。然而,如果p指向一個真實的內存地址,那麼a和b將分別被賦成同一個地址值,因此a和b理應相等。編譯器就是基於這一假設而設計的,因此它將上述代碼的最後一行優化成更高效的代碼:

同樣,對於相等的p, a, b,考慮如下代碼:

這段代碼是將兩個字長寫入*p中,但優化器卻將*p假設成內存,把上述的兩次賦值認爲是重複的代碼,優化了其中一次賦值。顯然這樣的“優化”破壞了代碼的本意。類似的場景可以出現在當一個變量同時被主線代碼和中斷服務程序(ISR)改變時。針對這種情況,爲了主線代碼與中斷服務程序間的通信,編譯器處理冗餘的讀寫是有必要的。

因此,在處理相同內存地址的相關代碼時(如內存映射端口或與ISR相關的內存),編譯器不得對其進行優化。對這類內存地址需有要特殊的處理,volatile就應運而生了。Volatile用於說明以下幾個含義:

(1) 聲明成volatile的變量其內容是“不穩定的”(unstable),它的值可能在編譯器不知道的情況下發生變化
(2) 所有對聲明成volatile的數據的寫操作都是“可見的”(observable),因此必須嚴格執行這些寫操作
(3) 所以對聲明成volatile的數據的操作都必須按源代碼中的順序執行

前兩條規則保證了正確的讀操作和寫操作,最後一條保證I/O協議能正確處理讀寫混合操作。這正是C/C++中volatile關鍵字所保證的。

Java語言對volatile作了進一步擴展:在多線程環境下也能保證volatile的上述性質。這是一步很重要的擴展,但還不足以使volatile完全適用於線程同步性,因爲volatile和非volatile操作間的順序依然沒有明確規則。由於忽略了這一點,爲了保證合適的執行順序,大量的變量都不得不聲明爲volatile。

Java 1.5版本中的對volatile[10]對“請求/釋放”語義有了更嚴格但更簡單的限制:確保所有對volatile的讀操作都發生在該語句後所有讀寫操作之前(無論這些讀寫操作是否爲針對volatile數據);確保所有對volatile的寫操作都發生在該語句前所有讀寫操作之後。.NET也定義了跨線程的volatile語義,它與目前Java所用的語義基本相同。而目前C/C++中的volatile還沒有類似的改動。

譯者注:

[1]序列點(sequence point)是指程序運行到某個特別的時間點,在這個時間點之前的所有副作用(side effect,譯者注[2])已經結束,並且後續的副作用還沒發生。

[2]副作用(side effect)是指對數據對象或者文件的修改。例如,”var=99″的副作用是把var的值修改成99。

[3]volatile限定(volatile-qualifying): volatile是c/c++中的關鍵字,與const類似,都是對變量進行附加修飾,旨在告之編譯器,該對象的值可能在編譯器未監測到的情況下被改變,編譯器不能武斷的對引用這些對象的代碼作優化處理。

[4]奧德修斯(Odysseus):《奧德賽》是古希臘最重要的兩部史詩之一(兩部史詩統稱爲《荷馬史詩》,另一部是《伊利亞特》),奧德修斯是該作品中主人公的名字。作品講述了主人公奧德修斯10年海上歷險的故事。途中,他們經過一個妖鳥島,島上的女巫能用自己的歌聲誘惑所有過路船隻的船員,使他們的船觸礁沉沒。智勇雙全的奧德修斯抵禦了女巫的歌聲誘惑,帶領他的船員們順利渡過難關。本文的作者借用《奧德賽》裏的這個故事告訴大家不要被C/C++的標準所迷惑 :D

[5]CUJ: C/C++ Users Journal |  C/C++用戶日報

[6]內存屏障/柵欄(Memory Barriers / Fences): 在多線程環境下,需要採用一些技術讓程序結果及時可見,一旦內存被推到緩存,就會引發一些協議消息,以確保所有共享數據的緩存一致性,這種使內存對處理器可見的技術被稱爲內存關卡或柵欄。(Ref: http://mechanical-sympathy.blogspot.com/2011/07/memory-barriersfences.html)

發佈了62 篇原創文章 · 獲贊 6 · 訪問量 6萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章