双重检查锁定问题,C++

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)

在这里,作者坚持我们要避免竞争条件。但我读过一篇文章,不幸的是我不太记得了,其中描述了以下流程。

  1. 线程1进入第一个if语句
  2. 线程 1 在第二个 if 主体中进入互斥端 get。
  3. 线程 1 调用运算符 new 并将内存分配给 pInstance,然后在该内存上调用构造函数;
  4. 假设线程1将内存分配给pInstance但没有创建对象,线程2进入该函数。
  5. 线程 2 发现 pInstance 不为 null(但尚未使用构造函数初始化)并返回 pInstance。

在那篇文章中,作者指出,技巧是可以在线 pInstance_ = new Singleton;分配内存,并将其分配给 pInstance,以便在该内存上调用构造函数。

依靠标准或其他可靠来源,任何人都可以确认或否认此流程的可能性或正确性吗?谢谢!

Bee*_*ope 5

问题是,在没有其他保证的情况下,在对象的构造完成之前,其他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::atomicmemory_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两个商店之间出现了障碍。

所以底线是:

  • 双重检查锁定在顺序一致的系统中是安全的。
  • 现实世界的系统几乎总是偏离顺序一致性,要么是由于硬件、编译器,要么是两者兼而有之。
  • 为了解决这个问题,您需要告诉编译器您想要什么,它既可以避免自身重新排序,又可以发出必要的屏障指令(如果有),以防止硬件引起问题。
  • 在 C++11 之前,“告诉编译器的方式”是特定于平台/编译器/操作系统的,但在 C++ 中,您可以简单地使用std::atomic加载memory_order_acquirememory_order_release存储。

负载

上面只解决了问题的一半pInstance_. 可能出错的另一半是负载,负载实际上对性能来说是最重要的,因为它代表了单例初始化后通常采用的快速路径。如果在加载自身并检查 nullpInstance_->x之前加载会怎样?pInstance在这种情况下,您仍然可以读取未初始化的值!

这似乎不太可能,因为需要在推迟之前pInstance_加载,对吗?也就是说,与存储情况不同,操作之间似乎存在阻止重新排序的基本依赖性。事实证明,硬件行为和软件转换仍然可能让您陷入困境,而且细节甚至比商店案例还要复杂。如果你使用的话,那就没问题了。如果您想要最后一次性能,尤其是在 PowerPC 上,您需要深入研究. 改天再讲故事。memory_order_acquirememory_order_consume


1特别是,这意味着编译器必须能够查看构造函数的代码Singleton(),以便确定它不从pInstance_.

2当然,依赖于此是非常危险的,因为如果有任何更改,您必须在每次编译后检查程序集!