线程安全惰性初始化:静态vs std::call_once vs双重检查锁定

Threadsafe lazy initialization: static vs std::call_once vs double checked locking

本文关键字:vs once 锁定 检查 call std 初始化 安全 静态 线程      更新时间:2023-10-16

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

这三者都可以从这个问题中看出。

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

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

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

Jeff Preshing在一篇详尽的文章中描述了双重检查锁定的几种变体。他确实提到使用静态变量作为选项,他甚至展示了编译器将生成用于双重检查锁定的代码来初始化静态变量。我不清楚他是否得出这样的结论:一种方法比另一种更好。

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

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

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

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

另一方面,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进行编写。它知道它只在编译器选择使用它的特殊情况下使用。因此,有了这些额外的信息,它可以节省一些时间。尽管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静态的效率,因为它类似地将可以对once_flag执行的操作数量限制为可以应用于原子的函数的一小部分。权衡的结果是,静态在适用时使用起来要方便得多,但call_once在静态不足的许多情况下都能工作(例如动态生成的对象拥有的once_flag)。

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