單例模式執行緒安全reorder問題

陈浩辉發表於2024-06-01

單例模式是一種常用的軟體設計模式,它確保一個類只有一個例項,並提供一個全域性訪問點來獲取這個例項。下面是一個使用C++實現的執行緒安全的單例模式的例子:
class Singleton {
private:
static std::atomic<Singleton*> instance; // 靜態私有例項指標使用原子操作類atomic執行緒安全
static std::mutex mutex; // 靜態互斥鎖

// 私有建構函式,防止外部直接建立例項,只能內部呼叫 相當於只會建立一個
Singleton() {
    std::cout << "Singleton instance created." << std::endl;
}

// 禁止複製建構函式和賦值運算子
Singleton(const Singleton& other) = delete;
Singleton& operator=(const Singleton& other) = delete;

public:
// 靜態公有方法,提供全域性訪問點
static Singleton* getInstance() {
// 雙重檢查鎖定模式,減少鎖的使用
if (instance == nullptr) {
std::lock_guardstd::mutex lock(mutex);
if (instance == nullptr) { // 再次檢查,確保執行緒安全
instance = new Singleton();
}
}
return instance;
}

// 示例方法
void doSomething() {
    std::cout << "Doing something." << std::endl;
}

// 銷燬單例例項的方法(通常在程式結束時呼叫)
static void destroyInstance() {
    delete instance;
    instance = nullptr;
    std::cout << "Singleton instance destroyed." << std::endl;
}

};

// 初始化靜態成員變數
Singleton* Singleton::instance = nullptr;
std::mutex Singleton::mutex;

getInstance方法返回的一個物件在單執行緒是絕對安全的,但是在多執行緒的情況下,我們就不一定了。加入執行緒A訪問了if (instance == nullptr)判斷instance為空,這個時候時間片結束了,執行緒B執行那麼執行緒B也判斷instance為空,那麼就會new一個新的instance,再回到執行緒A的時候繼續執行,就會建立另外一個新的instance,就違背了單例模式的初衷。這個時候就可以用鎖或者是volatile關鍵字或者是atomic原子操作類别範本。
假如我們使用的是鎖的機制來確保執行緒安全的話第一種getInstance方法就是:
static Singleton* getInstance() {
// 雙重檢查鎖定模式,減少鎖的使用
std::lock_guardstd::mutex lock(mutex);
if (instance == nullptr) {
instance = new Singleton();
}
return instance;
}
我們在判斷instance為空前加鎖,這個時候的確可以確保執行緒安全,但是開銷太大,此時這個執行緒結束了,那第二個執行緒進來獲取instance的時候就會阻塞拿不到鎖,導致整體的效率變低。

第二種方法使用volatile關鍵字來修飾我們的volatile static Singleton* instance ,這種方法是讓編譯器不要進行最佳化,每次使用例項的時候都從記憶體裡面去取出來,而不是使用存在暫存器內的副本。但是他不能保證複合操作的原子性。
第三種就是雙檢查鎖相比於第一種的檢查鎖,我們在判斷條件之後進行加鎖:
static Singleton* getInstance() {
// 雙重檢查鎖定模式,減少鎖的使用
if (instance == nullptr) {
std::lock_guardstd::mutex lock(mutex);
if (instance == nullptr) { // 再次檢查,確保執行緒安全
instance = new Singleton();
}
}
return instance;
}
為什麼要判斷兩次呢? 因為第一次如果執行緒A和執行緒B都進入了這行程式碼都判斷為空,然後執行緒A拿到鎖建立了新的instance返回,執行緒A結束了。但是執行緒B已經判斷為空,B開始執行就去拿鎖,拿到鎖就直接建立一個新的instance沒有判斷是不是已經有其他執行緒建立了新的物件。這個時候加上二次判斷就可以保證返回的只有一個例項。
但是這樣也會造成一個問題也就是:記憶體讀寫reorder問題
這個問題是什麼呢?其實很簡單。
在我們的記憶體建立一個新的物件的時候,是首先在記憶體開闢新的記憶體空間,然後呼叫建構函式,然後在返回記憶體地址給一個指標。這裡分為三步走,但是reorder就有可能會把第二步和第三步交換。先返回一個記憶體地址,但是這個時候還沒有進行第三步建構函式還沒呼叫呢,成員變數都沒初始化是一個空的記憶體空間。這個時候就容易出現問題,有的執行緒判斷了instance為空,但是拿到的物件其實是空的沒有初始化。也就被編譯器給欺騙了。那我們該如何解決這個問題呢?在之前遇到這個問題是沒有辦法的,但是C++11之後引入了std::atomic模板類我們就可以解決這個問題,下面是新的getInstance方法的程式碼:
std::atomic<Singleton*> Singleton::m_instance;
std::mutex Singleton::m_mutex;

Singleton* Singleton::getInstance() {
Singleton* tmp = m_instance.load(std::memory_order_relaxed);
//確保記憶體不會被reorder最佳化
std::atomic_thread_fence(std::memory_order_acquire);//獲取記憶體fence
if (tmp == nullptr) {
std::lock_guardstd::mutex lock(m_mutex);
tmp = m_instance.load(std::memory_order_relaxed);
if (tmp == nullptr) {
tmp = new Singleton;
//配套使用
atomic_thread_fence(std::memory_order_release);//釋放記憶體fence
m_instance.store(tmp, std::memory_order_relaxed);
}
}
return tmp;
}
這個時候就是一個真正的執行緒安全的單例模式。

相關文章