memory_order_relaxed 轻松指南

标题:A Relaxed Guide to memory_order_relaxed

日期:2020/10/02

作者:Paul E. McKenney & Hans Boehm

链接:https://www.youtube.com/watch?v=cWkUqK71DZ0

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。

备注:并不轻松。好建议就是只用自己知道的写法。


所以我们有 C++ 中的原子操作。默认情况下,它们是顺序一致的(sequentially consistent)。这意味着如果你声明一个原子对象,并对其进行加载(load)或存储(store),你会在你拥有的所有原子对象之间获得完整的交错语义。但这仅在使用了默认设置时才成立。这是昂贵的。在弱序架构上尤其如此。在许多情况下,你并不需要这种交错语义。而这是被允许的。你可以通过使用内存序(memory order)枚举值来避免这种情况,特别是我们今天要讨论的 memory_order_relaxed。好的。

从多个角度来看,memory_order_relaxed 都是极好的。我的意思是,如果你给出一个 memory_order_relaxed 加载,你得到的就是一条加载指令。如果你进行 memory_order_relaxed 存储,你得到的就是一条存储指令。这简直太棒了。你拥有了控制权。你获得了极高的效率和可扩展性,当然,前提是你的对象大小是硬件可以容纳的。这仅仅是一条加载,仅仅是一条存储,简直太棒了。

不幸的是,存在一些复杂性。你看,我们不仅仅要处理硬件。我们还有这些叫做编译器的东西。编译器会凭空引入一些有趣的东西,并且从未执行的分支中读取。Hans 稍后会在后面的幻灯片中更详细地描述这些。我这里只是快速概述一下这个问题的历史。

小问题但是有点讽刺。使用 OOTA(Out-Of-Thin-Air,凭空出现),在许多情况下,理论上你可以得到几乎任意的行为。Java 也有一个类似的问题,他们攻击了超过 20 年,但并没有真正得到解决方案。

然而,在过去的几年里,我们取得了一些进展。首先,我们现在有方法来分类 OOTA 到底是什么,而不仅仅是简单的重排序。在这两项进展之前,我们甚至没有任何关于 OOTA 确切是什么、不是什么的一致定义,除了双方的一些试金石测试(litmus tests)。

不幸的是,第一件事是我和一群人在几年前做的一份 C++ 工作文件(working paper)。它甚至不是一个算法。它是一个决策过程。你需要人来执行它。你需要人的创造力来得到正确答案。

最近,有一些学术团体提出了一些非常好的东西——实际上是一些算法,但它们还不能放进编译器里,至少目前还不行。所以,我个人预计我们很快就能得到一些可以放进编译器的东西。但如果你回顾历史,我们可能需要几年时间,甚至五年,才能得到一个解决方案的陈述。然后我们再需要几年时间让它进入标准,再需要几年时间让它传播到编译器中。所以,我们不得不在一段时间内处理这个问题。

现在,我们确实有一个务实的解决方案,这是 Hans 和其他人想出来的,他们似乎有一个形式化的变体。但那里发生的情况是,你在弱序算法(如 ARM)上得到了本不必要的开销。Hans 确实提供了一个改进的提案,增加了一个 memory_order_load_store 内存序枚举,可以在不需要的地方避免它——这样你仍然可以在需要的地方使用 memory_order_relaxed

但无论如何,我们有一个问题。在这一点上,我要做的是翻到下一张幻灯片,让 Hans 接手,准确地告诉你 OOTA 和 RFUB(Read-From-Untaken-Branch,从未执行的分支中读取)是什么。

Hans: 好的,我接着描述一下什么是“凭空出现”问题。最容易描述它的方法是对比一堆不同类型的例子。所以,我将从一个有疑问结果的例子开始,实际上这个结果是故意被允许的,我们也必须允许它。然后我会描述一些“凭空出现”的例子,在这些例子中,我们真的不希望发生幻灯片中描述的那些可疑结果,但我们不知道如何通过正确表述规范来防止这种情况发生。最后,我会看一个更现代的变体,它介于两者之间,正好指出了为什么这很困难,以及为什么我们可能难以区分这两者。那么,我们继续吧。

这个领域的实际 C++ 语法在设计时假设 memory_order_relaxed 原子操作非常罕见,应该被突出显示。所以它的语法相当冗长。不幸的是,这不太适合像这样试图查看不同试金石测试的幻灯片软件。所以我们将在这里引入一些简写符号,这不完全是 C++。

通常,当我们使用像 X 和 Y 这样的变量时,它们表示潜在的共享内存位置,全局变量或类似的东西。R1 和 R2 表示局部变量。你可以把它们想象成寄存器,但在 C++ 术语中,它们是地址未被获取的局部变量。除非另有说明,我们通常假设所有共享位置都隐式初始化为零、空(null)或假(false)。有时我们会明确说明这一点。并且我们会大大简化实际的 C++ 语法。因此,与其说 load(memory_order_relaxed),我们通常会在语句附近加上一个 relaxed 后缀,以表明该语句涉及的内存操作是放松的(relaxed)。你的任务是用 std::memory_order_relaxed 来填充完整的语法。好的,下一个。

Must be allowed
atomic<int> x(0), y(0);

Thread 1                          Thread 2
+---------------------+           +---------------------+
| // y = x;           |           | // x = 42;          |
|  int r1 =_rlx x;    |           |  int r2 =_rlx y;    |
|  y =_rlx r1;        |           |  x =_rlx 42;        |
+---------------------+           +---------------------+

r1 = r2 = 42 is fine!

(幻灯片:基本示例 - 允许的)

