仔细检查锁定问题,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. 线程1先进入if语句
  2. 线程1在第二个if body中进入mutex end get。
  3. 线程 1 调用 operator new 并将内存分配给 pInstance,然后在该内存上调用构造函数;
  4. 假设线程1分配了内存给pInstance但没有创建对象,线程2进入函数
  5. 线程 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 中读取并将其分配给 xSingleton 的唯一成员.

使用神栓,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

所以我们将 strpInstance_ 作为最后一个商店,在 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::atomicmemory_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::atomicmemory_order_acquire 加载和 memory_order_release 商店。

负载

以上仅涵盖了问题的一半:pInstance_store。另一半可能出错的是负载,负载实际上对性能最重要,因为它代表了单例初始化后通常采用的快速路径。如果 pInstance_->xpInstance 本身被加载并检查是否为 null 之前被加载怎么办?在那种情况下,您仍然可以读取未初始化的值!

这似乎不太可能,因为 pInstance_ 需要在 加载之前 它被推迟,对吧?也就是说,与商店的情况不同,操作之间似乎存在基本的依赖关系以防止重新排序。好吧,事实证明,硬件行为和软件转换仍然会让你在这里绊倒,细节甚至比商店案例还要复杂。但是,如果您使用 memory_order_acquire ,您会没事的。如果你想要最后一次性能,尤其是在 PowerPC 上,你需要深入研究 memory_order_consume 的细节。另一天的故事。


1 特别是,这意味着编译器必须能够看到构造函数 Singleton() 的代码,以便它可以确定它没有阅读 pInstance_.

2 当然,依赖这个是非常危险的,因为如果有任何变化,你必须在每次编译后检查程序集!