翻译自:https://preshing.com/20130823/the-synchronizes-with-relation/
略有修改
同步关系
“ 与…同步 ”(Synchronizes-with)是语言设计者发明的一个术语,用于描述确保原子操作(甚至是非原子操作)的内存效果对其他线程可见的方式。在编写无锁代码时,这是理想的保证,因为您可以使用它来避免由内存重新排序引起的不受欢迎的意外。
“ 与…同步 ”是一个相当现代的计算机科学术语。您可以在C ++ 11,Java 5+和LLVM的规范中找到它,所有这些规范都是在最近10年内发布的。每个规范都定义了该术语,然后使用它为程序员提供正式保证。他们的共同点是,每当两个操作之间存在同步关系(通常在不同的线程上)时,这些操作之间也就存在happens-before的关系。
在深入探讨之前,我将为您提供一个小的洞见:在每个 与同步 的关系中,您应该能够识别两个关键要素,我将它们称为保护变量和有效负载。有效负载是在线程之间传播的一组数据,而保护变量则保护对有效负载的访问。我会指出这些成分。
现在,让我们来看一个使用C ++ 11原子的熟悉示例。
Write-Release可以与Read-Acquire同步
假设我们有一个Message
结构,该结构由一个线程生成,并由另一个线程使用。它具有以下字段:
1 | struct Message |
我们将通过Message
在线程之间传递一个实例,方法是将其放在共享的全局变量中。该共享变量充当有效负载。
1 | Message g_payload; |
现在,没有一种可移植的方法可以使用单个原子操作来填充g_payload
。所以我们不会尝试。相反,我们将定义一个单独的原子变量g_guard
,以指示是否g_payload
准备就绪。您可能会猜到,g_guard
它充当我们的保护变量。保护变量必须使用原子操作来操作,因为两个线程将同时对其进行操作,并且其中一个线程执行写操作。
1 | std::atomic<int> g_guard(0); |
为了g_payload
安全地在线程之间传递,我们将使用获取
和释放
语义,这是我之前使用与之非常相似的示例编写的主题。如果您已经阅读过该文章,则可以将以下函数的最后一行识别为对的 Write-Release 操作g_guard
。
1 | void SendTestMessage(void* param) |
在第一个线程调用时SendTestMessage
,第二个线程TryReceiveMessage
间歇地调用,重试直到看到返回值true
。您将会认出此函数的第一行在g_guard
身上为 Read-Acquire 操作。
1 | bool TryReceiveMessage(Message& result) |
如果您已经关注该博客一段时间,那么您已经知道该示例可以可靠地运行(尽管它只能传递一条消息)。我已经解释了获取和释放语义如何引入内存障碍,并给出在工作的C ++ 11应用程序中获取和释放语义的详细示例。
另一方面,C ++ 11标准没有任何解释。这是因为标准旨在充当合同或协议,而不是作为教程。只是简单地保证此示例将起作用,而无需进一步说明。在工作草案N3337 的第29.3.2节中做出了承诺:
原子操作A对原子对象M执行release操作 与 原子操作B对M执行acquire操作并从以A为开始的release序列中的任何副作用中获取其值的 同步。(这句话真难理解)
An atomic operation A that performs a release operation on an atomic object M synchronizes with an atomic operation B that performs an acquire operation on M and takes its value from any side effect in the release sequence headed by A.
值得对此进行分解。在我们的示例中:
- 原子操作A 是在
SendTestMessage
执行的write-release
。 - 原子对象M 是保护变量
g_guard
。 - 原子操作B 是在
TryReceiveMessage
中执行的read-acquire
。
至于read-acquire
必须“从任何副作用中获取其值”的条件 —— 只需说read-acquire
读取由write-release
写入的值就足够了。如果发生这种情况,则同步*关系将完成,并且我们已经实现了梦寐以求的线程之间 *happens-before 关系。有些人喜欢称其为synchronize-with 或 happens-before “edge”。
最重要的是,该标准保证(在§1.10.11-12中),只要存在synchronizes-with edge,则 happens-before 关系也将扩展到相邻操作。这包括线程1中的边沿之前的所有操作,以及线程2中的边沿之后的所有操作。在上面的示例中,它确保对所有修改g_payload
都在另一个线程读取它们时可见。
编译器供应商如果希望声明其符合C ++ 11,则必须遵守此保证。起初,他们如何做到似乎有些神秘。但是实际上,编译器使用了C ++ 11出现之前,程序员在技术上必须使用的相同技巧,来实现这一诺言。例如,在这篇文章中,我们看到了ARMv7编译器如何使用一对dmb
指令来实现这些操作。PowerPC编译器可以使用lwsync
来实现它们,而x86编译器可以仅使用编译器屏障,由于其相对强的硬件内存模型。
当然,获取和释放语义并不是C ++ 11独有的。例如,从Java版本5开始,对volatile
变量的每次写都是写释放
,而对volatile
变量的每次读都是读获取
。因此,Java中的任何volatile
变量都可以充当保护变量,并可用于在线程之间传播任意大小的有效负载。杰里米·曼森(Jeremy Manson)在他的Java volatile变量博客文章中对此进行了解释。他甚至使用与上图非常相似的图,称其为“两个锥体”图。
这是运行时关系
在前面的示例中,我们看到了最后一行SendTestMessage
与 TryReceiveMessage
的第一行是如何同步 的。但是不要陷入源代码中语句之间构成与同步关系思考的陷阱。不是!它是基于这些语句在运行时发生的操作之间的关系。
这种区别很重要,当您考虑它时应该很明显。单个语句可以在运行的进程中执行任意多次。如果TryReceiveMessage
调用得太早(在线程1的存储g_guard
位置可见之前),则将不会有任何同步关系。
这完全取决于读获取
是否看到由写释放
写入的值。这就是C ++ 11标准所说的原子操作B必须从原子操作A“获取其值”时的含义。
实现同步的其他方法
就像synchronizes-with 不是实现 happens-before 关系的唯一方式一样,一对写释放/读获取
操作不是实现synchronizes-with 的唯一方法;C ++ 11原子操作也不是实现获取和释放语义的唯一方法。我在下表中还组织了其他几种方式。请记住,此图表绝不是详尽无遗的。
PS:下面这张图非常重要,揭示了同步的层次结构
这篇文章中的示例生成了无锁代码(实际上在所有现代编译器和处理器上),但是C ++ 11和Java公开了也会引入与边同步的阻塞操作。例如,解锁互斥锁始终与该互斥锁的后续锁定保持同步。语言规范对此很明确,作为程序员,我们自然希望如此。您可以将互斥锁本身视为保护对象,将受保护的变量视为有效负载。IBM甚至在2004年发表了有关Java更新的内存模型的文章,其中包含一个“两个锥体”图,该图显示了一对彼此同步的lock/unlock
操作。
正如我以前文章所示,获取和释放语义也可以使用独立的,明确的屏障指令来实现。换句话说,只要满足正确的条件,release 屏障(rfence)就可以与 acquire 屏障(afence)同步。实际上,显式的屏障指令是Mintomic中唯一可用的选项,Mintomic是我自己的用于无锁编程的便携式API。我认为,rfence
和afence
现在在网络上被严重误解了,因此,我接下来可能会写一篇专门的文章。
最重要的是,仅在语言和API规范指出存在同步的地方,才存在同步关系。在源代码级别定义自己的保证条件是他们的工作。因此,当在C ++ 11 atomics
中使用低级顺序约束时,您不能在一些操作上直接std::memory_order_acquire
和release
,然后希望事情能神奇地解决。您需要确定哪个原子变量是保护对象,什么是有效负载,以及在哪个代码路径中确保同步关系。
有趣的是,Go编程语言有点违反常规。Go的内存模型已明确指定,该规范不会在任何地方使用术语“ 与…同步 ”。它只是坚持使用“ happens-before ”一词,这也不错,因为很明显,任何 与之同步 可以扮演角色,happens-before 也可以扮演。也许Go的作者选择了一个简化的词汇表,因为“ synchizes-with ”通常用于描述不同线程上的操作,而Go并未公开线程的概念。