19

对于线程安全的延迟初始化,应该更喜欢函数内的静态变量、std::call_once,还是显式的双重检查锁定?有什么有意义的区别吗?

这三个都可以在这个问题中看到。

C++11 中的双重检查锁单例

Google 中出现了两个版本的 C++11 中的双重检查锁定。

Anthony Williams展示了带有显式内存排序的双重检查锁定和 std::call_once。他没有提到静态,但那篇文章可能是在 C++11 编译器可用之前编写的。

Jeff Preshing在一篇广泛的文章中描述了双重检查锁定的几种变体。他确实提到了使用静态变量作为选项,他甚至表明编译器将生成用于双重检查锁定的代码以初始化静态变量。我不清楚他是否认为一种方法比另一种更好。

我觉得这两篇文章都是为了教学,没有理由这样做。如果您使用静态变量或 std::call_once,编译器将为您执行此操作。

4

1 回答 1

31

GCC 使用特定于平台的技巧来完全避免快速路径上的原子操作,利用它可以static比 call_once 或双重检查更好的分析这一事实。

因为双重检查使用原子作为避免竞争情况的方法,所以每次都必须付出获取的代价。这不是一个高价,但它是一个价格。

它必须为此付出代价,因为原子必须在所有情况下都保持原子性,即使是比较交换之类的困难操作也是如此。这使得优化变得非常困难。一般来说,编译器必须保留它,以防万一您使用该变量不仅仅是双锁。没有简单的方法可以证明您从未在 atomic 上使用过更复杂的操作之一。

另一方面,static它是高度专业化的,并且是语言的一部分。从一开始,它就被设计为非常容易进行可证明的初始化。因此,编译器可以采用更通用版本不可用的快捷方式。 编译器实际上为静态发出以下代码:

一个简单的功能:

void foo() {
    static X x;
}

在 GCC 内部被重写为:

void foo() {
    static X x;
    static guard x_is_initialized;
    if ( __cxa_guard_acquire(x_is_initialized) ) {
        X::X();
        x_is_initialized = true;
        __cxa_guard_release(x_is_initialized);
    }
}

这看起来很像双重检查锁。但是,编译器会在这里作弊。它知道用户永远不能cxa_guard直接编写 use a 。它知道它只在编译器选择使用它的特殊情况下使用。因此,有了这些额外的信息,它可以节省一些时间。CXA 保护规范,尽管它们是分布式的,都有一个共同的规则__cxa_guard_acquire永远不会修改保护的第一个字节,__cxa_guard__release并将其设置为非零。

这意味着每个守卫都必须是单调的,并且它准确地指定了哪些操作将这样做。因此,它可以利用主机平台内现有的竞态保护。例如,在 x86 上,由强同步 CPU 保证的 LL/SS 保护足以执行这种获取/释放模式,因此它可以在执行双重锁定时对第一个字节进行原始读取,而不是获取-阅读。这只是可能的,因为 GCC 没有使用 C++ 原子 API 来执行它的双重锁定 - 它使用的是特定于平台的方法

在一般情况下,GCC 无法优化原子。在设计为较少同步的架构上(例如为 1024+ 内核设计的架构),GCC 不会依赖架构来为其执行 LL/SS。因此GCC被迫实际发射原子。但是,在 x86 和 x64 等常见平台上,它可以更快。

call_once可以具有 GCC 的静态函数的效率,因为它类似地将可以对 a 执行的操作的数量限制为once_flag可以应用于原子的函数的一小部分。权衡是静态变量在适用时使用起来要方便得多,但call_once在许多静态变量不足的情况下(例如once_flag由动态生成的对象拥有)。

call_once静态和这些更高平台上的性能略有不同。许多这些平台虽然不提供 LL/SS,但至少会提供整数的非撕裂读取。这些平台可以使用它和线程特定的指针来进行每个线程的 epoch 计数以避免 atomics。这对于 static 或 是足够的call_once,但取决于计数器不翻转。如果您没有无撕裂的 64 位整数,call_once不得不担心翻车。实现可能会或可能不会担心这一点。如果它忽略这个问题,它可以和静态一样快。如果它关注这个问题,它必须像原子一样慢。Static 在编译时知道有多少静态变量/块,因此它可以证明在编译时没有翻转(或者至少有信心!)

于 2014-11-29T20:16:06.000 回答