程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> C++入門知識 >> C++中多線程與Singleton的那些事兒,多線程singleton

C++中多線程與Singleton的那些事兒,多線程singleton

編輯:C++入門知識

C++中多線程與Singleton的那些事兒,多線程singleton


前言

  前段時間在網上看到了個的面試題,大概意思是如何在不使用鎖和C++11的情況下,用C++實現線程安全的Singleton。

  看到這個題目後,第一個想法就是用Scott Meyer在《Effective C++》中提到的,把local static變量放到static成員函數中來實現,但是經過一番查找、思考,才明白這種實現在某些情況下是有問題的。本文主要將從最基本的單線程中的Singleton開始,慢慢講述多線程與Singleton的那些事。

單線程

  在多線程下,下面這個是常見的寫法:

 1 template<typename T>
 2 class Singleton
 3 {
 4 public:
 5     static T& getInstance()
 6     {
 7         if (!value_)
 8         {
 9             value_ = new T();
10         }
11         return *value_;
12     }
13 
14 private:
15     Singleton();
16     ~Singleton();
17 
18     static T* value_;
19 };
20 
21 template<typename T>
22 T* Singleton<T>::value_ = NULL;

在單線程中,這樣的寫法是可以正確使用的,但是在多線程中就不行了。

多線程加鎖

  在多線程的環境中,上面單線程的寫法就會產生race condition從而產生多次初始化的情況。要想在多線程下工作,最容易想到的就是用鎖來保護shared variable了。下面是偽代碼:

 1 template<typename T>
 2 class Singleton
 3 {
 4 public:
 5     static T& getInstance()
 6     {
 7         {
 8             MutexGuard guard(mutex_)  // RAII
 9             if (!value_)
10             {
11                 value_ = new T();
12             }
13         }
14         return *value_;
15     }
16 
17 private:
18     Singleton();
19     ~Singleton();
20 
21     static T*     value_;
22     static Mutex  mutex_;
23 };
24 
25 template<typename T>
26 T* Singleton<T>::value_ = NULL;
27 
28 template<typename T>
29 Mutex Singleton<T>::mutex_;

這樣在多線程下就能正常工作了。這時候,可能有人會站出來說這種做法每次調用getInstance的時候都會進入臨界區,在頻繁調用getInstance的時候會比較影響性能。這個時候,為了解決這個問題,DSL寫法就被聰明的先驅者發明了。

DCL

  DCL即double-checked locking。在普通加鎖的寫法中,每次調用getInstance都會進入臨界區,這樣在heavy contention的情況下該函數就會成為系統性能的瓶頸,這個時候就有先驅者們想到了DCL寫法,也就是進行兩次check,當第一次check為假時,才加鎖進行第二次check:

 1 template<typename T>
 2 class Singleton
 3 {
 4 public:
 5     static T& getInstance()
 6     {
 7         if(!value_)
 8         {
 9             MutexGuard guard(mutex_);
10             if (!value_)
11             {
12                 value_ = new T();
13             }
14         }
15         return *value_;
16     }
17 
18 private:
19     Singleton();
20     ~Singleton();
21 
22     static T*     value_;
23     static Mutex  mutex_;
24 };
25 
26 template<typename T>
27 T* Singleton<T>::value_ = NULL;
28 
29 template<typename T>
30 Mutex Singleton<T>::mutex_;

