C++中单例模式的线程安全懒加载构造

38

有没有一种方法可以在C++中实现一个单例对象,它必须:

  1. 以线程安全的方式进行延迟构造(两个线程可能同时成为单例的第一个用户 - 它仍然只能构造一次)。
  2. 不依赖于静态变量先于此被构造(因此单例对象本身在静态变量构造期间使用是安全的)。

(我对C++还不够了解,但是整数和常量静态变量是否在执行任何代码之前初始化(即使在静态构造函数执行之前)? 如果是这样-也许可以利用这一点来实现单例互斥锁-可以用它来保护真正单例的创建..)


很好,现在我有了几个好答案(可惜我不能将2或3标记为答案)。 似乎有两种广泛的解决方案:

  1. 使用POD静态变量的静态初始化(而不是动态初始化),并使用内置原子指令实现自己的互斥锁。 这是我在问题中暗示的类型的解决方案,我相信我已经知道了。
  2. 使用其他库函数,如pthread_onceboost::call_once。 对于这些,我确实不知道 - 并且非常感谢发布的答案。
9个回答

14
很不幸,Matt的答案采用了所谓的“双重检查锁定”,而这种方法并不受C/C++内存模型的支持。(Java 1.5及更高版本和.NET内存模型支持该方法)。这意味着在执行

pObj == NULL 检查和获取锁(互斥体)之间,pObj可能已经在另一个线程上被分配。线程切换发生在操作系统想要切换时,而不是程序的“行”(在大多数语言中编译后没有意义)之间。

此外,正如Matt所承认的那样,他使用一个int作为锁,而不是操作系统原语。不要这样做。适当的锁需要使用内存屏障指令、潜在的缓存行刷新等;使用操作系统的原语进行锁定。这尤其重要,因为所使用的原语可能会在操作系统运行的各个CPU线之间发生变化;在CPU Foo上有效的方法可能在CPU Foo2上无效。大多数操作系统都本地支持POSIX线程(pthreads),或者将它们作为操作系统线程包装器提供,所以最好使用它们来说明例子。
如果您的操作系统提供了适当的原语,并且如果您绝对需要它来提高性能,那么您可以使用原子比较和交换操作来初始化共享全局变量。基本上,您编写的内容将如下所示:
MySingleton *MySingleton::GetSingleton() {
    if (pObj == NULL) {
        // create a temporary instance of the singleton
        MySingleton *temp = new MySingleton();
        if (OSAtomicCompareAndSwapPtrBarrier(NULL, temp, &pObj) == false) {
            // if the swap didn't take place, delete the temporary instance
            delete temp;
        }
    }

    return pObj;
}

只有在安全创建多个单例实例(每个线程同时调用GetSingleton()时创建一个)然后扔掉多余的实例时,此方法才有效。Mac OS X提供的OSAtomicCompareAndSwapPtrBarrier函数(大多数操作系统都提供类似的原语)检查pObj是否为NULL,只有在它是NULL时才将其设置为temp。这使用硬件支持来确保只执行一次交换并告诉是否发生了交换。

如果您的操作系统提供了介于这两个极端之间的另一种方法,则可以利用pthread_once。这使您能够设置仅运行一次的函数 - 基本上是通过为您进行所有锁定/障碍等技巧 - 无论它被调用多少次或在多少个线程上调用。


14

基本上,您要求同步创建一个单例,而不使用任何同步(先前构建的变量)。总的来说,不,这是不可能的。您需要可用于同步的某些内容。

至于您的另一个问题,可以静态初始化的静态变量(即不需要运行时代码)保证在执行其他代码之前进行初始化。这使得可以使用静态初始化的互斥锁来同步创建单例。

根据C++标准的2003年修订版:

具有静态存储期限(3.7.1)的对象在进行任何其他初始化之前必须进行零初始化(8.5)。零初始化和使用常量表达式初始化称为静态初始化;所有其他初始化都是动态初始化。使用常量表达式(5.19)初始化的POD类型的具有静态存储期的对象必须在进行任何动态初始化之前进行初始化。在同一翻译单元中以名称空间范围定义并动态初始化的具有静态存储期的对象应按其出现在翻译单元中的顺序进行初始化。

如果您“知道”您将在其他静态对象的初始化期间使用此单例,则我认为您会发现同步不是问题。据我所知,所有主要编译器都在单个线程中初始化静态对象,因此在静态初始化期间进行线程安全。您可以将单例指针声明为NULL,然后在使用它之前检查是否已经初始化。

但是,这假设您“知道”您将在静态初始化期间使用此单例。这也不能保证标准,因此如果要完全安全,请使用静态初始化的mutex。

