为了简单起见,我离开了其余的实现,因为它与此无关。 考虑Double-check loking 中描述的Modern C++ Design的经典实现。
Singleton& Singleton::Instance()
{
if(!pInstance_)
{
Guard myGuard(lock_);
if (!pInstance_)
{
pInstance_ = new Singleton;
}
}
return *pInstance_;
}
这里作者坚持认为我们避免了竞争条件。但是我读过一篇文章,不幸的是我记得很清楚,其中描述了以下流程。
在那篇文章中,作者说明了诀窍是在行pInstance_ = new Singleton;
上可以分配内存,分配给pInstance,在该内存上调用构造函数。
依赖标准或其他可靠来源,任何人都可以确认或否认此流程的可能性或正确性吗?谢谢!
答案 0 :(得分:4)
您描述的问题只有在我无法想象单身人士的概念使用显式(和破坏)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原子语义的多核系统上情况会更糟。
答案 1 :(得分:3)
问题在于,在没有保证的情况下,在对象构造完成之前,某些其他线程可能会看到指向pInstance_
的指针。在这种情况下,另一个线程不会进入互斥锁,只会立即返回pInstance_
,当调用者使用它时,它可以看到未初始化的值。
与Singleton
上的构造相关联的商店与pInstance_
的商店之间的明显重新排序可能是由编译器或硬件引起的。我将快速浏览下面的两个案例。
如果没有与并发读取相关的任何特定保证保证(例如C ++ 11和std::atomic
对象提供的保证),编译器只需要保留代码的语义,如当前线程。这意味着,例如,它可能编译代码&#34;无序&#34;它是如何出现在源中的,只要它在当前线程上没有可见的副作用(由标准定义)。
特别是,编译器重新排序在Singleton
的构造函数中执行的存储,并将存储转换为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
读取,并将其分配给x
,Singleton
的唯一成员。 / p>
使用godbolt,we can check exactly how gcc and clang compile this。注释的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
的未初始化(垃圾)值。 / p>
因此,即使没有调用任何奇怪的硬件重新排序,这种模式也不安全而不需要做更多的工作。
让我们说你组织起来,以便上面的商店重新排序不会发生在您的编译器 2 上,可能是通过放置编译器障碍例如asm volatile ("" ::: "memory")
。使用that small change,gcc现在将其编译为在&#34;期望&#34;中设置两个关键商店。顺序:
mov DWORD PTR [rax], edx
mov QWORD PTR Singleton::pInstance_[rip], rax
所以我们很好,对吧?
在x86上,我们是。碰巧x86具有相对强大的内存模型,并且所有商店都已经拥有release semantics。我不会描述完整的语义,但是在上面两个存储的上下文中,它意味着存储按程序顺序出现到其他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);
}
我们得到reasonable assembly没有硬件内存障碍或任何东西(现在有冗余加载,但这是gcc的错过优化和我们编写函数的方式的结果)。
所以我们已经完成了,对吧?
不 - 大多数其他平台都没有x86强大的商店到商店订购。
让我们看看ARM64 assembly围绕新对象的创建:
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_
作为最后一个商店,在temp->x = value
商店之后,根据需要。但是,ARM64内存模型doesn't guarantee表示这些存储在被另一个CPU观察时按程序顺序出现。因此,即使我们已经驯服了编译器,硬件仍然会让我们失望。你需要一个障碍来解决这个问题。
在C ++ 11之前,没有针对此问题的便携式解决方案。对于特定的ISA,您可以使用内联汇编来发出正确的障碍。您的编译器可能具有__sync_synchronize
或OS might even have something提供的内置gcc
。
然而,在C ++ 11及更高版本中,我们最终有一个内置于该语言的正式内存模型,而我们需要的是,对于双重检查锁定,是一个发布存储,如到pInstance_
的最终商店。我们已经在x86中看到了这一点,我们使用std::atomic
memory_order_release
对象发布代码becomes来检查没有发出编译器障碍:
bl operator new(unsigned long)
adrp x1, .LANCHOR0
ldr w1, [x1, #:lo12:.LANCHOR0]
str w1, [x0]
stlr x0, [x20]
主要区别在于最终商店现在为stlr
- release store。您也可以查看PowerPC端,两个商店之间出现lwsync
障碍。
所以底线是:
std::atomic
与memory_order_acquire
加载和memory_order_release
存储一起使用。以上只涉及问题的一半:pInstance_
的商店。可能出错的另一半是负载,负载实际上对性能最重要,因为它代表了在单例初始化之后采用的通常的快速路径。如果pInstance_->x
在pInstance
本身加载并检查为空之前加载了怎么办?在这种情况下,您仍然可以读取未初始化的值!
这似乎不太可能,因为{<1}}需要在之前加载,对吗?也就是说,与商店案例不同,似乎存在阻止重新排序的操作之间的基本依赖关系。好吧,事实证明,硬件行为和软件转换仍然可能会让你失望,而且细节甚至比商店案例更复杂。如果你使用pInstance_
,你会没事的。如果您想要最后一次性能,特别是在PowerPC上,您需要深入了解memory_order_acquire
的详细信息。另一天的故事。
1 特别是,这意味着编译器必须能够看到构造函数memory_order_consume
的代码,以便它可以确定它不会从{读取} {1}}。
2 当然,依赖于此非常危险,因为如果发生任何变化,您必须在每次编译后检查程序集!
答案 2 :(得分:1)
在C ++ 11之前,它以前是未指定的,因为没有标准内存模型讨论多个线程。
IIRC指针可以在构造函数完成之前设置为已分配的地址,只要该线程永远无法区分(这可能只发生在一个简单/非平凡的情况下)投掷构造函数)。
从C ++ 11开始,sequenced-before规则不允许重新排序,特别是
8)内置赋值运算符的副作用(左参数的修改)在左右参数的值计算...之后排序,...
由于右参数是一个新表达式,因此必须完成分配&amp;左手边的施工可以修改。