是不是覺得這樣就完美啦?其實在一段時間內,大家都以為這是正確的、有效的做法。實際上卻不是這樣的。幸運的是,後來有大牛們發現了DCL中的問題,避免了這樣錯誤的寫法在更多的程序代碼中出現。

  那麼到底錯在哪呢?我們先看看第12行value_ = new T這一句發生了什麼:

  主觀上,我們會覺得計算機在會按照1、2、3的步驟來執行代碼,但是問題就出在這。實際上只能確定步驟1最先執行,而步驟2、3的執行順序卻是不一定的。假如某一個線程A在調用getInstance的時候第12行的語句按照1、3、2的步驟執行,那麼當剛剛執行完步驟3的時候發生線程切換,計算機開始執行另外一個線程B。因為第一次check沒有上鎖保護,那麼在線程B中調用getInstance的時候,不會在第一次check上等待,而是執行這一句,那麼此時value_已經被賦值了,就會直接返回*value_然後執行後面使用T類型對象的語句,但是在A線程中步驟3還沒有執行!也就是說在B線程中通過getInstance返回的對象還沒有被構造就被拿去使用了!這樣就會發生一些難以debug的災難問題。

  volatile關鍵字也不會影響執行順序的不確定性。

  在多核心機器的環境下,2個核心同時執行上面的A、B兩個線程時,由於第一次check沒有鎖保護,依然會出現使用實際沒有被構造的對象的情況。

  關於DCL問題的詳細討論分析,可以參考Scott Meyer的paper:《C++ and the Perils of Double-Checked Locking》

  不過在新的C++11中,這個問題得到了解決。因為新的C++11規定了新的內存模型,保證了上述的執行順序是123,DCL又可以正確使用了,不過在C++11下卻有更簡潔的多線程Singleton寫法了,這個留在後面再介紹。

  關於新的C++11的內存模型,可以參考:C++11中文版FAQ:內存模型、C++11FAQ:Memory Model、C++ Data-Dependency Ordering: Atomics and Memory Model

  可能有人要問了,那麼有什麼辦法可以在C++11之前的版本下,使得DCL正確工作呢?要使其正確執行的話,就得在步驟2、3直接加上一道memory barrier。強迫CPU執行的時候按照1、2、3的步驟來運行。

 1 static T& getInstance()
 2 {
 3     if(!value_)
 4     {
 5         MutexGuard guard(mutex_);
 6         if (!value_)
 7         {
 8             value_ = static_cast<T*>(operator new(sizeof(T)));
 9             // insert some memory barier
10             new (value_) T();
11         }
12     }
13     return *value_;
14 }

  也許有人會說,你這已經把先前的value_ = new T()這一句拆成了下面這樣的兩條語句, 為什麼還要在中間插入some memory barrier?

value_ = static_cast<T*>(operator new(sizeof(T)));
new (value_) T();

  原因是現代處理器都是以Out-of-order execution(亂序執行)的方式來執行指令的。現代CPU基本都是多核心的,一個核包含多個執行單元。例如,一個現代的Intel CPU 包含6個執行單元,可以做一組數學,條件邏輯和內存操作的組合。每個執行單元可以做這些任務的組合。這些執行單元並行地操作,允許指令並行地執行。如果從其它 CPU 來觀察,這引入了程序順序的另一層不確定性。

  如果站在單個CPU核心的角度上講,它(一個CPU核心)看到的程序代碼都是單線程的,所以它在內部以自己的“優化方式”亂序、並行的執行代碼,然後保證最終的結果和按代碼邏輯順序執行的結果一致。但是如果我們編寫的代碼是多線程的,當不同線程訪問、操作共享內存區域的時候,就會出現CPU實際執行的結果和代碼邏輯所期望的結果不一致的情況。這是因為以單個CPU核心的視角來看代碼是“單線程”的。

  所以為了解決這個問題,就需要memory barrier了,利用它來強迫CPU按代碼的邏輯順序執行。例如上面改動版本的getInstance代碼中,因為第9行有memory barrier,所以CPU執行第8、9、10按“順序”執行的。即使在CPU核心內是並行執行指令(比如一個單元執行第8行、一個單元執行第10行)的,但是他們在退役單元(retirement unit)更新執行結果到通用寄存器或者內存中時也是按照8、9、10順序更新的。例如一個單元A先執行完了第10行,CPU讓單元A等待直到第9行的單元B執行完成並在退役單元更新完結果以後再在退役單元更新A的結果。

  memory barreir是一種特殊的處理器指令,他指揮處理器做下面三件事:(參考文章Mutex And Memory Visibility)

  • 刷新store buffer。
  • 等待直到memory barreir之前的操作已經完成。
  • 不將memory barreir之後的操作移到memory barreir之前執行。

  通過使用memory barreir,可以確保之前的亂序執行已經全部完成,並且未完成的寫操作已全部刷新到主存。因此,數據一致性有重新回到其他線程的身邊,從而保證正確內存的可見性。實際上,原子操作以及通過原子操作實現的模型(例如一些鎖之類的),都是通過在底層加入memory barrier來實現的。

  至於如何加入memory barrier,在unix上可以通過內核提供的barrier()宏來實現。或者直接嵌入ASM匯編指令mfence也可以,barrier宏也是通過該指令實現的。

  關於memory barreir可以參考文章Memory Barriers/Fences。

