这个双重检查锁定修复方案有什么问题?

5

我看到了很多关于C++双重检查锁定的文章,常用于防止多个线程尝试初始化懒惰创建的单例对象。但现在有文章称其存在问题。正常的双重检查锁定代码如下:

class singleton {
private:
    singleton(); // private constructor so users must call instance()
    static boost::mutex _init_mutex;

public:
    static singleton & instance()
    {
        static singleton* instance;

        if(!instance)
        {
            boost::mutex::scoped_lock lock(_init_mutex);

            if(!instance)           
                instance = new singleton;
        }

        return *instance;
    }
};

问题似乎在于分配实例的那一行--编译器可以自由地分配对象,然后将指针分配给它,或者将指针设置为将要分配的位置,然后再分配它。后一种情况会破坏单例模式--一个线程可能会分配内存并分配指针,但在将其放入睡眠状态之前不运行单例构造函数--然后第二个线程将看到实例不为 null 并尝试返回它,即使它还没有被构造。

看到一个建议使用一个线程本地布尔值来检查而不是 instance。类似这样:

class singleton {
private:
    singleton(); // private constructor so users must call instance()
    static boost::mutex _init_mutex;
    static boost::thread_specific_ptr<int> _sync_check;

public:
    static singleton & instance()
    {
        static singleton* instance;

        if(!_sync_check.get())
        {
            boost::mutex::scoped_lock lock(_init_mutex);

            if(!instance)           
                instance = new singleton;

            // Any non-null value would work, we're really just using it as a
            // thread specific bool.
            _sync_check = reinterpret_cast<int*>(1);
        }

        return *instance;
    }
};

这样每个线程最终都会检查实例是否已经被创建了一次,但是在此之后就停止了,这会带来一些性能损失,但仍然远不及每次调用时都加锁的情况那么糟糕。但是如果我们只使用本地静态bool变量呢?
class singleton {
private:
    singleton(); // private constructor so users must call instance()
    static boost::mutex _init_mutex;

public:
    static singleton & instance()
    {
        static bool sync_check = false;
        static singleton* instance;

        if(!sync_check)
        {
            boost::mutex::scoped_lock lock(_init_mutex);

            if(!instance)           
                instance = new singleton;

            sync_check = true;
        }

        return *instance;
    }
};

为什么这样行不通?即使sync_check被一个线程读取时另一个线程正在对其进行赋值,垃圾值仍然不为零,因此为真。这篇Dr. Dobb's文章声称你必须锁定,因为你永远不会在重排序指令上与编译器争斗。这让我想到出于某种原因这个方法肯定不起作用,但我想不出为什么。如果序列点的要求像Dr. Dobb's文章让我相信的那样宽松,我就不明白为什么锁之后的任何代码都不能被重新排序到锁之前。这将使C++多线程期间失效。
我猜编译器允许特别地将sync_check重新排序到锁之前,因为它是一个本地变量(尽管它是静态的,我们没有返回引用或指针)——但这仍然可以通过将其变为静态成员(实际上是全局的)来解决。
所以这个方法能行得通吗?为什么?

