天天看點

設計模式之一、單例模式及多線程安全前言單例模式參考

前言

這是在頭條用戶端面試的時候提到的,當時隻知道單例模式保證對象唯一,沒有考慮實際使用中會發生什麼,面完了認真了解下“單例模式”,做下總結。

另外,面試和平時準備的東西還是有差別的,平時準備的可能比較基礎(概念為主),面試更多是這些概念在實際使用中能否解決對應的問題,并是否會引入其他的問題等。

實際使用中,均是多程序、多線程程式設計為主,是以程序之間的通信(IPC),線程之間的同步很重要,在思考問題的時候,一定要考慮目前問題的解法,是否“多程序或多線程安全”,如果不安全,是否有解決方法來保證目前算法達到“多程序或多線程安全”的要求。

單例模式

單例模式

單例模式確定一個類隻有一個對象,并提供一個全局通路點。

實作思路

  1. 構造函數設為私有
  2. 使用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本身隐含了一下幾個操作:

  1. 按照類的大小,申請對象的記憶體區域
  2. 執行類的構造函數
  3. 将記憶體區域的首位址傳回給instance_

其中,2, 3在執行的時候可能會颠倒,即先傳回位址,再執行構造函數。

考慮這樣的場景:

單例模式還未執行個體化,A線程先進入 g e t i n s t a n c e 函 數 get_instance函數 geti​nstance函數,并加鎖,執行 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);
}
           

多線程安全問題

寫到這裡就告一段落了,總結一下上面遇到的多線程問題:

  1. 多線程對共享資源的判斷(一定要加鎖進行通路判斷,然後再進入臨界區)
  2. 通路被釋放的資源(當一個線程對資源進行通路的時候,一定要確定該資源存在(因為别的線程可能将其資源釋放))
  3. 對臨界資源操作時,一定要保證操作的原子性。

參考

這裡隻是簡單總結了下單例模式以及正常的實作,沒有涉及到生産環境中的具體使用(如,單例類被其他類繼承該怎麼辦,使用中如何實作可靠的運算符重載等…),

想要更深一步了解單例模式的可以參考下面的資料。

  1. 面試中的Singleton,強烈推薦
  2. C++單例模式總結與剖析

繼續閱讀