所以,这是一个必须被允许的基本示例。我们这里有一个在两个原子变量和两个线程上运行的简单程序。线程一基本上通过将 X 加载到 R1 中,然后将结果存储回 Y 来将 X 复制到 Y。线程二加载 Y,但它只是将 42 存储到 X。X 和 Y 最初都是零。

我们预期,如果这只是在顺序一致语义下执行,那么实际上 R1 和 R2 都应该为零。嗯,或者至少 R1 和 R2 中应该有一个是零。特别是,R2 应该是零。

然而,在现实中,我们必须预期这里的情况是 R1 和 R2 都是 42,因为我们说过这些是放松操作。因此,编译器完全可以将这些操作重新排序,在线程二中先将 42 存储到 X,然后再将 Y 加载到 R2。如果线程一正好在线程二将 42 存储到 X 和从 Y 加载之间执行,那么我们正好得到了——我们得到了一个结果,其中 R1 等于 R2 等于 42。这是完全合理且预期的。

我们可以转到下一张幻灯片吗?不行?谢谢。

OOTA: Should be disallowed
atomic<int> x(0), y(0);

Thread 1:                Thread 2:
+----------------------+      +----------------------+
| // y = x;            |      | // x = y;            |
| int r1 = _rlx x;     |      | int r2 = _rlx y;     |
| y = _rlx r1;         |      | x = _rlx r2;         |
+----------------------+      +----------------------+

r1 = r2 = 42 should be disallowed!

(幻灯片:凭空出现示例 - 不允许的)

另一方面,这里有一个稍微不同的例子,其中 R2 没有被显式存储,程序中只涉及零。但如果我们看到与上一张幻灯片相同的读取模式,即每个线程在其加载中看到了另一个线程的存储——即使那个存储发生在加载之后——我们可能会得到一个非常奇怪的结果。

所以,我们在这里关心的结果——这是“凭空出现”执行的典型例子——是这里的值 42 并没有出现在程序中,但我们突然在输出中得到像 42 这样的值。

在这个特定情况下,我们有一个情况:Y 读取——Y 看起来是 42,然后线程二将其存储到 X,然后线程一读取线程二存储的 42,线程一再次将值 42 存储回去。让我们进入下一张幻灯片来具体讨论这种事情是如何发生的。

atomic<int> x(0), y(0);

Thread 1:                          Thread 2:
+---------------------------------+ +---------------------------------+
| // y = x;                       | | // x = y;                       |
|  int r1 =_{rlx} x; /*guess 42*/  | |  int r2 =_{rlx} y; // guess 42  |
|  y =_{rlx} r1;                  | |  x =_{rlx} r2;                  |
|  // Confirm speculation         | |  // Confirm speculation         |
+---------------------------------+ +---------------------------------+

Formally each load observes the other thread’s store, as before.

(幻灯片:如何发生)

所以,我们在这里谈论的这种执行是指,比如说,硬件或编译器以某种方式猜测 X 和 Y 初始是 42。所以,当我们在线程一和线程二中初始读取 R1 和 R2 时,它们都猜测值是 42。然后它们将 42 存储回另一个变量,并且由于我们最初猜测 X 和 Y 是 42,编译后的代码现在必须验证这个猜测实际上是正确的,如果不是则撤销。最后,我们只需检查加载是否没问题,并且 X 和 Y 实际上确实是 42。但既然我们刚刚将 42 存储进了 X 和 Y,我们可以确认我们的推测是正确的,现在就没问题了。

这再次在 C++ 内存模型中形式上对应于这种情况:每次加载都看到由另一个线程产生的存储。由另一个线程产生的并发竞争存储。通常,嗯,除了我们将在这里讨论的一些事情外,这很难禁止,并且通常不会被 C++ 内存模型的形式方面直接禁止。

下一张幻灯片。

OOTA (variant B): Should be disallowed

atomic<unsigned int> x(0), y(0);
int a[1], b[1];

Thread 1:                Thread 2:
+-----------------------+ +-----------------------+
| int r1 =_{rlx} x;     | | int r2 =_{rlx} y;     |
| a[r1] = 42;           | | b[r2] = 42;           |
+-----------------------+ +-----------------------+

r1 = r2 = 42 should be disallowed!
But could have a + 42 = &y and b + 42 = &x

(幻灯片:更令人不安的变体)

还有另一种“凭空出现”结果的变体,实际上更令人不安。那就是下面的这个。在这个变体中,存储实际上甚至不存在于程序中。但发生的情况是,我们在线程开始时使用放松内存操作推测性地读取某个值,我们推测了一个实际上无效的值。因此,结果是下一条语句在标准术语中产生了未定义行为。但这种未定义行为可以包括向另一个原子变量存储某些东西,而该变量正被另一个线程读取。

在这里的线程一中,当我们看到 sub r1, 42(如果 r1 恰好是那个错误的无效值),我们最终可能确实存储回了 y。因此,在这种行为下,我们再次可以得到 R1 等于 R2 等于 42,尽管这完全不合逻辑,因为我们是从 x 和 y 读取的,而它们应该与 a 和 b 毫无关系。

形式上,这与未定义行为的工作方式有关。这也与这样一个事实有关:再次,我们有了这种有趣的模式,每个线程中的加载都看到了来自另一个线程的并发存储,只是在这个案例中,并发存储实际上并不存在。它是未定义行为的一部分。我们可以继续吗?

OOTA (variant B): Should be disallowed

atomic<unsigned int> x(0), y(0);
int a[1], b[1];

Thread 1:                                      Thread 2:
+------------------------------------------+ +------------------------------------------+
| int r1 =_{rlx} x; /* Guesses 42 */       | | int r2 =_{rlx} y; // Guesses 42          |
| a[r1] = 42; /* Out-of-bounds access */   | | b[r2] = 42; // Out-of-bounds access      |
|            /* writes 42 to y. */         | |            // writes 42 to x.            |
+------------------------------------------+ +------------------------------------------+

