volatile語義及線程安全singleton模式探討

1.引言 

詳盡的討論了volatile語義以及如何用C++實現線程安全的Singleton模式。 

主要參考Scott Meyers and Andrei Alexandrescu寫的“C++ and the Perils of Double-Checked Locking”,這是2004年的文章,以及網上的其他資源。 

其他參考:

Threads Basics 

http://www.hpl.hp.com/personal/Hans_Boehm/c++mm/threadsintro.html

The "Double-Checked Locking is Broken" Declaration

   http://www.cs.umd.edu/%7Epugh/java/memoryModel/DoubleCheckedLocking.html 

非完美C++ Singleton實現[2] Ismayday的官方技術博客

http://hi.baidu.com/ismayday/blog/item/a797d6cae24b0d41f21fe788.html

《C++0x漫談》系列之:多線程內存模型By 劉未鵬(pongba)

http://blog.csdn.net/pongba/archive/2007/06/20/1659952.aspx


一個老外的博客,包括需不需要對int加鎖,gcc中的原子變量操作

http://www.alexonlinux.com/ 

在前面我寫了一個使用c++0x STL自帶的多線程API的示例http://www.cnblogs.com/rocketfan/archive/2009/12/02/1615093.html

而這裏涉及的不是多線程庫的API,因爲幾乎所有的多線程互斥同步問題都可以通過互斥鎖mutex和條件變量解決。但是頻繁的加鎖解鎖很耗時,

畢竟用多線程就是要速度,本文涉及的都是相關的與線程庫無關的問題。 

2.多線程問題簡介

  一個 多線程的程序允許多個線程同時運行,可能要允許它們訪問及更新它們共享的變量,每個線程都有其局部變量但是所有的線程都會看到同樣的全局變量或者,“static"靜態的類成員變量。

  不同的線程可能會運行在同一個處理器上,輪流執行(by interleaving execution).也有可能會運行在不同處理器上,所謂的hardware thread現在很常見。 

3.volatile變量是什麼


這個網上有很多介紹但都沒有全面解釋。而在“C++ and the Perils of Double-Checked Locking”的附註,作者給出了詳細全面的解釋。

作者是從volatile的產生講起的,當時是爲了統一的使用相同的地址處理內存地址和IO port地址,所謂memory-mapped I/O (MMIO)。

個人覺得本質都是不同層次存儲映射關係吧,類比寄存器和內存。下面都用寄存器和內存解釋。

讀的情況 

unsigned int *p = GetMagicAddress();

unsigned int a, b;

a = *p

b = *p; 

考慮上面的代碼,假設GetMagicAddress()是獲得內存的地址,那麼a = *p, b = *p,會被編譯器認爲是相同的操作,假設a = *p 使得值緩存在寄存器中,那麼爲了速度優化,編譯器可能會將最後一行的代碼換成

b = a;

這樣就不去內存讀而是直接去寄存器讀但這可能並不是我們想要的,因爲如果這期間其他的線程改寫了內存中*P的內容呢?

thread1 thread2 

a = *p;

*p = 3

b = a //---------- 編譯器優化的結果使得我們讀到的並不是最新的p所指的內存的值

寫的情況

*p = a;

*p = b;

向上面的代碼,編譯器可能會認爲*p = a是冗餘操作從而去掉它,而這也可能不是我們想要的。

volatile的作用 :

volatile exists for specifying special treatment for such locations, specifically:

(1) the content of a volatile variable is “unstable” (can change by means unknown to the compiler),

(2) all writes to volatile data are “observable” so they must be executed religiously, and

(3) all operations on volatile data are executed in the sequence in which they appear in the source code.

被聲明爲volatile的變量其內容是不穩定的(unstable),它的值有可能由編譯器所不能知曉的情況所改變。

所有對聲明爲volatile的變量的寫操作都是可見的,必須嚴格執行be executed religiously。

所有對聲明爲volatile的變量的操作(讀寫)都必須嚴格按照源代碼的順序執行。

所以上面的第一條確保讀正確,第二條確保寫正確,第三條確保讀寫混合的情況正確。JAVA更進一步跨越線程保證上面的條件。而C/C++只對單一線程內部保證。劉未鵬在博客中這麼解釋:“總而言之,由於C++03標準是單線程的,因此volatile只能保證單線程內語意。對於前面的那個例子,將x和y設爲volatile只能保證分別在Thread1和Thread2中的兩個操作是按代碼順序執行的,但並不能保證在Thread2“眼裏”的Thread1的兩個操作是按代碼順序執行的。也就是說,只能保證兩個操作的線程內次序,不能保證它們的線程間次序。一句話,目前的volatile語意是無法保證多線程下的操作的正確性的。” 

