问题在于,在没有其他保证的情况下,指针存储到中的内容可能会被其他线程看到,而这时对象的构造尚未完成。在这种情况下,其他线程不会进入互斥锁,只会立即返回,当调用者使用它时,可能会看到未初始化的值。
与
Singleton
上的构造相关联的存储和对的存储之间的这种表面重排序可能是由编译器或硬件引起的。下面我将快速查看这两种情况。
编译器重排序
在没有与并发读取相关的特定保证(例如C++11的
std::atomic
对象提供的保证)的情况下,编译器只需要保留代码的语义,如当前线程所见。这意味着,例如,它可以按照与源代码不同的顺序编译代码,只要这不对当前线程产生可见的副作用(由标准定义)。
特别是,编译器可能会重新排序在
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
中读取并将其赋值给
Singleton
的唯一成员变量
x
。
使用 godbolt,
我们可以精确检查 gcc 和 clang 如何编译此代码。下面显示了带注释的 gcc 版本:
Singleton::Instance():
mov rax, QWORD PTR Singleton::pInstance_[rip]
test rax, rax
jz .L9
ret
.L9:
sub rsp, 24
mov esi, OFFSET FLAT:_ZN9Singleton5lock_E
lea rdi, [rsp+15]
call Guard::Guard(Lock&)
mov rax, QWORD PTR Singleton::pInstance_[rip]
test rax, rax
jz .L10
.L1:
add rsp, 24
ret
.L10:
mov edi, 4
call operator new(unsigned long)
mov edx, DWORD value[rip]
mov QWORD pInstance_[rip], rax
mov DWORD [rax], edx
jmp .L1
最后几行非常关键,特别是两个商店:
mov QWORD pInstance_[rip], rax
mov DWORD [rax], edx
实际上,这一行代码 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
未初始化(垃圾)的值。
所以即使没有引发任何奇怪的硬件重新排序,这种模式也需要做更多的工作才能安全。
硬件重新排序
假设你通过放置像 asm volatile ("" ::: "memory")
这样的 编译器屏障 来组织上述存储的重新排序在您的编译器上不会发生2。通过 这个小改变,gcc 现在编译它以使两个关键存储器按“所需”的顺序进行:
mov DWORD PTR [rax], edx
mov QWORD PTR Singleton::pInstance_[rip], rax
那我们就没问题了,对吧?
对于x86来说,没问题。恰巧x86有一个相对强大的内存模型,并且所有存储已经具有发布语义。我不会描述完整的语义,但在上述两个存储的上下文中,它意味着存储将按照程序顺序出现在其他CPU上:因此,任何看到上面第二个写入(到pInstance_
)的CPU必然会看到先前的写入(到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
adrp x0, .LANCHOR0
ldr w0, [x0, #:lo12:.LANCHOR0]
str w0, [x1]
mov x0, x1
str x1, [x19, #pInstance_]
所以,我们将
str
存储到
pInstance_
中,作为最后一个存储,位于
temp->x = value
存储之后,正如我们所希望的。然而,ARM64内存模型
不保证在另一个CPU观察时这些存储按程序顺序出现。因此,即使我们已经控制了编译器,硬件仍然可能会使我们失误。您需要一个障碍来解决这个问题。
在C++11之前,没有可移植的解决方案。对于特定的ISA,您可以使用内联汇编语言来发出正确的障碍。您的编译器可能具有像
__sync_synchronize
这样的内置函数,由
gcc
提供,或者您的
操作系统甚至可能有其他东西。
在C++11及以后的版本中,我们终于拥有了语言内置的正式内存模型。对于双重检查锁定,我们需要一个“释放”存储器作为最终存储器,用于
pInstance_
。我们已经在x86上看到了这一点,在那里我们使用
std::atomic
和
memory_order_release
检查了没有编译器障碍,对象发布代码
变得:
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::atomic
和
memory_order_acquire
加载以及
memory_order_release
存储。
负载
上面只涵盖了问题的一半:即
pInstance_
的存储。另外一半可能出现问题的是加载,而且从性能角度来看,加载实际上是最重要的,因为它代表了单例初始化后通常采用的快速路径。如果在检查
pInstance
是否为空之前,先加载了
pInstance_->x
,那么你仍然可以读取未初始化的值!
这似乎不太可能发生,因为需要在引用之前加载
pInstance_
,对吧?也就是说,这两个操作之间存在基本依赖关系,防止重新排序,不像存储情况。但事实证明,硬件行为和软件转换都可能在这里使你失误,而且细节比存储情况更加复杂。但如果使用
memory_order_acquire
,就不会有问题。如果你想要最后一点性能,特别是在 PowerPC 上,你需要深入了解
memory_order_consume
的细节。这是另一天的故事。
1 特别是,这意味着编译器必须能够看到构造函数Singleton()
的代码,以便确定它不会从pInstance_
读取。
2 当然,依赖于此非常危险,因为您必须在每次编译后检查汇编代码是否有任何更改!
new
所做的。然后赋值操作会在new
之后独立进行。new
和赋值是两个不同的操作,按正确顺序依次执行。 - Some programmer dudepInstance_
是如何声明的。它需要被原子地load
和store
以在具有更松散内存模型的CPU上正常工作。否则,即使线程#1释放了互斥锁,线程#2可能仍然会看到pInstance_
为nullptr
。Alexandrescu在双重检查锁定模式的一节末尾提到了这一点。 - Sean ClineSingleton::Instance()
,然后线程B随后调用Singleton::Instance()
并尝试使用返回的指针时,形式上是未定义的。特别地,在某些体系结构上,线程B可能会看到未初始化或部分初始化状态的Singleton
对象。请参见:https://en.cppreference.com/w/cpp/language/memory_model - Solomon Slowoperator new (size_t)
),然后进行构造并分配指针,但编译器可以重新排序所有这些操作,gcc实际上如下面的答案所示。此类重排序是典型和常见的:编译器不需要发出按源代码顺序执行的汇编代码,它只需要发出使当前线程“好像”源代码顺序被遵循的东西。其他线程可能会看到不同的结果。因此,几乎这里的每个评论都是错误的。 - BeeOnRope