Assigns 42 to x and y if a + 42 = &y and b + 42 = &x

(幻灯片:详细示例)

这更像是这里的一个更详细的例子,具体说明这将如何运作。我已在上一张幻灯片中讨论过这个。再次强调,是这里的越界访问导致了向另一个原子变量的写入。我们可以转到下一张幻灯片吗?

(幻灯片:禁止“凭空出现”)

正如 Paul 已经说过的,禁止“凭空出现”(Outlawing out of thin air)实际上是一个长期存在的问题。在规范中定义这一点非常困难。直到最近,我们还认为这纯粹是一个规范问题,从某种意义上说,我们只需要正确的措辞。实际上,正如我们将要讨论的,情况比那更复杂一些。正如 Paul 提到的,Java、C11 和 C++11 尝试过但失败了。事实证明,C11 和 C++11 实际上尝试过解决这个问题,但结果甚至比问题本身更糟。这个问题继续被许多人研究,包括 WG21 并发小组。

下一张幻灯片。

(幻灯片:C++ 标准中的现状)

所以,在我们修复了 C++11 中解决这个问题的错误尝试之后,我们现在在标准中有一些措辞,这些措辞令人尴尬地模糊不清,甚至在我们将它放入标准时,没有人真正相信它是充分的。它基本上只是承认了问题。它只是说实现应确保不会计算那些循环依赖于自身计算的“凭空出现”的值。我们从一开始就知道这实在太不精确了,但我们还是尴尬地把它留了下来,因为我们不知道更好的解决方案。

由于这在当前版本中是一种模糊的限制,实际上,让我试试。(提示有回声)让我快速看看是否能找出回声的来源。

(短暂停顿)

不幸的是,找不到。好吧,让我继续讲。

(幻灯片:严格 C++20 定义)

因此,为了处理这个模糊规范的存在,我们实际上,出于本次演讲的目的,将引入 严格 C++20(strict C++20) 的概念,它被定义为没有这个模糊声明的 C++20。所以当我们谈论某件事在严格 C++20 中是否正确时,我们的意思是它实际上严格符合 C++20 标准的精确部分。我们将在某些情况下讨论,这实际上可能比我们需要的要求更严格。另一方面,这是我们可以精确定义的,也是我们可以精确推理的。

我们可以转到下一张幻灯片吗?

Read-from unexecuted branch (RFUB)

atomic<int> x(0), y(0);

Thread 1:
+-----------------+
| int r1 =_{rlx} x;|
| y =_{rlx} r1;    |
+-----------------+

Thread 2:
+------------------------------------------------+
| bool assigned_42 = false;                      |
| r2 =_{rlx} y;                                   |
| if (r2 != 42) {                                |
|   assigned_42 = true;                          |
|   r2 = 42;                                      |
| }                                              |
| x =_{rlx} r2;                                   |
+------------------------------------------------+

Can result in
x = y = 42 and
assigned_42 = false!
wg21.link/p1217 has details.

(幻灯片:RFUB 的例子)

我向你承诺过另一种例子,这里我只简要概述一下,因为详细讲解相当复杂和冗长。它说明了事实上这不仅仅是一个纯粹的规范问题,因为确实存在一些介于中间的情况,难以区分什么是“凭空出现”的结果,什么不是。

所以,我在这张幻灯片上展示的例子与这个典型的“凭空出现”例子非常相似,其中一个线程将 X 复制到 Y,另一个线程将 Y 复制到 X。你在这里看到的蓝色代码实际上就是那个代码。我所做的是添加了绿色的代码,稍微扩充了一下。所以我在这里将 42 赋给一个局部变量(你可以认为是局部变量)。它没有被其他任何东西触及。

我引入了那个变量。然后我在这里引入了一些其他的绿色代码,在有趣的执行路径中,我可以证明它没有被执行。所以它实际上不应该有影响,也不应该影响程序的语义,因为我们知道它实际上没有执行。但尽管如此,有了这些额外的未执行代码在里面,事实上,我可以得到“凭空出现”的结果,而且可以在现有的实现中得到它。

所以,如你所见,我所做的是检查是否没有看到 R2 等于 42。然后我在之后将 R2 赋值为 42。我记录了我在 if 中走了这个分支,然后将其赋值回去。事实证明,这具有说服编译器 R2 将总是 42 的效果,这又使编译器相信可以再次重排这个操作。但如果发生了这种情况,我就可以得到这个执行,其中 X 等于 Y,R1 和 R2 都是 42,但 assigned_42 是 false。所以我在这里唯一更改的代码是未执行的代码。这个例子的细节你可以在那份代码中找到(指工作文件)。我们继续吧。

下面代码太多了
豆包转不过来
自己看幻灯片吧

(幻灯片:交还给 Paul)

Paul: 这次,我巧妙地解除了自己的静音(换种方式)。这是我上一张幻灯片的重复。我们有这个历史。根据 Hans 告诉你的信息,一个极好的问题是,在此期间,在我们定义好所有这些、在我们从形式上弄清楚什么是 Thin Air 和 RFUB 之前,你可以在哪里安全地使用 memory_order_relaxed

我们说的是,这也是这篇论文和本次演讲的重点,在此期间,你使用已知的良好模式(known good patterns)。好吗?换句话说,如果你去随机生成使用 memory_order_relaxed 的代码,我很抱歉,但就目前而言,你可能会得到应得的结果。也许是 OOTA,也许是 RFUB。不太可能是 OOTA,据我们所知,没有多少应用程序会那样做。事实是,对于其他内存序值也是如此,只是使用获取-释放(acquire-release)和顺序一致性(sequential consistency)等时,反直觉的陷阱更少一些。

