正确使用弱序 C++ 原子操作¶
标题:Using weakly ordered C++ atomics correctly
日期:2016/10/07
作者:Hans Boehm
链接:https://www.youtube.com/watch?v=M15UKpNlpeM
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
备注一:这篇演讲的亮点在于给出详实的用例。比如我一直不清楚 store-load 乱序有啥好例子(要有具体场景,而不是硬凑的代码),作者直接用 Dekker 算法秒了。但是理论知识还是那样,不想看就跳过吧。
备注二:作者的自我介绍实在是太谦虚了,他领导了 C++ 内存模型的形式化工作,却一点也不提。
备注三:代码请先看幻灯片,后续我可能会手动修正。
大家好,我叫 Hans Boehm。我在 Google 的 Android 部门工作。
我将要讨论如何正确使用 C++ 中的原子操作(atomics),特别是弱顺序原子操作(weakly ordered atomics),这是一个充满问题的话题。本次演讲旨在稍微减少一些对它们的滥用。
我们为什么关心原子操作?¶
我们讨论的大背景是 C++ 中的多线程编程。通常,在 C++ 中编写多线程程序时,规则是必须避免数据竞争(data races)。我们应该确保一个变量或对象(严格来说是一个内存位置)在被一个线程修改的同时,绝不会被另一个线程访问。这是默认规则。我们通常通过使用某种同步机制(如互斥锁,mutexes)来强制执行这一规则。
一个典型的例子就在这张幻灯片上。例如,如果我们想为一个共享变量增量,我们可能会用一个互斥锁来保护它,然后执行增量操作,确保在同一时间没有其他线程能访问变量 x
,并确保所有其他对 x
的访问都由同一个互斥锁保护。这样一切都能正常工作,这是常规的做法。
这种方法有很多优点,也有很多缺点。
最大的优点是它可以在不同的原子性粒度上操作。我可以使用相同的方案来保护更新复杂数据结构的复杂操作。只要我通过使用互斥锁来保护数据结构,确保一次只有一个“人”在更新它,一切就都能正常工作。这是它的一大优势。
而人们通常联想到的缺点——有些是真实的,有些是想象出来的,就像在之前的一些演讲中看到的那样——是它引入了关于锁顺序和死锁的一些担忧。这些问题在基于锁的方案中是真实存在的,需要被解决。有时它们是可以解决的。也有其他与锁非常相似的方法来处理这些问题。你已经听说有一个事务性内存(transactional memory)技术规范,它在很大程度上避免了这个问题。所以有其他方法可以避免死锁,但这无疑是基于锁的方法的一个问题。
如果你试图用信号(signal)或中断处理器(interrupt handler)来实现互斥,基于锁的方法也基本行不通,因为你极有可能发生死锁。如果你在已经持有锁的时候收到了一个信号或中断,你将无法在信号或中断处理器中再次获取那个锁。
另一个缺点是,如果你特别关心可预测的执行时间,操作系统的调度器可能会不合作,在你持有互斥锁的时候抢占你的线程。在这种情况下,其他线程可能也无法继续前进,这偶尔会引发问题。
这里最主要的问题,至少在观念上,是人们认为互斥锁很慢,因此倾向于避免使用它。
C++11 之前的状况与原子操作的引入¶
在 C++11 之前,事实上的规则是,所有访问都需要由锁来保护。互斥锁,以某种形式存在,是你用来防止线程间竞争的唯一工具。然而,程序员们普遍对此不满意。我们发现很多程序员最终会以各种方式绕开这些规则,而这些绕过规则的做法往往是非常有问题的。
其中比较好的做法是滥用 C 和 C++ 中已经存在的 volatile
关键字,有时还会结合使用汇编语言的栅栏(fences)等。在其他情况下,人们只是辩称,“我只是在这里无锁地访问这一个变量,没问题的。我知道一切都会正常工作。”问题在于,你这样做是在对编译器撒谎,编译器会因此得出一些错误的结论。当你升级到一个对这类事情进行更激进优化的新编译器时,我们完全不知道会发生什么。
人们期望通过绕开这些规则获得的收益,有时是真实的,有时是想象的。在之前的一个演讲中,我们讨论了使用锁和尝试无锁编程之间的一些权衡。但我们也在主题演讲中学到,这其实并不重要。人们无论如何都会去用它,因为他们认为它更快,即使实际上并非如此。
因此,在 C++11 中,我们引入了原子变量(atomic variable)或原子对象(atomic object)的概念,它可以在临界区之外被并发访问。这些是可以被安全地并发访问的对象,并且它们的行为是原子的。也就是说,如果我在一个线程中更新其中一个对象,在另一个线程看来,这个更新要么已经完全执行,要么根本没有执行。从这个意义上说,它们是不可分割的。
此外,默认情况下,如果我不做任何其他事情,它们会保留一个相当不错的编程模型。它们仍然确保我获得基于交错(interleaving-based)的语义。我仍然可以认为多个线程的执行,就好像有一个调度器在每个时间点随机从这些线程中选择一条指令来执行下一步。一切看起来仍然是理智的。这套机制工作得相当好。
处理这些东西相当容易,很大程度上是因为,事实上,这些原子操作的行为,在大多数情况下(如果不考虑信号或中断处理器),就好像它们实际上是被互斥锁保护的操作一样。它们甚至可以那样实现。所以,这些原子操作基本上和互斥锁一样容易使用,前提是你的所有临界区里只有一个对单一变量的单一操作。在这种情况下,它们是完全相同的。它们确实有一个优势,那就是比互斥锁快一些,至少在那些你只是替换一个内部只有一个操作的临界区的简单情况下是这样。
然而,即使是使用这种原子操作,你也必须小心。有大量的研究文献是关于如何使用这类原子操作以无锁(lock-free)的方式编写复杂算法的。这里的技巧是,将一个实际上操作许多不同内存位置的更复杂的操作,让它看起来像一个单一的原子操作,尽管它是由许多独立的原子操作实现的。这实际上非常棘手,人们经常会搞错这些算法。做这类事情时,你应该非常非常小心。
顺序一致性原子操作 (Sequentially Consistent Atomics)¶
如果我们仔细看一下 C++ 提供的顺序一致性原子操作,实际上,仅仅通过使用这些原子操作,实现本身就免费为我们提供了许多属性。
显然,实现必须保证操作对其他线程来说是不可分割的,即“全有或全无”。但为了维持这种基于交错执行的错觉,让线程看起来仍然是按照线程内的顺序一步一步执行的,我们还必须提供一些其他的保证。我在这张幻灯片上对它们做了一个非常粗略的描述。我在这里说得不完全准确,实际上还需要更多东西,但这些是最重要的。
基本上,我需要依赖的保证是:
存储-后续操作排序 (Store Ordering): 如果我执行一个原子存储(更新一个原子变量),我需要确保所有之前的内存操作,实际上在该原子存储之前对其他线程可见。这可以防止发生重排序问题,在现代硬件和编译器上,这是一个显著的约束。
加载-后续操作排序 (Load Ordering): 类似地,在加载端,我需要确保加载操作必须在后续的内存访问生效前完成。我们稍后会通过例子看到这一点。这也是实现必须强制执行的一个限制。
存储-加载排序 (Store-Load Ordering): 事实证明,如果我有一个原子存储后面跟着一个原子加载,我需要确保它们不会被实现重排。我们也会在后续的例子中看到原因。
幻灯片上解释了这些保证的成本。在大多数实现上,它们都不是免费的。
不可分割性 (Indivisibility):在现代硬件上通常是免费的。
存储排序 (Store Ordering):在 x86 上是免费的,但在像 ARMv7 这样的平台上,通常会花费一条内存屏障指令。
加载排序 (Load Ordering):情况与存储排序类似。
存储-加载排序 (Store-Load Ordering):在 ARMv7 和 x86 上,确保一个原子存储和后续原子加载的顺序是昂贵的。
为了说明这些,让我们来看一些使用普通的顺序一致性 C++ 原子操作的简单例子。
例1:消息传递 (Message Passing)
这是你能想到的最经典的例子之一,我们稍后还会再次讨论它。它通常被称为 MP 或消息传递例子。我用一个原子变量来将一条信息从一个线程传递到另一个线程。
/* 生成代码,仔细甄别 */
// Thread 1
data = 42;
data_ready.store(true); // atomic store
// Thread 2
if (data_ready.load()) { // atomic load
// use data
}
在这种情况下,线程1初始化一些数据,然后将一个原子标志设置为 true
,表示数据已经初始化完毕,现在可以安全访问了。线程2读取这个原子标志,如果它被设置了,就继续读取数据,因为它知道数据已经被初始化了。
这里不可能存在对 data
的并发访问,因为线程2使用原子变量来等待线程1完成操作。所以 data
上没有数据竞争,一切工作正常。实现必须确保 data = 42
这条语句在对 data_ready
的赋值之前对其他线程可见,这可能是一个不那么明显的约束。但如果你把自己放在编译器的位置上,通常线程1中的两个赋值看起来是完全独立的,编译器可能会认为它们可以被重排,因为它们修改的是不同的内存位置。但在这种情况下,编译器不能重排它们,并且要确保如果对 data_ready
的赋值对另一个线程可见,那么对 data
的赋值也必须可见。所有这些都捆绑在原子操作的实现中。
这个例子需要我之前列出的属性中的存储排序保证。data = 42
需要在存储到 data_ready
之前可见。类似地,从 data
的加载需要在从 data_ready
的加载之后可见。你可能会认为这是自动的,因为它在 if
条件语句内部,但现代处理器会进行推测执行,它们会猜测条件的结果,这并不能阻止它们。所以这是一个明确的保证。不过,这个例子实际上并不需要存储-加载排序保证。
例2:交通灯 (Dekker’s Algorithm style)
这是一个略有不同的例子,再次解释了我们的一些实现要求。
/* 生成代码,仔细甄别 */
// Thread 1
x.store(true);
if (!y.load()) {
// critical_section_1();
}
// Thread 2
y.store(true);
if (!x.load()) {
// critical_section_2();
}
这里发生的是,线程1将 x
存储为 true
,然后检查原子变量 y
是否仍然是 false
。如果是,意味着线程2还没有做任何事,那么我就可以把东西向的交通灯变成绿色。线程2则将 true
存储到另一个变量 y
中,并做互补的事情,把南北向的交通灯变成绿色。
这里的论点是,这是安全的,因为线程1或线程2中至少有一个会看到一个已经设置为 true
的变量,因此它们不会同时把灯变成绿色。
这个特定的例子需要存储-加载排序保证。我需要确保编译器不会重排线程1中的加载和存储操作,即不会把对 y
的加载和对 x
的存储重排。因为如果存储操作发生在加载之后,那么你很容易说服自己,两个线程都有可能把灯变成绿色,从而导致问题。所以,这是一个我们确实需要之前提到的 SL 排序保证的例子。
成本与弱顺序原子操作的动机¶
所有这些保证的成本是多少?为什么人们想要弱顺序原子操作?
在一个简单的微基准测试中,一个内存栅栏(memory fence),通常也叫内存屏障(memory barrier),通常花费大约 2 到 200 个时钟周期。这是一个相当大的范围,不同架构的差异确实很大,但我认为在大多数现代处理器上,我们正普遍趋向于十几个或二十几个周期。
与缓存未命中或内存争用等相比,这个成本或许可以忽略不计,但与大多数指令执行相比,它仍然是一个非常昂贵的成本。问题是,为了确保我们之前提到的各种保证,我们需要内存栅栏指令,以确保硬件层面不会违反这些排序约束。所以为了实现原子操作,我需要确保编译器不会重排指令,也需要确保硬件不会重排指令。通常,阻止硬件重排指令是更昂贵的部分,这需要这些内存指令。
所以,基本上,每个顺序一致性原子操作都可能伴随着几十个周期的开销。在 x86 上,这个问题要小得多。在 x86 上,我只需要为原子存储提供一个栅栏,而原子加载不需要任何额外的东西,所以成本更为温和。在像 ARMv7 这样的平台上,我通常每个操作至少需要一个栅栏。对于原子读-改-写操作(如原子增量),我需要两个。对于 ARMv8,成本可能介于两者之间,因为 C++ 的顺序一致性原子语义基本上在硬件中得到了实现,所以我可以做得更好一些。
关于成本,有一点值得注意。很多人认为,为实现顺序一致性原子操作而产生的内存栅栏额外成本非常高,并会阻碍软件的扩展性。这其实不完全是正确的思考方式。
这里有一张几年前一个简单的并行埃拉托斯特尼筛法程序的性能图,它展示了一个我认为相当典型的效应。
蓝线是使用互斥锁保护并发访问的例子。
绿线基本上是使用顺序一致性原子操作。
下面的红线,你可以认为是使用我们即将讨论的、避免了栅栏的宽松原子操作(relaxed atomics),在这种情况下恰好是安全的。
你可以看到,在低处理器数量时,性能确实有显著差异。但在高处理器数量时,这台特定的机器受到内存带宽的严重限制。基本上,你仍然有栅栏的所有开销,但实际上这些开销在不同线程之间被重叠了。所以它并没有增加总的运行时间,因为瓶颈仍然是内存。所以,至少在这台特定的机器上,在高处理器数量时,它其实并没有太大的区别。成本是存在的,但它实际上让你的扩展性看起来更好了,因为某种程度上它减慢了单处理器版本的速度。互斥锁版本在某种程度上扩展性最好,因为它有最高的单处理器成本。而在32个处理器时,它们之间已经没有太大区别了。
尽管如此,为了解决这个成本问题——因为许多人仍然希望降低成本,而且在很多情况下降低成本确实有益——C++11 及后续版本也提供了弱顺序原子操作 (weakly ordered atomics)。它们放宽了通常与顺序一致性原子操作相关联的排序保证。
它们不再维持那种线程通过交错执行单个步骤的假象。事情可以非常明显地表现为被重排和乱序执行。我们不再隐藏这一点,主要是为了提高性能,消除那些为强制执行更强排序而需要的内存栅栏。它们也给了编译器一些额外的自由度,这可能会有帮助,也可能不会。
所以,我们基本上为你提供了在原子访问时明确指定内存顺序的选项。在这次演讲中,我将主要讨论两种内存顺序的放宽:
memory_order_acquire
/memory_order_release
: (以及用于读-改-写操作的组合acquire_release
)这些基本上保留了你在大多数时候需要的排序。然而,它们放弃了我称之为 SL 排序的保证。它们不再保证一个存储后面跟着一个加载不会被重排。这种重排是允许的,但其他的重排仍然被禁止。memory_order_relaxed
: 这基本上牺牲了所有的排序保证,只留下了与原子性相关的不可分割性。它仍然提供了一个非常弱的重排序保证,即对同一内存位置的访问不能被重排。否则会相当令人困惑,我们决定不提供一个连这个保证都没有的原子类型。
memory_order_relaxed
在可以使用的场合会便宜得多,特别是在像 ARMv7 这样的平台上,因为它基本上避免了所有通常与原子操作相关的栅栏。
警告与丑陋的一面¶
我将在这里插入大量的警告,因为我并不真的想鼓励人们使用这些东西,但在某些情况下它们是合适的,我试图在这里传达一种平衡。
这些弱顺序原子操作的丑陋之处在于,它们极其复杂且难以理解,所以请极为谨慎地使用它们。
我会让你相信,这些规则一点也不明显,事实上,这些规则是如此糟糕,以至于连委员会自己都不太理解。如果你去看 memory_order_relaxed
的定义,在 C++14 标准中其实有很多含糊其辞的说法。C++11 标准更精确,但我们后来认定它是错的。所以我们把它扔掉了,并认为我们不知道如何精确地定义它,所以在 C++14 中用含糊的说法替换了有问题的措辞。这对于那些试图进行形式化程序验证的人来说很不受欢迎,他们对此一头雾水,所以你应该意识到这个问题。如果你想形式化地验证一个程序,不要使用 memory_order_relaxed
。
我在这里不讨论 memory_order_consume
,我认为它目前的形式可以安全地描述为一个失败的实验。它在 C++11 和 14 中,而在 17 中,我们基本上会有一个免责声明:在我们把它搞对之前,请不要使用它。所以请遵循这个建议。
我只想顺便提及另一点,但这实际上很重要:如果你认为你可以在一个库的巧妙实现中使用弱顺序原子操作,而你的客户永远不会注意到,那你可能错了。对于非平凡的使用,一般的经验是,很难将这些东西完全隐藏在库的内部。它们的语义往往会渗透到客户端,所以你应该记住这一点。
使用建议¶
所以,我的建议是:
首先,如果可行,使用互斥锁或事务性内存。如果你有一个简单的情况,即由一个给定互斥锁保护的所有访问都只访问一个变量,并且只访问一次,那么用一个顺序一致性原子操作来替换它既简单又直接。你会得到相同的语义,推理起来不会更难,并且能获得一点速度提升。
如果你需要在信号处理器中访问变量,你通常不太关心性能。你应该普遍使用顺序一致性原子操作,并非常小心地使用它们,因为你很可能最终需要使用更复杂的无锁算法,而这些算法非常难以写对。
如果你测量后发现你的程序确实运行得太慢,你真的想为了性能而使用原子操作,你应该三思。但你可以考虑使用更复杂的带有原子操作的无锁算法。在这一点上,你可能也想考虑使用弱顺序原子操作,但要记住真的要非常小心,这些东西是 bug 的温床,很多都被错误地使用了。
在接下来的演讲中,我将通过一些陷阱来阐述这些问题,然后给你们一系列我遇到过的、使用弱顺序原子操作看起来确实有意义且相对安全的“配方”(recipes)。
常见陷阱 (Pitfalls)¶
1. 原子性粒度¶
我要开始的第一个陷阱是显而易见的,它适用于所有原子操作,而不仅仅是弱顺序原子操作,那就是你必须小心原子性的粒度。
一个很好的例子是 x = x + 1
与 x++
是不同的。x = x + 1
包含对 x
的两次访问:一次原子加载、一次增量和一次原子存储。如果两个线程并发地执行这个操作,它们可能都读取到相同的值,然后写回相同的结果,而不是将它增加两次。而对于一个 atomic_int
,x++
是一个真正的原子增量,因为它是这样定义的。它作为一个不可分割的操作原子地发生,保证了如果两个线程并发执行,x
将被增加两次。只有对 atomic<T>
的单个函数调用是不可分割的,而不是它们的组合。
2. 微妙的排序约束¶
这是一个元评论(meta comment):弱顺序原子操作的排序约束真的非常微妙。这是非常常见的错误来源。
3. 错误的内存顺序放宽¶
现在来看一些更具体的陷阱。这个例子试图将我之前需要存储-加载排序的交通灯例子,用不正确的方式放宽内存顺序。
/* 生成代码,仔细甄别 */
// !!!错误的代码 !!!
// Thread 1
x.store(true, memory_order_release); // 放宽
if (!y.load()) { // 仍然是顺序一致性
// ...
}
// Thread 2
y.store(true, memory_order_release); // 放宽
if (!x.load()) { // 仍然是顺序一致性
// ...
}
这个例子是不正确的。在这里,我只放宽了存储操作的顺序。加载操作仍然是顺序一致的。许多人似乎有这样一种想法——这在某些架构上成立,在其他架构上不成立——即因为我在这里使用了一个顺序一致性操作,它就能以某种方式为其他弱顺序操作排序。这通常是不对的,这些东西的定义不是这样的。
如果我将存储操作放宽到 memory_order_release
,我就放弃了我的 SL 保证,即存储-加载排序保证。这样一来,编译器和硬件就不再被要求保持存储和加载的顺序。事实上,编译器可能会选择在两个线程中都先执行加载,看到它们的值都还是 false
,然后两个线程都继续执行存储。到那时,两个线程都会把灯变成绿色,导致错误。
4. 与其他同步机制的交互¶
与锁等其他同步机制的交互也并非人们所期望的那样。我见过一些流传甚广的代码,它会检查:我是否在使用 C++11 编译?我是否有合适的原子栅栏操作?如果有,就用它们来强制排序。否则,我就用一个临界区(比如互斥锁)来强制这些不同操作之间的排序,因为毕竟临界区会确保事情不会在它周围被重排。
这是完全错误的。这里是典型的例子:
/* 生成代码,仔细甄别 */
// !!!错误的代码 !!!
x.store(true, memory_order_relaxed);
mutex.lock();
mutex.unlock();
if (!y.load(memory_order_relaxed)) {
// ...
}
在这里,我把存储和加载操作都改成了 relaxed
,并试图通过在中间放置一个临界区来强制排序。事实证明这是不够的。最简单的理解方式是,一个临界区通常会确保临界区内部的操作不会移动到外部。但它可以把外部的操作移动到内部。特别是,内存模型的规则允许这里的存储和加载操作都移动到中间的临界区内,并相互越过。因此,这实际上并不能强制存储和加载之间的顺序。
这个问题特别棘手,因为过去大多数临界区的实现实际上确实强制了这种排序。但我们现在开始看到一些不这样做的实现。所以我预计随着我们解决这个问题,一些代码会崩溃。这正是问题的关键:传统上,编译器不会跨同步操作进行重排,而且同步操作本身也是作为硬件栅栏实现的。但现在越来越多地,它们不再作为硬件栅栏实现。例如,在 ARMv8 上,同步操作只有单向的重排屏障:你可以把东西移入临界区,但不能移出。
5. 数据依赖不保证排序¶
许多人认为,强制原子操作之间排序的一种方法是利用数据依赖或控制依赖。当然,如果一个加载依赖于另一个,它们必须按顺序执行。不幸的是,这种“依赖”的概念被证明很难精确定义。
这里有一个例子来说明为什么它如此难以定义,以及为什么在任何合理的定义下它实际上都不成立。
/* 生成代码,仔细甄别 */
// 伪代码
auto p = atomic_x.load(memory_order_relaxed);
if (p == &some_global) {
auto val = p->field; // 依赖于 p
}
我先加载 X
,然后用加载 X
的结果去加载 X
指向的结构体的一个字段。你可能认为这两个操作保证是按顺序执行的。但事实上,如果你用 C++ 编译器编译这段代码,存在完全合理且可接受的转换会违反这一点。
可能发生的是,编译器注意到在 if
的上下文中,p
和 &some_global
必须是相同的。所以编译器可能会想,为什么不直接解引用 &some_global
而不是 p
呢,因为我知道它们是相同的。于是它就这样做了。
与此同时,硬件决定要快速执行你的代码,它不想等待那个条件判断的结果。它会猜测条件为真并继续执行,事后再验证。现代处理器通常都这样做。
所以,在这种情况下,硬件可以在知道条件真假之前就提前执行了对 some_global->field
的加载,然后再去执行对 X
的加载,顺序就颠倒了。之后它可能验证说,“嗯,我的猜测是对的”,条件满足了,所以一切都好。但现在,第二个加载实际上看起来在第一个加载之前执行了。
正确使用的“配方” (Recipes)¶
现在让我们从陷阱转向如何正确地做这件事。
1. Acquire-Release 的使用¶
基本规则是,我能得到的唯一保证是:在一个 release
存储之前的内存操作,在一个看到了该存储结果的 acquire
加载之后是可见的。其他类型的关于排序的推理在这里仍然是无效的,所以这很棘手。
典型用例1:消息传递 这是我们之前看到的 MP 例子。我当时就注意到,我不需要 SL 保证。我真正需要确保的只是,如果我看到 data_ready
被另一个线程设置为 true
,那么我也保证能看到 data
被初始化。这正是 acquire-release
所提供的保证。
/* 生成代码,仔细甄别 */
// Thread 1
data = 42;
data_ready.store(true, memory_order_release); // 使用 release
// Thread 2
if (data_ready.load(memory_order_acquire)) { // 使用 acquire
// use data
}
在这种情况下,我可以放宽原始的例子,在存储上使用 release
,在加载上使用 acquire
,并且仍然正确地保持语义。这仍然有效,而且这是一个非常常见的用例。
典型用例2:双重检查锁定 (Double-Checked Locking) 这也是之前例子(现在经过了进一步优化)的重现。
/* 生成代码,仔细甄别 */
if (!initialized.load(memory_order_acquire)) { // 初始检查,需要 acquire
lock_guard<mutex> lock(m);
if (!initialized.load(memory_order_relaxed)) { // 锁内检查,可用 relaxed
// ... initialize ...
initialized.store(true, memory_order_release); // 发布初始化,需要 release
}
}
当我检查 initialized
时,基本上和上一个例子一样。在初始检查时,我只需要 acquire
语义,以确保如果初始化已经发生,我能看到它。当我进行初始化并最终存储标志时,我需要 release
语义,以确保初始化对那些通过 acquire
加载看到 initialized
为 true
的线程是可见的。
典型用例3:实现简单的锁 如果你要实现自己的锁(虽然 99.9% 的情况下你应该使用标准库设施),你会做类似下面的事情:
/* 生成代码,仔细甄别 */
// Lock Acquire
while (lock_flag.exchange(true, memory_order_acquire));
// Lock Release
lock_flag.store(false, memory_order_release);
对于锁的获取,我需要确保操作不会移动到临界区之外,这正是 acquire
的作用。对于锁的释放,我需要确保所有之前的操作在释放锁之前都变得可见,这正是 release
的作用。所以,对于一个简单的自旋锁,入口用 acquire
,出口用 release
就足够了。
2. Relaxed 的使用¶
典型用例1:单位置、无交互的数据结构 一个常见的、我见过成功使用 relaxed
的地方是更新单一位置的数据结构,它们不与其他数据交互。这些在真实代码库中出奇地常见。比如,在程序的并行部分累积信息,然后在所有线程正确同步(join)之后再查看最终结果。
最常见的例子显然是原子计数器。
/* 生成代码,仔细甄别 */
// 多个线程并发执行
counter.fetch_add(1, memory_order_relaxed);
我们可以用 fetch_add
来增加计数器。这个操作可以使用 memory_order_relaxed
,因为我并不关心 fetch_add
返回的值,我通常会把它丢掉。我不会根据返回的值做任何决定,我只想在最后得到正确的值。
一个需要警惕的信号是:如果你看到一个 relaxed
操作,并且代码检查了它的返回值,那么你真的需要仔细审查。
典型用-例2:不影响程序正确性的操作 另一个完全安全使用 memory_order_relaxed
的地方是,如果你执行的操作不影响程序的正确性,它只是对程序高效运行很重要。
一个常见的例子是比较并交换(compare-exchange)循环。
/* 生成代码,仔细甄别 */
auto old_val = atomic_var.load(memory_order_relaxed); // 初始加载可用 relaxed
T new_val;
do {
new_val = compute_new_value(old_val);
} while (!atomic_var.compare_exchange_weak(old_val, new_val));
一开始你加载一个值,然后基于这个旧值计算一个新值,并尝试用新值替换旧值(如果它在此期间没有改变的话)。事实上,你一开始加载的值是不是最新的并不重要。如果它是一个过时的值,最坏的情况也只是让 while
循环多迭代一次。这完全没问题。
典型用例3:已知没有竞争访问 另一个常见的使用 relaxed
的地方是,当你在一个你知道没有竞争访问的上下文中访问一个原子变量时。一个很好的例子就是双重检查锁定中,在持有锁之后的第二次检查。
/* 生成代码,仔细甄别 */
if (!initialized.load(memory_over_acquire)) {
lock_guard<mutex> lock(m);
if (!initialized.load(memory_order_relaxed)) { // 在锁内,没有竞争,可用 relaxed
// ...
}
}
此时,因为我持有保护初始化的锁,我知道没有其他线程会改变 initialized
。因此,使用 memory_order_relaxed
是完全可以的。
复杂示例¶
读多写少的数据(类 Seqlock 算法)¶
这个问题在 Linux 内核等地方相当常见。我们有一些数据,很少更新,但被非常频繁地读取。如果我们用互斥锁保护它,问题在于读者也需要修改互斥锁,这会引入缓存行争用,从而降低速度。
人们发明了聪明的技术来避免读者修改内存。基本模型是这样的:
有一个原子版本计数器。
写入者:获取一个锁(确保只有一个写入者),将版本计数器加一(使其变为奇数),修改数据,然后再次将版本计数器加一(使其变回偶数)。
读取者:读取版本号,读取数据,再次读取版本号。如果两次读取的版本号相同且为偶数,那么就知道得到了一个一致的快照。如果不是,就重试。
现在的问题是,如何用内存顺序来优化这个过程? 首先,根据 C++ 的规则,当你在读取数据时,它可能同时被写入者修改,这会引发未定义行为。因此,数据本身也必须由原子对象组成。所以,代码看起来会是这样:中间的数据读取将全部是原子的 relaxed
读取。
你需要确保版本号的读取不会与这些数据读取发生重排。
在开头,可以通过一个
acquire
加载来轻松确保不发生重排。在结尾,确保不发生重排实际上非常棘手。这可能是我发现的 C++11 内存栅栏最有说服力的用例。我在第二次版本号检查前插入一个
atomic_thread_fence(memory_order_acquire)
。这大致的效果是将之前所有的relaxed
加载提升为acquire
加载,但不阻止它们之间的重排。所以数据加载之间仍然可以重排,但它们必须发生在后面的版本号加载之前。
结论¶
弱顺序原子操作很难。好消息是,根据我最近的经验,似乎有相对较少的一系列“配方”可以覆盖相当一部分的用例。对于任何更复杂的情况,你真的需要比我在这里介绍的更详细地理解内存模型。
问答环节 (Q&A)¶
问: 您能否多解释一下在做引用计数时对排序的要求?在我看来,如果我为一个共享对象使用引用计数,当释放共享指针引用时,我不应该有任何需要变得可见的对该共享对象的修改。
答: 问题在于,如果我在两个不同的线程中独立地使用一个对象,然后最后……最简单的思考方式可能是通过修改的例子。假设线程1仍然在修改对象,然后它只是对引用计数做了一个 relaxed
递减。然后线程2决定它用完对象了,做了另一次引用计数递减,然后释放了对象。问题在于,此时线程1的修改可能还不可见,并且可能在线程2释放对象之后才表现出来。所以线程2可能已经把它放回了空闲列表并重用了它,而此时线程1的修改才变得可见。
问: 关于幻灯片6,您说存储操作在 x86 上基本上是免费的。我一直以为它是一个完整的栅栏(full fence),是 XCHG
操作。您能详细说明一下吗?
答:- 这是个好问题。x86 有点棘手。如果你看为顺序一致性原子操作在 x86 上生成的汇编代码,你肯定会感到困惑。问题是,store
操作本身隐式地保证了存储排序属性(S property),这是最重要的一个。所以你实际上不需要为此使用栅栏。因为 x86 天然是相当强有序的。但它不保证存储-加载排序(SL property)。硬件本身不保证 SL。所以你实际上需要使用一个栅栏,以防万一在原子存储之后跟着一个原子加载。所以那个昂贵的栅栏是为了一个非常罕见的情况而发出的。常见的情况是免费的。
问: 您是否认为,在需要比如一百万个互斥锁的情况下,使用原子操作代替互斥锁是合理的?
答: 这取决于具体情况。如果是一个非常简单的情况,而且你对顺序一致性原子操作感到满意,只是作为单操作临界区的优化,那很简单。在其他需要大量互斥锁的情况下,将互斥锁分片(shard the mutexes)也是合理的,即你将一大堆对象映射到同一个互斥锁。这实际上也是 atomic
在底层实现中通常做的事情,在硬件不支持该对象大小的情况下,对象的地址会被映射到一个合理大小的互斥锁集合中,然后用这些锁来保护那些对象。
问: 关于幻灯片33,您说应该是 acquire
栅栏然后是 relaxed
加载。为什么不直接对第二次版本号读取使用 acquire
加载呢?
答: 好问题。问题在于,那样会强制完全错误的排序。你可以认为 acquire
加载是相对于后续内存操作进行排序。而在这里,这是少数几个奇怪的案例之一,你真正想要的是一个加载相对于先前的操作进行排序。而这是原子操作本身没有很好支持的东西。
问: 关于依赖关系不能强制线程间排序的那张幻灯片,在现代硬件上,除了 Alpha 架构,真的可能看到硬件层面的重排,而不是编译器重排加载吗?
答: 哦,你是指这种重排。硬件本身在这种情况下不会破坏事情。原因在于,所有现代架构实际上在汇编层面都有某种它们会强制执行的依赖概念。问题是,这个概念在源代码层面没有太大意义。这是我们一直在 memory_order_consume
上遇到的障碍之一。正如你在这个例子中看到的,在源代码层面看起来存在依赖,但编译器可以把它编译成消除依赖的东西,从而加速代码,但同时也消除了汇编代码中基于依赖的排序保证。所以这真的需要硬件和编译器之间的“合谋”。