Singleton(單例類)
設計模式學習:概述
意圖
保證每一個類僅有一個實體,并為它提供一個全域訪問點,
顧名思義,單例類Singleton保證了程式中同一時刻最多存在該類的一個物件,
有些時候,某些組件在整個程式運行時就只需要一個物件,多余的物件反而會導致程式的錯誤,
或者,有些屬性型物件也只需要全域存在一個,比如,假設黑體字屬性是一種物件,呼叫黑體字屬性物件的函式來讓一個字變成黑體,顯然并不需要在每創造一個黑體字時就生成一種屬性物件,只需要呼叫當前存在的唯一物件的函式即可,
Singleton模式的功能有兩點:一是保證程式的正確性,使得最多存在一種實體的物件不會被多次創建,二是提高程式性能,避免了多余物件的創建從而降低了記憶體占用,
代碼案例 + 解釋
單例模式比較簡單,我們來看一下它的最簡易實作:
class Singleton
{
private:
static Singleton* sin;
Singleton(){}
public:
static Singleton* getInstance()
{
if(sin == nullptr)
sin = new Singleton();
return sin;
}
}
首先,我們將建構式宣告為私有,這樣就防止了任何人用new關鍵字創建物件,而只能呼叫函式的getInstance函式來獲取單例指標,然后,檢查是否有已經存在的物件,如果有,直接回傳該指標,如果沒有,創建物件并回傳指標,
這種做法在單執行緒程式中是沒問題的,但它不是執行緒安全的,這也很好理解:當執行緒A執行完判斷陳述句,剛剛進入if陳述句內但還沒有完成物件創建前,如果這時作業系統切換到執行緒B,而B也呼叫了該函式,這樣,B呼叫程序的判斷也為真(因為執行緒A只是完成了判斷,還沒有來得及創建物件就休眠了),導致A,B各自擁有了一個單例類物件,這顯然是不合理的,
下面是一種改進方案:
class Singleton
{
private:
static Singleton* sin;
Singleton(){}
public:
static Singleton* getInstance()
{
Lock lock;
if(sin == nullptr)
sin = new Singleton();
return sin;
}
}
這里,我們引入了一個執行緒鎖,了解多執行緒和執行緒鎖的話應該很容易理解:如果任何一個執行緒呼叫了getInstance并且還沒有完成呼叫程序,其他執行緒就無法進入這個函式,也就避免了上述問題,
但是,這種寫法雖然的的確確實作了單例類應當具備的功能,但它的代價過大:在物件創建后,幾乎所有呼叫getInstance的行為都是只讀的,而這一只讀行為卻無法多執行緒地進行,低并發環境下還好,但如果在高并發(比如互聯網服務器)環境下,同時可能存在成千上萬給執行緒試圖呼叫getInstance,卻因為鎖而導致只有其中一個可以正常運作,這會導致該高并發體系幾乎無法運作,
如何解決這個問題,下面的做法是一種方案:
class Singleton
{
private:
static Singleton* sin;
Singleton(){}
public:
static Singleton* getInstance()
{
if(sin == nullptr)
Lock lock;
if(sin == nullptr)
sin = new Singleton();
return sin;
}
}
這個做法就是著名的雙檢查鎖,它就是為了解決高并發單例類的訪問問題而出現的解決方案,這一方案讓人們在數年內都認為是沒有問題的,
上面的單檢查鎖導致只讀程序代價太高,究其原因,是因為在物件已經創建后仍然不加判斷地使用鎖,因此,在雙檢查鎖方案中,我們只在物件沒有被創建時上鎖,這種方案,似乎解決了高并發訪問的問題,也保留了單例類的特性,
然而,這種做法由于記憶體讀寫reorder的問題,可能導致雙檢查鎖的失效,這一問題在雙檢查鎖被提出的數年后才被發現,
什么是記憶體讀寫reorder?如果你了解編譯原理的話,你就會知道,所謂高級語言(C,C++,JAVA)等,最后都會被編譯器翻譯為若干條功能更簡單的匯編指令,在我們的例子中,這個陳述句:
sin = new Singleton();
事實上包含了三個小步驟:
- 為變數分配一塊記憶體,
- 呼叫構造器將其初始化,
- 將記憶體地址賦給指標,
我們知道,所謂執行緒背景關系切換,事實上是在匯編語言層級上完成的,由作業系統完成執行緒的時間片分配,因此,上面一個陳述句的三個步驟,有可能無法在一次執行緒切換時完成,
只要這三個步驟的順序不發生改變,雙檢查鎖的邏輯仍然不會出錯,然而,機器記憶體讀寫的reorder優化機制,可能會導致上面三個步驟不按正常順序執行!
當然,不管怎樣都一定要先分配記憶體,因此,在極端情況下,會出現下面的指令執行順序:
- 為變數分配一塊記憶體,
- 將記憶體地址賦給指標,
- 背景關系切換,執行其他執行緒的getInstance()
- 呼叫構造器將其初始化,
在第3步發生了什么?我們再看一下這段雙檢查鎖代碼:
static Singleton* getInstance()
{
if(sin == nullptr)
Lock lock;
if(sin == nullptr)
sin = new Singleton();
return sin;
}
要知道,當記憶體地址賦給指標后,指標就已經不再是Null了!因此,在執行緒A執行完前兩步(分配記憶體、賦值),切換到執行緒B之后,執行緒B會認為自己拿到了一個已經初始化的、可以直接使用的指標!也就是Sin == null判斷為false,函式直接回傳!
這是危險的,因為雖然今后切換到執行緒A后,A仍然可以完成物件的初始化作業,但在那之前,B已經拿到了一個未初始化的物件指標,并且很有可能會呼叫這一物件做一些事情,這將大概率導致程式崩潰,
這是一個十分隱蔽的致命Bug,它甚至直接導致了高級語言公司紛紛為自己的語言添加新特性使得能夠彌補雙檢查鎖的缺陷,可以說,是一個跨時代的超級Bug,
不同語言有針對該問題的不同解決方案,這里只列出C++11給出的方案,不再進行過多的解釋,想要了解的話,可以自行查詢C++庫,
std::atomic<Singleton*> Singleton::sin;
std::mutex Singleton::m_mutex;
Singleton* getInstance()
{
Singleton* tmp = sin.load(std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_acquire);
if(tmp == nullptr)
{
std::lock_guard<std::mutex> lock(m_mutex);
tmp = sin.load(std::memory_order_relaxed);
if(tmp == nullptr)
{
tmp = new Singleton();
std::atomic_thread_fence(std::memory_order_released);
sin.store(tmp, std::memory_order_relaxed);
}
}
return tmp;
}
總結
| 設計模式 | Singleton(單例類) |
|---|---|
| 穩定點: | 無 |
| 變化點: | 無 |
| 效果: | 使程式中同時只存在最多一個該類物件 |
?
2020.2.17轉載請標明出處
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/260708.html
標籤:其他
上一篇:AcWing 836. 合并集合
下一篇:執行緒池學習Note