所以,再次强调,如果你要使用 memory_order_relaxed,你应该在已知良好模式的背景下使用它。链接指向 Hans 和我去年二月份就这个主题提交的工作文件。

(幻灯片:何时完全安全?)

好的,那么一个问题是,它何时是完全安全的?在这里,我们的意思是在 Hans 定义的严格 C++20 范围内完全安全,我们排除了那些措辞含糊的例外。保持在这个界限内的好处是,正如 Hans 所说,我们可以精确地推理它。我们知道添加未执行的代码不会导致我们从从未执行的分支中读取这些有趣的值。

在本次演讲的剩余部分,Hans 和我会介绍一些东西。其中一些将是完全安全的。另一些虽然重要、安全且被大量使用,但仍然需要那些措辞含糊的规则。我们会为你区分它们。

(幻灯片:简单的安全用例)

好的,让我们先看一些简单的用例。一个例子是原子计数器(atomic counters)。这些是你运行时更新的计数器。这些可能是原子递增、放松的原子递增(relaxed),用于计算事件之类的。也许它们是每线程变量,是本地存储,或者只由使用它的线程递增的本地存储。每个计数器上只发生这些事情。没有人看别人的计数器。直到程序结束时,所有线程(主线程除外)都已终止。现在我们将它们读出并打印出来。

在这种情况下,我们没有竞争存储。我们无法得到典型的 OOTA 模式。所以这个用例是安全的,即使在严格的 C++ 中,也不需要那些有趣的建议。

另一个类别是通过 atomic_thread_fence 提供排序的情况。好吗?所以,与其说 memory_order_release 存储然后 memory_order_acquire 加载或别的,不如在适当的位置放置一个获取(acquire)的 atomic_thread_fence 或释放(release)的 atomic_thread_fence

在那种情况下,那些 atomic_thread_fence 调用阻止了典型 OOTA 模式的形成。

最后,这个听起来可能有点没用,但它出人意料地经常被使用。那就是**只有一个共享对象(only one shared object)**的情况。例如,我们可能在一个给定的多线程进程内运行一些独立的算法。它们可能只有一个共享变量,表示现在该停止了。可能是一个紧急停止类型的指示器。

在这种情况下,我们只有一个共享对象。因为典型模式需要两个共享对象,我们不可能有“凭空出现”的执行或“从未执行的分支中读取”的执行。这些是一些简单的例子。

(幻灯片:单向数据流)

继续这里。另一个例子,这个更复杂一点。所以我们要多花点时间在它上面。这就是单向数据流(unidirectional data flow)。其思想是数据在程序中只沿一个方向从一个线程流到另一个线程。

乍一看,这很安全,因为没有循环。但有一些技巧,我们会讲一遍。现在你必须小心。

通用方法是:线程一正在读取外部状态。我们有这个 get_external_state() 函数,它有传感器一和二正在读取。它将它们放入一些局部变量 S1 和 S2 中。它需要获取那个状态并对其进行某种归约(reduction)。也许根据过去完成的某些校准将其转换为标准单位。所以我们有 reduce_state_one()reduce_state_two() 来做这件事。我们稍后会讨论这两个函数必须提供的属性。

同时,线程二异步地获取最近的测量值。它们可能来自两个不同循环的测量值,这没关系。它们只是测量值。它们在某些时间发生。从外部世界通过传感器硬件、驱动程序等等存在延迟,我们最终得到这些东西。所以由于物理定律,它本来就奇怪地有序。所以多一点排序不是问题。

但我们要做的是,我们获取你读取的那两个值,然后计算一个控制动作。这就是 compute_ctl() 函数的作用。

所以所有三个函数,reduce_state_onereduce_state_twocompute_ctl,都必须小心编写。你有那个小纯函数的东西。我们现在就讨论一下那是什么意思。

这些函数必须是纯函数(pure functions)。它们显然不能到处改变共享变量。但它们还必须是纯粹且完备的,意思是无论通过它们的参数传入什么位模式,它们都必须产生一个确定的值,该值不能、也不可能引入未定义行为。明白吗?不能除以零,不能存储到数组末尾之外,不能有任何类似的东西。必须非常小心地编写,以避免任何可能引发未定义行为的痕迹。它必须是一个纯函数。

然后,一旦我们非常小心地通过线程一中的传感器输入、经过状态归约、再通过共享内存传递到线程二,计算出控制动作,我们使用 set_external_control() 函数更新外部状态,以提供所需的任何控制。好的?

还有其他一些管道的例子。但同样,对于这些值的计算,你必须非常小心,因为如果你有未定义行为,有趣的事情就可能发生。

(幻灯片:交还给 Hans)

好的。在这一点上,我把它交回给 Hans,他将更多地讨论是什么使这些习语安全或不安全。

Hans: 好的。那么是什么让某些东西不安全?通常让习语不安全的是我们在这里已经说明的:我们使用放松加载(relaxed load)加载一个原子值,然后我们依赖该值来保证正确性。要么是为了确定存储到另一个原子中的值,就像在我们将一个原子变量复制到另一个的简单例子中那样;要么是我们依赖我们得到的值来避免灾难性的错误行为。Paul 刚才也提到了这点。通常我们通过可能允许未定义行为作为错误推测值的结果而陷入第二种情况。

下一张幻灯片。

(幻灯片:问题模式的本质)