但是即使是JAVA能夠跨越線程保證,仍然是不夠的因爲volatile和非volatile操作之間的順序仍然是未定義的,有可能產生問題,考慮下面的代碼:

volatile int vi;

void bar(void) {

vi = 1;

foo();

vi = 0;

}

我們一般會認爲vi會在調用foo之前設置爲1,調用完後會被置爲0。然而編譯器不會對你保證這一點,它會很高興的將你的foo()移位,比如跑到vi = 1前面,只要它知道在foo()裏不會涉及到其它的volatile操作。所以安全的方法是用柵欄memory barrier例如“asm volatile (”" ::: “memory”)加到foo的前面和後面 來保證嚴格的執行順序。

Meyers提到由於上面的原因我們通常會需要加大量的volatile變量,java1.5中的volatile給出了更嚴格簡單的定義,所有對volatile的讀操作,都將被確保發生在該語句後面的讀寫(any memory reference volatile or not)操作的前面。而寫操作則保證會發生在該語句前面的讀寫操作的後面。.NET也定義了跨線程的volatile語意。

4.線程安全的C++ singleton模式

最簡單的singleton

注:下面有些來自原文,有些來自"非完美Singleton實現",其它參考之3。

1 // from the header file

2 class Singleton {

3 public:

4 static Singleton* instance();

5 ...

6 private:

7 static Singleton* pInstance;

8 };

10 // from the implementation file

11 Singleton* Singleton::pInstance = 0;

12 

13 Singleton* Singleton::instance() {

14 if (pInstance == 0) {

15 pInstance = new Singleton;

16 }

17 return pInstance;

18 }

如果在單線程模式那麼上面的代碼除了instance()可能會有異常安全問題外沒有太大問題。但是對於多線程而言,如果兩個線程在14判斷都讀到pInstance = 0同時進入15,那麼就會產生兩個Singleton對象。而pInstance指向後產生的那一個。 

加鎖保護的singleton

代碼 

1 Singleton* Singleton::instance() {

2 Lock lock; // acquire lock (params omitted for simplicity)

3 if (pInstance == 0) {

4 pInstance = new Singleton;

5 }

6 return pInstance;

7 } // release lock (via Lock destructor)

這樣是絕對的安全了,但是由於加鎖解鎖的代價大,而instance又是可能被頻繁調用的函數所以大大影響性能。事實上只要pInstance == 0的時候纔可能出現問題,需要加鎖,那麼有了下面的寫法:代碼

1 Singleton* Singleton::instance() {

2 if (pInstance == 0) {

3 Lock lock; // acquire lock (params omitted for simplicity)

4 pInstance = new Singleton;

5 }

6 return pInstance;

7 } // release lock (via Lock destructor)

但是這樣是錯誤的,因爲兩個線程如果同時在2判斷爲true,雖然會在3處互斥,但是還是會輪流進入保護區,生成兩個Singleton.於是有人想到下面的方法。

DCL方法(Double Checked Locking)

這也是ACE中Singleton的實現方法: 

代碼

1 Singleton* Singleton::instance() {

2 if (pInstance == 0) { // 1st test

3 Lock lock;

4 if (pInstance == 0) { // 2nd test

5 pInstance = new Singleton;

6 }

7 }

8 return pInstance;

9 }

這看上去很完美但是它也是有問題的

在編譯器未優化的情況下順序如下:

1.new operator分配適當的內存;

2.在分配的內存上構造Singleton對象;

3.內存地址賦值給_instance。

但是當編譯器優化後執行順序可能如下:

1.new operator分配適當的內存;

2.內存地址賦值給_instance;

3.在分配的內存上構造Singleton對象。

編譯器優化後的代碼看起來像下面這樣:

代碼

1 Singleton* Singleton::instance() {

2 if (pInstance == 0) {

3 Lock lock;

4 if (pInstance == 0) {

5 pInstance = // Step 3

6 operator new(sizeof(Singleton)); // Step 1

7 new (pInstance) Singleton; // Step 2

8 }

9 }

10 return pInstance;

11 }

這樣如果一個線程按照編譯器優化的順序執行到5,這時後pInstance就已經非0了,而實際上它所指向的內存上Singleton對象還沒有被構造,這個時候有可能另一個線程運行到2,發現pInstance不是0,NULL,於是return pInstance,假設它又用這個指向還未構造對象的指針調用pInstance->doSomeThing() .........:(

未了避免這種情況,於是有了下面的做法

代碼


1 Singleton* Singleton::instance() {

2 if (pInstance == 0) {

3 Lock lock;

4 if (pInstance == 0) {

5 Singleton* temp = new Singleton; // initialize to temp

6 pInstance = temp; // assign temp to pInstance

7 }

8 }

9 return pInstance;

10 }

企圖利用一個臨時變量,僅當Singleton對象構造完成後才把地址賦給pInstance,然而很不幸,編譯器會把你的臨時變量視爲無用的東西從而優化掉。。。。 

這裏插一句爲什麼用pthread之類的庫能夠解決問題,保證順序,因爲它們是非語言本身的,往往 強迫編譯器產生與之適應的代碼,往往會調用系統調用,很多是用匯編實現的。

加入volatile

前面講到volatile的定義,那麼這裏能否利用volatile呢。上面最後的代碼想法不錯,只是編譯器會搗亂:)那麼我們加入volatile來避免編譯器的優化,保證語句的順序執行。不過這裏我們考慮這種情況,Singlton帶有一個變量x,默認的私有構造函數會將其賦值爲5. 

代碼 

1 class Singleton {

2 public:

3 static Singleton* instance();

4 ...

5 private:

6 static Singleton* volatile pInstance; // volatile added

7 int x;

8 Singleton() : x(5) {}

9 };

10 

11 // from the implementation file

12 Singleton* Singleton::pInstance = 0;

13 

14 Singleton* Singleton::instance() {

15 if (pInstance == 0) {

16 Lock lock;

17 if (pInstance == 0) {

18 Singleton* volatile temp = new Singleton; // volatile added

19 pInstance = temp;

20 }

21 }

22 return pInstance;

23 }

下面考慮編譯器inline構造函數和之後的instance()函數的樣子

代碼

1 if (pInstance == 0) {

2 Lock lock;

3 if (pInstance == 0) {

4 Singleton* volatile temp =

5 static_cast<Singleton*>(operator new(sizeof(Singleton)));

6 temp->x = 5; // inlined Singleton constructor

7 pInstance = temp;

8 }

問題出來了,儘管temp被聲明爲volatile,但是*temp不是,這意味着temp->x也不是,這意味着編譯器可能會把6,7句換位置,從而使得另一個線程得到一個指向x域並未被構造好的Sigleton對象!

解決辦法是我們不僅僅聲明pInstance爲volatile也將*pInstance聲明爲volatile.如下:

代碼 

1 class Singleton {

2 public:

3 static volatile Singleton* volatile instance();

4 ...

5 private:

6 // one more volatile added

7 static Singleton* volatile pInstance;

8 };

10 // from the implementation file

11 volatile Singleton* volatile Singleton::pInstance = 0;

12 volatile Singleton* volatile Singleton::instance() {

13 if (pInstance == 0) {

14 Lock lock;

15 if (pInstance == 0) {

16 // one more volatile added

17 volatile Singleton* volatile temp = new volatile Singleton;

18 pInstance = temp;

19 }

20 return pInstance;

21 }

這裏Lock不需要聲明爲volatile因爲它來自其它的線程庫入pthread會提供相應保證其前面的代碼不會被編譯器調到後面,它後面的代碼也不會被調到前面,相當於柵欄效應。

也許到現在這個版本是很完美的了,但是仍然可能失敗,有下面兩個原因:

the Standard’s constraints on observable behavior are only for an abstract machine defined by the Standard, and that abstract machine has no notion of multiple threads of execution. As a result, though the Standard prevents compilers from reordering reads and writes to volatile data within a thread, it imposes no constraints at all on such reorderings across threads. 也就是說還是上面volatile解釋語義時提到的,volatile,只保證線程內部順序,不保證線程之間的。

就像const變量直到其構造函數完成之後不是const的一樣,volatile變量直到其構造函數和完成之前也不是volatile的。volatile Singleton* volatile temp = new volatile Singleton; 要生成的變量直到new volatile Singleton完成之後纔是volatile的。這意味這temp->x在構造函數中不是volatile的從而還是可能產生上面的問題和pInstance = temp 被編譯器調換順序。這個問題是可以解決的,辦法如下,對默認構造函數改寫,將對x的初始化列表構造初始化改爲在構造函數中顯示賦值。

1 Singleton()

2 {

3 static_cast<volatile int&>(x) = 5; // note cast to volatile

4 } 

這樣的work around就解決了問題。

編譯器會生成類似下面的代碼:

代碼

1 Singleton* Singleton::instance()

2 {

3 if (pInstance == 0) {

4 Lock lock;

5 if (pInstance == 0) {

6 Singleton* volatile temp =

7 static_cast<Singleton*>(operator new(sizeof(Singleton)));

8 static_cast<volatile int&>(temp->x) = 5;

9 pInstance = temp;

10 }

11 }

12 }

這保證了8和9不會被調換順序。

一個可行的解決方案

如果你的程序運行在多處理器機器上,會面臨CACHE一致性問題,就是說每一個處理器會有自己的CACHE,而所有的處理器有共享的MAIN CACHE,什麼時候將自己的CAHE內容寫到MAIN CACHE中,以及什麼時候去從MAIN CACHE中讀數據都是問題,編譯器有時候可能會把多個寫入MAIN CACHE的數據按照地址升序寫入已達到最佳速度,從而調換代碼執行順序。一般而言解決CACHE一致性問題採用柵欄,barrier方法。

代碼 

1 Singleton* Singleton::instance () {

2 Singleton* tmp = pInstance;

4 ... // insert memory barrier

6 if (tmp == 0) {

7 Lock lock;

8 tmp = pInstance;

9 if (tmp == 0) {

10 tmp = new Singleton;

11 

12 ... // insert memory barrier

13 pInstance = tmp;

14 }

15 }

16 return tmp;

17 }

事實上我們不需要完整的雙向柵欄,上面一處柵欄只需要保證前面的代碼不要提到柵欄後面去,下面的柵欄保證後面的代碼不要挪到柵欄前面去。 

但是注意柵欄是平臺相關的,尤其是assembler. 如果你的平臺支持柵欄那麼這就是一個可行的解決方案。注意GCC4.4.2中的string對於ref count引用計數,就是採用原子操作加柵欄實現的。

線程安全的Singleton模式總結

1.首先這裏上面主要是討論線程安全的問題,其實可以有很多可改變的地方。比如說第一種加鎖的方案它是簡潔安全的但是效率低,我們其實可以建議客戶端代碼這樣寫來減少對instance函數的調用,只需要先保存一個Singleton指針即可。 

Singleton *instance = Singleton::instance();

instance->doThing1();

instance->donThing2();

而不要每次都調用instance(), Singleton::instance()->doThings().

也可以先做實驗看看直接用這種加鎖的方案是否真的很大程度影響了效率值得你去改變它。 

2. 另外我們上面演示的代碼都是採用了lazily-initialized Singleton,就是說只有用到的時候纔會有Singleton對象產生,如果程序沒有調用instance就不會有Singleton對象的產生。這樣固然比eager initialization好,但是真的那麼有必要嗎,如果我們採用 eager initialization在進入main之前產生Singleton對象,

而一般的多線程程序都是在進入main後再啓動的其它線程,就是說進入 mian之前是單線程環境,於是就沒有這麼多問題了。。。。boost 就是這麼幹的。

"1) sington 在進入main函數前初始化.

2)第一次使用時, singlton已得到正確的初始化(包括在static code中情況). Written by gavinkwoe"

"由於create_object將在被調用(static object_type & instance())之前進行初始化,因此singleton_default對象的初始化被放到了main之前。非常巧妙的一個設計" 

可以參考 遊戲人生博客 Writen by Fox(yulefox.at.gmail.com) 設計模式(三)——Singleton

http://www.cppblog.com/Fox/archive/2009/09/22/96898.html http://www.yulefox.com/20081119/design-patterns-03.html/

代碼 

1 template <typename T>

2 struct singleton

3 {

4 private:

5 struct object_creator

6 {

7 object_creator() { singleton<T>::instance(); } // 創建實例

8 inline void do_nothing() const { } 

9 };

10 static object_creator create_object;

11 singleton();

12 public:

13 typedef T object_type;

14 static object_type & instance()

15 {

16 static object_type obj;

17 create_object.do_nothing();// 需要create_object的初始化

18 return obj;

19 }

20 };

21 template <typename T> typename singleton<T>::object_creator singleton<T>::create_object; 

22 


這個設計用到了模板可複用,如Singleton<MySingletonClass>::instance().


3. 如果我們允許每個線程能有一個自己的Singleton對象呢,那樣就既可以推遲Singleton對象生成,也不用考慮多線程問題。



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章