仔细检查锁定问题,c++
Double-check locking issues, c++
为了简单起见,我留下了其余的实现,因为它与这里无关。
考虑 Double-check loking descibed in Modern C++ Design.
的经典实现
Singleton& Singleton::Instance()
{
if(!pInstance_)
{
Guard myGuard(lock_);
if (!pInstance_)
{
pInstance_ = new Singleton;
}
}
return *pInstance_;
}
这里作者坚持要避免竞争条件。但是我看过一篇文章,不幸的是我不太记得了,其中描述了以下流程。
- 线程1先进入if语句
- 线程1在第二个if body中进入mutex end get。
- 线程 1 调用 operator new 并将内存分配给 pInstance,然后在该内存上调用构造函数;
- 假设线程1分配了内存给pInstance但没有创建对象,线程2进入函数
- 线程 2 看到 pInstance 不为空(但尚未使用构造函数初始化)并且 returns pInstance。
在那篇文章中,作者指出,技巧是在 pInstance_ = new Singleton;
行上可以分配内存,分配给 pInstance ,构造函数将在该内存上调用。
根据标准或其他可靠来源,任何人都可以确认或否认此流程的可能性或正确性吗?谢谢!
你描述的问题只有在我无法想象单例的概念使用显式(和损坏的)两步构造的情况下才会发生:
...
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 原子语义的多核系统上,情况可能会更糟。
它在 C++11 之前是未指定的,因为没有讨论多线程的标准内存模型。
IIRC 指针可以在构造函数完成之前设置为分配的地址,只要 thread 永远无法区分(这可能只发生在一个 trivial/non-throwing 构造函数)。
自 C++11 起,sequenced-before 规则不允许重新排序,特别是
8) The side effect (modification of the left argument) of the built-in assignment operator ... is sequenced after the value computation ... of both left and right arguments, ...
因为右边的参数是一个新表达式,所以必须在左边的参数被修改之前完成分配和构造。
问题是在没有其他保证的情况下,指向 pInstance_
的指针的存储可能会在对象构造之前被某些 other 线程看到做完了。在这种情况下,另一个线程不会进入互斥量,而是立即 return pInstance_
并且当调用者使用它时,它可以看到未初始化的值。
与 Singleton
上的构造关联的存储与 pInstance_
上的存储之间明显的重新排序可能是由编译器或硬件引起的。下面我将快速浏览一下这两种情况。
编译器重新排序
缺少与并发读取相关的任何特定保证(例如 C++11 的 std::atomic
对象提供的保证),编译器只需要保留 [=129= 看到的代码语义]当前线程。这意味着,例如,它可以将代码 "out of order" 编译为它在源代码中的显示方式,只要这在当前线程上没有可见的副作用(由标准定义)。
特别是,编译器对 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
的唯一成员.
使用神栓,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
.[=67 的未初始化(垃圾)值=]
因此,即使根本不调用任何奇怪的硬件重新排序,如果不做更多工作,这种模式也不安全。
硬件重新排序
假设您进行了组织,以便在您的编译器2 上不会发生上述存储的重新排序,也许是通过放置一个编译器障碍 例如 asm volatile ("" ::: "memory")
。使用 that small change,gcc 现在将其编译为 "desired" 顺序中的两个关键存储:
mov DWORD PTR [rax], edx
mov QWORD PTR Singleton::pInstance_[rip], rax
所以我们很好,对吧?
嗯,在 x86 上,我们是。正好x86内存模型比较强,所有的store都已经有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,您可以使用内联汇编来发出正确的屏障。您的编译器可能有 gcc
提供的 __sync_synchronize
之类的内置函数,或者您的 OS might even have something.
然而,在 C++11 及更高版本中,我们终于有了一个内置于该语言的正式内存模型,我们需要的是 release 存储,作为最终存储到 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
障碍。
所以底线是:
- 双重检查锁定在顺序一致的系统中是安全的。
- 现实世界的系统几乎总是偏离顺序一致性,这可能是由于硬件、编译器或两者的原因。
- 要解决这个问题,您需要告诉编译器您想要什么,它会避免重新排序并发出必要的屏障指令(如果有的话)以防止硬件引起问题。
- 在 C++11 之前,"way you tell the compiler" 是 platform/compiler/OS 特定的,但在 C++ 中,您可以简单地将
std::atomic
与 memory_order_acquire
加载和 memory_order_release
商店。
负载
以上仅涵盖了问题的一半:pInstance_
的 store。另一半可能出错的是负载,负载实际上对性能最重要,因为它代表了单例初始化后通常采用的快速路径。如果 pInstance_->x
在 pInstance
本身被加载并检查是否为 null 之前被加载怎么办?在那种情况下,您仍然可以读取未初始化的值!
这似乎不太可能,因为 pInstance_
需要在 加载之前 它被推迟,对吧?也就是说,与商店的情况不同,操作之间似乎存在基本的依赖关系以防止重新排序。好吧,事实证明,硬件行为和软件转换仍然会让你在这里绊倒,细节甚至比商店案例还要复杂。但是,如果您使用 memory_order_acquire
,您会没事的。如果你想要最后一次性能,尤其是在 PowerPC 上,你需要深入研究 memory_order_consume
的细节。另一天的故事。
1 特别是,这意味着编译器必须能够看到构造函数 Singleton()
的代码,以便它可以确定它没有阅读 pInstance_
.
2 当然,依赖这个是非常危险的,因为如果有任何变化,你必须在每次编译后检查程序集!
为了简单起见,我留下了其余的实现,因为它与这里无关。 考虑 Double-check loking descibed in Modern C++ Design.
的经典实现Singleton& Singleton::Instance()
{
if(!pInstance_)
{
Guard myGuard(lock_);
if (!pInstance_)
{
pInstance_ = new Singleton;
}
}
return *pInstance_;
}
这里作者坚持要避免竞争条件。但是我看过一篇文章,不幸的是我不太记得了,其中描述了以下流程。
- 线程1先进入if语句
- 线程1在第二个if body中进入mutex end get。
- 线程 1 调用 operator new 并将内存分配给 pInstance,然后在该内存上调用构造函数;
- 假设线程1分配了内存给pInstance但没有创建对象,线程2进入函数
- 线程 2 看到 pInstance 不为空(但尚未使用构造函数初始化)并且 returns pInstance。
在那篇文章中,作者指出,技巧是在 pInstance_ = new Singleton;
行上可以分配内存,分配给 pInstance ,构造函数将在该内存上调用。
根据标准或其他可靠来源,任何人都可以确认或否认此流程的可能性或正确性吗?谢谢!
你描述的问题只有在我无法想象单例的概念使用显式(和损坏的)两步构造的情况下才会发生:
...
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 原子语义的多核系统上,情况可能会更糟。
它在 C++11 之前是未指定的,因为没有讨论多线程的标准内存模型。
IIRC 指针可以在构造函数完成之前设置为分配的地址,只要 thread 永远无法区分(这可能只发生在一个 trivial/non-throwing 构造函数)。
自 C++11 起,sequenced-before 规则不允许重新排序,特别是
8) The side effect (modification of the left argument) of the built-in assignment operator ... is sequenced after the value computation ... of both left and right arguments, ...
因为右边的参数是一个新表达式,所以必须在左边的参数被修改之前完成分配和构造。
问题是在没有其他保证的情况下,指向 pInstance_
的指针的存储可能会在对象构造之前被某些 other 线程看到做完了。在这种情况下,另一个线程不会进入互斥量,而是立即 return pInstance_
并且当调用者使用它时,它可以看到未初始化的值。
与 Singleton
上的构造关联的存储与 pInstance_
上的存储之间明显的重新排序可能是由编译器或硬件引起的。下面我将快速浏览一下这两种情况。
编译器重新排序
缺少与并发读取相关的任何特定保证(例如 C++11 的 std::atomic
对象提供的保证),编译器只需要保留 [=129= 看到的代码语义]当前线程。这意味着,例如,它可以将代码 "out of order" 编译为它在源代码中的显示方式,只要这在当前线程上没有可见的副作用(由标准定义)。
特别是,编译器对 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
的唯一成员.
使用神栓,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
.[=67 的未初始化(垃圾)值=]
因此,即使根本不调用任何奇怪的硬件重新排序,如果不做更多工作,这种模式也不安全。
硬件重新排序
假设您进行了组织,以便在您的编译器2 上不会发生上述存储的重新排序,也许是通过放置一个编译器障碍 例如 asm volatile ("" ::: "memory")
。使用 that small change,gcc 现在将其编译为 "desired" 顺序中的两个关键存储:
mov DWORD PTR [rax], edx
mov QWORD PTR Singleton::pInstance_[rip], rax
所以我们很好,对吧?
嗯,在 x86 上,我们是。正好x86内存模型比较强,所有的store都已经有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,您可以使用内联汇编来发出正确的屏障。您的编译器可能有 gcc
提供的 __sync_synchronize
之类的内置函数,或者您的 OS might even have something.
然而,在 C++11 及更高版本中,我们终于有了一个内置于该语言的正式内存模型,我们需要的是 release 存储,作为最终存储到 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
障碍。
所以底线是:
- 双重检查锁定在顺序一致的系统中是安全的。
- 现实世界的系统几乎总是偏离顺序一致性,这可能是由于硬件、编译器或两者的原因。
- 要解决这个问题,您需要告诉编译器您想要什么,它会避免重新排序并发出必要的屏障指令(如果有的话)以防止硬件引起问题。
- 在 C++11 之前,"way you tell the compiler" 是 platform/compiler/OS 特定的,但在 C++ 中,您可以简单地将
std::atomic
与memory_order_acquire
加载和memory_order_release
商店。
负载
以上仅涵盖了问题的一半:pInstance_
的 store。另一半可能出错的是负载,负载实际上对性能最重要,因为它代表了单例初始化后通常采用的快速路径。如果 pInstance_->x
在 pInstance
本身被加载并检查是否为 null 之前被加载怎么办?在那种情况下,您仍然可以读取未初始化的值!
这似乎不太可能,因为 pInstance_
需要在 加载之前 它被推迟,对吧?也就是说,与商店的情况不同,操作之间似乎存在基本的依赖关系以防止重新排序。好吧,事实证明,硬件行为和软件转换仍然会让你在这里绊倒,细节甚至比商店案例还要复杂。但是,如果您使用 memory_order_acquire
,您会没事的。如果你想要最后一次性能,尤其是在 PowerPC 上,你需要深入研究 memory_order_consume
的细节。另一天的故事。
1 特别是,这意味着编译器必须能够看到构造函数 Singleton()
的代码,以便它可以确定它没有阅读 pInstance_
.
2 当然,依赖这个是非常危险的,因为如果有任何变化,你必须在每次编译后检查程序集!