下一张幻灯片。所以一般来说,有问题的习语都有这种模式:我们最终对一个原子变量进行放松加载,然后如果我们最终得到一个错误的值,我们最终会表现出错误的行为。问题是,如果我们在两个不同的线程中将两个这样的东西组合在一起,我们再次得到这种有趣的交叉读取行为,我们从另一个线程中由错误行为产生的东西中读取放松的原子值。因此,再次,如果我们最终在另一个线程中产生错误行为。因此,再次,如果我们以某种方式错误推测,错误行为最终可能产生我们读取的那个值,我们随后可以确认推测,让自己相信结果可能是正确的。这在相同的事情中不会发生。然后我们将能够做到这一点。(此处最后几句重复且语义不清,可能是转录错误或口误)。所以,这实际上不是你能做的,但它不会在现实生活中发生,除了在那种从未执行的分支中读取的意义上。但在规范中很难排除,也很难推理。

(幻灯片:不安全但重要的习语:惰性初始化)

下一张幻灯片。所以,这是第一张幻灯片,某种程度上回答了“这实际上有什么用”的问题。所以,这实际上是一个在实践中使用但我们不知道如何推理的习语。

我们在这里做的是对原子值 X 和 Y 进行惰性初始化(lazily initializing)。我们通过读取 X,检查它是否有一个特殊值(我这里用了零)表示它尚未初始化。如果它还没有初始化,我们就通过评估某个纯函数来初始化它,这个函数保证总是产生相同的结果。所以,如果我多次评估它也没关系,我们使用那个结果作为 X 的值并赋值回 X。

通常,对于这种惰性初始化,我们需要更强的内存排序,需要更小心。但这是一个非常特殊的情况,我们以一种幂等的方式初始化一个简单的标量值,这样如果多个线程认为它们必须初始化这个值并且都去做了,它实际上也能正确工作。代码经过仔细编写以实现这一点。

所以,一般来说,这应该是正确的。但问题再次在于,如果我们信任 R1 的值并以某种方式使用它,以至于如果 R1 是错的,我们实际上引入了未定义行为,我这里通过具体做一件坏事来表明,即向一个随机位置赋一个随机值。只是我们认为未定义行为可以做的事情。

那么我再次可能陷入这样一种情况:如果我有两个线程,每个执行其中一个惰性初始化,并且每个都可能遇到未定义行为,它们在这里甚至没有触及相同的共享变量。但它们可以通过未定义行为隐式地触及另一个共享变量,该行为向另一个线程中的惰性初始化变量存储。因此,我在这里可以得到相同的错误模式。

再次,这是我在实践中实际使用的几个例子中的第一个,但我们不知道如何推理它。我们真的希望能够做到这一点。

下一张幻灯片。

(幻灯片:不安全但重要的习语:可重入互斥锁)

好的,下一个例子是我相信在实践中也相当常用的。这就是实现一个可重入互斥锁(re-entering mutex),一个你可以在同一个线程中多次获取而不会在自己身上死锁的互斥锁,它构建在一个现有的互斥锁之上。

这里的实现策略是:我们有一个简单的、普通的现有互斥锁,我们在外层获取它。然后如果我们再次尝试获取这个互斥锁,我们会小心避免再次锁定它,而只是记录我们获取了互斥锁多少次。

为了做到这一点,除了互斥锁本身,我们必须在原子变量中跟踪互斥锁的所有者。我们还要跟踪计数。互斥锁被持有的次数实际上是持有次数减一。后者保存在一个简单的整数中,由互斥锁保护。

所有者字段以一种复杂的方式被分散处理,我们可以修改它。我们只在持有互斥锁时修改它,但我们可以在不持有互斥锁的情况下读取它。我们保证所有者字段只能由特定线程 ID 对应的线程修改为该线程 ID 或将其修改为非该线程 ID。所以,没有其他线程可以将值改为我的线程 ID 或从我的线程 ID 改走。

(幻灯片:可重入互斥锁获取逻辑)

下一张幻灯片。所以,获取这种锁的方式基本上如我在这里所示:如果我尝试获取锁,我首先必须获取我的线程 ID(get_my_tid())。然后我检查,所有者是我吗?我已经拥有锁了吗?在不持有锁的情况下这样做是安全的,因为没有其他线程可以使该测试的真实性失效。另一个线程能够更改所有者,但不能改成我或从我改走。

如果我已经拥有锁,我只是增加计数(count++)。否则,我做更复杂的事情:我实际获取互斥锁(mutex.lock()),然后我存储所有者字段(owner.store(my_tid, relaxed))。

(幻灯片:可重入锁的问题)

下一张幻灯片。所以这从根本上存在相同类型的问题,尽管在这个案例中更微妙。这里的问题是,再次,如果我测试所有者是否是我,我正在进行一次放松加载。我在这里把放松加载和测试缩写成了一个语句。我对所有者进行放松加载并检查它是否是我。如果以某种方式我最终错误推测了。

我在这里在特定的执行中指明了:线程一,我处于线程一已经持有锁并且没有做任何特殊事情的情况。如果线程二错误地推测所有者字段实际上是我,会发生的情况是两者最终都增加了计数。我在这里在关键区中指明了,作为结果,我将最终有两个线程在关键区中。我在这里非常明确地表示,在大多数情况下,这会导致实际上未定义的行为。我在这里通过增加某个受互斥锁保护的变量 X 来做到这一点。然后如果我看到超过一个线程在里面,我就做一些坏事。我向一个随机位置存储,某种程度上反映了这里潜在的未定义行为。

所以,如果我让两个线程进入这个关键区,这里实际可能发生的是:线程一最终会向一个随机位置进行随机赋值,这可能最终赋值给所有者字段,这将最终满足线程二中的推测,从而说服线程二它的推测是正确的,继续执行是没问题的。所以,再次,这是一个更复杂的例子,我本质上遇到了相同类型的问题。我有一个放松加载,我信任它,我据此行动,结果我得到了错误的行为,这最终满足了推测。

