原子操作

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 安全地由多个线程递增计数器。

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