1. 程式人生 > >C++和雙重檢查鎖定模式(DCLP)的風險

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

原文連結

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

1 簡介

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

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

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

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

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

2 單例模式與多執行緒

單例模式[7]傳統的實現方法是,當物件第一次使用時將其例項化,並用一個指標指向該物件,實現程式碼如下:

// from the header file 以下程式碼來自標頭檔案
class Singleton {
public:
static Singleton* instance();
...
private:
static Singleton* pInstance;
};
 
// from the implementation file 以下程式碼來自實現檔案
Singleton* Singleton::pInstance = 0;
 
Singleton* Singleton::instance() {
if (pInstance == 0) {
pInstance = new Singleton;
}
return pInstance;
}

在單執行緒環境下,雖然中斷會引起某些問題,但大體上這段程式碼可以執行得很好。如果程式碼執行到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)即可  

Singleton* Singleton::instance() {
Lock lock; // acquire lock (params omitted for simplicity) 加鎖(為了簡便起見,程式碼中忽略了加鎖所需要的引數)
if (pInstance == 0) {
pInstance = new Singleton;
}
return pInstance;
} // release lock (via Lock destructor) // 解鎖(通過Lock的解構函式實現)

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

3 雙重檢查鎖定模式

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

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


Singleton* Singleton::instance() {
if (pInstance == 0) { // 1st test 第一次檢查
Lock lock;
if (pInstance == 0) { // 2nd test 第二次檢查
pInstance = new Singleton;
}
}
return pInstance;
}

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

4 DCLP與指令執行順序

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

pInstance = new Singleton;

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

第一步:為Singleton物件分配一片記憶體
第二步:構造一個Singleton物件,存入已分配的記憶體區
第三步:將pInstance指向這片記憶體區

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

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

Singleton* Singleton::instance() {
if (pInstance == 0) {
Lock lock;
if (pInstance == 0) {
pInstance = // Step 3 步驟3
operator new(sizeof(Singleton)); // Step 1 步驟1
new (pInstance) Singleton; // Step 2 步驟2
}
}
return pInstance;
}

一般情況下,將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)定義了正確的程式行為。但抽象機器裡並非所有行為都可見。例如下文中這個簡單的函式:

void Foo() {
int x = 0, y = 0; // Statement 1 語句1
x = 5; // Statement 2 語句2
y = 10; // Statement 3 語句3
printf("%d, %d", x, y); // Statement 4 語句4
}

這個函式看起來挺傻,但它可能是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構造完成之前決不發生任何改變,以此巧勝編譯器。你可能會試著加入一個臨時變數,如下:


Singleton* Singleton::instance() {
if (pInstance == 0) {
Lock lock;
if (pInstance == 0) {
Singleton* temp = new Singleton; // initialize to temp 初始化temp
pInstance = temp; // assign temp to pInstance 將temp賦值給pInstance
}
}
return pInstance;
}

本質上,你已經挑起了一場程式碼優化之戰。編譯器想優化程式碼,可你不希望它這麼做,至少你不希望它對這段程式碼這麼做。但這決不是一場你想參與的戰爭。因為你的敵人老奸巨滑,滿肚子詭計,要知道它可是由一群幾十年來成天啥也不做一心只想著如何進行編譯優化的人實現的。除非你能自己寫優化編譯器,否則它們總是領先於你。以上段程式碼為例,編譯器能很輕易地通過相關性分析得出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是不夠的,我們考慮以下程式碼:

class Singleton {
public:
static Singleton* instance();
...
private:
static Singleton* volatile pInstance; // volatile added 加上volatile宣告
int x;
Singleton() : x(5) {}
};
// from the implementation file 實現檔案內容如下
Singleton* volatile Singleton::pInstance = 0;
Singleton* Singleton::instance() {
if (pInstance == 0) {
Lock lock;
if (pInstance == 0) {
Singleton* volatile temp = new Singleton; // volatile added 加上volatile宣告
pInstance = temp;
}
}
return pInstance;
}
將建構函式內聯化後,程式碼展開如下:
if (pInstance == 0) {
Lock lock;
if (pInstance == 0) {
Singleton* volatile temp =
static_cast<Singleton*>(operator new(sizeof(Singleton)));
temp->x = 5; // inlined Singleton constructor
pInstance = temp;
}
}

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

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

class Singleton {
public:
static volatile Singleton* volatile instance();
...
private:
// one more volatile added 加入另一個volatile宣告
static volatile Singleton* volatile pInstance;
};
// from the implementation file 實現檔案內容如下
volatile Singleton* volatile Singleton::pInstance = 0;
volatile Singleton* volatile Singleton::instance() {
if (pInstance == 0) {
Lock lock;
if (pInstance == 0) {
// one more volatile added 加入另一個volatile宣告
volatile Singleton* volatile temp =
new volatile Singleton;
pInstance = temp;
}
}
return pInstance;
}

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

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

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

第二,正如宣告為const的物件要成為const得等它的建構函式執行完成後一樣,限制成volatile的物件也要等到其建構函式退出。請看以下語句:

volatile Singleton* volatile temp = new volatile Singleton;
建立的temp物件要直到以下表達式執行完成之後才能成為volatile的

new volatile Singleton;

這意味著我們又回到了之前的境況:記憶體分配指令與物件初始化指令可能被任意調換順序。

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

Singleton()
{
static_cast<volatile int&>(x) = 5; // note cast to volatile 注意強制轉換成volatile
}
對pInstance加入適當的volatile限定,並將行內函數展開,我們可以得到如下程式碼:

class Singleton {
public:
static Singleton* instance();
...
private:
static Singleton* volatile pInstance;
int x;
...
};
 
Singleton* Singleton::instance()
{
  if (pInstance == 0) 
  {
    Lock lock;
    if (pInstance == 0) 
    {
      Singleton* volatile temp =
      static_cast<Singleton*>(operator new(sizeof(Singleton)));
      static_cast<volatile int&>(temp->x) = 5;
      pInstance = temp;
    }
  }
}

現在,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]中的一個例子非常相似,我們只在需要加入記憶體關卡之處加入相應的註釋,因為實際的程式碼是平臺相關的(通常使用匯編)。


Singleton* Singleton::instance () {
Singleton* tmp = pInstance;
... // insert memory barrier 加入記憶體屏障
if (tmp == 0) {
Lock lock;
tmp = pInstance;
if (tmp == 0) {
tmp = new Singleton;
... // insert memory barrier 加入記憶體屏障
    pInstance = tmp;
    }
  }
return tmp;
}

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

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

7 結論以及DCLP的替代方法

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

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

Singleton::instance()->transmogrify();
Singleton::instance()->metamorphose();
Singleton::instance()->transmute();
clients do things this way:
而應該將上述程式碼改寫成:
Singleton* const instance =
Singleton::instance(); // cache instance pointer 用變數存instance()指標
instance->transmogrify();
instance->metamorphose();
instance->transmute();

要實現這個想法有個有趣的辦法,就是鼓勵使用者儘量在每個需要使用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變數:

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

b = a;
同樣,對於相等的p, a, b,考慮如下程式碼:
*p = a;
*p = 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)