Meyers Singleton

  Scott Meyer在《Effective C++》中提出了一種簡潔的singleton寫法

 1 template<typename T>
 2 class Singleton
 3 {
 4 public:
 5     static T& getInstance()
 6     {
 7         static T value;
 8         return value;
 9     }
10 
11 private:
12     Singleton();
13     ~Singleton();
14 };

  先說結論:

  • 單線程下,正確。
  • C++11及以後的版本(如C++14)的多線程下,正確。
  • C++11之前的多線程下,不一定正確。

  原因在於在C++11之前的標准中並沒有規定local static變量的內存模型,所以很多編譯器在實現local static變量的時候僅僅是進行了一次check(參考《深入探索C++對象模型》),於是getInstance函數被編譯器改寫成這樣了:

 1 bool initialized = false;
 2 char value[sizeof(T)];
 3 
 4 T& getInstance()
 5 {
 6     if (!initialized)
 7     {
 8        initialized = true;
 9        new (value) T();
10     }
11     return *(reinterpret_cast<T*>(value));
12 }

於是乎它就是不是線程安全的了。

  但是在C++11卻是線程安全的,這是因為新的C++標准規定了當一個線程正在初始化一個變量的時候,其他線程必須得等到該初始化完成以後才能訪問它。

  在C++11 standard中的§6.7 [stmt.dcl] p4:

If control enters the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for completion of the initialization.

  在stackoverflow中的Is Meyers implementation of Singleton pattern thread safe?這個問題中也有討論到。

  不過有些編譯器在C++11之前的版本就支持這種模型,例如g++,從g++4.0開始,meyers singleton就是線程安全的,不需要C++11。其他的編譯器就需要具體的去查相關的官方手冊了。

Atomic Singleton

  在C++11之前的版本下,除了通過鎖實現線程安全的Singleton外,還可以利用各個編譯器內置的atomic operation來實現。(假設類Atomic是封裝的編譯器提供的atomic operation)

 1 template<typename T>
 2 class Singleton
 3 {
 4 public:
 5     static T& getInstance()
 6     {
 7         while (true)
 8         {
 9             if (ready_.get())
10             {
11                 return *value_;
12             }
13             else
14             {
15                 if (initializing_.getAndSet(true))
16                 {
17                     // another thread is initializing, waiting in circulation
18                 }
19                 else
20                 {
21                     value_ = new T();
22                     ready_.set(true);
23                     return *value_;
24                 }
25             }
26         }
27     }
28 
29 private:
30     Singleton();
31     ~Singleton();
32 
33     static Atomic<bool>  ready_;
34     static Atomic<bool>  initializing_;
35     static T*            value_;
36 };
37 
38 template<typename T>
39 Atomic<int> Singleton<T>::ready_(false);
40 
41 template<typename T>
42 Atomic<int> Singleton<T>::initializing_(false);
43 
44 template<typename T>
45 T* Singleton<T>::value_ = NULL;

  肯定還有其他的寫法,但是思路都差不多,需要區分三種狀態:

  • 對象已經構造完成
  • 對象還沒有構造完成,但是某一線程正在構造中
  • 對象還沒有構造完成,也沒有任何線程正在構造中

pthread_once

  如果是在unix平台的話,除了使用atomic operation外,在不適用C++11的情況下,還可以通過pthread_once來實現Singleton。

  pthread_once的原型為

int pthread_once(pthread_once_t *once_control, void (*init_routine)(void))

  APUE中對於pthread_once是這樣說的:

如果每個線程都調用pthread_once,系統就能保證初始化話例程init_routine只被調用一次,即在系統首次調用pthread_once時。

  所以,我們就可以這樣來實現Singleton了

 1 template<typename T>
 2 class Singleton : Nocopyable
 3 {
 4 public:
 5     static T& getInstance()
 6     {
 7         threads::pthread_once(&once_control_, init);
 8         return *value_;
 9     }
10 
11 private:
12     static void init()
13     {
14         value_ = new T();
15     }
16 
17     Singleton();
18     ~Singleton();
19 
20     static pthread_once_t  once_control_;
21     static T*              value_;
22 };
23 
24 template<typename T>
25 pthread_once_t Singleton<T>::once_control_ = PTHREAD_ONCE_INIT;
26 
27 template<typename T>
28 T* Singleton<T>::value_ = NULL;

  如果需要正確的釋放資源的話,可以在init函數裡面使用glibc提供的atexit函數來注冊相關的資源釋放函數,從而達到了只在進程退出時才釋放資源的這一目的。

