簡體   English   中英

仔細檢查鎖定問題,c ++

[英]Double-check locking issues, c++

為了簡單起見,我離開了其余的實現,因為它與此無關。 考慮在現代C ++設計中使用雙重檢查的經典實現。

Singleton& Singleton::Instance()
{
    if(!pInstance_) 
    { 
         Guard myGuard(lock_); 
         if (!pInstance_) 
         {
            pInstance_ = new Singleton; 
         }
     }
     return *pInstance_;
}

在這里,作者堅持認為我們避免了競爭條件。 但是我讀過一篇文章,不幸的是我記得很清楚,其中描述了以下流程。

  1. 線程1首先輸入if語句
  2. 線程1進入互斥體端進入第二個if體。
  3. 線程1調用operator new並將內存分配給pInstance,而不是調用該內存上的構造函數;
  4. 假設線程1將內存分配給pInstance但未創建對象,線程2進入該功能。
  5. 線程2看到pInstance不為null(但尚未使用構造函數初始化)並返回pInstance。

在那篇文章中,作者說過,訣竅在於行pInstance_ = new Singleton; 可以分配內存,將其分配給pInstance,以便在該內存上調用構造函數。

依賴標准或其他可靠來源,任何人都可以確認或否認此流程的可能性或正確性嗎? 謝謝!

你描述的問題只有在我無法想象單身人士的概念使用明確(和破壞)2步構造的原因時才會發生:

     ...
     Guard myGuard(lock_); 
     if (!pInstance_) 
     {
        auto alloc = std::allocator<Singleton>();
        pInstance_ = alloc.allocate(); // SHAME here: race condition
        // eventually other stuff
        alloc.construct(_pInstance);   // anything could have happened since allocation
     }
     ....

即使由於任何原因需要這樣的兩步構造, _pInstance成員也不應包含nullptr或完全構造的實例的任何其他內容:

        auto alloc = std::allocator<Singleton>();
        Singleton *tmp = alloc.allocate(); // no problem here
        // eventually other stuff
        alloc.construct(tmp);              // nor here
        _pInstance = tmp;                  // a fully constructed instance

要注意 :修復只能在單CPU上保證。 在確實需要C ++ 11原子語義的多核系統上情況會更糟。

問題是,在沒有保證的情況下,在完成對象構造之前,某些其他線程可能會看到指向pInstance_的指針存儲。 在這種情況下,另一個線程不會進入互斥鎖,只會立即返回pInstance_ ,當調用者使用它時,它可以看到未初始化的值。

Singleton上的構造相關聯的存儲與存儲到pInstance_之間的這種明顯的重新排序可能是由編譯器或硬件引起的。 我將快速瀏覽下面的兩個案例。

編譯器重新排序

如果沒有與並發讀取相關的任何特定保證保證(例如C ++ 11的std::atomic對象提供的保證),編譯器只需要保留當前線程所看到的代碼語義。 這意味着,例如,它可以將代碼“亂序”編譯為它在源中的顯示方式,只要它在當前線程上沒有可見的副作用(由標准定義)。

特別是,編譯器重新排序在Singleton的構造函數中執行的存儲, pInstance_存儲設置為pInstance_ ,只要它可以看到效果相同1就不常見了。

讓我們來看看你的例子的一個充實版本:

struct Lock {};
struct Guard {
    Guard(Lock& l);
};

int value;

struct Singleton {
    int x;
    Singleton() : x{value} {}

    static Lock lock_;
    static Singleton* pInstance_;
    static Singleton& Instance();
};

Singleton& Singleton::Instance()
{
    if(!pInstance_) 
    { 
         Guard myGuard(lock_); 
         if (!pInstance_) 
         {
            pInstance_ = new Singleton; 
         }
     }
     return *pInstance_;
}

這里, Singleton的構造函數非常簡單:它只是從全局value讀取並將其valuex ,這是Singleton的唯一成員。

使用godbolt, 我們可以確切地檢查gcc和clang如何編譯它 注釋的gcc版本如下所示:

Singleton::Instance():
        mov     rax, QWORD PTR Singleton::pInstance_[rip]
        test    rax, rax
        jz      .L9       ; if pInstance != NULL, go to L9
        ret
.L9:
        sub     rsp, 24
        mov     esi, OFFSET FLAT:_ZN9Singleton5lock_E
        lea     rdi, [rsp+15]
        call    Guard::Guard(Lock&) ; acquire the mutex
        mov     rax, QWORD PTR Singleton::pInstance_[rip]
        test    rax, rax
        jz      .L10     ; second check for null, if still null goto L10
.L1:
        add     rsp, 24
        ret
.L10:
        mov     edi, 4
        call    operator new(unsigned long) ; allocate memory (pointer in rax)
        mov     edx, DWORD value[rip]       ; load value global
        mov     QWORD pInstance_[rip], rax  ; store pInstance pointer!!
        mov     DWORD [rax], edx            ; store value into pInstance_->x
        jmp     .L1

最后幾行很關鍵,特別是兩家商店:

        mov     QWORD pInstance_[rip], rax  ; store pInstance pointer!!
        mov     DWORD [rax], edx            ; store value into pInstance_->x

有效地,行pInstance_ = new Singleton; 被轉化為:

Singleton* stemp = operator new(sizeof(Singleton)); // (1) allocate uninitalized memory for a Singleton object on the heap
int vtemp     = value; // (2) read global variable value
pInstance_    = stemp; // (3) write the pointer, still uninitalized, into the global pInstance (oops!)
pInstance_->x = vtemp; // (4) initialize the Singleton by writing x