所以,在严格 C++20(strict C++20) 中,没有“凭空出现”的限制,我再次可以得到这个错误的执行。

(幻灯片:为什么使用这种困难代码?)

好的,这里还有另一个问题。那么,我为什么要折腾这么困难的代码?这很难测试。我如何测试这段代码?这确实是个问题。这确实很难测试。嗯,很多并发代码都很难测试。你可能想避免这种代码。另一方面,有一些情况我们实际上必须依赖它。我想下一张幻灯片实际上有一个我们目前几乎必须依赖它的例子。根据经验,这样的代码是有效的,而且它加速了程序。它可能显著加速程序,因为使用 memory_order_relaxed 可以避免关键路径上的内存屏障,这可能带来巨大的性能提升。是否使用它取决于你。Paul 将更多地讨论实际上 memory_order_relaxed 完全可以安全使用的例子。

(幻灯片:其他重要的不安全习语)

下一张幻灯片。所以,在严格 C++20 中不安全的其他重要代码例子。我过去处理过的一个例子是,如果我们想实现一个并发垃圾收集器,例如用于 Java。情况往往是,嗯,通常是因为并发垃圾收集器需要读取正被客户端代码更改的指针值,我需要确保那些并发访问是安全的。所以,堆中的指针值本质上需要被视为原子访问。并且我需要使那些访问是放松的,否则我的 Java 代码运行得太慢,因为我最终在 Java 客户端代码中到处插入排序约束或屏障。

所以,这是一个我认为实际上无法避免使用这种代码的例子。

另一个有趣的例子是所谓的混沌松弛法(chaotic relaxation)。数值算法基本上依赖于只读取特定位置中最近的浮点值,而没有任何它们之间的同步。有时你可以从数值分析的角度证明这是可以的。另一方面,再次由于缺乏“凭空出现”禁令,我们可能会遇到这样的情况:基本上我们最终通过推测或多或少随机地得到一个最终结果,然后这个结果到处传播。所以,至少理论上存在它不起作用的场景。

下一张幻灯片。

(幻灯片:可重入锁的使用场景)

好的。我们有一个问题,可重入互斥锁(re-entering mutex) 在用户空间还是内核空间作为例子?使用它的情况是,你希望能够拥有一段执行需要加锁操作的公共代码,并且你想能够调用它,无论你是否已经持有那个锁。这种模式在内核和用户空间都相当常见。它不是主导性的。不是很大一部分,但它确实会发生。

(幻灯片:Paul 的总结与安全习语示例)

Paul: 我们还有一个重复的问题:为什么我要折腾困难的代码?如何测试那段代码?并发性不一定容易。另一点是,我们这里说的是你需要使用已知的良好模式(known good patterns)。如果你选择只是把并发代码拼凑在一起,你会有问题。即使是顺序一致性也救不了你。好的。所以,如果你在做并发,除非你遵循已知的、经过良好测试的指南,是的,那会非常困难。这是 Hans 和我自己已经成功做了很长时间的事情。但就我自己而言,我知道我偶尔也会搞砸,不得不努力修复。

我们为什么要这样做?因为在某些情况下,它能给你巨大的性能提升。在 Linux 内核中有一些案例,这种代码在某些事情上获得了多个数量级的加速。所以,我的意思是,对于那种速度提升,我愿意投入一些认真的时间和精力,以及一些严肃的验证。事实上,我现在的雇主,我猜 Hans 的雇主也一样,对于某些事情,甚至愿意为 1% 的提升付出巨大代价,考虑到 Hans 的雇主和我的雇主据传拥有的机器数量。所以,是的,如果你不需要,就别用它。你完全正确地说到了点子上。如果可以,保持简单。如果可以,只写单线程代码。如果那对你有用,就用单线程代码,开心点。如果你能使用纯锁而不必做任何这些花哨的东西,那就那样做。开心点。完成你的工作。继续生活。

但有些时候你必须超越这些。而双重检查锁定(double-check locking) 就是其中一个例子。所以,我们在这里做的是,这是一个即使在严格 C++ 中也是安全的例子。它被非常大量地使用,并且是可证明安全的。所以,我们有这个 x_init。我们加载它,我们使用 memory_order_acquire。我们使用一个锁守卫来做互斥。然后我们用 relaxed 重复加载。好吗?然后我们初始化它。然后我们存储初始化后的值。

所以这是安全的,并且相当容易证明它是安全的。我们有一个获取(acquire)。所以发生在那个加载之后的事情不能重排。然后我们获取一个锁。一旦我们获取了那个锁,对 X 或与 X 相关的任何东西的唯一存储都是在持有该锁的情况下进行的。所以不可能有任何竞争访问。然后一旦我们释放锁,其他人会进来看到 x_init 已经是真,他们会继续做他们的事,生活就很美好。这将避免如果我们让他们并发运行可能产生的混淆和未定义行为。

这是 Hans 之前给出的例子的更复杂版本。好吗?他那个例子只有一个值,因此不需要那么小心。好的。

(幻灯片:为什么需要双重检查锁定?)

那么为什么要费心做这个呢?嗯,这个 lock_guard mutex,那是一个全局锁。理论上,忽略性能和可扩展性,你可以只获取锁然后取出值。那理论上会很棒。但你在一个有 100 个 CPU 的机器上试试,你会发现你得到的吞吐量比单个 CPU 还少得多。所以,如果你有一个需要初始化一次并且被许多 CPU 非常频繁使用的情况。顺便说一句,100 是夸张的说法。在 16 个 CPU 上你就可能有问题了。我们过去在 16 个 CPU 上就见过这个问题。而这种小的增量复杂性允许非常简单的代码在非常大的系统上非常可扩展、非常快速地工作。

