原子(Atomics)

Rust很明显地只是从C++20继承了原子的内存模型.这不是因为该模型特别优秀或易于理解.实际上,这种模型非常复杂,并且已知有几个缺陷.相反,这是一个务实的让步,因为 每个人(everyone) 都非常擅长建模原子.至少,我们可以从围绕C/C++内存模型的现有工具和研究中受益.

(你经常会看到将此模型称为”C/C++11”或简称为”C11”.C只是复制C++内存模型,C++11是该模型的第一个版本,但从那以后它已经修复了一些bug.)

试图在本书中完全解释模型是相当无望的.它是根据令人发狂的因果关系图来定义的,这些图需要一整本书以实际的方式正确理解.如果你想了解所有本质细节,请查看[C++20 specification.不过,我们将尝试涵盖Rust开发人员面临的基础知识和一些问题.

C++内存模型基本上是试图弥合我们想要的语义,编译器想要的优化和我们的硬件所需的不一致混乱之间的差距. 我们(We) 想编写程序并让它们完全按照我们的说法完成,但是,你知道,快速.那不是很棒吗?

编译器重排序(Compiler Reordering)

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

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

编译器可能会认为你的程序最好

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

这颠倒了事件的顺序并完全消除了一个事件.从单线程角度来看,这是完全不可观察的:在所有语句执行完毕后,我们处于完全相同的状态.但是如果我们的程序是多线程的,我们可能依赖于xy赋值前之前实际赋值1.我们希望编译器能够进行这些优化,因为它们可以严重提高性能.另一方面,我们也希望能够依靠我们的程序 来完成我们所说的事情(doing the thing we said) .

硬件重排序(Hardware Reordering)

另一方面,即使编译器完全理解我们想要的并尊重我们的愿望,我们的硬件也可能会让我们陷入困境.麻烦来自内存层次结构形式的CPU.硬件中某处确实存在全局共享内存空间,但从每个CPU内核的角度来看, 它非常遥远而且非常慢(so very far away and so very slow) .每个CPU宁愿使用其本地数据缓存,只有当它实际上没有缓存中的内存时,才会经历与共享内存交谈的所有痛苦.

毕竟,这是缓存的重点,对吧?如果每次从缓存读取都必须运行回共享内存以仔细检查它是否没有改变,那么重点是什么?最终结果是硬件不保证在 一个(one) 线程上以相同顺序发生的事件在 另一个(another) 线程上以相同的顺序发生.为了保证这一点,我们必须向CPU发出特殊指令,告诉它不那么聪明.

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

  1. initial state: x = 0, y = 1
  2. THREAD 1 THREAD2
  3. y = 3; if x == 1 {
  4. x = 1; y *= 2;
  5. }

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

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

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

然而,硬件具有第三种可能的状态:

  • y = 2:(线程2看到x = 1,但不是y = 3,然后覆盖y = 3)

通常将硬件分为两类:强排序(strongly-ordered)和弱排序(weakly-ordered).最值得注意的是x86/64提供了强排序保证,而ARM提供了弱排序保证.这对并发编程有两个影响:

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

  • 要求在强排序的硬件上过于弱的保证更有可能 碰巧(happen) 工作,即使你的程序严格错误.如果可能,应在弱排序的硬件上测试并发算法.

数据访问(Data Accesses)

C++内存模型试图通过允许我们讨论程序的 因果(causality) 关系来弥补这个缺陷.通常,这是通过在程序的部分和运行它们的线程之间建立 以前发生的(happens before) 关系来实现的.这为硬件和编译器提供了更积极地优化程序的空间,使其在严格发生的情况下—在没有建立关系之前—进行优化,但会迫使他们在建立关系时更加谨慎.我们通信这些关系的方式是通过 数据访问(data accesses)原子访问(atomic accesses) .

数据访问是编程世界的基础.它们基本上是不同步的,编译器可以自由地积极地优化它们.特别是,在假设程序是单线程的情况下,编译器可以自由地重新排序数据访问.硬件也可以自由地将数据访问中所做的更改传播到其他线程,并且可以随意地,不一致地传播给其他线程.最关键的是,数据访问是数据竞争发生的方式.数据访问对硬件和编译器非常友好,但正如我们所见,它们提供了 糟糕的(awful) 语义来尝试编写同步代码.实际上,这太弱了.

仅使用数据访问来编写正确的同步代码简直是不可能的.

原子访问是我们如何告诉硬件和编译器我们的程序是多线程的.每个原子访问都可以使用一个 顺序(ordering) 进行标记,该顺序指定它与其他访问建立的关系类型.在实践中,这归结为告诉编译器和硬件他们 不能(can’t) 做的某些事情.对于编译器,这主要围绕指令的重新排序.对于硬件,这很大程度上围绕着如何将写入传播到其他线程.Rust公开的顺序集是:

  • Sequentially Consistent(SeqCst)

  • Release

  • Acquire

  • Relaxed

(注意:我们明确地不公开C++ 消耗(consume) 顺序)

TODO:消极推理与积极推理? TODO:”不能忘记同步”

Sequentially Consistent

顺序一致(Sequentially Consistent)是最强大的,暗示所有其他排序的限制.直观地说,顺序一致的操作不能被重新排序:在SeqCst访问之前和之后发生的一个线程上的所有访问都停留在它之前和之后.一个只使用顺序一致的原子和数据访问的无数据竞争程序具有非常好的属性,即所有线程都同意程序指令的单个全局执行.这个执行也特别好推理:它只是每个线程的各个执行的交错.如果你开始使用较弱的原子排序,则不成立.

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

实际上,程序正确性很少需要顺序一致性.但是,如果你对其他内存顺序没有信心,则顺序一致性绝对是正确的选择.你的程序运行速度比它需要的慢一点肯定比错误地运行更好!将原子操作降级为稍后具有较弱的一致性也是机械上微不足道的.只需将SeqCst改为Relaxed就行了!当然,证明这种转变是 正确的(correct) 是另一回事.

Acquire-Release

Acquire和Release主要用于配对.他们的名字暗示了他们的用例:它们非常适合获取(acquiring)和释放(releasing)锁,并确保关键部分不重叠.

直观地说,获取访问权限可以确保每次它之后的访问都停留在它之后.但是,在获取之前发生的操作可以在其之后自由重新排序.同样,释放访问可确保每次它之前的访问都停留在它之前.但是,在释放之后发生的操作可以在它之前自由重新排序.

当线程A在内存中释放一个位置,然后线程B随后获取内存中的 相同(the same) 位置时,就会建立因果关系.在A的释放之前发生的每一次写(包括非原子写和放松的原子写)都将在B获取后被观察到.但是,任何其他线程都没有建立因果关系.同样,如果A和B访问内存中的 不同(different) 位置,则不会建立因果关系.

因此,release-acquire的基本用法很简单:获取内存的位置以开始关键部分,然后释放该位置以结束它.例如,一个简单的自旋锁(spinlock)看起来像:

  1. use std::sync::Arc;
  2. use std::sync::atomic::{AtomicBool, Ordering};
  3. use std::thread;
  4. fn main() {
  5. let lock = Arc::new(AtomicBool::new(false)); // value answers "am I locked?"
  6. // ... distribute lock to threads somehow ...
  7. // Try to acquire the lock by setting it to true
  8. while lock.compare_and_swap(false, true, Ordering::Acquire) { }
  9. // broke out of the loop, so we successfully acquired the lock!
  10. // ... scary data accesses ...
  11. // ok we're done, release the lock
  12. lock.store(false, Ordering::Release);
  13. }

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

Relaxed

Relaxed(放松的)访问是绝对最弱的.它们可以自由重新排序,并且不会发生任何关系.尽管如此,放松的操作仍然是原子的.也就是说,它们不算作数据访问,并且对它们执行的任何读取-修改-写入操作都是以原子方式进行的.放松的操作适合你绝对想要发生的事情,但不要特别注意.例如,如果你没有使用计数器来同步任何其他访问,则可以使用放松的fetch_add通过多个线程安全地完成递增计数器.

在强排序的平台上放松操作很少有好处,因为它们通常提供release-acquire语义.然而,在弱排序的平台上,放松的操作可能更便宜.