原子操作

Rust 非常明显地继承了 C++20 的原子操作内存模型。这并不是因为该模型特别优秀或易于理解。事实上,这个模型相当复杂,并且已知存在一些缺陷。相反,这是一个务实的让步,因为每个人在对原子操作建模方面都相当糟糕。至少,我们可以从现有的围绕 C/C++ 内存模型的工具和研究中受益。(您经常会看到此模型被称为“C/C++11”或简称“C11”。C 只是复制了 C++ 内存模型;而 C++11 是该模型的第一个版本,但此后它收到了一些错误修复。)

试图在这本书中完整地解释这个模型是相当无望的。它是由令人抓狂的因果关系图定义的,需要一整本书才能以实用的方式正确理解。如果您想要所有细节,您应该查看C++ 规范。尽管如此,我们还是会尝试介绍 Rust 开发人员面临的一些基本概念和问题。

C++ 内存模型基本上是试图弥合我们想要的语义、编译器想要的优化以及我们的硬件想要的混乱之间的差距。我们希望编写程序并让它们完全按照我们说的去做,但你知道,要快。那不是很棒吗?

编译器重排序

编译器从根本上希望能够进行各种复杂的转换,以减少数据依赖性并消除死代码。特别是,它们可能会从根本上改变事件的实际顺序,或者使事件永远不会发生!如果我们写这样的代码

x = 1;
y = 3;
x = 2;

编译器可能会得出结论,如果您的程序执行以下操作,那将是最好的

x = 2;
y = 3;

这颠倒了事件的顺序,并完全消除了一件事。从单线程的角度来看,这是完全不可观察的:毕竟所有语句都已执行,我们处于完全相同的状态。但是,如果我们的程序是多线程的,我们可能一直依赖于在分配 y 之前实际将 x 分配给 1。我们希望编译器能够进行此类优化,因为它们可以显着提高性能。另一方面,我们还希望能够依赖于我们的程序来做我们所说的事情。

硬件重排序

另一方面,即使编译器完全理解我们想要什么并尊重我们的意愿,我们的硬件也可能会给我们带来麻烦。麻烦来自内存层次结构形式的 CPU。在您的硬件中的某个地方确实有一个全局共享内存空间,但从每个 CPU 内核的角度来看,它是如此遥远,而且如此缓慢。每个 CPU 都宁愿使用其本地数据缓存,并且只有在缓存中实际上没有该内存时才经历与共享内存通信的所有痛苦。

毕竟,这就是缓存的全部意义,对吧?如果每次从缓存读取都必须返回到共享内存以仔细检查它是否没有更改,那有什么意义呢?最终结果是硬件不保证在一个线程上按某种顺序发生的事件在另一个线程上按相同的顺序发生。为了保证这一点,我们必须向 CPU 发出特殊指令,告诉它不要那么聪明。

例如,假设我们说服编译器发出以下逻辑

initial state: x = 0, y = 1

THREAD 1        THREAD 2
y = 3;          if x == 1 {
x = 1;              y *= 2;
                }

理想情况下,该程序有 2 种可能的最终状态

  • y = 3:(线程 2 在线程 1 完成之前进行了检查)
  • y = 6:(线程 2 在线程 1 完成之后进行了检查)

