設計模式系列之單例模式

單例模式是使用最普遍,也最簡單的設計模式之一,做用是保證一個類只有一個實例。單例模式是對全局變量的一種改進,避免全局變量污染命名空間。由於如下幾個緣由,全局變量不能做爲單例的實現方式:c++

1. 不能保證只有一個全局變量編程

2. 靜態初始化時可能沒有足夠的信息建立對象設計模式

3. c++中全局對象的構造順序是未定義的,若是單件之間存在依賴將可能產生錯誤安全

單例模式的實現代碼很簡單:多線程

//singleton.hpp
#ifndef SINGLETON_HPP
#define SINGLETON_HPP

class Singleton{
  public:
    static Singleton* getInstance();
  private:
    static Singleton* pInstance;
};
#endif

1 //singleton.cpp
2 #include "singleton.hpp"
3
4 Singleton* Singleton:: pInstance = nullptr;
5 
6 Singleton* Singleton::getInstance(){
7   if(nullptr == pInstance){
8     pInstance = new Singleton;
9   }
10  return pInstance;
11 }

單例模式這麼簡單,原本講到這裏就能夠結束了。不過若是把上面代碼放到多線程編程中使用就不那麼可靠了。在《C++and the Perils of Double-Checked Locking》這篇文章中,Scott Meyers和Andrei Alexandrescu以單例模式爲例詳細講述了多線程編程中的坑。下面的內容基本出自這篇論文,跟你們分享一下,很是經典。函數

上面的實如今單線程時沒有問題,如今假設有兩個線程A和B,A執行到第8行後因中斷掛起,這時候instance尚未建立,B執行到第8行,因而A和B都會建立Singleton對象,性能

如今就有兩個單例對象了,這固然是錯誤的。改爲線程安全很不難,進入 getInstance加個鎖就能保證每次只有一個線程進入函數,因而只會有一個線程實例化 pInstance。優化

Singleton* Singleton::getInstance(){
Lock lock;
  if(nullptr == instance){
    pInstance = new Singleton;
  }
  return pInstance;
}

可是每次調用 getInstance都加鎖是一件效率很是低的事情,特別是這裏只有第一次實例化 pInstance 時才須要互斥,之後都不須要鎖。因而DCLP(Double-Checked Locking Pattern)產生了。ui

DCLP的經典實現以下:atom

Singleton* Singleton::instance() {
  if (pInstance == 0) {
    // 1st test
    Lock lock;
    if (pInstance == 0) {
      // 2nd test
      pInstance = new Singleton;
    }
  }
  return pInstance;
}

經過兩次檢測 pInstance,這樣實例化後全部的調用都不須要加鎖。看樣子問題已經解決了,互斥鎖保證了只有一個線程會實例化 pInstance,之後的調用不須要鎖,性能也不會有問題,很完美是否是。讓咱們一步步來看看這裏面隱藏的坑。

pInstance = new Singleton;

這條實例化語句其實作了3件事情:

1. 分配一塊動態內存

2. 在這塊內存上調用Singleton構造函數構造對象

3. pInstance指向這塊內存

問題的關鍵是第2和第3步可能會被編譯器因優化緣由調換順序,先給pInstance賦值,在構建對象。在單線程上這是行的通的,由於編譯器優化的原則是不改變結果,調換2,3兩步對結果並無影響。因而代碼就相似於下面這樣:

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

再來考慮兩個線程A和B,

1. A第一次檢查 pInstance,獲取鎖,執行第1和第3步,掛起,這時候 pInstance非空,可是尚未調用構造函數,pInstance指向的是未初始化內存。

2. 線程B檢查 pInstance,發現非空,因而跳出函數,後面開始使用 pInstance,一個未初始化的對象。

DCLP只有在步驟1,2,3按照嚴格順序執行時才能保證正確,然而,c/c++並無這方面的支持,c/c++語言自己沒有多線程,編譯器優化只要保證單線程語義正確就行,多線程是不考慮的。爲了保證第2步在第3步以前完成,可能須要增長一個臨時變量,

Singleton* Singleton::instance() {
  if (pInstance == 0) {
    Lock lock; 
    if (pInstance == 0) {
      Singleton* temp = new Singleton; // initialize to temp
      pInstance = temp;
      // assign temp to pInstance
    }
  }
  return pInstance;
}

很惋惜,temp極可能也會被編譯器優化掉。爲了防止優化,文章圍繞volatile關鍵字作了詳細的討論,劉未鵬以及何登成都深刻解釋了volatile關鍵字在多線程編程中的效果,volatile明確告訴編譯器不要對被修飾的變量作優化,包括讀寫值時必須直接讀取內存值,兩個volatile變量的前後順序不可變等。不過

1. volatile只能保證單線程內指令順序不變,不能保證多線程間的指令順序的正確性

2. 一個volatile對象只有在構造函數完成後才具備volatile特性,因此仍然存在前面討論的問題。

總之,volatile沒法保證多線程正確。

另外,在多處理器機器上,還存在cache一致性問題。若是線程A和B在不一樣的處理器上,

即便A嚴格按照1,2,3步驟執行,在將cache寫回主存的過程當中仍然可能改變順序,由於按照內存地址升序順序寫回數據能夠提升效率。

完全的解決方法是使用memory barrier,這篇文章給出了c++11中的作法,

std::atomic<Singleton*> Singleton::instance;
std::mutex Singleton::m_mutex;

Singleton* Singleton::getInstance(){
  Singleton* tmp = instance.load(std::memory_order_relaxed);
  std::atomic_thread_fence(std::memory_order_acquire);
  if(nullptr == tmp){
    std::lock_guard<std::mutex> lock(m_mutex);
    tmp = instance.load(std::memory_order_relaxed);
    if(nullptr == tmp){
      tmp = new Singleton();
      std::atomic_thread_fence(std::memory_order_release);
      instance.store(tmp, std::memory_order_relaxed);
    }
  }
  return instance;
}

爲了實現線程安全的DCLP,可謂費勁周章。其實有時候咱們也能夠採起另外的解決問題的方式,好比多線程程序開始只有主線程,咱們能夠先在主線程中初始化單例模式,而後再建立其餘線程,從而徹底避免以上問題,這也是咱們公司項目中採用的方法!

 

Reference

http://preshing.com/20130930/double-checked-locking-is-fixed-in-cpp11/

C++ and the Perils of Double-Checked Locking

相關文章
相關標籤/搜索