同步关系

翻译自:https://preshing.com/20130823/the-synchronizes-with-relation/

略有修改

同步关系

与…同步 ”(Synchronizes-with)是语言设计者发明的一个术语,用于描述确保原子操作(甚至是非原子操作)的内存效果对其他线程可见的方式。在编写无锁代码时,这是理想的保证,因为您可以使用它来避免由内存重新排序引起的不受欢迎的意外

与…同步 ”是一个相当现代的计算机科学术语。您可以在C ++ 11,Java 5+和LLVM的规范中找到它,所有这些规范都是在最近10年内发布的。每个规范都定义了该术语,然后使用它为程序员提供正式保证。他们的共同点是,每当两个操作之间存在同步关系(通常在不同的线程上)时,这些操作之间也就存在happens-before的关系

img

在深入探讨之前,我将为您提供一个小的洞见:在每个 与同步 的关系中,您应该能够识别两个关键要素,我将它们称为保护变量有效负载。有效负载是在线程之间传播的一组数据,而保护变量则保护对有效负载的访问。我会指出这些成分。

现在,让我们来看一个使用C ++ 11原子的熟悉示例。

Write-Release可以与Read-Acquire同步

假设我们有一个Message结构,该结构由一个线程生成,并由另一个线程使用。它具有以下字段:

1
2
3
4
5
6
struct Message
{
clock_t tick;
const char* str;
void* param;
};

我们将通过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
2
3
4
5
6
7
8
9
10
void SendTestMessage(void* param)
{
// Copy to shared memory using non-atomic stores.
g_payload.tick = clock();
g_payload.str = "TestMessage";
g_payload.param = param;

// Perform an atomic write-release to indicate that the message is ready.
g_guard.store(1, std::memory_order_release);
}

在第一个线程调用时SendTestMessage,第二个线程TryReceiveMessage间歇地调用,重试直到看到返回值true。您将会认出此函数的第一行在g_guard身上为 Read-Acquire 操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
bool TryReceiveMessage(Message& result)
{
// Perform an atomic read-acquire to check whether the message is ready.
int ready = g_guard.load(std::memory_order_acquire);

if (ready != 0)
{
// Yes. Copy from shared memory using non-atomic loads.
result.tick = g_payload.tick;
result.str = g_payload.str;
result.param = g_payload.param;

return true;
}

// No.
return false;
}

如果您已经关注该博客一段时间,那么您已经知道该示例可以可靠地运行(尽管它只能传递一条消息)。我已经解释了获取和释放语义如何引入内存障碍,并给出在工作的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-withhappens-before “edge”。

img

最重要的是,该标准保证(在§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变量博客文章中对此进行了解释。他甚至使用与上图非常相似的图,称其为“两个锥体”图。

img

这是运行时关系

在前面的示例中,我们看到了最后一行SendTestMessage TryReceiveMessage的第一行是如何同步 的。但是不要陷入源代码中语句之间构成与同步关系思考的陷阱。不是!它是基于这些语句在运行时发生的操作之间的关系。

这种区别很重要,当您考虑它时应该很明显。单个语句可以在运行的进程中执行任意多次。如果TryReceiveMessage调用得太早(在线程1的存储g_guard位置可见之前),则将不会有任何同步关系。

img

这完全取决于读获取是否看到由写释放写入的值。这就是C ++ 11标准所说的原子操作B必须从原子操作A“获取其值”时的含义。

实现同步的其他方法

就像synchronizes-with 不是实现 happens-before 关系的唯一方式一样,一对写释放/读获取操作不是实现synchronizes-with 的唯一方法;C ++ 11原子操作也不是实现获取和释放语义的唯一方法。我在下表中还组织了其他几种方式。请记住,此图表绝不是详尽无遗的。

PS:下面这张图非常重要,揭示了同步的层次结构

img

这篇文章中的示例生成了无锁代码(实际上在所有现代编译器和处理器上),但是C ++ 11和Java公开了也会引入同步的阻塞操作。例如,解锁互斥锁始终该互斥锁的后续锁定保持同步。语言规范对此很明确,作为程序员,我们自然希望如此。您可以将互斥锁本身视为保护对象,将受保护的变量视为有效负载。IBM甚至在2004年发表了有关Java更新的内存模型的文章,其中包含一个“两个锥体”图,该图显示了一对彼此同步lock/unlock操作。

img

正如我以前文章所示,获取和释放语义也可以使用独立的,明确的屏障指令来实现。换句话说,只要满足正确的条件,release 屏障(rfence)就可以 acquire 屏障(afence)同步。实际上,显式的屏障指令是Mintomic中唯一可用的选项,Mintomic是我自己的用于无锁编程的便携式API。我认为,rfenceafence现在在网络上被严重误解了,因此,我接下来可能会写一篇专门的文章。

最重要的是,仅在语言和API规范指出存在同步的地方,才存在同步关系。在源代码级别定义自己的保证条件是他们的工作。因此,当在C ++ 11 atomics中使用低级顺序约束时,您不能在一些操作上直接std::memory_order_acquirerelease,然后希望事情能神奇地解决。您需要确定哪个原子变量是保护对象,什么是有效负载,以及在哪个代码路径中确保同步关系。

有趣的是,Go编程语言有点违反常规。Go的内存模型已明确指定,该规范不会在任何地方使用术语“ 与…同步 ”。它只是坚持使用“ happens-before ”一词,这也不错,因为很明显,任何 与之同步 可以扮演角色,happens-before 也可以扮演。也许Go的作者选择了一个简化的词汇表,因为“ synchizes-with ”通常用于描述不同线程上的操作,而Go并未公开线程的概念。

------ 本文结束------
赞赏此文?求鼓励,求支持!
0%