Edu*_*yan 3 c++ singleton multithreading double-checked-locking
为了简单起见,我保留了其余的实现,因为它与这里无关。考虑现代 C++ 设计中描述的双重检查查找的经典实现。
Singleton& Singleton::Instance()
{
if(!pInstance_)
{
Guard myGuard(lock_);
if (!pInstance_)
{
pInstance_ = new Singleton;
}
}
return *pInstance_;
}
Run Code Online (Sandbox Code Playgroud)
在这里,作者坚持我们要避免竞争条件。但我读过一篇文章,不幸的是我不太记得了,其中描述了以下流程。
在那篇文章中,作者指出,技巧是可以在线 pInstance_ = new Singleton;分配内存,并将其分配给 pInstance,以便在该内存上调用构造函数。
依靠标准或其他可靠来源,任何人都可以确认或否认此流程的可能性或正确性吗?谢谢!
问题是,在没有其他保证的情况下,在对象的构造完成之前,其他pInstance_线程可能会看到指针的存储。在这种情况下,另一个线程不会进入互斥体,而是立即返回,当调用者使用它时,它可以看到未初始化的值。pInstance_
与构造相关联的存储Singleton和存储之间的这种明显的重新排序pInstance_可能是由编译器或硬件引起的。我将快速浏览一下下面的两种情况。
如果没有与并发读取相关的任何特定保证(例如 C++11 对象提供的保证),编译器只需要保留当前线程std::atomic所看到的代码语义。例如,这意味着它可能会按照源代码中的显示方式“乱序”编译代码,只要这对当前线程没有明显的副作用(如标准所定义)。
Singleton特别是,编译器将在构造函数for中执行的存储重新排序为 的情况并不少见,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_;
}
Run Code Online (Sandbox Code Playgroud)
这里, 的构造函数Singleton非常简单:它只是从全局读取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
Run Code Online (Sandbox Code Playgroud)
最后几行很关键,特别是这两个商店:
mov QWORD pInstance_[rip], rax ; store pInstance pointer!!
mov DWORD [rax], edx ; store value into pInstance_->x
Run Code Online (Sandbox Code Playgroud)
实际上,该行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
Run Code Online (Sandbox Code Playgroud)
哎呀!当 (3) 发生但 (4) 未发生时到达的任何第二个线程将看到非 null pInstance_,但随后读取 的未初始化(垃圾)值pInstance->x。
因此,即使根本不调用任何奇怪的硬件重新排序,如果不做更多工作,这种模式也是不安全的。
假设您进行了组织,以便在您的编译器2上不会发生上述存储的重新排序,也许可以通过放置编译器屏障(例如asm volatile ("" ::: "memory"). 经过这个小小的更改,gcc 现在将其编译为按“所需”顺序排列两个关键存储:
mov DWORD PTR [rax], edx
mov QWORD PTR Singleton::pInstance_[rip], rax
Run Code Online (Sandbox Code Playgroud)
所以我们很好,对吧?
在 x86 上,我们确实如此。恰好x86有比较强的内存模型,所有的store都已经有了release语义。我不会描述完整的语义,但在上述两个存储的上下文中,这意味着存储按照其他 CPU 的程序顺序出现:因此任何看到上面第二个写入 (to pInstance_) 的 CPU 必然会看到先前的写入 (到pInstance_->x)。
我们可以通过使用 C++11std::atomic功能显式请求发布存储来说明这pInstance_一点(这也使我们能够摆脱编译器障碍):
static std::atomic<Singleton*> pInstance_;
...
if (!pInstance_)
{
pInstance_.store(new Singleton, std::memory_order_release);
}
Run Code Online (Sandbox Code Playgroud)
我们得到了合理的汇编,没有硬件内存障碍或任何东西(现在有冗余负载,但这既是 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
Run Code Online (Sandbox Code Playgroud)
因此,我们将strtopInstance_作为最后一个商店,位于 store 之后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]
Run Code Online (Sandbox Code Playgroud)
主要区别是最终商店现在是stlr发布商店。您也可以查看 PowerPC 一侧,lwsync两个商店之间出现了障碍。
所以底线是:
std::atomic加载memory_order_acquire和memory_order_release存储。上面只解决了问题的一半:pInstance_. 可能出错的另一半是负载,负载实际上对性能来说是最重要的,因为它代表了单例初始化后通常采用的快速路径。如果在加载自身并检查 nullpInstance_->x之前加载会怎样?pInstance在这种情况下,您仍然可以读取未初始化的值!
这似乎不太可能,因为需要在推迟之前pInstance_加载,对吗?也就是说,与存储情况不同,操作之间似乎存在阻止重新排序的基本依赖性。事实证明,硬件行为和软件转换仍然可能让您陷入困境,而且细节甚至比商店案例还要复杂。如果你使用的话,那就没问题了。如果您想要最后一次性能,尤其是在 PowerPC 上,您需要深入研究. 改天再讲故事。memory_order_acquirememory_order_consume
1特别是,这意味着编译器必须能够查看构造函数的代码Singleton(),以便确定它不从pInstance_.
2当然,依赖于此是非常危险的,因为如果有任何更改,您必须在每次编译后检查程序集!
| 归档时间: |
|
| 查看次数: |
897 次 |
| 最近记录: |