C ++中单线程的线程安全惰性构造

时间:2020-03-05 18:38:47  来源:igfitidea点击:

有没有一种方法可以在C ++中实现单例对象:

  • 以线程安全的方式懒散地构造(两个线程可能同时是单例的第一个用户-仍应只构造一次)。
  • 不依赖于事先构造的静态变量(因此,单例对象本身在构造静态变量时可以安全使用)。

(我不太了解我的C ++,但是是在执行任何代码之前初始化整数和常量静态变量的情况(即,即使在执行静态构造函数之前,其值也可能已在程序映像中"初始化"了) )?如果是这样,也许可以利用它来实现单例互斥体,而后者又可以用来保护实际单例的创建。

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

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

解决方案

回答

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

而且,正如Matt所承认的那样,他使用" int"作为锁而不是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。这样,我们就可以通过执行所有的lock / barrier / etc等操作来设置一个基本上只能运行一次的功能。无论我们调用多少次或者调用多少线程,都为我们带来麻烦。

回答

基本上,我们要同步创建一个单例,而不使用任何同步(先前构造的变量)。通常,不行,这是不可能的。我们需要一些可用于同步的东西。

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

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

Objects with static storage duration (3.7.1) shall be zero-initialized (8.5) before any other initialization takes place. Zero-initialization and initialization with a constant expression are collectively called static initialization; all other initialization is dynamic initialization. Objects of POD types (3.9) with static storage duration initialized with constant expressions (5.19) shall be initialized before any dynamic initialization takes place. Objects with static storage duration defined in namespace scope in the same translation unit and dynamically initialized shall be initialized in the order in which their definition appears in the translation unit.

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

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

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

回答

我们不能没有任何静态变量来执行此操作,但是,如果我们愿意容忍一个,则可以使用Boost.Thread来实现此目的。阅读"一次性初始化"部分以了解更多信息。

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

回答

尽管已经回答了这个问题,但我认为还有其他几点要提到:

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

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

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

回答

You could use Matt's solution, but you'd need to use a proper mutex/critical section for locking, and by checking "pObj == NULL" both before and after the lock. Of course, pObj would also have to be static ;) . A mutex would be unnecessarily heavy in this case, you'd be better going with a critical section.

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

编辑:没问题,OJ。在可以正常工作的语言中,它确实很棒。我希望它可以在C ++ 0x中运行(尽管我不确定),因为它是一个方便的习惯用法。

回答

我想说不要这样做,因为这样做不安全,而且可能会打破,而不仅仅是在main()中初​​始化这些东西不会那么流行。

(是的,我知道这暗示着我们不应尝试在全局对象的构造函数中做一些有趣的事情。这就是重点。)

回答

对于gcc,这相当简单:

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

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

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

另外,也许还有一点:最好始终避免使用静态非POD类型。 (指向POD的指针是可以的。)其原因有很多:正如我们提到的,初始化顺序未定义-析构函数的调用顺序也未定义。因此,程序在尝试退出时将最终崩溃。通常没什么大不了的,但是当我们尝试使用的事件探查器时,有时需要关闭窗口。

回答

  • 在弱内存模型上读取。它可能会破坏双重检查的锁和自旋锁。英特尔是强大的内存模型(目前),因此在英特尔上更容易
  • 谨慎使用" volatile",以避免将对象的部分缓存到寄存器中,否则,我们将初始化对象指针,而不是对象本身,并且其他线程将崩溃
  • 静态变量初始化与共享代码加载的顺序有时并不容易。我已经看到了用于销毁对象的代码已经卸载的情况,因此程序在退出时崩溃
  • 这样的东西很难被销毁

通常,单例很难正确地进行调试。最好完全避免使用它们。

回答

这是一个非常简单的延迟构造的单例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。