编辑:Chris建议使用原子比较和交换肯定有效。如果可移植性不是问题(并且创建其他临时单例不是问题),则是一个稍微低开销的解决方案。


13

这是一个非常简单的延迟构建的单例获取器:

Singleton *Singleton::self() {
    static Singleton instance;
    return &instance;
}

这种方法是惰性的,并且下一个C++标准(C++0x)要求它是线程安全的。实际上,我相信至少g++以线程安全的方式实现了它。所以,如果你的目标编译器是这样的或者如果你使用的编译器也以线程安全的方式实现了它(也许新的Visual Studio编译器?我不知道),那么这可能就是你需要的全部内容。

另请参见http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2008/n2513.html有关此主题的内容。


不错!这将比我们目前的解决方案整洁得多。C++0x(或者应该是C++1x)什么时候才能最终完成呢? - pauldoo
VS2015引入了线程安全支持,用于此初始化模式。 - Chris Betti

8
你不能不使用任何静态变量来实现,但如果你能容忍一个静态变量,你可以使用Boost.Thread来实现。阅读“一次性初始化”部分以获取更多信息。
然后,在你的单例访问函数中,使用boost::call_once构造对象并返回它。

这只是我的个人意见,但我认为你必须小心使用Boost。尽管它有许多与线程相关的子项目,但我并不确信它是线程安全的。(这是在进行了两次审计并观察到被标记为“不予修复”的错误报告后得出的结论)。 - jww

6

对于gcc而言,这相当容易:

LazyType* GetMyLazyGlobal() {
    static const LazyType* instance = new LazyType();
    return instance;
}

GCC会确保初始化是原子的。但对于VC++,情况并非如此。 :-(

这种机制的一个主要问题是缺乏可测试性:如果您需要在测试之间将LazyType重置为新的LazyType,或者想要将LazyType*更改为MockLazyType*,则无法实现。鉴于此,最好使用静态互斥锁+静态指针。

另外,可能还有一点需要注意:最好始终避免使用静态非POD类型。(指向POD的指针可以)这样做的原因很多:就像您提到的那样,初始化顺序未定义--析构函数调用的顺序也是如此。由于这个原因,当程序尝试退出时,程序通常会崩溃;虽然这对大多数情况来说不是什么大问题,但有时候会成为一个难以解决的问题,特别是当您尝试使用需要干净退出的分析工具时。


你说得很对。但最好将“对于VC++而言,情况并非如此”这句话加粗。 - Varuna
退出时崩溃:是的,cxa_finalize 崩溃了... 在不同访问器中指定静态局部变量的构造/析构顺序? - jww

1
  1. 了解弱内存模型。它可以破坏双重检查锁和自旋锁。英特尔是强内存模型(但还不是最强的),所以在英特尔上更容易。

  2. 小心使用“volatile”来避免对象部分缓存在寄存器中,否则您将初始化对象指针,但不是对象本身,另一个线程将崩溃。

  3. 静态变量初始化顺序与共享代码加载的顺序有时并不是微不足道的。我见过一些情况,当销毁对象的代码已经卸载时,程序就会在退出时崩溃。

  4. 这样的对象很难正确销毁。

通常来说,单例模式很难做到正确且难以调试。最好完全避免使用。


1

虽然这个问题已经得到了回答,但我认为还有一些其他要点需要提及:

  • 如果您想在使用指向动态分配实例的指针时进行单例的懒惰实例化,那么您必须确保在正确的时间清理它。
  • 您可以使用Matt的解决方案,但您需要使用适当的互斥体/临界区进行锁定,并在锁定之前和之后检查“pObj == NULL”。当然,pObj也必须是静态的 ;) 。 在这种情况下,互斥量会过于沉重,您最好选择一个临界区。

但正如已经说明的那样,如果不使用至少一个同步原语,无法保证线程安全的懒惰初始化。

编辑:是的Derek,你是对的。我的错。:)


1
你可以使用Matt的解决方案,但需要使用适当的互斥锁/临界区进行锁定,并在锁定之前和之后检查“pObj == NULL”。当然,pObj也必须是静态的;在这种情况下,互斥锁会过于沉重,最好使用临界区。
OJ,那不起作用。正如Chris所指出的,那是双重检查锁定,在当前的C++标准中不能保证其可行性。参见:C++ and the Perils of Double-Checked Locking 编辑:没问题,OJ。在它能够工作的语言中,它确实非常好用。我预计它将在C++0x中工作(尽管我不确定),因为它是一个如此方便的习惯用法。

0

我想说,因为这种方式不安全,而且很可能比在 main() 中初始化这些东西更容易出现错误,所以不要这样做,可能并不会受欢迎。

(是的,我知道这意味着您不应该尝试在全局对象的构造函数中执行有趣的操作。那正是重点。)


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