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

3

我之后的实现都是为了简单起见,因为这里不相关。考虑经典的双重检查锁定Double-check loking 的描述,见于现代 C++ 设计

Singleton& Singleton::Instance()
{
    if(!pInstance_) 
    { 
         Guard myGuard(lock_); 
         if (!pInstance_) 
         {
            pInstance_ = new Singleton; 
         }
     }
     return *pInstance_;
}

在这里,作者坚持我们要避免竞态条件。但我读过一篇文章,不幸的是我记得不太清楚,在那篇文章中描述了以下流程:
  1. 线程1进入第一个if语句
  2. 线程1进入互斥锁并进入第二个if块。
  3. 线程1调用operator new分配内存给pInstance然后在该内存上调用构造函数;
  4. 假设线程1将内存分配给pInstance但尚未创建对象,而线程2进入该函数。
  5. 线程2看到pInstance不为空(但尚未用构造函数初始化)并返回pInstance。
在那篇文章中,作者指出,技巧在于在pInstance_ = new Singleton;这一行,可以分配内存,将其分配给pInstance,然后在该内存上调用构造函数。
依据标准或其他可靠来源,有人能否确认或否认这种流程的可能性或正确性?谢谢!

这就是我的观点,operator new的工作方式如下:分配内存,然后在其上调用构造函数。因此,文章指出,内存可以被分配,分配给pInstance,但构造函数没有被调用,并且在第二个线程中发生了上下文切换。 - Eduard Rostomyan
1
你把顺序搞错了。内存已被分配,对象已被构造,这是 new 所做的。然后赋值操作会在 new 之后独立进行。new 和赋值是两个不同的操作,按正确顺序依次执行。 - Some programmer dude
1
在C++中,你不应该担心构造顺序。赋值将在构造函数调用后进行排序。你发布的代码示例的真正问题是我们无法看到pInstance_是如何声明的。它需要被原子地loadstore以在具有更松散内存模型的CPU上正常工作。否则,即使线程#1释放了互斥锁,线程#2可能仍然会看到pInstance_nullptr。Alexandrescu在双重检查锁定模式的一节末尾提到了这一点。 - Sean Cline
1
@EdChum 上述代码的行为在线程A首先调用Singleton::Instance(),然后线程B随后调用Singleton::Instance()并尝试使用返回的指针时,形式上是未定义的。特别地,在某些体系结构上,线程B可能会看到未初始化或部分初始化状态的Singleton对象。请参见:https://en.cppreference.com/w/cpp/language/memory_model - Solomon Slow
@Someprogrammerdude - 这并不是new通常的工作方式。通常您会获得未初始化的存储(例如来自operator new (size_t)),然后进行构造并分配指针,但编译器可以重新排序所有这些操作,gcc实际上如下面的答案所示。此类重排序是典型和常见的:编译器不需要发出按源代码顺序执行的汇编代码,它只需要发出使当前线程“好像”源代码顺序被遵循的东西。其他线程可能会看到不同的结果。因此,几乎这里的每个评论都是错误的。 - BeeOnRope
显示剩余8条评论
3个回答

5
问题在于,在没有其他保证的情况下,指针存储到中的内容可能会被其他线程看到,而这时对象的构造尚未完成。在这种情况下,其他线程不会进入互斥锁,只会立即返回,当调用者使用它时,可能会看到未初始化的值。
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       ; 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 未初始化(垃圾)的值。

所以即使没有引发任何奇怪的硬件重新排序,这种模式也需要做更多的工作才能安全。

硬件重新排序

假设你通过放置像 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                         ; 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内存模型不保证在另一个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]

主要区别在于最终存储现在是stlr - 一个发布存储。您也可以查看PowerPC方面,两个存储之间出现了lwsync屏障。
所以,底线是:
- 在一个顺序一致的系统中,双重检查锁定是安全的。 - 现实世界中的系统几乎总是偏离顺序一致性,这要么是由于硬件,要么是由于编译器或两者都有。 - 要解决这个问题,您需要告诉编译器您想要什么,它将避免重新排序并发出必要的屏障指令(如果有),以防止硬件引起问题。 - 在C++11之前,“告诉编译器”的方式是特定于平台/编译器/操作系统的,但在C++中,您可以简单地使用std::atomicmemory_order_acquire加载以及memory_order_release存储。

负载

上面只涵盖了问题的一半:即 pInstance_ 的存储。另外一半可能出现问题的是加载,而且从性能角度来看,加载实际上是最重要的,因为它代表了单例初始化后通常采用的快速路径。如果在检查 pInstance 是否为空之前,先加载了 pInstance_->x,那么你仍然可以读取未初始化的值!
这似乎不太可能发生,因为需要在引用之前加载 pInstance_,对吧?也就是说,这两个操作之间存在基本依赖关系,防止重新排序,不像存储情况。但事实证明,硬件行为和软件转换都可能在这里使你失误,而且细节比存储情况更加复杂。但如果使用 memory_order_acquire,就不会有问题。如果你想要最后一点性能,特别是在 PowerPC 上,你需要深入了解 memory_order_consume 的细节。这是另一天的故事。

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

2 当然,依赖于此非常危险,因为您必须在每次编译后检查汇编代码是否有任何更改!


4
你所描述的问题只会出现在单例模式的设计者使用了明确但错误的两步构造方式时。
     ...
     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原子语义的多核系统,情况可能会更糟。


1
由于外部的 if(!_pInstance) 会在 _pInstance 不为空时绕过锁定。 - Serge Ballesta
1
DCL模式的目的是处理其他线程观察对象创建和指针分配顺序不一致的情况。你的第一个代码片段就是其他线程可能看到的,这就是为什么需要DCL的原因。 - LWimsey
1
答案仅描述了程序顺序(生成的汇编指令顺序),但忽略了CPU顺序。但恰恰后者使得DCL 不安全,除非pInstance_具有适当的类型。问题在于,当一个CPU在分配变量本身之前分配pInstance_字段时,另一个CPU可能会看到(因为缓存)未初始化的字段和非NULL pInstance_。如维基所述,在C++11中,pInstance_应为原子类型,并使用“获取”加载和“释放”存储内存顺序。 - Tsyvarev
1
@Tsyvarev:你的评论点亮了一个有趣的观点。我试图在我的编辑中总结它。如果你觉得有用,请随意进一步编辑。 - Serge Ballesta
在内部,编译器可以(并且确实!)将 new 编译成几乎完全与您上面所示的“错误的概念”相同。当前线程无法区分,因此允许这样做,但另一个线程可能会看到它并爆炸,因此“DCL 不安全”的公认智慧(但它可以变得安全)。 - BeeOnRope
显示剩余7条评论

1
在C++11之前,由于没有标准的内存模型讨论多线程,因此它以前是未指定的。我记得,在构造函数完成之前,指针可能已经设置为分配的地址,只要该线程永远无法区分(这可能仅适用于简单/非抛出构造函数)。
自C++11以来,sequenced-before规则禁止重新排序,具体如下:
8) 内置赋值运算符的副作用(修改左参数)...序列化自左右参数的值计算之后...
由于右参数是new-expression,因此必须在修改左侧之前完成分配和构造。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接