线程安全的延迟初始化:静态变量 vs std::call_once vs 双重检查锁定

21

对于线程安全的延迟初始化,应该优先选择在函数内部使用静态变量、std::call_once,还是显式双重检查锁定?它们之间是否有任何实质性的区别?

这个问题中包含了这三种方法。

在C++11中的双重检查锁定单例模式

C++11中出现了两个版本的双重检查锁定,可以在Google上找到。

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

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

我觉得这两篇文章都是为了教育目的而写的,所以没有必要这样做。如果你使用静态变量或std::call_once,编译器会自动完成这个任务。


1
请注意,VC++在线程安全的函数局部静态变量方面存在滞后。它们在VS2013中并不存在。但据报道,它们将在VS2014中加入:http://blogs.msdn.com/b/vcblog/archive/2014/06/11/c-11-14-feature-tables-for-visual-studio-14-ctp1.aspx - Howard Hinnant
1
另一方面,GCC 可以比 call_once 或 double-checked 更快地生成本地静态变量,因为它可以使用特定于平台的技巧,避免任何原子操作。 - Cort Ammon
1
@CortAmmon 如果您能提供一些证据并将其作为答案发布,我会接受。 - Praxeolitic
1个回答

40

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

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

它必须支付这个代价,因为原子必须在所有情况下保持原子性,即使是像比较交换这样困难的操作。这使得优化变得非常困难。一般来说,编译器必须将其留在代码中,以防你使用该变量进行更多的双重锁定之外的操作。它没有简单的方法来证明您从未在原子上使用其中更复杂的操作之一。

另一方面,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保护规范分发时都共享一个common rule:__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)。

在这些高级平台上,静态和call_once之间的性能略有差异。虽然这些平台没有提供LL/SS,但至少会提供整数的非撕裂读取。这些平台可以利用这一点和线程特定指针来进行每个线程的时代计数以避免原子操作。这对于静态或call_once已经足够,但这取决于计数器不会翻转。如果您没有一个无撕裂的64位整数,call_once就必须担心翻转问题。实现可能会考虑这个问题,也可能不会。如果忽略此问题,它可以与静态变量一样快。如果注意到这个问题,它就必须像原子操作一样慢。静态知道在编译时有多少静态变量/块,因此它可以在编译时证明没有翻转(或至少非常自信!)

我意识到我晚了几年才看到这个优秀的答案。对于初学者来说,“LL/SS保护”是什么意思? - fbrereto
2
@fbrereto LL/SS是“Load Load/Store Store”的缩写。它表示内存地址的两个加载操作保证按顺序发生,而内存地址的两个存储操作也保证按顺序发生。然而,这种保证并不涉及加载和存储操作之间的顺序。 - Cort Ammon
1
@fbrereto 这与代码应该执行的内容相符。如果我和你看到的是一样的godbolt汇编输出,你提到的比较是在获取返回值时进行的。重新阅读我的措辞,我认为代码后面的段落与它应该相符的程度不够。我的本意是说,在调用acquire时,gcc可以在许多平台上进行本地读取(而不是原子读取)的值。在适当的C++原子内存模型中,这是不安全的,但gcc知道你在哪个平台上以及使用这种技巧是安全的平台。 - Cort Ammon
感谢您的澄清,我非常感激。 - fbrereto
我对这个答案有点困惑。在x86上,无论是原始加载还是获取加载,我们总是会得到一个简单的mov指令+分支,对吧?但你声称原始读取会更快?为什么?你这么说是因为编译器理论上可以将任何后初始化加载重新排序到初始化之前吗?如果它这样做了,那效果不是微不足道吗,特别是考虑到分支预测会让CPU继续执行检查吗?我觉得我必须看到这个的演示才能相信。 - user541686
显示剩余6条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接