2
问题在于变量可能在构造函数运行(或完成)之前被赋值,而不是在对象分配之前。 - kdgregory
谢谢,已经更正了。我完全记错了竞态条件。 - Joseph Garvin
1
是的,你是对的,当前的C++确实是“多线程破碎时期”,当考虑到标准时。然而,编译器供应商通常会提供解决这个问题的方法,因此实际结果并不那么糟糕。 - Suma
考虑使用单例 *tmp = new singleton; instance = tmp; 这段代码。在这里的第二行,你不是保证 tmp 现在指向一个正确初始化的对象吗?或者编译器现在可以优化掉本地的 'tmp' 变量吗? - nos
由于标准假定是单线程机器,并且由于tmp仅分配给new调用并且从未传递到函数外部,如果编译器没有将其优化掉,我会非常惊讶。它肯定没有义务保留它。 - Joseph Garvin
1
@ Joseph Gavin:如果在 sync_check = true; 语句之前添加特定于平台的内存屏障指令,例如 Windows 上的 _ReadWriteBarrier()(http://msdn.microsoft.com/en-us/library/f20w0x5e%28VS.80%29.aspx),则您最后的示例将可行。此外,从同一篇文章中可以看出,对于该编译器,自 VS2003 起,只需将 sync_check 声明为 volatile 即可解决问题。 - Praetorian
3个回答

5
你的修复程序并没有解决问题,因为在CPU上对sync_check和instance的写入可能会乱序执行。例如,假设两个不同的CPU在大约相同的时间内调用了instance的前两个调用。第一个线程将获取锁、初始化指针并按顺序将sync_check设置为true,但处理器可能会更改对内存的写入顺序。在另一个CPU上,第二个线程可以检查sync_check,看到它为true,但instance可能尚未被写入内存。有关详细信息,请参见Xbox 360和Microsoft Windows的无锁编程注意事项
你提到的线程特定的sync_check解决方案应该可行(假设你将指针初始化为0)。

关于你最后一句话:是的,但是我不确定,但我认为thread_specific_ptr在内部使用互斥锁。那么与始终锁定互斥锁(无双重锁定)相比,使用该解决方案的重点是什么? - n1ckp

1

关于这个问题有一些非常好的文章(尽管是以.net/c#为导向)在这里:http://msdn.microsoft.com/en-us/magazine/cc163715.aspx

归根结底,您需要能够告诉CPU它不能重新排序您对此变量的读/写(自原始奔腾以来,如果它认为逻辑不受影响,CPU可以重新排序某些指令),并且它需要确保缓存一致性(不要忘记这一点--我们开发人员可以假装所有内存只是一个平面资源,但实际上,每个CPU核心都有缓存,一些未共享的(L1),有些可能有时共享(L2))--您的初始化可能会写入主RAM,但另一个核心可能在缓存中具有未初始化的值。如果没有任何并发语义,CPU可能不知道它的缓存是脏的。

我不知道C++方面的情况,但在.net中,您将把变量指定为易失性,以保护对其的访问(或者您将使用System.Threading中的Memory read/write barrier方法)。

顺便提一下,我读过在 .net 2.0 中,双重检查锁定可以保证在没有 "volatile" 变量的情况下工作(对于任何 .net 读者来说)-- 这并不能帮助你解决 c++ 代码的问题。

如果你想要安全,你需要在 c# 中标记一个变量为 volatile 的 c++ 等效物。


1
C++变量可以声明为volatile,但我怀疑这与C#的语义完全相同。我还记得在某个地方读到过这是对volatile的滥用,但我不记得原因,所以无法判断文章的合理性。 - Joseph Garvin
在不同的编程语言中,这可能是一种滥用(甚至在C#中也可能是滥用)。编写低锁定或无锁代码的真正困难之处之一就在于指导的差异。我花了时间阅读有关此事的文章,似乎即使在微软内部,一些博客作者在何时需要内存屏障以及何时应该使用volatile方面存在矛盾。毫无疑问,这是一个棘手的问题。 - JMarsch
当前的C++标准中没有与.NET volatile相等的东西。这是即将到来的C++0x标准将带来的领域之一。与此同时,您需要使用编译器提供的内容(在Visual Studio中意味着使用volatile和内存栅栏)。 - Suma
volatile 在 C++1x 中不会改变:它仍将保持单线程感知,在线程内操作。在 C++1x 中请使用 atomic<T>。 - Johannes Schaub - litb

0

“后一种情况破坏了惯用法——两个线程可能会创建单例。”

但是如果我正确理解代码,第一个例子中,您检查实例是否已经存在(可能由多个线程同时执行),如果不存在,则一个线程将锁定它并创建实例——只有一个线程可以在那时执行创建。所有其他线程都被锁定并等待。

一旦实例被创建并且互斥锁被解锁,下一个等待的线程将锁定互斥锁,但它不会尝试创建新实例,因为检查将失败。

下次检查实例变量时,它将被设置,因此没有线程会尝试创建新实例。

我不确定当一个线程将新实例指针分配给实例而另一个线程检查相同变量时会发生什么,但我相信在这种情况下它将被正确处理。

我有遗漏吗?

好的,我不确定操作的重新排序,但在这种情况下,它将改变逻辑,因此我不希望它发生——但我对这个主题不是专家。


1
你是对的 - 我关于实际竞态条件的错误。问题在于第二个线程可能会看到实例为非空,并在第一个线程构造它之前尝试返回它。我已经编辑了我的帖子。 - Joseph Garvin

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