Atomics
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;
这颠倒了事件的顺序,并且完全删除了一个事件。从单线程的角度来看,这是完全无法观察到的:在所有语句执行完毕后,我们处于完全相同的状态。但是如果我们的程序是多线程的,我们可能一直依赖x
在y
被分配之前实际被分配为 1。我们希望编译器能够进行这类优化,因为它们可以大量地提高性能;而另一方面,我们也希望能够相信我们的程序做我们所说的事情。
硬件重排序
另一方面,即使编译器完全理解我们的意图并尊重我们的意愿,我们的硬件可能反而会给我们带来麻烦。麻烦来自于 CPU 的内存层次结构。在你的硬件中确实有一个全局共享的内存空间,但从每个 CPU 核心的角度来看,它是非常遥远的,而且非常慢。每个 CPU 宁可使用其本地的数据缓存,而只在其缓存中没有该内存的时候才去和共享内存对话,这是很痛苦的。
毕竟,这就是缓存的全部意义所在,对吗?如果每次从缓存中读出的数据都要跑回共享内存中去仔细检查是否有变化,那还有什么意义呢?最终的结果是,硬件并不能保证在一个线程上以某种顺序发生的事件,在另一个线程上以同样的顺序发生。为了保证这一点,我们必须向 CPU 发出特殊指令,让它变得不那么聪明。
例如,假设我们说服编译器发出这样的逻辑:
initial state: x = 0, y = 1
线程 1 线程 2
y = 3; if x == 1 {
x = 1; y *= 2;
}
理想情况下,这个程序有两种可能的最终状态:
y = 3
:线程 2 在线程 1 完成之前做了检查y = 6
:线程 2 在线程 1 完成后做了检查
然而,还有第三种潜在的状态是硬件可以实现的:
y = 2
:线程 2 看到了x = 1
,但没有看到y = 3
,然后改写了y = 3
值得注意的是,不同种类的 CPU 提供不同的保证。通常将硬件分为两类:强有序和弱有序。最值得注意的是 x86/64 提供强有序保证,而 ARM 提供弱有序保证。这对并发编程有两个后果:
- 在强有序的硬件上要求更强的保证可能很便宜,甚至是无开销的,因为它们已经无条件地提供了强保证;较弱的保证可能只在弱有序的硬件上产生性能优势
- 在强有序硬件上要求太弱的保证,更有可能恰巧发生作用,即使你的程序严格来说是不正确的;如果可能的话,并发算法应该在弱有序的硬件上进行测试
数据访问
C++ 内存模型试图通过允许我们谈论我们程序的因果性来弥补这一差距。一般来说,这是通过在程序的各个部分和运行它们的线程之间建立一种happen-before的关系。这给了硬件和编译器一定的自由度,在没有建立严格的 happen-before 关系的地方更积极地优化程序,但也迫使他们在建立了关系的地方更加小心。我们沟通这些关系的方式是通过数据访问(data accesses)和原子访问(atomic accesses)。
数据访问是编程世界的主体,它们从根本上说是不同步的,编译器可以自由地对它们进行积极的优化。特别是,数据访问可以自由地被编译器重新排序,前提是程序是单线程的。硬件也可以自由地将数据访问中的变化传播给其他线程,只要它想,就可以懒散地、不一致地传播。最关键的是,数据访问是数据竞争发生的方式。数据访问对硬件和编译器非常友好,但正如我们所看到的,如果试图用它来编写同步代码,它提供的语义太弱了。
仅仅使用数据访问是不可能写出正确的同步代码的。
原子访问是我们告诉硬件和编译器我们的程序是多线程的方式。每个原子访问都可以用一个顺序来标记,指定它与其他访问的关系。在实践中,这可以归结为告诉编译器和硬件它们不能做的某些事情。对于编译器来说,这主要是围绕着指令的重新排序展开的。对于硬件来说,这主要是围绕着如何将写操作传播给其他线程。Rust 所提供的顺序集合是:
- 顺序一致(Squentially Consistent,SeqCst)
- Release
- Acquire
- Relaxed
(注意:我们明确地不暴露 C++ 的 consume 排序)
TODO:消极推理与积极推理?TODO:“不能忘记同步”
顺序一致性
顺序一致是所有顺序中最强大的,它意味着包含所有其他顺序的限制。直观地说,一个顺序一致的操作不能被重新排序:一个线程上所有发生在 SeqCst 访问之前和之后的访问都保持在它之前和之后。一个只使用顺序一致的原子和数据访问的无数据竞争程序有一个非常好的特性,即有一个所有线程都同意的程序指令的单一全局执行的顺序。这种执行方式也特别好推理:它只是每个线程的单独执行的交错。如果你开始使用较弱的原子顺序,这就不成立了(译者注:也就是说,同一时刻,针对同一个别名/内存位置,仅能有一条指令在执行,不能出现并发)。
顺序一致性对开发者的相对友好并不是免费的。即使在强排序的平台上,顺序一致性也会涉及到内存屏障。
在实践中,顺序一致性对于程序的正确性很少有必要。然而,如果你对其他的内存顺序没有信心的话,顺序一致性绝对是正确的选择。让你的程序运行得比它需要的慢一点,肯定比它运行得不正确要好!从机制上来说,降低原子操作的等级,以便在以后拥有较弱的一致性也是很容易的。只要把SeqCst
改成Relaxed
就可以了! 当然,证明这种转换是正确的是一个完全不同的问题。
Acquire-Release
Acquire 和 Release 在很大程度上是用来配对使用的。它们的名字暗示了它们的使用情况:它们非常适合于获取和释放锁,并确保关键部分不会重叠。
直观地说,一个 Acquire 的访问可以确保它之后的每一个访问都保持在它之后。然而,在 Acquire 之前发生的操作可以自由地被重新排序到它之后发生。同样地,一个 Release 访问确保它之前的每一个访问都保持在它之前。然而,在 Release 之后发生的操作可以自由地被重新排序到它之前发生。
当线程 A Release 了内存中的一个位置,然后线程 B 随后 Acquire 了内存中相同的位置,因果关系就建立了。在 A Release 之前发生的每一个写(包括非原子写和 Relaxed 的原子写)都会在 B Acquire 之后被观察到。然而,与任何其他线程的因果关系都没有建立。同样地,如果 A 和 B 访问内存中不同的位置,也不会建立因果关系。
因此,Release-Acquire 的基本用法很简单:你 Acquire 一个内存位置来开始关键部分,然后 Release 这个位置来结束它。例如,一个简单的自旋锁可能看起来像这样:
use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::thread; fn main() { let lock = Arc::new(AtomicBool::new(false)); // 我上锁了吗 // ... 用某种方式将锁分发到各个线程(thread::spawn) ... // 尝试将原子变量设置为 true,以此来获得锁 while lock.compare_and_swap(false, true, Ordering::Acquire) { } // 从循环中跳出,说明此时已经获取了锁 // ... 恐怖的数据访问 ... // 工作完成了,释放锁 lock.store(false, Ordering::Release); }
在强有序平台上,大多数访问都有 Release 或 Acquire 语义,使得 Release 和 Acquire 往往是完全免费的。而在弱有序平台上则不是这样。
Relaxed
Relaxed 的访问是绝对最弱的。它们可以被自由地重新排序,并且不提供任何 happen-before 的关系。不过,Relaxed 的操作仍然是原子性的。也就是说,它们不算是数据访问,对它们进行的任何读-改-写操作都是原子性的。Relaxed 操作适用于那些你肯定希望发生,但并不特别在意的事情。例如,如果你不使用计数器来同步任何其他访问,那么多个线程可以安全地使用 Relaxed 的fetch_add
来增加一个计数器。
在强有序平台上,Relaxed 操作很少有好处,因为它们通常提供 Release-Acquire 的语义。然而,在弱有序平台上,Relaxed 的操作会更便宜。