前言
這是在頭條用戶端面試的時候提到的,當時隻知道單例模式保證對象唯一,沒有考慮實際使用中會發生什麼,面完了認真了解下“單例模式”,做下總結。
另外,面試和平時準備的東西還是有差別的,平時準備的可能比較基礎(概念為主),面試更多是這些概念在實際使用中能否解決對應的問題,并是否會引入其他的問題等。
實際使用中,均是多程序、多線程程式設計為主,是以程序之間的通信(IPC),線程之間的同步很重要,在思考問題的時候,一定要考慮目前問題的解法,是否“多程序或多線程安全”,如果不安全,是否有解決方法來保證目前算法達到“多程序或多線程安全”的要求。
單例模式
單例模式
單例模式確定一個類隻有一個對象,并提供一個全局通路點。
實作思路
- 構造函數設為私有
- 使用static定義對象指針,定義類的get_instance成員函數。
具體實作(Lazy Initlization)
按照以上的規則和要求,實作了一下,這是“Lazy Initlization”實作方式,将對象的生成推遲到第一次通路的時候。
//1. Lazy initlization.
class Singleton{
public:
static Singleton * get_instance(){
if(instance_ == nullptr){
instance_ = new Singleton();
}
return instance_;
};
protected:
Singleton(){};
private:
static Singleton * instance_;
};
Singleton * Singleton::instance_ = nullptr;
void test1(){
Singleton * p1 = Singleton::get_instance();
Singleton * p2 = Singleton::get_instance();
printf("p1 addr : %p\n", *p1);
printf("p2 addr : %p\n", *p2);
}
int main() {
test1();
return 0;
}
上面的代碼中,test1函數中對象的位址輸出均一樣,說明通路的對象是唯一的。
在單線程的情況下,該單例模式的實作是安全可用的。
但是多線程環境中,尤其是在初始化的這段代碼中:
static Singleton * Singleton::get_instance(){
if(instance_ == nullptr){
instance_ = new Singleton();
}
return instance_;
}
兩個線程可能同時執行到 i f ( i n s t a n c e = = n u l l p t r ) if(instance_\ == nullptr) if(instance ==nullptr)這句,由于均是首次通路,條件都成立,然後都進行了對象的執行個體化,導緻程序中有該類有多個對象。
多線程安全的單例模式
而解決上述問題,最簡單粗暴的方法是加鎖,在每次判斷的時候確定隻有一個線程在執行該語句。
//2. lazy initlization + mutex
static mutex m_;
static Singleton * Singleton::get_instance(){
m_.lock();
if(instance_ == nullptr){
instance_ = new Singleton();
}
m_.unlock();
return instance_;
}
然而這種加鎖方法在每次判斷前都會進行一次加鎖,會極大的增加系統的開銷。
于是有人提出了“雙檢鎖”的概念,相較于之前,增加了一次判斷,并将“加鎖”操作放到了第一次判斷成立之後。
//2.1 lazy initlization + double-check
static mutex m_;
static Singleton * Singleton::get_instance(){
if(instance_ == nullptr){
m_.lock();
if(instance_ == nullptr){
instance_ = new Singleton();
}
m_.unlock();
}
return instance_;
}
線上程較多的情況下,“雙檢鎖”能明顯的降低了“加鎖”帶來的開銷。
但這樣真的就線程安全了麼?
new 背後的操作
我們再來看看單例模式中最核心的一條語句:
if(instance_ == nullptr){
m_.lock();
if(instance_ == nullptr){
instance_ = new Singleton();
}
m_.unlock();
}
其中, i n s t a n c e = n e w S i n g l e t o n ( ) ; instance_ = new Singleton(); instance=newSingleton(); 用new執行個體化了一個對象,而new本身隐含了一下幾個操作:
- 按照類的大小,申請對象的記憶體區域
- 執行類的構造函數
- 将記憶體區域的首位址傳回給instance_
其中,2, 3在執行的時候可能會颠倒,即先傳回位址,再執行構造函數。
考慮這樣的場景:
單例模式還未執行個體化,A線程先進入 g e t i n s t a n c e 函 數 get_instance函數 getinstance函數,并加鎖,執行 i n s t a n c e = n e w S i n g l e t o n ( ) ; instance_ = new Singleton(); instance=newSingleton();這句的時候,new中的操作順序是1, 3, 2,即位址傳回,構造函數還未執行。
但這種情況下,instance_ 已經不為空,另一個線程B在進行 i f ( i n s t a n c e = = n u l l p t r ) if(instance_ == nullptr) if(instance==nullptr)判斷時,會直接得到instance_,并使用。
而此時線程A中的new還沒來得及執行類的構造函數,是以線程B在使用 i n s t a n c e instance instance指針的時候一定會出現問題。
由此,這種方式實作的“雙檢鎖”并不能真正達到多線程安全。
C++11中的雙檢鎖
仔細思考下new背後的三個步驟,如果按照1, 2, 3的順序執行的話,并不會發生問題。
再進一步的思考,這裡實際上違反了"多線程操作的原子性",如果将1, 2, 3步驟按順序封裝成一個原子操作,即可解決問題。
C++11中的新特性能很好的解決這個問題,而且不止有一種解決方法,這裡先提一種:
C++11原子操作
C++11中引入了原子操作,在多個線程中對這些類型的共享資源進行操作,編譯器将保證這些操作都是原子性的。
對以上的代碼修改如下:
//3. base 1. implemetation, use atomic.
class Singleton {
public:
static Singleton * get_instance()
{
Singleton * temp = instance_.load();
if(temp == nullptr) {
m_.lock();
temp = instance_.load();
if(temp == nullptr) {
temp = new Singleton();
instance_.store(temp);
}
m_.unlock();
}
return temp;
};
protected:
Singleton() {};
private:
static atomic<Singleton*> instance_;
static mutex m_;
};
atomic<Singleton *> Singleton::instance_;
mutex Singleton::m_;
這裡使用使用atomic來保證instance_操作時的原子性。
靜态對象
我們分析下,上述發生的線程安全問題均是在初始化的時候,自然地,如果在程序剛開始的時候便生成對象,生命周期貫穿整個程序的周期,似乎可避免這個問題。
如果我們把單例對象類型設為static,這樣唯一的對象會被配置設定在資料區,而不是new之後的堆區,便能達到以上的效果。
這裡将對象定義為局部靜态變量。
這裡還可能存在一個問題:static保證了對象的唯一性,但多線程的環境下,static修飾的對象可能會被初始化多次,這種情況怎麼辦?
萬幸的是,C++11的新特性解決了我們這個顧慮。
If control enters the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for completion of the initialization.
如果當變量在初始化的時候,并發同時進入聲明語句,并發線程将會阻塞等待初始化結束。
換言之,局部靜态變量隻會被初始化一次。
//4
class Singleton {
public:
static Singleton & get_instance()
{
static Singleton instance;
return instance;
};
protected:
Singleton() {};
};
//使用
void get_singleton_instance()
{
Singleton & p = Singleton::get_instance();
printf("instance addr : %p\n", &p);
}
多線程安全問題
寫到這裡就告一段落了,總結一下上面遇到的多線程問題:
- 多線程對共享資源的判斷(一定要加鎖進行通路判斷,然後再進入臨界區)
- 通路被釋放的資源(當一個線程對資源進行通路的時候,一定要確定該資源存在(因為别的線程可能将其資源釋放))
- 對臨界資源操作時,一定要保證操作的原子性。
參考
這裡隻是簡單總結了下單例模式以及正常的實作,沒有涉及到生産環境中的具體使用(如,單例類被其他類繼承該怎麼辦,使用中如何實作可靠的運算符重載等…),
想要更深一步了解單例模式的可以參考下面的資料。
- 面試中的Singleton,強烈推薦
- C++單例模式總結與剖析