設(shè)計模式之單例模式(C++版)

動機

保證一個類僅有一個實例,并提供一個該實例的全局訪問點。 ——《設(shè)計模式》GoF

在軟件系統(tǒng)中,經(jīng)常有這樣一些特殊的類,必須保證他們在系統(tǒng)中只存在一個實例,才能確保它們的邏輯正確性、以及良好的效率。

所以得考慮如何繞過常規(guī)的構(gòu)造器(不允許使用者new出一個對象),提供一種機制來保證一個類只有一個實例。

應(yīng)用場景:

  • Windows的Task Manager(任務(wù)管理器)就是很典型的單例模式,你不能同時打開兩個任務(wù)管理器。Windows的回收站也是同理。
  • 應(yīng)用程序的日志應(yīng)用,一般都可以用單例模式實現(xiàn),只能有一個實例去操作文件。
  • 讀取配置文件,讀取的配置項是公有的,一個地方讀取了所有地方都能用,沒有必要所有的地方都能讀取一遍配置。
  • 數(shù)據(jù)庫連接池,多線程的線程池。

實現(xiàn)一[線程不安全版本]

class Singleton{
public:
    static Singleton* getInstance(){
        // 先檢查對象是否存在
        if (m_instance == nullptr) {
            m_instance = new Singleton();
        }
        return m_instance;
    }
private:
    Singleton(); //私有構(gòu)造函數(shù),不允許使用者自己生成對象
    Singleton(const Singleton& other);
    static Singleton* m_instance; //靜態(tài)成員變量 
};

Singleton* Singleton::m_instance=nullptr; //靜態(tài)成員需要先初始化

這是單例模式最經(jīng)典的實現(xiàn)方式,將構(gòu)造函數(shù)和拷貝構(gòu)造函數(shù)都設(shè)為私有的,而且采用了延遲初始化的方式,在第一次調(diào)用getInstance()的時候才會生成對象,不調(diào)用就不會生成對象,不占據(jù)內(nèi)存。然而,在多線程的情況下,這種方法是不安全的。

分析:正常情況下,如果線程A調(diào)用getInstance()時,將m_instance 初始化了,那么線程B再調(diào)用getInstance()時,就不會再執(zhí)行new了,直接返回之前構(gòu)造好的對象。然而存在這種情況,線程A執(zhí)行m_instance = new Singleton()還沒完成,這個時候m_instance仍然為nullptr,線程B也正在執(zhí)行m_instance = new Singleton(),這是就會產(chǎn)生兩個對象,線程AB可能使用的是同一個對象,也可能是兩個對象,這樣就可能導(dǎo)致程序錯誤,同時,還會發(fā)生內(nèi)存泄漏。

實現(xiàn)二[線程安全,鎖的代價過高]

//線程安全版本,但鎖的代價過高
Singleton* Singleton::getInstance() {
    Lock lock; //偽代碼 加鎖
    if (m_instance == nullptr) {
        m_instance = new Singleton();
    }
    return m_instance;
}

分析:這種寫法不會出現(xiàn)上面兩個線程都執(zhí)行new的情況,當線程A在執(zhí)行m_instance = new Singleton()的時候,線程B如果調(diào)用了getInstance(),一定會被阻塞在加鎖處,等待線程A執(zhí)行結(jié)束后釋放這個鎖。從而是線程安全的。

但這種寫法的性能不高,因為每次調(diào)用getInstance()都會加鎖釋放鎖,而這個步驟只有在第一次new Singleton()才是有必要的,只要m_instance被創(chuàng)建出來了,不管多少線程同時訪問,使用if (m_instance == nullptr)進行判斷都是足夠的(只是讀操作,不需要加鎖),沒有線程安全問題,加了鎖之后反而存在性能問題。

實現(xiàn)三[雙檢查鎖,由于內(nèi)存讀寫reoder導(dǎo)致不安全]

上面的做法是不管三七二十一,某個線程要訪問的時候,先鎖上再說,這樣會導(dǎo)致不必要的鎖的消耗,那么,是否可以先判斷下if (m_instance == nullptr)呢,如果滿足,說明根本不需要鎖?。∵@就是所謂的雙檢查鎖(DCL)的思想,DCL即double-checked locking。

//雙檢查鎖,但由于內(nèi)存讀寫reorder不安全
Singleton* Singleton::getInstance() {
    //先判斷是不是初始化了,如果初始化過,就再也不會使用鎖了
    if(m_instance==nullptr){
        Lock lock; //偽代碼
        if (m_instance == nullptr) {
            m_instance = new Singleton();
        }
    }
    return m_instance;
}

這樣看起來很棒!只有在第一次必要的時候才會使用鎖,之后就和實現(xiàn)一中一樣了。

在相當長的一段時間,迷惑了很多人,在2000年的時候才被人發(fā)現(xiàn)漏洞,而且在每種語言上都發(fā)現(xiàn)了。原因是內(nèi)存讀寫的亂序執(zhí)行(編譯器的問題)。

分析:m_instance = new Singleton()這句話可以分成三個步驟來執(zhí)行:

  1. 分配了一個Singleton類型對象所需要的內(nèi)存。
  2. 在分配的內(nèi)存處構(gòu)造Singleton類型的對象。
  3. 把分配的內(nèi)存的地址賦給指針m_instance

可能會認為這三個步驟是按順序執(zhí)行的,但實際上只能確定步驟1是最先執(zhí)行的,步驟23卻不一定。問題就出現(xiàn)在這。假如某個線程A在調(diào)用執(zhí)行m_instance = new Singleton()的時候是按照1,3,2的順序的,那么剛剛執(zhí)行完步驟3Singleton類型分配了內(nèi)存(此時m_instance就不是nullptr了)就切換到了線程B,由于m_instance已經(jīng)不是nullptr了,所以線程B會直接執(zhí)行return m_instance得到一個對象,而這個對象并沒有真正的被構(gòu)造!!嚴重bug就這么發(fā)生了。