(幻灯片:安全的习语:带检查的 CAS)

我们来看一个——Hans 提到过一个你没有选择的情况。这个例子,理论上你有选择,但在大型机器的实践中,你没有选择。你得不到你需要的性能和可扩展性。

下一个例子是即使在理论上你也没有选择的情况,对吗?一组常见的算法使用 比较并交换(compare and swap),比较并交换。这里将要发生的是,我们将从内存加载一个值,我们可以使用放松加载。我们能使用放松加载的原因是,比较并交换会为我们进行检查。如果我们得到一个错误的值,那么它会说,哦,我们没有那个值。不匹配。然后我们就可以继续处理。它会失败。我们会再试一次。

(幻灯片:回答关于双重检查锁定的问题)

我们还有一个问题。对于 Mutex 作用域锁中的存储,memory_order_release 是必要的吗?让我们回去看看。事实上,如果你看那里,有一个 memory_order_release 的存储。x_init.store(true, release)。这样做的原因是,这样它将与稍后或同时进行的获取加载配对。如果它们同时进行,它们将获取锁。这将理顺事情。如果有人正好在那个初始化存储(init_store)完成时执行,它将是一个释放,他们将执行获取。这些将配对。并且他们后续的访问将被保证看到 X 的初始化。这回答了问题吗?嗯,如果没有,我们稍后再讨论。所以,是的,你确实需要一个释放。你只需要一个。而且它只需要在 x_init 上,不需要在 X 上。好的。

(幻灯片:安全的习语:带检查的 CAS(续))

所以,回到比较并交换。我们有一个加载。我们可能有推测。你知道,编译器可能发疯并决定它想推测并猜测一些奇怪的值。如果发生了,如果那个奇怪的值从未出现过,那么 compare_exchange_weak 保证会失败。我们将去重新加载。希望这次它能聪明地得到一个真实的值。如果它反复推测出虚假的值,我将向该编译器版本提交一个错误报告,希望能解决它。

所以,这又是一个例子,如果你想做某些并发算法,你别无选择,只能使用容忍放松的模式。你可以为那个加载使用获取,但那样在许多系统上会给你额外的开销。在某些情况下,这种开销是不可接受的。

好的。继续前进。我们有点落后了,所以我不打算详细讲解这个。我想留出时间回答更多问题。这是从这份工作文件(working paper)的更新版本中复制的一个表格,我本希望昨天提交,但生活阻碍了我。

我们有一堆类别。我们已经涵盖了一些例子,涉及其中许多类别。好的。有些类别在严格 C++ 中是安全的。那是最后一列。如果那里有一个大写的 Y,据我们所知,该类别是安全的,并且在严格 C++ 中是可分析的,没有那些措辞含糊的规则。生活很美好。如果你能把自己限制在那个组里,那就那样做。事情会更容易。你的生活会更好。

然而,正如我们所见,有像可重入互斥锁(Reentrant Mutex) 这样的情况,我们可能需要它来使……你可以……如果你不提供一个可重入互斥锁,你的开发者会手工发明一个,从而犯下所有可能犯的错误。所以,把你的原语(primitive)放进去,包装好,让能做的人来做,对它进行彻底的测试,然后发布让人们使用。

Hans 谈到了惰性标量初始化。我们也看到混沌松弛法和垃圾收集作为重要的算法被使用,这些算法是需要的,并且是放松访问对于性能很重要的地方。

(幻灯片:总结)

综上所述。正如一些问题所暗示的,我的意思是,你们不只是随便说说。在这里正确使用 memory_order_relaxed 可能很棘手,因为我们还不知道一种有效的方式来正式定义“凭空出现”和“从未执行的分支中读取”的边界。

有一些重要的用例在某些情况下是必要的,在实践中有效,但我们仍然没有精确的正确性论证来支持它们。作为其中一部分的权宜之计,我们正在定义一个严格 C++20(strict C++20),它允许我们精确地推理其中一些用例。其中一些是重要的,这是一个良好的进步。

然而,好消息是,有许多常见的放松使用模式在严格 C++20 中是可证明正确的(demonstrably correct)。甚至更好的消息,或者说额外的好消息是,许多其他我们没有精确正确性论证的模式在实践中似乎有效,并且我们通过查看实现有很好的理由在标准范围之外论证它们确实有效。但我们希望能够将这种推理水平带入标准,这样我们就不必检查大量的实现。

(幻灯片:结束语)

所以,说到这里,如果你有更多问题,Hans 在这里列出了一个参考文献列表,供那些想更深入研究的人参考。

我们还有更多问题吗?实际上,让我回答一个没有被问到但可能隐含在一些问题中的问题,我认为这里的动机之一是 memory_order_relaxedmemory_order_seq_cst 甚至 acquire-release 之间可能存在巨大的成本差异。所以,在像 ARM 这样的架构上,这通常是一到两个数量级的差异,比如说。至少在缓存命中的情况下。Hans 说:我同意,我也见过。如果你有一条快速路径,并且你已经把事情安排得让它工作得很快,那个额外的内存屏障或额外的获取加载指令真的会让你很受伤。

(幻灯片:问答环节)

