設計模式學習(一)單例模式補充——指令重排

paw5zx發表於2024-03-19

目錄
  • 前言
  • 指令重排簡介
  • 指令重排對單例模式的影響
  • 改進方法
    • std::call_once和std::once_flag
    • std::atomic和記憶體順序
    • 區域性靜態變數
  • 總結
  • 參考文章

前言

《單例模式學習》中曾提到懶漢式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

相關文章