單例模式很簡單?但是你真的能寫對嗎?
- 2019 年 11 月 27 日
- 筆記
來源:https://segmentfault.com/a/1190000015950693
作者:朱宇清
編輯:公眾號【編程珠璣】

單例模式看起來簡單,但是需要考慮的問題卻很多。
保證一個類僅有一個實例,並提供一個該實例的全局訪問點。——《設計模式》
在軟體系統中,經常有這樣一些特殊的類,必須保證他們在系統中只存在一個實例,才能確保它們的邏輯正確性、以及良好的效率。
所以得考慮如何繞過常規的構造器(不允許使用者new出一個對象),提供一種機制來保證一個類只有一個實例。
應用場景:
- Windows的Task Manager(任務管理器)就是很典型的單例模式,你不能同時打開兩個任務管理器。Windows的回收站也是同理。
- 應用程式的日誌應用,一般都可以用單例模式實現,只能有一個實例去操作文件。
- 讀取配置文件,讀取的配置項是公有的,一個地方讀取了所有地方都能用,沒有必要所有的地方都能讀取一遍配置。
- 資料庫連接池,多執行緒的執行緒池。
實現
單例模式的實現有很多中,我們來看看一些常見的實現。某些實現可能是適合部分場景,但並不是說不能用。
實現一[執行緒不安全版本]
class Singleton{ public: static Singleton* getInstance(){ // 先檢查對象是否存在 if (m_instance == nullptr) { m_instance = new Singleton(); } return m_instance; } private: Singleton(); //私有構造函數,不允許使用者自己生成對象 Singleton(const Singleton& other); static Singleton* m_instance; //靜態成員變數 }; Singleton* Singleton::m_instance=nullptr; //靜態成員需要先初始化
這是單例模式最經典的實現方式,將構造函數和拷貝構造函數都設為私有的,而且採用了延遲初始化的方式,在第一次調用getInstance()的時候才會生成對象,不調用就不會生成對象,不佔據記憶體。然而,在多執行緒的情況下,這種方法是不安全的。
分析:正常情況下,如果執行緒A調用getInstance()時,將m_instance 初始化了,那麼執行緒B再調用getInstance()時,就不會再執行new了,直接返回之前構造好的對象。然而存在這種情況,執行緒A執行m_instance = new Singleton()還沒完成,這個時候m_instance仍然為nullptr,執行緒B也正在執行m_instance = new Singleton(),這是就會產生兩個對象,執行緒A和B可能使用的是同一個對象,也可能是兩個對象,這樣就可能導致程式錯誤,同時,還會發生記憶體泄漏。
實現二[執行緒安全,鎖的代價過高]
//執行緒安全版本,但鎖的代價過高 Singleton* Singleton::getInstance() { Lock lock; //偽程式碼 加鎖 if (m_instance == nullptr) { m_instance = new Singleton(); } return m_instance; }
分析:這種寫法不會出現上面兩個執行緒都執行new的情況,當執行緒A在執行m_instance = new Singleton()的時候,執行緒B如果調用了getInstance(),一定會被阻塞在加鎖處,等待執行緒A執行結束後釋放這個鎖。從而是執行緒安全的。
但這種寫法的性能不高,因為每次調用getInstance()都會加鎖釋放鎖,而這個步驟只有在第一次new Singleton()才是有必要的,只要m_instance被創建出來了,不管多少執行緒同時訪問,使用if (m_instance == nullptr) 進行判斷都是足夠的(只是讀操作,不需要加鎖),沒有執行緒安全問題,加了鎖之後反而存在性能問題。
實現三[雙檢查鎖]
上面的做法是不管三七二十一,某個執行緒要訪問的時候,先鎖上再說,這樣會導致不必要的鎖的消耗,那麼,是否可以先判斷下if (m_instance == nullptr) 呢,如果滿足,說明根本不需要鎖啊!這就是所謂的雙檢查鎖(DCL)的思想,DCL即double-checked locking。
//雙檢查鎖,但由於記憶體讀寫reorder不安全 Singleton* Singleton::getInstance() { //先判斷是不是初始化了,如果初始化過,就再也不會使用鎖了 if(m_instance==nullptr){ Lock lock; //偽程式碼 if (m_instance == nullptr) { m_instance = new Singleton(); } } return m_instance; }
這樣看起來很棒!只有在第一次必要的時候才會使用鎖,之後就和實現一中一樣了。
在相當長的一段時間,迷惑了很多人,在2000年的時候才被人發現漏洞,而且在每種語言上都發現了。原因是記憶體讀寫的亂序執行(編譯器的問題)。
分析:m_instance = new Singleton()這句話可以分成三個步驟來執行:
- 分配了一個Singleton類型對象所需要的記憶體。
- 在分配的記憶體處構造Singleton類型的對象。
- 把分配的記憶體的地址賦給指針m_instance。
可能會認為這三個步驟是按順序執行的,但實際上只能確定步驟1是最先執行的,步驟2,3卻不一定。問題就出現在這。假如某個執行緒A在調用執行m_instance = new Singleton()的時候是按照1,3,2的順序的,那麼剛剛執行完步驟3給Singleton類型分配了記憶體(此時m_instance就不是nullptr了)就切換到了執行緒B,由於m_instance已經不是nullptr了,所以執行緒B會直接執行return m_instance得到一個對象,而這個對象並沒有真正的被構造!!嚴重bug就這麼發生了。
編輯註:
編譯器可能會對程式碼進行調整優化,讓那些看起來順序不影響結果的程式碼進行順序的調整,但是實際上在多執行緒中可能會有問題。
實現四[C++ 11版本的跨平台實現]
java和c#發現這個問題後,就加了一個關鍵字volatile,在聲明m_instance變數的時候,要加上volatile修飾,編譯器看到之後,就知道這個地方不能夠reorder(一定要先分配記憶體,在執行構造器,都完成之後再賦值)。
而對於c++標準卻一直沒有改正,所以VC++在2005版本也加入了這個關鍵字,但是這並不能夠跨平台(只支援微軟平台)。
而到了c++ 11版本,終於有了這樣的機制幫助我們實現跨平台的方案。
//C++ 11版本之後的跨平台實現 // atomic c++11中提供的原子操作 std::atomic<Singleton*> Singleton::m_instance; std::mutex Singleton::m_mutex; /* * std::atomic_thread_fence(std::memory_order_acquire); * std::atomic_thread_fence(std::memory_order_release); * 這兩句話可以保證他們之間的語句不會發生亂序執行。 */ Singleton* Singleton::getInstance() { Singleton* tmp = m_instance.load(std::memory_order_relaxed); std::atomic_thread_fence(std::memory_order_acquire);//獲取記憶體fence if (tmp == nullptr) { std::lock_guard<std::mutex> lock(m_mutex); tmp = m_instance.load(std::memory_order_relaxed); if (tmp == nullptr) { tmp = new Singleton; std::atomic_thread_fence(std::memory_order_release);//釋放記憶體fence m_instance.store(tmp, std::memory_order_relaxed); } } return tmp; }
實現五[pthread_once函數]
在linux中,pthread_once()函數可以保證某個函數只執行一次。
聲明:
int pthread_once(pthread_once_t once_control, void (init_routine) (void));
功能: 本函數使用初值為PTHREAD_ONCE_INIT的once_control 變數保證init_routine()函數在本進程執行序列中僅執行一次。 示例如下:
class Singleton{ public: static Singleton* getInstance(){ // init函數只會執行一次 pthread_once(&ponce_, &Singleton::init); return m_instance; } private: Singleton(); //私有構造函數,不允許使用者自己生成對象 Singleton(const Singleton& other); //要寫成靜態方法的原因:類成員函數隱含傳遞this指針(第一個參數) static void init() { m_instance = new Singleton(); } static pthread_once_t ponce_; static Singleton* m_instance; //靜態成員變數 }; pthread_once_t Singleton::ponce_ = PTHREAD_ONCE_INIT; Singleton* Singleton::m_instance=nullptr; //靜態成員需要先初始化
實現六[c++ 11版本最簡潔的跨平台方案]
實現四的方案有點麻煩,實現五的方案不能跨平台。其實c++ 11中已經提供了std::call_once方法來保證函數在多執行緒環境中只被調用一次,同樣,他也需要一個once_flag的參數。用法和pthread_once類似,並且支援跨平台。
實際上,還有一種最為簡單的方案!
在C++memory model中對static local variable,說道:
The initialization of such a variable is defined to occur the first time control passes through its declaration; for multiple threads calling the function, this means there』s the potential for a race condition to define first.
局部靜態變數不僅只會初始化一次,而且還是執行緒安全的。
class Singleton{ public: // 注意返回的是引用。 static Singleton& getInstance(){ static Singleton m_instance; //局部靜態變數 return m_instance; } private: Singleton(); //私有構造函數,不允許使用者自己生成對象 Singleton(const Singleton& other); };
這種單例被稱為Meyers' Singleton 。這種方法很簡潔,也很完美,但是注意:
- gcc 4.0之後的編譯器支援這種寫法。
- C++11及以後的版本(如C++14)的多執行緒下,正確。
- C++11之前不能這麼寫。
- 但是現在都19年了,新項目一般都支援了c++11了。
用模板包裝單例
從上面已經知道了單例模式的各種實現方式。但是有沒有感到一點不和諧的地方?如果我class A需要做成單例,需要這麼改造class A,如果class B也需要做成單例,還是需要這樣改造一番,是不是有點重複勞動的感覺?利用c++的模板語法可以避免這樣的重複勞動。
template<typename T> class Singleton { public: static T& getInstance() { static T value_; //靜態局部變數 return value_; } private: Singleton(); ~Singleton(); Singleton(const Singleton&); //拷貝構造函數 Singleton& operator=(const Singleton&); // =運算符重載 };
假如有A,B兩個類,用Singleton類可以很容易的把他們也包裝成單例。
class A{ public: A(){ a = 1; } void func(){ cout << "A.a = " << a << endl; } private: int a; }; class B{ public: B(){ b = 2; } void func(){ cout << "B.b = " << b << endl; } private: int b; }; // 使用demo int main() { Singleton<A>::getInstance().func(); Singleton<B>::getInstance().func(); return 0; }
假如類A的構造函數具有參數呢?上面的寫法還是沒有通用性。可以使用C++11的可變參數模板解決這個問題。但是感覺實際中這種需求並不是很多,因為構造只需要一次,每次getInstance()傳個參數不是很麻煩嗎。
總結
單例模式本身十分簡單,但是實現上卻發現各種麻煩,主要是多執行緒編程確實是個難點。而對於c++的對象模型、記憶體模型,並沒有什麼深入的了解,還在一知半解的階段,仍需努力。
需要注意的一點是,上面討論的執行緒安全指的是getInstance()是執行緒安全的,假如多個執行緒都獲取類A的對象,如果只是只讀操作,完全OK,但是如果有執行緒要修改,有執行緒要讀取,那麼類A自身的函數需要自己加鎖防護,不是說執行緒安全的單例也能保證修改和讀取該對象自身的資源也是執行緒安全的。
編輯的話:
單例模式雖然聽起來簡單,但是要考慮的方面非常多,例如:
- 性能
- 多執行緒
- 阻止拷貝構造和賦值
- 通用化