但是,硬件启用了第三种潜在状态

  • y = 2:(线程 2 看到 x = 1,但没有看到 y = 3,然后覆盖了 y = 3

值得注意的是,不同类型的 CPU 提供不同的保证。通常将硬件分为两类:强排序和弱排序。最值得注意的是,x86/64 提供强排序保证,而 ARM 提供弱排序保证。这对并发编程有两个后果

  • 在强排序硬件上要求更强的保证可能很便宜,甚至免费,因为它们已经无条件地提供了强保证。较弱的保证可能只会在弱排序硬件上产生性能优势。

  • 在强排序硬件上要求过弱的保证更有可能碰巧有效,即使您的程序严格来说是不正确的。如果可能,并发算法应该在弱排序硬件上进行测试。

数据访问

C++ 内存模型试图通过允许我们讨论程序的因果关系来弥合差距。通常,这是通过在程序的部分和运行它们的线程之间建立先发生后发生的关系。这为硬件和编译器提供了在未建立严格的先发生后发生关系的情况下更积极地优化程序的空间,但迫使它们在我们建立先发生后发生关系的情况下更加小心。我们传达这些关系的方式是通过数据访问和原子访问。

数据访问是编程世界的基础。它们本质上是不同步的,编译器可以自由地积极优化它们。特别是,数据访问可以由编译器自由地重新排序,前提是程序是单线程的。硬件也可以自由地将数据访问中所做的更改尽可能懒惰和不一致地传播到其他线程。最关键的是,数据访问是数据竞争发生的方式。数据访问对硬件和编译器非常友好,但正如我们所见,它们为尝试使用同步代码编写提供了糟糕的语义。实际上,这太弱了。

实际上,不可能仅使用数据访问来编写正确的同步代码。

原子访问是我们告诉硬件和编译器我们的程序是多线程的。每个原子访问都可以标记一个顺序,该顺序指定它与其他访问建立什么样的关系。在实践中,这归结为告诉编译器和硬件某些他们不能做的事情。对于编译器来说,这主要围绕指令的重新排序。对于硬件来说,这主要围绕写入如何传播到其他线程。Rust 公开的排序集是

  • 顺序一致性 (SeqCst)
  • 释放
  • 获取
  • 宽松

(注意:我们没有明确公开 C++ 的 consume 排序)

TODO:负面推理与正面推理?TODO:“不能忘记同步”

顺序一致性

顺序一致性是最强大的,它暗示着所有其他排序的限制。直观地说,顺序一致的操作不能重新排序:一个线程上发生在 SeqCst 访问之前和之后的所有访问都保持在其之前和之后。仅使用顺序一致的原子操作和数据访问的无数据竞争程序具有非常好的属性,即程序指令有一个所有线程都同意的全局执行顺序。这种执行也特别容易推理:它只是每个线程的单个执行的交织。如果您开始使用较弱的原子排序,这将不成立。

顺序一致性的相对开发人员友好性并非免费的。即使在强排序平台上,顺序一致性也涉及发出内存栅栏。

在实践中,顺序一致性很少是程序正确性所必需的。但是,如果您对其他内存顺序没有信心,那么顺序一致性绝对是正确的选择。让您的程序运行速度比需要的慢一点,当然比它运行不正确要好!将原子操作降级为稍后具有较弱的一致性在机械上也是微不足道的。只需将 SeqCst 更改为 Relaxed 即可!当然,证明这种转换是正确的完全是另一回事。

获取-释放

获取和释放很大程度上旨在配对使用。它们的名字暗示了它们的用例:它们非常适合获取和释放锁,并确保关键部分不重叠。

直观地说,获取访问确保它之后的所有访问都保持在它之后。但是,在获取之前发生的的操作可以自由地重新排序到它之后发生。类似地,释放访问确保它之前的每个访问都保持在它之前。但是,在释放之后发生的的操作可以自由地重新排序到它之前发生。

当线程 A 释放内存中的某个位置,然后线程 B 随后获取*同一个*内存位置时,因果关系就建立了。A 释放之前发生的所有写入(包括非原子写入和宽松原子写入)都将在 B 获取之后被观察到。但是,与任何其他线程都没有建立因果关系。类似地,如果 A 和 B 访问内存中的*不同*位置,则不会建立因果关系。

因此,释放-获取的基本用法很简单:获取内存中的某个位置以开始关键部分,然后释放该位置以结束它。例如,一个简单的自旋锁可能如下所示

use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;

fn main() {
    let lock = Arc::new(AtomicBool::new(false)); // value answers "am I locked?"

    // ... distribute lock to threads somehow ...

    // Try to acquire the lock by setting it to true
    while lock.compare_and_swap(false, true, Ordering::Acquire) { }
    // broke out of the loop, so we successfully acquired the lock!

    // ... scary data accesses ...

    // ok we're done, release the lock
    lock.store(false, Ordering::Release);
}

在强排序平台上,大多数访问都具有释放或获取语义,这使得释放和获取通常是完全免费的。弱排序平台并非如此。

宽松

宽松访问是最弱的。它们可以自由地重新排序,并且不提供先发生后发生关系。尽管如此,宽松操作仍然是原子的。也就是说,它们不算作数据访问,并且对它们执行的任何读-修改-写操作都是原子发生的。宽松操作适用于您肯定希望发生的事情,但除此之外并不特别关心的事情。例如,如果您没有使用计数器来同步任何其他访问,则可以使用宽松的 `fetch_add` 通过多个线程安全地递增计数器。

在强排序平台上,使操作宽松很少有好处,因为它们通常无论如何都提供释放-获取语义。但是,在弱排序平台上,宽松操作可能会更便宜。