C++ 中单例的线程安全惰性构造
-
08-06-2019 - |
题
有没有一种方法可以在 C++ 中实现单例对象:
- 以线程安全的方式延迟构造(两个线程可能同时是单例的第一个用户 - 它仍然应该只构造一次)。
- 不依赖于预先构造的静态变量(因此在构造静态变量期间单例对象本身可以安全使用)。
(我不太了解我的C++,但是在执行任何代码之前是否会初始化整型和常量静态变量(即,甚至在执行静态构造函数之前 - 它们的值可能已经在程序中“初始化”)图像)?如果是这样 - 也许可以利用它来实现单例互斥体 - 这反过来又可以用来保护真正单例的创建..)
太棒了,看来我现在有几个很好的答案了(可惜我不能将 2 或 3 标记为 答案)。似乎有两种广泛的解决方案:
- 使用 POD 静态变量的静态初始化(而不是动态初始化),并使用内置原子指令实现我自己的互斥体。这就是我在问题中暗示的解决方案类型,我相信我已经知道了。
- 使用其他一些库函数,例如 线程一次 或者 升压::call_once. 。这些我当然不知道 - 并且非常感谢发布的答案。
解决方案
基本上,您要求同步创建单例,而不使用任何同步(先前构造的变量)。一般来说,不,这是不可能的。您需要一些可用于同步的东西。
至于您的其他问题,是的,可以静态初始化的静态变量(即不需要运行时代码)保证在执行其他代码之前初始化。这使得可以使用静态初始化的互斥体来同步单例的创建。
从2003年修订的C++标准开始:
具有静态存储持续时间(3.7.1)的对象应在任何其他初始化发生之前进行零初始化(8.5)。零初始化和用常量表达式初始化统称为静态初始化;所有其他初始化都是动态初始化。具有用常量表达式(5.19)初始化的静态存储持续时间的 POD 类型(3.9)的对象应在任何动态初始化发生之前进行初始化。在同一翻译单元的命名空间范围内定义并动态初始化的具有静态存储持续时间的对象应按照其定义在翻译单元中出现的顺序进行初始化。
如果你 知道 如果您将在其他静态对象的初始化期间使用此单例,我想您会发现同步不是问题。据我所知,所有主要编译器都在单个线程中初始化静态对象,因此静态初始化期间的线程安全。您可以将单例指针声明为 NULL,然后在使用它之前检查它是否已初始化。
但是,这假设您 知道 您将在静态初始化期间使用此单例。标准也不能保证这一点,因此如果您想完全安全,请使用静态初始化的互斥体。
编辑:克里斯关于使用原子比较和交换的建议肯定会起作用。如果可移植性不是问题(并且创建额外的临时单例也不是问题),那么它是一个开销稍低的解决方案。
其他提示
不幸的是,马特的回答具有所谓的 双重检查锁定 C/C++ 内存模型不支持它。(Java 1.5 及更高版本(我认为是 .NET)内存模型支持它。)这意味着在 pObj == NULL
进行检查并获取锁(互斥体)时, pObj
可能已经被分配到另一个线程上。只要操作系统需要,线程切换就会发生,而不是在程序的“行”之间(在大多数语言中,编译后没有任何意义)。
此外,正如马特承认的那样,他使用 int
作为锁而不是操作系统原语。不要那样做。正确的锁需要使用内存屏障指令、潜在的缓存行刷新等;使用操作系统的原语进行锁定。这一点尤其重要,因为所使用的原语可能会在运行操作系统的各个 CPU 线之间发生变化;在 CPU Foo 上有效的方法可能不适用于 CPU Foo2。大多数操作系统要么本身支持 POSIX 线程(pthread),要么将它们作为操作系统线程包的包装器提供,因此通常最好使用它们来说明示例。
如果您的操作系统提供了适当的原语,并且如果您绝对需要它来提高性能,那么您可以使用 原子比较和交换 初始化共享全局变量的操作。本质上,你写的内容将如下所示:
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() 的实例),然后丢弃多余的实例时,这才有效。这 OSAtomicCompareAndSwapPtrBarrier
Mac OS X 上提供的函数——大多数操作系统都提供类似的原语——检查是否 pObj
是 NULL
并且实际上只将其设置为 temp
如果是的话。这使用硬件支持来真正执行交换 一次 并告诉它是否发生了。
如果您的操作系统提供介于这两个极端之间的另一个可以利用的功能是 pthread_once
. 。这使您可以设置一个仅运行一次的函数 - 基本上是通过执行所有锁定/屏障/等操作。对你来说这是个骗局——无论它被调用多少次或在多少个线程上被调用。
这是一个非常简单的惰性构造的单例 getter:
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 关于这个话题。
如果没有任何静态变量,你就无法做到这一点,但是如果你愿意容忍一个,你可以使用 增强线程 以此目的。阅读“一次性初始化”部分了解更多信息。
然后在您的单例访问器函数中,使用 boost::call_once
构造对象并返回它。
对于 gcc 来说,这相当简单:
LazyType* GetMyLazyGlobal() {
static const LazyType* instance = new LazyType();
return instance;
}
GCC 将确保初始化是原子的。 对于VC++来说,情况并非如此. :-(
这种机制的一个主要问题是缺乏可测试性:如果您需要在测试之间将 LazyType 重置为新的,或者想要将 LazyType* 更改为 MockLazyType*,您将无法做到这一点。鉴于此,通常最好使用静态互斥体+静态指针。
另外,可能还有一个旁白:最好始终避免静态非 POD 类型。(指向 POD 的指针是可以的。)造成这种情况的原因有很多:正如您所提到的,初始化顺序没有定义——但析构函数的调用顺序也没有定义。因此,程序在尝试退出时最终会崩溃;通常没什么大不了的,但有时当您尝试使用的探查器需要干净退出时,就会造成严重后果。
虽然这个问题已经有了答案,但我认为还有其他几点需要提及:
- 如果您希望在使用指向动态分配实例的指针时对单例进行延迟实例化,则必须确保在正确的位置清理它。
- 您可以使用马特的解决方案,但您需要使用适当的互斥体/关键部分进行锁定,并在锁定之前和之后检查“pObj == NULL”。当然, 对象 也必须是 静止的 ;)。在这种情况下,互斥锁会变得不必要的繁重,您最好使用关键部分。
但如前所述,如果不使用至少一个同步原语,就无法保证线程安全的延迟初始化。
编辑:是的,德里克,你是对的。我的错。:)
您可以使用 Matt 的解决方案,但您需要使用适当的互斥锁/关键部分进行锁定,并在锁定之前和之后检查“pObj == NULL”。当然,pObj 也必须是静态的;)。在这种情况下,互斥锁会变得不必要的繁重,您最好使用关键部分。
奥杰,那是行不通的。正如 Chris 指出的那样,这是双重检查锁定,不能保证在当前的 C++ 标准中有效。看: C++ 和双重检查锁定的危险
编辑:没问题,OJ。在它确实有效的语言中,它真的很棒。我希望它能在 C++0x 中工作(尽管我不确定),因为它是一个非常方便的习惯用法。
阅读弱内存模型。它可以破坏双重检查锁和自旋锁。英特尔是强大的内存模型(还),所以在英特尔上它更容易
小心使用“易失性”以避免在寄存器中缓存对象的部分,否则您将初始化对象指针,而不是对象本身,并且另一个线程将崩溃
静态变量初始化与共享代码加载的顺序有时并不重要。我见过这样的情况:销毁对象的代码已经被卸载,因此程序在退出时崩溃
这些物体很难被正确摧毁
一般来说,单例很难正确执行,也很难调试。最好完全避免它们。
我想说不要这样做,因为它不安全,并且可能会比仅仅初始化这些东西更频繁地破坏 main()
不会那么受欢迎。
(是的,我知道这意味着您不应该尝试在全局对象的构造函数中做有趣的事情。这才是重点。)