目錄前言指令重排簡介指令重排對單例模式的影響改進方法std::call_once和std::once_flagstd::atomic和記憶體順序局部靜態變數總結參考文章 前言 在《單例模式學習》中曾提到懶漢式DCLP的單例模式實際也不是線程安全的,這是編譯器的指令重排導致的,本文就簡單討論一下指令重排 ...
目錄
前言
在《單例模式學習》中曾提到懶漢式DCLP的單例模式實際也不是線程安全的,這是編譯器的指令重排導致的,本文就簡單討論一下指令重排對單例模式的影響,以及對應的解決方法。
指令重排簡介
指令重排(Instruction Reordering)是編譯器或處理器為了優化程式執行效率而對程式中的指令序列進行重新排序的過程。這種重排可以發生在編譯時也可以發生在運行時,目的是為了減少指令的等待時間和提高執行的並行性。
指令重排可能會引入併發程式中的一些問題,特別是在多線程環境中,沒有適當同步機制的情況下,可能會導致程式的執行結果不符合預期。
下麵介紹指令重排在單例模式中的影響
指令重排對單例模式的影響
首先回顧一下懶漢式DCLP單例模式的代碼
class CSingleton
{
public:
static CSingleton* getInstance();
private:
CSingleton()
{
std::cout<<"創建了一個對象"<<std::endl;
}
CSingleton(const CSingleton&) = delete;
CSingleton& operator=(const CSingleton&) = delete;
~CSingleton()
{
std::cout<<"銷毀了一個對象"<<std::endl;
}
static CSingleton* instance;
static std::mutex mtx;
};
CSingleton* CSingleton::instance;
CSingleton* CSingleton::getInstance()
{
mtx.lock();
if(nullptr == instance)
{
instance = new CSingleton();
}
mtx.unlock();
return instance;
}
註意這一句:
instance = new CSingleton(); //並非一個原子操作,不是可重入函數
instance
的初始化其實做了三個事情:
- ①記憶體分配:為CSingleton對象分配一片記憶體
- ②對象構造:調用構造函數構造一個CSingleton對象,存入已分配的記憶體區
- ③地址綁定:將指針instance指向這片記憶體區(執行完這步instance才是非 nullptr)
但是由於指令重排,編譯器會將順序改變為:
instance = //步驟三
operator new(sizeof(CSingleton));//步驟一
new(instance)CSingleton;//步驟二
現在考慮以下場景:
1.線程A進入getInstance(),判斷instance為空,請求加鎖,然後執行步驟一和三組成的語句,之後A被掛起。此時instance為非空指針(指向了一塊記憶體),但instance指向記憶體裡面的CSingleton對象還未被構造出來。
2.線程B進入getInstance(),判斷instance非空(因為在A線程中instance已經為非空指針了),直接返回instance。之後用戶使用該指針訪問CSingleton對象,嘿!您猜怎麼著,這個CSingleton對象還沒被構造出來呢。
總的來說,只有步驟一和二在三前面執行,DCLP才有效
改進方法
std::call_once和std::once_flag
std::call_once
配合std::once_flag
確保了instance = new CSingleton()
只會被執行一次,無論它被多少個線程訪問。這避免了指令重排在多線程下導致的問題。
class CSingleton
{
private:
...
public:
static CSingleton* getInstance();
private:
static CSingleton* instance;
static std::once_flag onceFlag;
}
CSingleton* CSingleton::instance;
std::once_flag CSingleton::onceFlag;
CSingleton* CSingleton::getInstance()
{
/*
call_once和once_flag保證了多線程下僅有一個線程可以執行該函數,因此無需手動加鎖
而且當 std::call_once 被多次調用時(無論是由同一個線程還是不同的線程)
只有第一次調用會執行傳遞給它的函數
所有隨後的調用,都不會再次執行該函數
*/
std::call_once(onceFlag,[](){instance = new CSingleton();});
return instance;
}
std::atomic和記憶體順序
class CSingleton
{
private:
...
public:
static CSingleton* getInstance()
private:
static std::atomic<CSingleton*> instance;
static std::mutex mtx;
}
std::atomic<CSingleton*> CSingleton::instance;
std::mutex CSingleton::mtx;
CSingleton* CSingleton::getInstance()
{
//核心框架還是雙檢查
//保證了這個讀操作之後發生的讀寫操作不會被重排到這個操作之前
CSingleton* tmp = instance.load(std::memory_order_acquire);
if (nullptr == tmp)
{
std::lock_guard<std::mutex> lock(mtx);
//再次獲取,檢查是否有其他線程在獲取鎖的過程中創建了實例
tmp = instance.load(std::memory_order_relaxed);
if (nullptr == tmp)
{
tmp = new CSingleton();
//保證了在這個寫操作之前的所有操作都不會被重排到這個操作之後
//確保了實例完全構造好之後,其他線程通過 `instance` 讀取到的值是最新的
instance.store(tmp, std::memory_order_release);
}
}
return tmp;
}
局部靜態變數
最後,害得是局部靜態變數形式的單例模式,大道至簡!
static CSingleton& getInstance()
{
static CSingleton instance;
return instance;
}
具體原因見:《單例模式學習》
總結
本文討論了指令重排對多線程下的單例模式的影響,並例舉了幾個解決方案。後面可能還會更新別的解決方案
參考文章
1.C++ and the Perils of Double-Checked Locking
2.Double-Checked Locking is Fixed In C++11