【GOF設計模式之路】-- Singleton
之前一直徘徊第一篇該寫哪一種設計模式,最後決定還是以Singleton模式開始吧。之所以以它開始,原因在我於個人認為,相對來說它在設計上比較單一,比較簡單一些。在通常情況下,它是最容易理解的。同樣也正因為它容易理解,細節才更值得注意,越是簡單的東西,往往會被我們忽略一些細節。關於Singleton模式的討論和實現也非常的多,GOF設計模式也只對它進行了簡單的描述,本文則打算相對全面的介紹一下Singleton模式,目的在於挖掘設計的思想。
Singleton模式又稱單件模式,我們都知道全域性變數,Singleton就相當於一個全域性變數,只不過它是一種被改進的全域性變數。體現在哪兒呢?普通的全域性變數可以有多個,例如某個類,可以定義很多個這個類的全域性物件,可以分別服務於整個應用程式的各個模組。而Singleton則只允許建立一個物件,就好比不允許有克隆人一樣,哪天在大街上看見一個一模一樣的你,你會是什麼感受?從計算機的角度,例如鍵盤、顯示器、系統時鐘等,都應該是Singleton,如果有多個這樣的物件存在,很可能帶來危險,而這些危險卻不能換來切實的好處。
在GOF設計模式裡,對Singleton的描述很簡單:“保證一個類只有一個實體(instance),併為其提供一個全域性訪問點(global access point)”。所謂全域性訪問點,就是Singleton類的一個公共全域性的訪問介面,用於獲得Singleton實體,對於使用者來說,只需要簡單的步驟就能獲得這個實體,與全域性變數的訪問一樣簡單,而Singleton物件的建立與銷燬有Singleton類自己承擔,使用者不必操心。既然是自己管理,我們就得將其管理好,讓使用者放心,現在是提倡服務質量的社會,我們應該有服務的態度。
理論講了一大堆,迫不及待想看看具體實現,先看一個初期的版本:
/* Singleton.h */
//-----------------------------------------------------------------------------------
// Desc: Singleton Header File
// Author: masefee
// Date: 2010.09
// Copyright (C) 2010 masefee
//-----------------------------------------------------------------------------------
#ifndef __SINGLETON_H__
#define __SINGLETON_H__
class Singleton
{
public:
static Singleton* Instance( void );
public:
int doSomething( void );
protected:
Singleton( void ){}
private:
static Singleton* ms_pInstance;
};
#endif
/* Singleton.cpp */
//-----------------------------------------------------------------------------------
// Desc: Singleton Source File
// Author: masefee
// Date: 2010.09
// Copyright (C) 2010 masefee
//-----------------------------------------------------------------------------------
#include "Singleton.h"
Singleton* Singleton::ms_pInstance = 0;
Singleton* Singleton::Instance( void )
{
if ( ms_pInstance == 0 )
ms_pInstance = new Singleton;
return ms_pInstance;
}
int Singleton::doSomething( void )
{
}
/* main.cpp */
#include "Singleton.h"
int main( void )
{
Singleton* sig = Singleton::Instance();
sig->doSomething();
delete sig;
return 0;
}
Singleton類的建構函式被宣告為protected,當然也可以宣告為private,宣告為protected是為了能夠被繼承。但使用者不能自己產生Singleton物件,唯一能產生Singleton物件的就只有Instance成員函式,這個Instance函式即所謂的全域性訪問點。訪問方式如上面main函式中的紅色程式碼。如果使用者沒有呼叫Instance函式,Singleton物件就不會產生出來,這樣優化的成本是Instance函式裡的if檢測,但是好處是,如果Singleton物件產生很昂貴,而本身有很少使用,這種“使用才誕生”的方案就會顯盡優勢了。
上面將ms_pInstance在全域性初始化為0,這樣做有個好處,即編譯器在編譯時就已經將ms_pInstance的初始值寫到了可執行二進位制檔案裡了,Singleton物件的唯一性在這個時期就已經決定了,這也正是C++實現Singleton模式的精髓所在。如果將ms_pInstance改造一下,如:
class Singleton
{
public:
static Singleton* Instance( void );
public:
void doSomething( void );
protected:
Singleton( void ){}
private:
static Singleton ms_Instance;
};
Singleton Singleton::ms_Instance;
Singleton* Singleton::Instance( void )
{
return &ms_Instance;
}
將ms_pInstance由指標改成了物件,這樣做未必是件好事,原因在於ms_Instance是被動態初始化(在程式執行期間呼叫建構函式進行初始化)的,而ms_pInstance在前面已經說過,它是屬於靜態初始化,編譯器在編譯時就將常量寫入到二進位制檔案裡,在程式裝載到記憶體時,就會被初始化。我們都知道,在進入main之前,有很多初始化操作,對於不同編譯單元的動態初始化物件,C++並沒有規定其初始化順序,因此上面的改造方法存在如下隱患:
#include "Singleton.h"
int g_iRetVal = Singleton::Instance()->doSomething();
由於無法確保編譯器一定先將ms_Instance物件初始化,所以全域性變數g_iRetVal在被初始賦值時,Singleton::Instance()呼叫可能返回一個尚未構造的物件,這也就意味著你無法保證任何外部的物件使用的ms_Instance物件都是一個已經被正確初始化的物件。危險也就不言而喻了。
Singleton模式意為單件模式,於是保證其唯一性就成了關鍵,看看上面的第一種實現,ms_pInstance雖然是Instance成員函式所建立,它返回了一個Singleton物件的指標給外界,並且將這個指標的銷燬權利賦予了外界,這就存在了第一個隱患,倘若外界將返回的指標給銷燬了,然後再重新呼叫Instance函式,則前後物件記憶體地址通常將發生變化,如:
Singleton* sig1 = Singleton::Instance();
sig->doSomething();
delete sig;
Singleton* sig2 = Singleton::Instance();
如上,sig1和sig2的指向的Singleton物件的記憶體地址通常是不一樣的,例如前面的sig1指向的物件儲存了一些狀態,這樣銷燬之後再次建立,狀態已經被清除,程式也就容易出錯了。所以為了避免這樣類似的情況發生,我們再改進一下Instance函式:
static Singleton& Instance( void );
傳回引用則不用擔心被使用者釋放掉物件了,這樣就比較安全了,Singleton物件都由其自身管理。
在C++類中,還有一個copy(複製)建構函式,在上面的Singleton類裡,我們並沒有顯示宣告copy構造,於是如果有以下寫法:
Singleton sig( Singleton::Instance() );
如果我們不顯示宣告一個copy構造,編譯器會幫你生成一個預設的public版本的copy構造。上面的寫法就會呼叫預設的copy構造,從而使用者就能在外部宣告一個Singleton物件了,這樣就存在了第二個隱患。因此,我們將copy構造也宣告為protected保護成員。
另外還有一個成員函式,賦值(assignment)操作符。因為你不能將一個Singleton物件賦值給另外一個Singleton物件,這違背了唯一性,不允許存在兩個Singleton物件。因此我們將賦值操作符也宣告為保護成員,同時對於Singleton來說,它的賦值沒有意義,唯一性的原則就使它只能賦值給自己,所以賦值操作符我們不用去具體實現。
最後一個是解構函式,如前面所說,使用者會在外界釋放掉Singleton物件,為了避免這一點,所以我們也將解構函式宣告為保護成員,就不會意外被釋放了。
上述所有手段統一到一起之後,Singleton類的介面宣告如下:
class Singleton
{
public:
static Singleton& Instance( void );
public:
void doSomething( void );
protected:
Singleton( void );
Singleton( const Singleton& other );
~Singleton( void );
Singleton& operator =( const Singleton& other );
private:
static Singleton* ms_pInstance;
};
這樣似乎已經完美了,Singleton物件的建立完全有Singleton類自身負責了,再看前面的建立過程,ms_pInstance是一個指標,Singleton物件是動態分配(new)出來的,那麼釋放過程就得我們手工呼叫delete,否則將發生記憶體洩露。然而解構函式又被我們定義為保護成員了,因此析構問題還沒有得到解決。
這成了一個比較棘手的問題,既要保證程式執行時整個範圍的唯一性,又要保證在銷燬Singleton物件時沒有人在使用它,所以銷燬的時機顯得尤為重要,也比較難把握。
於是有人想到了一個比較簡單的方案,不動態分配Singleton物件便可以自動銷燬了,但銷燬的最好時期是在程式結束時最好,於是想到了如下方案:
Singleton& Singleton::Instance( void )
{
static Singleton _inst;
return _inst;
}
_inst是一個靜態的區域性變數,它的初始化是在第一次進入Instance函式時,這屬於執行期初始化,而與編譯期間常量初始化不同,_inst物件初始化要呼叫建構函式,這不可能在編譯期間完成,與:
int func( void )
{
static int a = 100; // 編譯期間常量初始化
return a;
}
不同。a的值在編譯期間就已經決定了,在應用程式裝載到記憶體時就已經為100了,而非在第一次執行func函式時才被賦值為100。
_inst物件的銷燬工作由編譯器承擔,編譯器將_inst物件的銷燬過程註冊到atexit,它是一個標準C語言庫函式,讓你註冊一些函式得以在程式結束時呼叫,呼叫次序與棧操作類似,後進先出的原則。atexit的原型:
int __cdecl atexit( void ( __cdecl* pFunc )( void ) );
在Instance函式的反彙編程式碼上有所體現(VS2008 Release 禁用優化(/Od)):
Singleton& Singleton::Instance( void )
{
00CB1040 push ebp
00CB1041 mov ebp,esp
static Singleton _inst;
00CB1043 mov eax,dword ptr [$S1 (0CB3374h)]
00CB1048 and eax,1
00CB104B jne Singleton::Instance+33h (0CB1073h)
00CB104D mov ecx,dword ptr [$S1 (0CB3374h)]
00CB1053 or ecx,1
00CB1056 mov dword ptr [$S1 (0CB3374h)],ecx
00CB105C mov ecx,offset _inst (0CB3370h)
00CB1061 call Singleton::Singleton (0CB1020h)
00CB1066 push offset `Singleton::Instance'::`2'::`dynamic atexit destructor for '_inst'' (0CB1880h)
00CB106B call atexit (0CB112Eh)
00CB1070 add esp,4
return _inst;
00CB1073 mov eax,offset _inst (0CB3370h)
}
00CB1078 pop ebp
00CB1079 ret
紅色的一句彙編程式碼即是得到_inst的析構過程地址壓入到atexit的引數列表,紅色粗體則呼叫了atexit函式註冊這個析構過程。這裡所謂的析構過程並不是Singleton類的解構函式,而是如下過程:
`Singleton::Instance'::`2'::`dynamic atexit destructor for '_inst'':
00CB1880 push ebp
00CB1881 mov ebp,esp
00CB1883 mov ecx,offset _inst (0CB3370h)
00CB1888 call Singleton::~Singleton (0CB1030h)
00CB188D pop ebp
00CB188E ret
這也是一個函式,在此函式裡再呼叫Singleton的解構函式,如藍色那句彙編程式碼。道理很簡單,由於Singleton的解構函式是__thiscall,需要傳遞類物件,所以不是直接call解構函式的地址。
這種方式銷燬在大多數情況下是有效的,在實際中,這種方式也用得比較多。可以根據實際的情況,選擇不同的機制,Singleton沒有定死只能用哪種方式。
既然上述方式在大多數情況下是有效的,那麼肯定就有一些情況會有問題,這就引出了KDL(keyboard、display、log)問題,假設我們程式中有三個singletons:keyboard、display、log,keyboard和display表示真實的物體,log表示日誌記錄,可以是輸出到螢幕或者記錄到檔案。而且log由於建立過程有一定的開銷,因此在有錯誤時才會被建立,如果程式一直沒有錯誤,則log將不會被建立。
假如程式開始執行,keyboard順利建立成功,而display建立過程中出現錯誤,這是需要產生一條log記錄,log也就被建立了。這時由於display建立失敗了,程式需要退出,由於atexit是後註冊的先呼叫,log最後建立,則也是最後註冊atexit的,因此log最先銷燬,這沒有問題。但是log銷燬了,如果隨後的keyboard如果銷燬失敗需要產生一條log記錄,而這是log已經銷燬了,log::Instance會不明事理的返回一個引用,指向了一個log物件的空殼,此後程式便不能確定其行為了,很可能發生其他的錯誤,這也稱之為"dead - reference"問題。
從上面的分析來看,我們是想要log最後銷燬,不管它是在什麼時候建立的,都得在keyboard和display之後銷燬,這樣才能記錄它們的析構過程中發生的錯誤。於是我們又想到,可以通過記錄一個狀態,來作為"dead - reference"檢測。例如定義一個static bool ms_bDestroyed變數來標記Singleton是否已經被銷燬。如果已經銷燬則置為true,反之置為false。
/* Singleton.h */
class Singleton
{
public:
static Singleton& Instance( void );
public:
void doSomething( void );
protected:
Singleton( void ){};
Singleton( const Singleton& other );
~Singleton( void );
Singleton& operator =( const Singleton& other );
private:
static Singleton* ms_pInstance;
static bool ms_bDestroyed;
};
/* Singleton.cpp */
#include <iostream>
#include "Singleton.h"
Singleton* Singleton::ms_pInstance = 0;
bool Singleton::ms_bDestroyed = false;
Singleton& Singleton::Instance( void )
{
if ( !ms_pInstance )
{
if ( ms_bDestroyed )
throw std::runtime_error( "Dead Reference Detected" );
else
{
static Singleton _inst;
ms_pInstance = &_inst;
}
}
return *ms_pInstance;
}
Singleton::~Singleton( void )
{
ms_pInstance = 0;
ms_bDestroyed = true;
}
void Singleton::doSomething( void )
{
}
這種方案能夠準確的檢測"dead - reference",如果Singleton已經被銷燬,ms_bDestroyed成員被置為true,再次獲取Singleton物件時,則會丟擲一個std::runtime_error異常,避免程式存在不確定行為。這種方案相對來說比較高效簡潔了,也可適用於一定場合。
但這種方案在有的時候也不能讓我們滿意,雖然丟擲了異常,但是KDL問題還是沒有被最終解決,只是規避了不確定行為。於是我們又想到了一種方案,即是讓log重生,一旦發現log被銷燬了,而又需要記錄log,則再次建立log。這就能保證log至始至終一直存在了,我們只需要在Singleton類裡新增一個新的成員函式:
class Singleton
{
... ... other member ... ...
private:
static void destroySingleton( void );
};
實現則為:
void Singleton::destroySingleton( void )
{
ms_pInstance->~Singleton();
}
Instance成員就得在改造一下了:
Singleton& Singleton::Instance( void )
{
if ( !ms_pInstance )
{
static Singleton _inst;
ms_pInstance = &_inst;
if ( ms_bDestroyed )
{
new( ms_pInstance ) Singleton;
atexit( destroySingleton );
ms_bDestroyed = false;
}
}
return *ms_pInstance;
}
destroySingleton與前面彙編那段相似,相當於這個工作讓我們自己來做了,而不是讓編譯器來做。destroySingleton手動呼叫Singleton的解構函式,而destroySingleton又被我們註冊到atexit。當ms_pDestroyed為真時,則再次在ms_pInstance指向的記憶體出建立一個新的Singleton物件,這裡使用的是placement new操作符,它並不會新開闢記憶體(參見:利用C++的operator new實現同一物件多次呼叫建構函式)。之後,註冊destroySingleton為atexit,在程式結束時呼叫並析構Singleton物件。如此而來,便保證了Singleton物件的生命期跨越整個應用程式。log如果作為這樣一個Singleton物件,那麼無論log在什麼時候被銷燬,都能記錄所有錯誤日誌了。
似乎到此已經就非常完美了,但是還有一點不得不提,使用atexit具有一個未定義行為:
void func1( void )
{
}
void func2( void )
{
atexit( func1 );
}
void func3( void )
{
}
int main( void )
{
atexit( func2 );
atexit( func3 );
return 0;
}
CC++標準裡並沒有規定上面這種情況的執行次序,按前面的後註冊先執行的說法,按理說func1被最後註冊,則應該最先執行它,但是它的註冊是由func2負責的,這就得先執行func2,才能註冊func1。這樣就產生了矛盾,所以以編譯器的次序為準,在VS2008下,上面的例子中,最先執行func3,再執行func2,最後執行func1。看起來像是一個層級關係,main函式的註冊順序是一層,後來的又是一層,也可以認為在被註冊為atexit的函式裡再註冊其它函式時,其它函式的執行次序在當前函式之後,如果註冊了多個,則最後註冊的在當前函式執行後立即執行。
上面這種機制是通過延長Singleton的宣告週期,它破壞了其正常的生命週期。很可能帶來不必要的迷惑,於是我們又想到了一種機制:是否能夠控制Singleton的壽命呢,讓log的壽命比keyboard和display的壽命長,便能夠解決KDL問題了。控制壽命還可以針對不同的物件,不單單只是Singleton,它可以說是一種可以移植的概念。
我們實現一個生命期管理器,如下:
有了這個管理器,就能設定Singleton物件的壽命了,pTrackerArray是按生命週期的長度進行升序排列的,最前面的就是最先銷燬的,這與前面的銷燬規則是一致的。 對於前面的KDL問題,我們就可以將keyboard和display的壽命設定為1,log的壽命設定為2,keyboard和display不存在先後問題,壽命相同也不影響。log壽命為2,大於keyboard和display就行,保證在最後銷燬。這樣一來,在單個執行緒下的KDL問題就完美解決了。
既然上面說了是在單執行緒裡,言外之意就會存在多執行緒問題,例如:
Singleton& Singleton::Instance( void )
{
if ( !ms_pInstance )
{
ms_pInstance = new Singleton;
}
return *ms_pInstance;
}
假如有兩個執行緒要訪問這個Instance,第一個執行緒進入Instance函式,並檢測if條件,由於是第一次進入,if條件成立,進入了if,執行到紅色程式碼。此時,有可能被OS的排程器中斷,而將控制權交給另外一個執行緒。
第二個執行緒同樣來到if條件,發現ms_pInstance還是為NULL,因為第一個執行緒還沒來得及構造它就已經被中斷了。此時假設第二個執行緒完成了new的呼叫,成功的構造了Singleton,並順利的返回。
很不幸,第一個執行緒此刻甦醒了,由於它被中斷在紅色那句程式碼,喚醒之後,繼續執行,呼叫new再次構造了Singleton,這樣一來,兩個執行緒就構建兩個Singleton,這就破壞了唯一性。
我們意識到,這是一個競態條件問題,在共享的全域性資源對競態條件和多執行緒環境而言都是不可靠的。怎麼避免上面的這種情況呢,有一種簡單的做法是:
Singleton& Singleton::Instance( void )
{
_Lock holder( _mutex );
if ( !ms_pInstance )
{
ms_pInstance = new Singleton;
}
return *ms_pInstance;
}
_mutex是一個互斥體,_Lock類專門用於管理互斥體,在_Lock的建構函式中對_mutex加鎖,在解構函式中解鎖。這樣保證同一在鎖定之後操作不會被其它執行緒打斷。holder是一個臨時的_Lock物件,在Instance函式結束時會呼叫其析構,自動解鎖。這也是著名的RAII機制。
似乎這樣做確實能夠解決競態條件的問題,在一些場合也是可以的,但是在需要更高效率的環境下,這樣做缺乏效率,比起簡單的if ( !ms_pInstance )測試要昂貴很多。因為每次進入Instance函式都加鎖解鎖一次,即使需要加鎖解鎖的只有第一次進入時。所以我們想要有這樣的解法:
Singleton& Singleton::Instance( void )
{
if ( !ms_pInstance )
{
_Lock holder( _mutex );
ms_pInstance = new Singleton;
}
return *ms_pInstance;
}
這樣雖然解決了效率問題,但是競態條件問題又回來了,打不到我們的要求,因為兩個執行緒都進入了if,再鎖定還是會產生兩個Singleton物件。於是有一個比較巧妙的用法,即“雙檢測鎖定”Double-Checked Locking模式。直接看效果吧:
Singleton& Singleton::Instance( void )
{
if ( !ms_pInstance )
{
_Lock holder( _mutex );
if ( !ms_pInstance )
ms_pInstance = new Singleton;
}
return *ms_pInstance;
}
非常美妙,這樣就解決了效率問題,同時還解決了競態條件問題。即使兩個執行緒都進入了第一個if,但第二個if只會有一個執行緒進入,這樣當某個執行緒構造了Singleton物件,其它執行緒因為中斷在_Lock holder( _mutex )這一句。等到喚醒時,ms_pInstance已經被構造了,第二個if測試就會失敗,便不會再次建立Singleton了。第一個if顯得很粗糙快速,第二個if顯得清晰緩慢,第一個if是為了第二次進入Instance函式提高效率不再加鎖,第二個if是為了第一次進入Instance避免產生多個Singleton物件,各施其職,簡單而看似多餘的if測試改進,顯得如此美妙。
本文從開頭到現在,一次又一次感到完美,又一次一次發現不足。到此,又似乎感到了完美,但完美背後還真容易有陰霾。雖然上面的雙檢測鎖定已經在理論上勝任了這一切,趨近於完美。但是,有經驗的程式設計師,會發現它還是存在一個問題。
對於RISC(精簡指令集)機器的編譯器,有一種優化策略,這個策略會將編譯器產生出來的彙編指令重新排列,使程式碼能夠最佳運用RISC處理器的平行特性(可以同時執行幾個動作)。這樣做的好處是能夠提高執行效率,甚至可以加倍。但是不好之處就是破壞了我們的“完美”設計“雙檢測鎖定”。編譯器很可能將第二個if測試的指令排列到_Lock holder( _mutex )的指令之前。這樣競態條件問題又出現了,哎!
碰到這樣的問題,就只有翻翻編譯器的說明文件了,另外可以在ms_pInstance前加上volatile修飾,因為合理的編譯器會為volatile物件產生恰當而明確的程式碼。
到此,常見的問題都基本解決了,不管是多執行緒還是單執行緒,在具體的環境我們再斟酌選擇哪一種方式,因此,本文並沒有給出一個統一的解決方案。你還可以將上面的機制組裝到一起,寫成一個SingletonHolder模板類,在此就不實現了。Singleton還能根據具體進行擴充套件,方法也不止上面這些,我們只有一個目的,讓它正確的為我們服務。
在本文開頭說Singleton是一個相對好理解的一種設計模式,但從整篇下來,它也並不是那麼單純。由簡單到複雜,每一種設計方案都有它的用武之地,例如,我們的程式里根本就不會出現KDL問題,那麼就可以簡單處理。再者我們有的Singleton不可能在多執行緒環境裡執行,那麼我們也沒有必要設計多執行緒這一塊,而只需要在考慮問題時意識到就可以了。做到一切盡在掌握之中即可。
好吧!本文就到此結束,重在體會這些細節的機制和挖掘問題然後解決問題的樂趣。在此感謝《Modern C++ Design》,望大家多提意見,感謝!!
【GOF設計模式之路】目錄