仔细检查锁定问题,c ++

时间:2018-06-01 10:47:10

标签: c++ multithreading singleton double-checked-locking

为了简单起见,我离开了其余的实现,因为它与此无关。 考虑Double-check loking 中描述的Modern C++ Design的经典实现。

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不为null(但尚未使用构造函数初始化)并返回pInstance。
  6. 在那篇文章中,作者说明了诀窍是在行pInstance_ = new Singleton;上可以分配内存,分配给pInstance,在该内存上调用构造函数。

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

3 个答案:

答案 0 :(得分:4)

您描述的问题只有在我无法想象单身人士的概念使用显式(和破坏)2步构造的原因时才会出现:

     ...
     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 :(得分:3)

问题在于,在没有保证的情况下,在对象构造完成之前,某些其他线程可能会看到指向pInstance_的指针。在这种情况下,另一个线程不会进入互斥锁,只会立即返回pInstance_,当调用者使用它时,它可以看到未初始化的值。

Singleton上的构造相关联的商店与pInstance_的商店之间的明显重新排序可能是由编译器或硬件引起的。我将快速浏览下面的两个案例。

编译器重新排序

如果没有与并发读取相关的任何特定保证保证(例如C ++ 11和std::atomic对象提供的保证),编译器只需要保留代码的语义,如当前线程。这意味着,例如,它可能编译代码&#34;无序&#34;它是如何出现在源中的,只要它在当前线程上没有可见的副作用(由标准定义)。

特别是,编译器重新排序在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的唯一成员。 / p>

使用godbolt,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的未初始化(垃圾)值。 / p>

因此,即使没有调用任何奇怪的硬件重新排序,这种模式也不安全而不需要做更多的工作。

硬件重新排序

让我们说你组织起来,以便上面的商店重新排序不会发生在您的编译器 2 上,可能是通过放置编译器障碍例如asm volatile ("" ::: "memory")。使用that small change,gcc现在将其编译为在&#34;期望&#34;中设置两个关键商店。顺序:

        mov     DWORD PTR [rax], edx
        mov     QWORD PTR Singleton::pInstance_[rip], rax

所以我们很好,对吧?

在x86上,我们是。碰巧x86具有相对强大的内存模型,并且所有商店都已经拥有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,您可以使用内联汇编来发出正确的障碍。您的编译器可能具有__sync_synchronizeOS might even have something提供的内置gcc

然而,在C ++ 11及更高版本中,我们最终有一个内置于该语言的正式内存模型,而我们需要的是,对于双重检查锁定,是一个发布存储,如到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之前,以#34;方式告诉编译器&#34;这样做是针对平台/编译器/操作系统特定的,但在C ++中,您只需将std::atomicmemory_order_acquire加载和memory_order_release存储一起使用。

负载

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

这似乎不太可能,因为{<1}}需要在之前加载,对吗?也就是说,与商店案例不同,似乎存在阻止重新排序的操作之间的基本依赖关系。好吧,事实证明,硬件行为和软件转换仍然可能会让你失望,而且细节甚至比商店案例更复杂。如果你使用pInstance_,你会没事的。如果您想要最后一次性能,特别是在PowerPC上,您需要深入了解memory_order_acquire的详细信息。另一天的故事。

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

2 当然,依赖于此非常危险,因为如果发生任何变化,您必须在每次编译后检查程序集!

答案 2 :(得分:1)

在C ++ 11之前,它以前是未指定的,因为没有标准内存模型讨论多个线程。

IIRC指针可以在构造函数完成之前设置为已分配的地址,只要该线程永远无法区分(这可能只发生在一个简单/非平凡的情况下)投掷构造函数)。

从C ++ 11开始,sequenced-before规则不允许重新排序,特别是

  

8)内置赋值运算符的副作用(左参数的修改)在左右参数的值计算...之后排序,...

由于右参数是一个新表达式,因此必须完成分配&amp;左手边的施工可以修改。