實現(xiàn)四[C++ 11版本的跨平臺實現(xiàn)]

javac#發(fā)現(xiàn)這個問題后,就加了一個關(guān)鍵字volatile,在聲明m_instance變量的時候,要加上volatile修飾,編譯器看到之后,就知道這個地方不能夠reorder(一定要先分配內(nèi)存,在執(zhí)行構(gòu)造器,都完成之后再賦值)。

而對于c++標準卻一直沒有改正,所以VC++2005版本也加入了這個關(guān)鍵字,但是這并不能夠跨平臺(只支持微軟平臺)。

而到了c++ 11版本,終于有了這樣的機制幫助我們實現(xiàn)跨平臺的方案。

//C++ 11版本之后的跨平臺實現(xiàn) 
// 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);
* 這兩句話可以保證他們之間的語句不會發(fā)生亂序執(zhí)行。
*/
Singleton* Singleton::getInstance() {
    Singleton* tmp = m_instance.load(std::memory_order_relaxed);
    std::atomic_thread_fence(std::memory_order_acquire);//獲取內(nèi)存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);//釋放內(nèi)存fence
            m_instance.store(tmp, std::memory_order_relaxed);
        }
    }
    return tmp;
}

實現(xiàn)五[pthread_once函數(shù)]

在linux中,pthread_once()函數(shù)可以保證某個函數(shù)只執(zhí)行一次。

聲明: int pthread_once(pthread_once_t once_control, void (init_routine) (void));

功能: 本函數(shù)使用初值為PTHREAD_ONCE_INIT的once_control
變量保證init_routine()函數(shù)在本進程執(zhí)行序列中僅執(zhí)行一次。 

示例如下:

class Singleton{
public:
    static Singleton* getInstance(){
        // init函數(shù)只會執(zhí)行一次
        pthread_once(&ponce_, &Singleton::init);
        return m_instance;
    }
private:
    Singleton(); //私有構(gòu)造函數(shù),不允許使用者自己生成對象
    Singleton(const Singleton& other);
    //要寫成靜態(tài)方法的原因:類成員函數(shù)隱含傳遞this指針(第一個參數(shù))
    static void init() {
        m_instance = new Singleton();
    }
    static pthread_once_t ponce_;
    static Singleton* m_instance; //靜態(tài)成員變量 
};
pthread_once_t Singleton::ponce_ = PTHREAD_ONCE_INIT;
Singleton* Singleton::m_instance=nullptr; //靜態(tài)成員需要先初始化

實現(xiàn)六[c++ 11版本最簡潔的跨平臺方案]

實現(xiàn)四的方案有點麻煩,實現(xiàn)五的方案不能跨平臺。其實c++ 11中已經(jīng)提供了std::call_once方法來保證函數(shù)在多線程環(huán)境中只被調(diào)用一次,同樣,他也需要一個once_flag的參數(shù)。用法和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.

局部靜態(tài)變量不僅只會初始化一次,而且還是線程安全的。

class Singleton{
public:
    // 注意返回的是引用。
    static Singleton& getInstance(){
        static Singleton m_instance;  //局部靜態(tài)變量
        return m_instance;
    }
private:
    Singleton(); //私有構(gòu)造函數(shù),不允許使用者自己生成對象
    Singleton(const Singleton& other);
};

這種單例被稱為Meyers' Singleton。這種方法很簡潔,也很完美,但是注意:

  1. gcc 4.0之后的編譯器支持這種寫法。
  2. C++11及以后的版本(如C++14)的多線程下,正確。
  3. C++11之前不能這么寫。

但是現(xiàn)在都18年了。。新項目一般都支持了c++11了。

用模板包裝單例

從上面已經(jīng)知道了單例模式的各種實現(xiàn)方式。但是有沒有感到一點不和諧的地方?如果我class A需要做成單例,需要這么改造class A,如果class B也需要做成單例,還是需要這樣改造一番,是不是有點重復(fù)勞動的感覺?利用c++的模板語法可以避免這樣的重復(fù)勞動。

template<typename T>
class Singleton
{
public:
    static T& getInstance() {
        static T value_; //靜態(tài)局部變量
        return value_;
    }

private:
    Singleton();
    ~Singleton();
    Singleton(const Singleton&); //拷貝構(gòu)造函數(shù)
    Singleton& operator=(const Singleton&); // =運算符重載
};

假如有AB兩個類,用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的構(gòu)造函數(shù)具有參數(shù)呢?上面的寫法還是沒有通用性??梢允褂?code>C++11的可變參數(shù)模板解決這個問題。但是感覺實際中這種需求并不是很多,因為構(gòu)造只需要一次,每次getInstance()傳個參數(shù)不是很麻煩嗎。。。

總結(jié)

單例模式本身十分簡單,但是實現(xiàn)上卻發(fā)現(xiàn)各種麻煩,主要是多線程編程確實是個難點。而對于c++的對象模型、內(nèi)存模型,并沒有什么深入的了解,還在一知半解的階段,仍需努力。

需要注意的一點是,上面討論的線程安全指的是getInstance()是線程安全的,假如多個線程都獲取類A的對象,如果只是只讀操作,完全OK,但是如果有線程要修改,有線程要讀取,那么類A自身的函數(shù)需要自己加鎖防護,不是說線程安全的單例也能保證修改和讀取該對象自身的資源也是線程安全的。

我的SegmentFault鏈接

參考

  1. C++中多線程與Singleton的那些事兒
  2. boolan c++設(shè)計模式
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容