[英]Double-check locking issues, c++
為了簡單起見,我離開了其余的實現,因為它與此無關。 考慮在現代C ++設計中使用的雙重檢查的經典實現。
Singleton& Singleton::Instance()
{
if(!pInstance_)
{
Guard myGuard(lock_);
if (!pInstance_)
{
pInstance_ = new Singleton;
}
}
return *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
讀取並將其value
給x
,這是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_release
的std::atomic
,對象發布代碼變為 :
bl operator new(unsigned long)
adrp x1, .LANCHOR0
ldr w1, [x1, #:lo12:.LANCHOR0]
str w1, [x0]
stlr x0, [x20]
最主要的區別是最終商店現在是stlr
- 一個發布商店 。 您也可以查看PowerPC方面,兩個商店之間出現了lwsync
障礙。
所以底線是:
std::atomic
與memory_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.