static object

  現在再回頭看看本文開頭說的面試題的要求,不用鎖和C++11,那麼可以通過atomic operation來實現,但是有人會說atomic不是誇平台的,各個編譯器的實現不一樣。那麼其實通過static object來實現也是可行的。

 1 template<typename T>
 2 class Singleton
 3 {
 4 public:
 5     static T& getInstance()
 6     {
 7         return *value_;
 8     }
 9 
10 private:
11     Singleton();
12     ~Singleton();
13 
14     class Helper
15     {
16     public:
17         Helper()
18         {
19             Singleton<T>::value_ = new T();
20         }
21 
22         ~Helper()
23         {
24             delete value_;
25             value_ = NULL;
26         }
27     };
28 
29     friend class Helper;
30 
31     static T*      value_;
32     static Helper  helper_;
33 };
34 
35 template<typename T>
36 T* Singleton<T>::value_ = NULL;
37 
38 template<typename T>
39 typename Singleton<T>::Helper Singleton<T>::helper_;

  在進入main之前就把Singleton對象構造出來就可以避免在進入main函數後的多線程環境中構造的各種情況了。這種寫法有一個前提就是不能在main函數執行之前調用getInstance,因為C++標准只保證靜態變量在main函數之前之前被構造完成。

local static

  上面一種寫法只能在進入main函數後才能調用getInstance,那麼有人說,我要在main函數之前調用怎麼辦?

  嗯,辦法還是有的。這個時候我們就可以利用local static來實現,C++標准保證函數內的local static變量在函數調用之前被初始化構造完成,利用這一特性就可以達到目的:

 1 template<typename T>
 2 class Singleton
 3 {
 4 private:
 5     Singleton();
 6     ~Singleton();
 7 
 8     class Creater
 9     {
10     public:
11         Creater()
12             : value_(new T())
13         {
14         }
15 
16         ~Creater()
17         {
18             delete value_;
19             value_ = NULL;
20         }
21 
22         T& getValue()
23         {
24             return *value_;
25         }
26 
27         T* value_;
28     };
29 
30 public:
31     static T& getInstance()
32     {
33         static Creater creater;
34         return creater.getValue();
35     }
36 
37 private:
38     class Dummy
39     {
40     public:
41         Dummy()
42         {
43             Singleton<T>::getInstance();
44         }
45     };
46 
47     static Dummy dummy_;
48 };
49 
50 template<typename T>
51 typename Singleton<T>::Dummy Singleton<T>::dummy_;

  這樣就可以了。dummy_的作用是即使在main函數之前沒有調用getInstance,它依然會作為最後一道屏障保證在進入main函數之前構造完成Singleton對象。這樣就避免了在進入main函數後的多線程環境中初始化的各種問題了。

  但是此種方法只能在在main函數執行之前的環境是單線程的環境下才能正確工作。

  實際上,上文所講述了各種寫法中,有一些不能在main函數之前調用。有一些可以在main函數之前調用,但是必須在進入main之前的環境是單線程的情況下才能正常工作。具體哪種寫法是屬於這兩種情況就不一一分析了。總之,個人建議最好不要在進入main函數之前獲取Singleton對象。因為上文中的各種方法都用到了staitc member,而C++標准只保證static member在進入main函數之前初始化,但是初始化的順序卻是未定義的, 所以如果在main之前就調用getInstance的話,就有可能出現實現Singleton的static member還沒有初始化就被使用的情況。

  如果萬一要在main之前獲取Singleton對象,並且進入main之前的環境是多線程環境,這種情形下,還能保證正常工作的寫法只有C++ 11下的Meyers Singleton,或者如g++ 4.0及其後續版本這樣的編譯器提前支持內存模型情況下的C++ 03也是可以的。

參考文獻

[1] Scott Meyers. Effective C++:55 Specific Ways to Improve Your Programs and Designs,3rd Edition. 電子工業出版社, 2011

[2] Stanley B. Lippman. 深度探索C++對象模型. 電子工業出版社, 2012

[3] Scott Meyers. C++ and the Perils of Double-Checked Locking. 2004

[4] 陳良喬(譯). C++11 FAQ中文版

[5] Bjarne Stroustrup. C++11 FAQ

[6] Paul E. McKenney, Hans-J. Boehm, Lawrence Crowl. C++ Data-Dependency Ordering: Atomics and Memory Model. 2008

[7] Wikipedia. Out-of-order execution

[8] Loïc. Mutex And Memory Visibility, 2009

[9] Randal E.Bryant, David O'Hallaron. 深入理解計算機系統(第2版). 機械工業出版社, 2010

[10] Martin Thompson. Memory Barriers/Fences, 2011

[11] Working Draft, Standard For Programing Language C++. 2012

[12] W.Richard Stevens. UNIX環境高級編程(第3版), 人民郵電出版社, 2014

[13] stackoverflow. Is Meyers implementation of Singleton pattern thread safe

(完)

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved