多執行緒下的單例模式
簡介:
保證一個類僅有一個例項,並提供一個該例項的全域性訪問點。《設計模式》GoF
動機
在軟體系統中,經常有這樣一個特殊的類,必須保證它們在系統中只存在一個示例,才能確保它們的邏輯正確性、以及良好的效率。這個應該類設計者的責任,而不是使用者的責任。
示例
class Singleton{
public:
Singleton(const Singleton &) = delete;
static Singleton* GetInstance();
private:
Singleton() = default;
static Singleton *instance_;
};
Singleton* Singleton::instance_ = nullptr;
// 執行緒非安全版本
Singleton* Singleton::GetInstance() {
if (instance_ == nullptr) {
instance_ = new Singleton();
}
return instance_;
}
上述程式碼,在單執行緒環境下是沒有問題的,但是在多執行緒下就很有可能發生問題,因為我們寫下的程式碼在由編譯器處理後,往往不是我們所寫的高階語言所表現的那樣,最後可能會分為好幾部來執行。
那麼我們就來分析一下上面這段程式碼:
- 執行緒 a 首先執行到了
if (instance_ == nullptr)
此時 instance_ 還是 nullptr,進入 if 語句,執行緒 a 時間片用完。 - 執行緒 b 執行到
if (instance_ == nullptr)
此時 instance_ 還是 nullptr,那麼執行緒b 也會進入 if 語句。 - 由於兩個執行緒 a 和 b 都進入 if 語句,都會執行
instance_ = new Singleton();
則程式會從堆中建立兩個甚至多個(多個執行緒情況下),只有最後賦給 instance_ 的那個物件得以儲存下來,其他物件將會“丟失”,並造成記憶體洩露。
從上面的分析我們可以得出,出現問題的原因在於,有多個執行緒可能會“同時”進入 if (instance_ == nullptr)
這條語句,那麼我們只要在進行判斷之前進行加鎖,讓任一時刻只有一個執行緒可以執行 if (instance_ == nullptr)
即可解決上面出現可能建立多個物件的問題。
Singleton* Singleton::getInstance() {
Lock lock; // 示例
if (m_instance == nullptr) {
m_instance = new Singleton();
}
return m_instance;
}
這樣加鎖,效率相對比較低。並且當物件建立完成之後,對所有的 GetInstance 函式來說,都只是讀取這個變數,無需加鎖,但這樣的寫法導致每次訪問都會申請拿鎖,導致效率降低。
我們可以利用雙檢查鎖來解決上述鎖的代價過高的問題
Singleton* Singleton::GetInstance() {
if (instance_ == nullptr) {
Lock lock;
if (instance_ == nullptr) {
instance_ = new Singleton();
}
}
return instance_;
}
我們咋一看,以上寫法好像並沒有什麼問題,並且很好的解決了鎖的代價過高的問題,在物件建立完成後,其他執行緒在訪問例項的時候並不會加鎖,而是直接返回。
但是,以上雙檢查鎖的寫法也存在著問題。我們看程式碼有一個指令序列,但程式碼在彙編之後,可能在執行的時候,搶CPU的指向權的時候,可能和我們預想的不一樣。一般m_instance = new Singleton();
我們認為是先分配記憶體,再呼叫建構函式建立物件,再把物件的地址賦值給變數。但在CPU實際執行的時候,以上的三個步驟可能會被重新打亂順序執行。可能會是先分配記憶體,然後就把記憶體地址直接賦值給變數,最後在呼叫建構函式來建立物件。那麼如果出現以上情況,變數已經被賦值了物件的指標,但實際卻指向了沒被初始化的記憶體。那麼此時,執行緒安全問題就再次出現了。
那麼該如何解決這個問題呢?
在 java 和 C# 這類語言來說,語言標準增加了一個 volatile 關鍵字,通過它來修飾單例的物件,此時就不會出現先賦值,然後呼叫建構函式的情況,保證了多執行緒下的正確性。msvc 編譯器自己添加了 volatile 關鍵字,屬於編譯器擴充,但跨平臺的問題沒辦法解決。直到 C++11 後才真正的解決了這個問題,實現了跨平臺。
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
#include <atomic>
#include <mutex>
class Singleton {
public:
Singleton(const Singleton &) = delete;
static Singleton* GetInstance();
private:
Singleton() = default;
static std::atomic<Singleton *> instance_;
static std::mutex mutex_;
};
std::atomic<Singleton *> Singleton::instance_;
std::mutex Singleton::mutex_;
Singleton* Singleton::GetInstance() {
// 通過原子的物件的load方法獲得物件的指標。
Singleton *tmp = instance_.load(std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_acquire); // 獲取記憶體屏障
if (tmp == nullptr) {
std::lock_guard<std::mutex> lock(mutex_);
tmp = instance_.load(std::memory_order_relaxed);
if (tmp == nullptr) {
tmp = new Singleton();
std::atomic_thread_fence(std::memory_order_release); // 釋放記憶體屏障
instance_.store(tmp, std::memory_order_relaxed);
}
}
return tmp;
}
// 參見 c++ 併發程式設計指南
int main() {
return 0;
}
總結
- 單例模式確保某一個類只有一個例項,而且自行例項化並向整個系統提供這個例項,這個類稱為單例類,它提供全域性訪問的方法。單例模式的要點有三個:一是某個類只能有一個例項;二是它必須自行建立這個例項;三是它必須自行向整個系統提供這個例項。單例模式是一種物件建立型模式。
- 單例模式只包含一個單例角色:在單例類的內部實現只生成一個例項,同時它提供一個靜態的工廠方法,讓客戶可以使用它的唯一例項;為了防止在外部對其例項化,將其建構函式設計為私有。
- 單例模式的主要優點在於提供了對唯一例項的受控訪問並可以節約系統資源;其主要缺點在於因為缺少抽象層而難以擴充套件,且單例類職責過重。
- 單例模式適用情況包括:系統只需要一個例項物件;客戶呼叫類的單個例項只允許使用一個公共訪問點。