我们有一个署了我名字的问题。memory_order_consume 怎么样?嗯,不太好,我想是最简短诚实的答案。我们确实有一些工作在进行。几周前我们在 Linux Plumbers Conference 开了一个会,讨论了让它更好地融入的方法。memory_order_consume 并不是 Linux 内核中唯一需要帮助的依赖关系用法。目前,我们仍然使用 volatile 访问来使其工作,同时也使用一些非常严格的编码限制。如果你在使用 RCU 引用,这是我希望能用 memory_order_consume 实现的,但我不能,因为在所有实现中它都被提升为 memory_order_acquire。这会在某些实现中插入一个内存屏障,而在某些架构上我们无法承受。所以我们仍然使用 volatile 加载来实现它,并且我们仍然使用编码限制。

哦,我想下一个问题是后续问题。已经研究了几种不同的东西,几个不同的方向。一个是苹果的一些人,JF Bastien 和 Tim Northover,他们保持一个——这作为一份工作文件出现过,我此刻不幸不记得编号了——他们所做的是保留一个额外的量与指针一起,跟踪依赖关系。他们小心地将这个额外的依赖关系与编译器隔离,然后在每次引用之前将其与指针组合。从而防止依赖关系丢失,无论你在此期间对指针做了什么。这是一个非常聪明的技巧。有些人反对指针因此变大。

另一种方法是 Google Summer of Code 的一个原型,我应该说是由 Google Summer of Code 的一个人做的,我自己指导的,Akshat Garg。他是 IIT Mumbai 的学生,非常聪明的小伙子。他所做的是将 GCC 中的 volatile 实现复制到一个标记中,该标记应用于需要保留依赖关系的指针。这在 GCC 邮件列表上引起了一些讨论。到目前为止,我们还没有找到进一步推进的方法,但希望我们能找到一种方法来告诉编译器,“这个变量携带依赖关系,请不要破坏它。”

我们还在 Linux 内核中有越来越多的地方依赖于控制依赖,而在这一点上,我们甚至没有一种商定的方式来指定控制依赖如何工作。所以这是一个更大的挑战。

(幻灯片:更多问答)

好的,还有一个问题,答案基本上是“是”。问题是:如果我不使用 memory_order_relaxed,我需要担心这类问题吗?或者更确切地说,我是否不会受到这种“凭空出现”问题的困扰?只要你也不使用 memory_order_consume,那么答案是“是”。使用 memory_order_consume,事情再次变得复杂。嗯,除了在当前的实现中,它是完全安全的,因为它是无用的,对吧?因为它变成了获取。抱歉。这是真的,是的。在当前的实现中,它没问题,是的。

Hans 对我补充了一点说明:这假设你避免了未定义行为。如果你不使用 memory_order_relaxed,但你确实在共享的非原子变量上使用正常的 C 语言加载和存储,那么,抱歉,所有的赌注都作废了。所以你还需要遵守的另一条限制是,你所有的共享变量都必须是原子变量。

例子中的那个带有 compare_exchange 的 while 循环,假设我们在几个核心上运行。内存屏障、读写屏障怎么样?我见过内核代码在性能上进行比较。我再读一遍:“例子中的那个带有 compare_exchange 的 while 循环,假设我们在几个核心上运行,内存屏障、读写屏障怎么样?我见过内核代码在性能上进行比较。”

所以你可能在问几件事。一件可能是:哎呀,如果我承受了通常与比较交换一起发生的缓存未命中开销,我还会注意到内存屏障开销吗?这将取决于硬件。但在许多情况下,是的,如果你在每次操作上都发生缓存未命中,是的,生活会很艰难。另一方面,如果你很小心,并且你设计的算法具有良好的缓存局部性,所以你做的事情是一个量通常由一个线程访问,但其他线程在任何时候都可能访问它,只是很少发生,那么你大多数时候会是缓存命中。然后那个内存屏障就是明显的。所以在那种情况下,你会想使用 relaxed

(幻灯片:关于混沌松弛法的问题)

Hans,你愿意回答下一个关于混沌松弛法的问题吗?我会试试。我怀疑我们俩都不是真正的专家。如果我没记错的话,原始的混沌松弛法论文并没有具体讨论内存排序。为了性能,放松是绝对必需的吗?我认为任何“发射后不管”的 += 都可以工作。

我的理解,基于和一些在这个领域工作更密切的同事交谈,是基本上你通常无法承受在这些代码中的许多地方有任何额外的内存屏障。因此,为了避免内存屏障,人们倾向于使用 memory_order_relaxed 而不是更强的东西。或者事实上,在许多情况下,他们作弊并使用非原子操作,这是强烈不鼓励的,因为它可能导致不正确的结果。所以我没有个人经验知道如果你在那里使用更强的内存排序而不是 memory_order_relaxed 会有多糟糕。但根据与人们交谈的印象,这是不可接受的。

Paul: 为了补充 Hans 关于人们对共享变量使用非原子操作的观点,我不得不在许多项目中不断打这场仗,有些人就想对一个共享变量说 A = 1 并相信它会工作,而它确实在令人惊讶的多数时候有效。

(幻灯片:结束)

Hans & Paul: 无论如何,我们超时了。Hans 和我确实还有一些额外的时间。如果你想继续提问,包括刚刚出现的那个(出现了几个),我将复制粘贴那些问题。我们将去 Remo 的九楼讨论这个。Hans 和我会在那里。如果你加入我们,我们可以通过聊天继续交谈。并且我们有可能能和每个人交谈,但鉴于我在视频会议方面的笨拙,我就不指望这个了。所以再次强调,Remo 的九楼,Hans 和我会加入那里。会有一个通用的聊天频道。我们会在那上面回答问题,或者任何你能让我们看到它们的方式。

我现在要复制粘贴最后两个问题,它们可能已经在那里了,但谁知道呢。无论如何,对于其他人,非常感谢你们的时间和关注。很高兴能和你们讨论这个。非常感谢。

谢谢。