38

有没有办法在 C++ 中实现单例对象:

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

(我不太了解我的 C++,但是在执行任何代码之前是否初始化了整数和常量静态变量(即,甚至在执行静态构造函数之前 - 它们的值可能已经在程序中“初始化”了)图像)?如果是这样 - 也许可以利用它来实现单例互斥锁 - 这又可以用来保护真正单例的创建..)


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

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

9 回答 9

14

不幸的是,Matt 的答案具有C/C++ 内存模型不支持的所谓的双重检查锁定。(Java 1.5 及更高版本(我认为是 .NET)内存模型支持它。)这意味着在进行pObj == NULL检查和获取锁(互斥锁)之间,pObj可能已经在另一个线程上分配了. 线程切换发生在操作系统想要的任何时候,而不是在程序的“行”之间(在大多数语言中,编译后没有意义)。

此外,正如 Matt 承认的那样,他使用 aint作为锁而不是 OS 原语。不要那样做。正确的锁需要使用内存屏障指令、可能的缓存行刷新等;使用操作系统的原语进行锁定。这一点尤其重要,因为使用的原语可以在操作系统运行的各个 CPU 行之间发生变化;在 CPU Foo 上有效的东西可能在 CPU Foo2 上无效。大多数操作系统要么本机支持 POSIX 线程 (pthread),要么将它们作为 OS 线程包的包装器提供,因此通常最好使用它们来说明示例。

如果您的操作系统提供了适当的原语,并且您绝对需要它来提高性能,那么您可以使用原子比较和交换操作来初始化共享全局变量,而不是执行这种类型的锁定/初始化。本质上,您编写的内容将如下所示:

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,并且只有在存在时才实际将其设置temp为它。这使用硬件支持实际上只执行一次交换并判断它是否发生。

如果您的操作系统提供介于这两个极端之间的另一个工具,那么可以利用的另一个工具是pthread_once. 这使您可以设置仅运行一次的功能-基本上是通过执行所有锁定/障碍/等。你的诡计 - 无论它被调用了多少次或它被调用了多少个线程。

于 2008-08-09T23:09:01.400 回答
13

基本上,您要求同步创建单例,而不使用任何同步(以前构造的变量)。一般来说,不,这是不可能的。您需要一些可用于同步的东西。

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

从 C++ 标准的 2003 年修订版开始:

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

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

但是,这假设您知道您将在静态初始化期间使用此单例。标准也不能保证这一点,因此如果您想完全安全,请使用静态初始化的互斥锁。

编辑:克里斯建议使用原子比较和交换肯定会奏效。如果可移植性不是问题(并且创建额外的临时单例也不是问题),那么它是一个开销稍低的解决方案。

于 2008-08-09T23:52:40.253 回答
12

这是一个非常简单的懒惰构造的单例吸气剂:

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 。

于 2010-05-19T16:20:12.490 回答
8

没有任何静态变量你不能做到这一点,但是如果你愿意容忍一个,你可以使用Boost.Thread来达到这个目的。阅读“一次性初始化”部分了解更多信息。

然后在您的单例访问器函数中,用于boost::call_once构造对象并返回它。

于 2008-08-10T04:13:08.543 回答
6

对于 gcc,这相当简单:

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

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

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

另外,可能还有一点:最好始终避免使用静态非 POD 类型。(指向 POD 的指针是可以的。)这样做的原因有很多:正如您所提到的,初始化顺序没有定义——尽管调用析构函数的顺序也没有定义。因此,程序在尝试退出时最终会崩溃;通常没什么大不了的,但有时当您尝试使用的分析器需要一个干净的退出时,这会是一个阻碍。

于 2008-09-16T10:21:51.773 回答
1

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

  • 如果您想在使用指向动态分配实例的指针时对单例进行延迟实例化,则必须确保在正确的位置清理它。
  • 您可以使用马特的解决方案,但您需要使用适当的互斥锁/关键部分进行锁定,并在锁定前后检查“pObj == NULL”。当然,pObj也必须是静态的;)。在这种情况下,互斥锁会不必要地繁重,最好使用关键部分。

但如前所述,如果不使用至少一个同步原语,就无法保证线程安全的延迟初始化。

编辑:是的,德里克,你是对的。我的错。:)

于 2008-08-10T04:34:59.793 回答
1

您可以使用马特的解决方案,但您需要使用适当的互斥锁/关键部分进行锁定,并在锁定前后检查“pObj == NULL”。当然, pObj 也必须是静态的;)。在这种情况下,互斥锁会不必要地繁重,最好使用关键部分。

OJ,这行不通。正如克里斯指出的那样,这是双重检查锁定,不能保证在当前的 C++ 标准中工作。请参阅:C++ 和双重检查锁定的风险

编辑:没问题,OJ。它在它确实有效的语言中非常好。我希望它可以在 C++0x 中工作(尽管我不确定),因为它是一个非常方便的习语。

于 2008-08-10T05:11:03.247 回答
1
  1. 阅读弱记忆模型。它可以打破双重检查锁和自旋锁。英特尔是强大的内存模型(但),所以在英特尔上更容易

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

  3. 静态变量初始化与共享代码加载的顺序有时并不重要。我见过破坏对象的代码已经被卸载的情况,所以程序在退出时崩溃了

  4. 这样的物体很难正确销毁

一般来说,单例很难做对,也很难调试。最好完全避免它们。

于 2009-11-09T17:32:29.957 回答
0

我想说不要这样做,因为它不安全,并且可能会比仅仅初始化这些东西更频繁地中断main()不会那么受欢迎。

(是的,我知道这意味着你不应该尝试在全局对象的构造函数中做有趣的事情。这就是重点。)

于 2008-08-20T16:13:40.890 回答