前段時間在網上看到了個的面試題,大概意思是如何在不使用鎖和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即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)
通過使用memory barreir,可以確保之前的亂序執行已經全部完成,並且未完成的寫操作已全部刷新到主存。因此,數據一致性有重新回到其他線程的身邊,從而保證正確內存的可見性。實際上,原子操作以及通過原子操作實現的模型(例如一些鎖之類的),都是通過在底層加入memory barrier來實現的。
至於如何加入memory barrier,在unix上可以通過內核提供的barrier()宏來實現。或者直接嵌入ASM匯編指令mfence也可以,barrier宏也是通過該指令實現的。
關於memory barreir可以參考文章Memory Barriers/Fences。
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之前的標准中並沒有規定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。其他的編譯器就需要具體的去查相關的官方手冊了。
在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;
肯定還有其他的寫法,但是思路都差不多,需要區分三種狀態:
如果是在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函數來注冊相關的資源釋放函數,從而達到了只在進程退出時才釋放資源的這一目的。
現在再回頭看看本文開頭說的面試題的要求,不用鎖和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函數之前之前被構造完成。
上面一種寫法只能在進入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
(完)