哎呀! 任何第二個線程在(3)發生時到達,但(4)沒有,將看到非空pInstance_ ,但隨后讀取pInstance->x的未初始化(垃圾)值。

因此,即使沒有調用任何奇怪的硬件重新排序,如果不做更多工作,這種模式也是不安全的。

硬件重新排序

假設您進行組織,以便在編譯器2上不會發生上述存儲的重新排序,可能是通過設置編譯器屏障,例如asm volatile ("" ::: "memory") 通過這個小小的改變 ,gcc現在編譯它以使兩個關鍵商店處於“期望”的順序:

        mov     DWORD PTR [rax], edx
        mov     QWORD PTR Singleton::pInstance_[rip], rax

所以我們很好,對吧?

在x86上,我們是。 碰巧x86具有相對強大的內存模型,並且所有商店都已經具有發布語義 我不會描述完整的語義,但是在上面兩個存儲的上下文中,它意味着存儲按程序順序出現其他CPU上:因此任何看到上面第二次寫入的CPU(對於pInstance_ )都必然會看到先前的寫入(對於pInstance_->x )。

我們可以通過使用C ++ 11 std::atomic特性來明確地請求pInstance_的發布存儲(這也使我們能夠擺脫編譯器障礙):

    static std::atomic<Singleton*> pInstance_;
    ...
       if (!pInstance_) 
       {
          pInstance_.store(new Singleton, std::memory_order_release); 
       }

我們得到合理的匯編 ,沒有硬件內存障礙或任何東西(現在有一個冗余的負載,但這是gcc的錯過優化和我們編寫函數的方式的結果)。

所以我們完成了,對吧?

不 - 大多數其他平台沒有x86所做的強大的商店到商店訂購。

讓我們看一下圍繞創建新對象的ARM64程序集

    bl      operator new(unsigned long)
    mov     x1, x0                         ; x1 holds Singleton* temp
    adrp    x0, .LANCHOR0
    ldr     w0, [x0, #:lo12:.LANCHOR0]     ; load value
    str     w0, [x1]                       ; temp->x = value
    mov     x0, x1
    str     x1, [x19, #pInstance_]  ; pInstance_ = temp

因此,我們將str作為最后一個商店的pInstance_ ,在pInstance_ temp->x = value存儲之后,如我們所願。 但是,ARM64內存模型不保證這些存儲在由另一個CPU觀察時按程序順序出現。 因此,即使我們已經馴服了編譯器,硬件仍然會讓我們失望。 你需要一個障礙來解決這個問題。

在C ++ 11之前,沒有針對此問題的可移植解決方案。 對於特定的ISA,您可以使用內聯匯編來發出正確的障礙。 你的編譯器可能有像gcc提供的__sync_synchronize這樣的內置__sync_synchronize ,或者你的操作系統甚至可能有東西

然而,在C ++ 11及更高版本中,我們最終有一個內置於該語言的正式內存模型,而我們需要的是,雙重檢查鎖定是一個發布存儲,作為pInstance_的最終存儲。 我們已經在x86中看到了這一點,我們檢查了沒有發出編譯器障礙,使用帶有memory_order_releasestd::atomic ,對象發布代碼變為

    bl      operator new(unsigned long)
    adrp    x1, .LANCHOR0
    ldr     w1, [x1, #:lo12:.LANCHOR0]
    str     w1, [x0]
    stlr    x0, [x20]

最主要的區別是最終商店現在是stlr - 一個發布商店 您也可以查看PowerPC方面,兩個商店之間出現了lwsync障礙。

所以底線是:

  • 雙重檢查鎖定在順序一致的系統中安全的。
  • 由於硬件,編譯器或兩者兼而有之,實際系統幾乎總是偏離順序一致性。
  • 要解決這個問題,您需要告訴編譯器您需要什么,並且它將避免重新排序自身並發出必要的屏障指令(如果有),以防止硬件導致問題。
  • 在C ++ 11之前,“告訴編譯器的方式”是指平台/編譯器/操作系統特定的,但在C ++中,您可以簡單地將std::atomicmemory_order_acquire加載和memory_order_release存儲一起使用。

負載

以上只涉及問題的一半: pInstance_存儲 可能出錯的另一半是負載,負載實際上對性能最重要,因為它代表了在單例初始化之后采用的通常的快速路徑。 如果在加載pInstance本身並檢查為null之前加載了pInstance_->x怎么辦? 在這種情況下,您仍然可以讀取未初始化的值!

這似乎不太可能,因為pInstance_需要它被pInstance_ 之前加載,對吧? 也就是說,與商店案例不同,似乎存在阻止重新排序的操作之間的基本依賴關系。 好吧,事實證明,硬件行為和軟件轉換仍然可能會讓你失望,而且細節甚至比商店案例更復雜。 如果你使用memory_order_acquire ,你會沒事的。 如果你想要最后一次性能,特別是在PowerPC上,你需要深入了解memory_order_consume的細節。 另一天的故事。


1特別是,這意味着編譯器必須能夠查看構造函數Singleton()的代碼,以便它可以確定它不從pInstance_讀取。

2當然,依賴於此是非常危險的,因為如果有任何改變,你必須在每次編譯后檢查程序集!

曾經在C ++ 11之前未指定,因為沒有標准內存模型討論多個線程。

IIRC指針可以在構造函數完成之前設置為已分配的地址,只要該線程永遠無法區分(這可能只發生在一個簡單/非拋出的構造函數中)。

從C ++ 11開始, 序列之前的規則不允許重新排序,具體而言

8)內置賦值運算符的副作用(左參數的修改)在左右參數的值計算...之后排序,...

由於右參數是一個新表達式,因此必須先完成分配和構造,然后才能修改左側。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM