第 3 章 内存序(Memory Ordering)
在第2章中,我们简要提到了内存序的概念。本章我们将深入探讨这一主题,了解所有可用的内存序选项,最重要的是,明确在何种情况下使用哪一种。
重排序与优化(Reordering and Optimizations)
处理器和编译器为了使程序运行得更快,会施展各种“把戏”。例如,处理器可能会判断程序中两条特定的连续指令彼此不会产生影响,并(如果那样更快的话)乱序执行它们。当一条指令因为要从主内存获取某些数据而暂时阻塞时,只要不会改变程序的行为,后续的几条指令就可能会在这第一条指令完成之前被执行并完成。同样地,编译器如果有理由相信改变顺序或重写程序的某些部分可能导致更快的执行,它也可能会这样做。
但是,重申一遍,只有在不会改变程序行为的前提下,它们才会这么做。
让我们看下面这个函数作为例子:
fn f(a: &mut i32, b: &mut i32) {
*a += 1;
*b += 1;
*a += 1;
}
在这里,编译器几乎肯定会理解这些操作的顺序无关紧要,因为在这三个加法操作之间,没有任何操作依赖于 *a 或 *b 的值。(假设溢出检查已禁用。)因此,它可能会重新排列第二个和第三个操作的顺序,然后将前两个合并为一次加法:
fn f(a: &mut i32, b: &mut i32) {
*a += 2;
*b += 1;
}
之后,在执行这个经过优化编译的程序函数时,处理器可能会因为各种原因,最终先执行第二个加法再执行第一个加法,可能是因为 *b 在缓存中可用,而 *a 必须从主内存中获取。
无论进行何种优化,结果保持不变:*a 增加 2,*b 增加 1。它们被增加的顺序对你的程序其余部分来说是完全不可见的。
验证特定重排序或其他优化是否会影响程序行为的逻辑并未将其他线程考虑在内。在我们上面的例子中,这完全没问题,因为唯一引用(&mut i32)保证了不可能有其他东西访问这些值,使得其他线程无关紧要。唯一会出现问题的情况是当修改线程间共享的数据时。或者换句话说,在使用原子操作时。这就是为什么我们必须明确地告诉编译器和处理器,它们能对我们的原子操作做什么、不能做什么,因为它们通常的逻辑忽略了线程间的交互,可能会允许那些确实会改变程序结果的优化。
有趣的问题是我们如何告知它们。如果我们想精确地说明什么是可以接受的、什么是不可以接受的,并发编程可能会变得极其冗长、容易出错,甚至可能依赖于特定架构:
let x = a.fetch_add(1,
Dear compiler and processor,
Feel free to reorder this with operations on b,but if there's another thread concurrently executing f,please don't reorder this with operations on c!
Also, processor, don't forget to flush your store buffer!If b is zero, though, it doesn't matter.
In that case, feel free to do whatever is fastest.
Thanks~ <3
);
相反,我们只能从一个小的选项集合中进行选择,这些选项由 std::sync::atomic::Ordering 枚举表示,每个原子操作都将其作为参数。可用的选项集非常有限,但经过精心挑选,能很好地适应大多数使用场景。这些排序选项非常抽象,并不直接反映所涉及的实际编译器和处理器机制,例如指令重排序。这使得你的并发代码能够做到与架构无关并经得起未来考验。它允许我们在不知道每一个现有及未来处理器和编译器版本细节的情况下进行验证。
Rust 中可用的排序选项有:
- 宽松排序:
Ordering::Relaxed - 释放与获取排序:
Ordering::{Release, Acquire, AcqRel} - 顺序一致排序:
Ordering::SeqCst
在 C++ 中,还有一种称为消费排序的东西,它被有意地从 Rust 中省略了,但讨论一下也很有趣。
内存模型(The Memory Model)
不同的内存排序选项有着严格的形式化定义,以确保我们确切地知道可以做出哪些假设,也让编译器开发者确切地知道他们需要为我们提供哪些保证。为了将其与特定处理器架构的细节解耦,内存排序是根据一个抽象的内存模型来定义的。
Rust 的内存模型(大部分借鉴自 C++)并不与任何现有的处理器架构完全匹配,而是一个抽象的模型,具有一组严格的规则。这些规则试图代表所有当前和未来架构的最大公约数,同时也给予编译器在分析和优化程序时足够的自由度,以做出有用的假设。
我们已经在“借用与数据竞争”一节中看到了内存模型的实际应用,在那里我们讨论了数据竞争如何导致未定义行为。Rust 的内存模型允许并发的原子存储,但将同一变量的并发非原子存储视为数据竞争,从而导致未定义行为。
然而,在大多数处理器架构上,原子存储和常规的非原子存储实际上并没有区别,这一点我们将在第7章中看到。可以说内存模型比实际需要的更严格,但这些严格的规则使得程序更易于推理,无论是对编译器还是对程序员而言都是如此,并且为未来的发展留下了空间。
Happens-Before关系(Happens-Before Relationship)
内存模型根据Happens-Before Relationship来定义操作发生的顺序。这意味着,作为一个抽象模型,它不谈论机器指令、缓存、缓冲区、时序、指令重排序、编译器优化等等,而是只定义了一种情况:保证一件事在另一件事之前发生,而其他所有事情的顺序则不予定义。
基本的Happens-Before规则是:在同一线程内发生的一切都是按顺序发生的。如果一个线程执行 f(); g();,那么 f() 发生在 g() 之前。
然而,在线程之间,发生前关系只出现在几种特定情况中,例如创建和连接线程、解锁和锁定互斥锁,以及通过使用非宽松内存排序的原子操作。宽松内存排序是最基本(且性能最高)的内存排序,它本身永远不会导致任何跨线程的Happens-Before关系。
为了探究这意味着什么,让我们看看下面的例子,我们假设 a 和 b 由不同的线程并发执行:
static X: AtomicI32 = AtomicI32::new(0);
static Y: AtomicI32 = AtomicI32::new(0);
fn a() {
X.store(10, Relaxed); // 1
Y.store(20, Relaxed); // 2
}
fn b() {
let y = Y.load(Relaxed); // 3
let x = X.load(Relaxed); // 4
println!("{x} {y}");
}
如上所述,基本的Happens-Before规则是:在同一线程内发生的一切都是按顺序发生的。在这个例子中:X.store(10, Relaxed) 发生在 Y.store(20, Relaxed) 之前,而 Y.load(Relaxed) 发生在 X.load(Relaxed) 之前,如图 3-1 所示。由于我们使用宽松内存排序,在我们的示例中没有其他的Happens-Before关系。

图 3-1. 示例代码中原子操作之间的发生前关系
如果 a 或 b 中的任何一个在另一个开始之前完成,那么输出将是 0 0 或 10 20。如果 a 和 b 并发运行,很容易看出输出可能是 10 0。发生这种情况的一种可能操作顺序如下:(此处省略具体顺序描述,但意指可能的交错执行导致此结果)。
创建与连接线程(Spawning and Joining)
创建一个线程会在 spawn() 调用之前发生的事情和新线程之间建立Happens-Before关系。类似地,连接一个线程会在被连接的线程和 join() 调用之后发生的事情之间建立Happens-Before关系。
为了演示,以下示例中的断言不可能失败:
static X: AtomicI32 = AtomicI32::new(0);
fn main() {
X.store(1, Relaxed);
let t = thread::spawn(f);
X.store(2, Relaxed);
t.join().unwrap();
X.store(3, Relaxed);
}
fn f() {
let x = X.load(Relaxed);
assert!(x == 1 || x == 2);
}
由于由连接和创建操作形成的Happens-Before关系,我们可以确定,对 X 的加载发生在第一次存储之后,但在最后一次存储之前,如图 3-2 所示。然而,它观察到的是第二次存储之前还是之后的值是不可预测的。换句话说,它可能加载到 1 或 2,但不会是 0 或 3。

图 3-2. 示例代码中创建、连接、存储和加载操作之间的Happens-Before关系
宽松排序(Relaxed Ordering)
虽然使用宽松内存排序的原子操作不提供任何发生前关系,但它们确实保证了每个独立原子变量的总修改顺序。这意味着,从每一个线程的视角来看,对同一原子变量的所有修改都以相同的顺序发生。
为了说明这意味着什么,请考虑以下示例,我们假设 a 和 b 由不同的线程并发执行:
static X: AtomicI32 = AtomicI32::new(0);
fn a() {
X.fetch_add(5, Relaxed);
X.fetch_add(10, Relaxed);
}
fn b() {
let a = X.load(Relaxed);
let b = X.load(Relaxed);
let c = X.load(Relaxed);
let d = X.load(Relaxed);
println!("{a} {b} {c} {d}");
}
在这个例子中,只有一个线程修改 X,这使得很容易看出 X 的修改顺序只有一种可能:0→5→15。它从零开始,然后变成五,最后被改为十五。线程不能观察到任何与这个总修改顺序不一致的 X 值。这意味着 0 0 0 0、0 0 5 15 和 0 15 15 15 是另一个线程中打印语句可能的一些结果,而 0 5 0 15 或 0 0 10 15 这样的输出是不可能的。
即使对于一个原子变量存在多个可能的修改顺序,所有线程也会就一个顺序达成一致。
让我们用两个独立的函数 a1 和 a2 替换 a,我们假设每个函数都由一个独立的线程执行:
fn a1() {
X.fetch_add(5, Relaxed);
}
fn a2() {
X.fetch_add(10, Relaxed);
}
假设这些是仅有的修改 X 的线程,那么现在有两种可能的修改顺序:要么是 0→5→15,要么是 0→10→15,这取决于哪个 fetch_add 操作先执行。无论发生哪种情况,所有线程都观察到相同的顺序。所以,即使我们有成百上千个额外线程都在运行我们的 b() 函数,我们知道如果其中一个打印出 10,那么顺序必须是 0→10→15,并且它们中没有一个可能打印出 5。反之亦然。
在第2章中,我们看到了几个用例示例,其中对单个变量的这种总修改顺序保证就足够了,使得宽松内存排序可以满足需求。然而,如果我们尝试超越这些例子进行更复杂的操作,很快就会看到我们需要比宽松内存排序更强的保证。
凭空出现的值
当操作以循环方式相互依赖时,宽松内存排序缺乏顺序保证可能会导致一些理论上的复杂情况。
为了演示,这里有一个刻意设计的例子,两个线程从一个原子变量加载值并将其存储到另一个原子变量中:
static X: AtomicI32 = AtomicI32::new(0);
static Y: AtomicI32 = AtomicI32::new(0);
fn main() {
let a = thread::spawn(|| {
let x = X.load(Relaxed);
Y.store(x, Relaxed);
});
let b = thread::spawn(|| {
let y = Y.load(Relaxed);
X.store(y, Relaxed);
});
a.join().unwrap();
b.join().unwrap();
assert_eq!(X.load(Relaxed), 0); // 可能会失败?
assert_eq!(Y.load(Relaxed), 0); // 可能会失败?
}似乎很容易得出结论:
X和Y的值除了零之外永远不会是别的值,因为存储操作只存储从这些相同的原子变量加载的值,而这些值始终是零。然而,如果我们严格遵循理论上的内存模型,就不得不承认我们的循环推理,并得出一个可怕的结论:我们可能错了。事实上,从技术上讲,内存模型允许这样一种结果:最终
X和Y都是 37,或者任何其他值,从而导致断言失败。由于缺乏顺序保证,这两个线程的加载操作可能都会看到另一个线程存储操作的结果,从而允许操作顺序中出现循环:我们将 37 存储到
Y,是因为我们从X加载了 37,而之所以将 37 存储到X,是因为我们从Y加载了 37,而这个值正是我们存储到Y的。幸运的是,这种凭空出现的值的可能性被普遍认为是理论模型中的一个缺陷,而不是你在实践中需要考虑的问题。如何在不允许此类异常的情况下形式化宽松内存排序,这个理论问题尚未解决。虽然这对于形式化验证来说是个棘手的问题,让许多理论家夜不能寐,但我们其他人则可以幸福地无视这个问题,因为知道这在实践中不会发生。
释放与获取排序(Release and Acquire Ordering)
释放与获取内存排序成对使用,以在线程间建立发生前关系。释放内存排序适用于存储操作,而获取内存排序适用于加载操作。
当一个获取-加载操作观察到释放-存储操作的结果时,就会形成发生前关系。在这种情况下,该存储操作以及它之前发生的所有操作,都发生在该加载操作以及它之后发生的所有操作之前。
当对获取-修改或比较并交换操作使用 Acquire 时,它仅适用于操作中加载值的部分。同样,Release 仅适用于操作中的存储部分。AcqRel 用于表示 Acquire 和 Release 的组合,这会导致加载使用获取排序,存储使用释放排序。
让我们看一个例子,了解这在实践中如何使用。在下面的例子中,我们从一个派生线程向主线程发送一个 64 位整数。我们使用一个额外的原子布尔值来告知主线程整数已被存储并准备就绪可供读取。
use std::sync::atomic::Ordering::{Acquire, Release};
static DATA: AtomicU64 = AtomicU64::new(0);
static READY: AtomicBool = AtomicBool::new(false);
fn main() {
thread::spawn(|| {
DATA.store(123, Relaxed);
READY.store(true, Release); // 在此存储之前的所有操作 ...
});
while !READY.load(Acquire) { // ... 在此加载到 `true` 之后都可见。
thread::sleep(Duration::from_millis(100));
println!("waiting...");
}
println!("{}", DATA.load(Relaxed));
}
当派生线程完成数据存储后,它使用释放-存储将 READY 标志设置为 true。当主线程通过其获取-加载操作观察到这一点时,这两个操作之间就建立了一个发生前关系,如图 3-3 所示。此时,我们可以确定,在释放-存储到 READY 之前发生的所有操作,对于获取-加载之后发生的所有操作都是可见的。具体来说,当主线程从 DATA 加载时,我们可以确定它将加载后台线程存储的值。该程序在最后一行只能打印出一种可能的结果:123。

图 3-3. 示例代码中原子操作之间的发生前关系,展示了通过获取和释放操作形成的跨线程关系
如果在这个例子中我们对所有操作都使用宽松内存排序,主线程可能看到 READY 翻转为 true,但随后仍然从 DATA 加载到零。
“释放”和“获取”的名称基于它们最基本的用例:一个线程通过原子地将某个值存储到原子变量中来释放数据,另一个线程通过原子地加载该值来获取它。这正是我们在一个线程上解锁(释放)互斥锁,随后在另一个线程上锁定(获取)它时所发生的情况。
在我们的例子中,来自 READY 标志的发生前关系保证了 DATA 的存储和加载操作不能并发发生。这意味着我们实际上并不需要这些操作是原子的。
然而,如果我们只是尝试对数据变量使用常规的非原子类型,编译器会拒绝我们的程序,因为 Rust 的类型系统不允许我们在另一个线程也在借用这些变量时从一个线程修改它们。类型系统不会神奇地理解我们在这里创建的发生前关系。需要一些不安全代码来向编译器承诺我们已经仔细考虑过这一点,并且确信我们没有违反任何规则,如下所示:
static mut DATA: u64 = 0;
static READY: AtomicBool = AtomicBool::new(false);
fn main() {
thread::spawn(|| {
// 安全性:没有其他代码在访问 DATA,因为我们还没有设置 READY 标志。
unsafe { DATA = 123 };
READY.store(true, Release); // 在此存储之前的所有操作 ...
});
while !READY.load(Acquire) { // ... 在此加载到 `true` 之后都可见。
thread::sleep(Duration::from_millis(100));
println!("waiting...");
}
// 安全性:没有代码在修改 DATA,因为 READY 已设置。
println!("{}", unsafe { DATA });
}
更形式化地说明
当一个获取-加载操作观察到释放-存储操作的结果时,就形成了发生前关系。但这意味着什么?
假设两个线程都向同一个原子变量释放-存储了一个值
7,而第三个线程从该变量加载了一个值7。那么第三个线程现在是与第一个线程还是与第二个线程建立了发生前关系?这取决于它加载的是“哪个7”:来自线程一的还是线程二的。(或者可能是一个无关的7。)这使我们得出结论:即使7等于7,来自两个线程的两个7也存在某种不同。思考这个问题的方式是借助我们在“宽松排序”中讨论过的总修改顺序:发生在原子变量上的所有修改的有序列表。即使相同的值被多次写入同一个变量,这些操作中的每一个都代表了该变量总修改顺序中的一个独立事件。当我们加载一个值时,加载的值与这个按变量划分的“时间线”上的一个特定点相匹配,这告诉我们可能与哪个操作同步。
例如,如果原子的总修改顺序是:
- 初始化为 0
- 释放-存储 7(来自线程二)
- 宽松-存储 6
- 释放-存储 7(来自线程一)
那么获取-加载到一个
7将与第二个事件中的释放-存储或最后一个事件中的释放-存储同步。然而,如果我们之前(根据发生前关系)看到过一个6,我们就知道我们看到的是最后一个7,而不是第一个,这意味着我们现在与线程一存在发生前关系,而不是与线程二。还有一个额外的细节是,释放-存储的值可能会被任意数量的获取-修改和比较并交换操作修改,但仍然会与读取最终结果的获取-加载操作产生发生前关系。
例如,假设一个原子变量具有以下总修改顺序:
- 初始化为 0
- 释放-存储 7
- 宽松-获取并加 1,将 7 变为 8
- 释放-获取并加 1,将 8 变为 9
- 释放-存储 7
- 宽松-交换 10,将 7 变为 10
现在,如果我们从这个变量获取-加载一个
9,我们不仅与存储此值的第四个操作建立了发生前关系,还与存储了7的第二个操作建立了关系,即使第三个操作使用了宽松内存排序。类似地,如果我们从这个变量获取-加载一个
10,而这个值是由一个宽松操作写入的,我们仍然与第五个操作(它存储了一个7)建立了发生前关系。因为那只是一个常规的存储操作(不是获取-修改或比较并交换操作),它打破了链条:我们不会与任何其他操作建立发生前关系。
示例:加锁(Example: Locking)
互斥锁是释放和获取排序最常见的用例(见“加锁:Mutex 和 RwLock”)。锁定时,它们使用原子操作来检查是否已解锁,使用获取排序,同时(原子地)将状态更改为“已锁定”。解锁时,它们使用释放排序将状态设置回“未锁定”。这意味着在解锁互斥锁和随后锁定它之间会存在一个发生前关系。
以下是这种模式的演示:
static mut DATA: String = String::new();
static LOCKED: AtomicBool = AtomicBool::new(false);
fn f() {
if LOCKED.compare_exchange(false, true, Acquire, Relaxed).is_ok() {
// 安全性:我们持有独占锁,因此没有其他代码在访问 DATA。
unsafe { DATA.push('!') };
LOCKED.store(false, Release);
}
}
fn main() {
thread::scope(|s| {
for _ in 0..100 {
s.spawn(f);
}
});
}
正如我们在“比较并交换操作”中简要看到的,比较并交换操作接受两个内存排序参数:一个用于比较成功且存储发生的情况,另一个用于比较失败且存储未发生的情况。在 f 中,我们尝试将 LOCKED 从 false 更改为 true,并且仅在成功时访问 DATA。所以,我们只关心成功时的内存排序。如果 compare_exchange 操作失败,那一定是因为 LOCKED 已经被设置为 true,在这种情况下 f 什么都不做。这与常规互斥锁的 try_lock 操作相匹配。
细心的读者可能已经注意到,比较并交换操作也可以是一个交换操作,因为在已锁定的情况下将
true交换为true不会改变代码的正确性:// 这样也可以。
if LOCKED.swap(true, Acquire) == false { … }
得益于获取和释放内存排序,我们可以确定没有两个线程可以并发访问 DATA。如图 3-4 所示,任何先前对 DATA 的访问都发生在后续向 LOCKED 释放-存储 false 之前,而后者又发生在下一个获取-比较并交换(或获取-交换)操作(将 false 更改为 true)之前,而该操作又发生在下一次访问 DATA 之前。

图 3-4. 加锁示例中原子操作之间的发生前关系,展示了两个线程顺序加锁和解锁
在第4章中,我们将把这个概念转化为一个可重用的类型:自旋锁。
示例:带间接层的惰性初始化(Example: Lazy Initialization with Indirection)
在“示例:惰性一次性初始化”中,我们实现了一个全局变量的惰性初始化,使用比较并交换操作来处理多个线程并发竞争初始化值的情况。因为该值是一个非零的 64 位整数,我们能够使用 AtomicU64 来存储它,在初始化之前使用零作为占位符。
要对无法放入单个原子变量的更大的数据类型进行相同的操作,我们需要寻找替代方案。
在这个例子中,假设我们想保持非阻塞行为,即线程永远不等待另一个线程,而是进行竞争并从第一个完成初始化的线程中取值。这意味着我们仍然需要能够通过单个原子操作从“未初始化”变为“完全初始化”。
正如软件工程的基本定理告诉我们的,计算机科学中的每个问题都可以通过添加另一层间接性来解决,这个问题也不例外。既然我们无法将数据放入单个原子变量中,我们可以改为使用原子变量来存储指向数据的指针。
AtomicPtr<T> 是 *mut T 的原子版本:一个指向 T 的指针。我们可以使用空指针作为初始状态的占位符,并使用比较并交换操作将其原子地替换为指向新分配的、完全初始化的 T 的指针,然后其他线程可以读取它。
由于我们不仅共享包含指针的原子变量,还共享它所指向的数据,因此我们不能再像第2章那样使用宽松内存排序。我们需要确保分配和初始化数据不会与读取数据产生竞争。换句话说,我们需要在存储和加载操作上使用释放和获取排序,以确保编译器和处理器不会破坏我们的代码,例如,通过重排序指针的存储和数据的初始化本身。
这引出了以下实现,针对某个名为 Data 的任意数据类型:
use std::sync::atomic::AtomicPtr;
fn get_data() -> &'static Data {
static PTR: AtomicPtr<Data> = AtomicPtr::new(std::ptr::null_mut());
let mut p = PTR.load(Acquire);
if p.is_null() {
p = Box::into_raw(Box::new(generate_data()));
if let Err(e) = PTR.compare_exchange(
std::ptr::null_mut(), p, Release, Acquire
) {
// 安全性:p 来自上面的 Box::into_raw,且未与其他任何线程共享。
drop(unsafe { Box::from_raw(p) });
p = e;
}
}
// 安全性:p 非空且指向已正确初始化的值。
unsafe { &*p }
}
如果我们从 PTR 获取-加载的指针非空,我们假定它指向已初始化的数据,并构造一个指向该数据的引用。
然而,如果它仍然是空值,我们则生成新数据并使用 Box::new 将其存储在新的分配中。然后我们使用 Box::into_raw 将此 Box 转换为原始指针,以便我们可以尝试使用比较并交换操作将其存储到 PTR 中。如果另一个线程赢得了初始化竞争,因为 PTR 不再为空,compare_exchange 会失败。如果发生这种情况,我们将原始指针转回 Box 以便使用 drop 释放它,避免内存泄漏,并继续使用另一个线程存储在 PTR 中的指针。
最终不安全块中的安全注释说明了我们的假设:它指向的数据已经初始化。请注意,这包括一个关于事情发生顺序的假设。为了确保我们的假设成立,我们使用释放和获取内存排序来确保数据的初始化确实发生在创建对它的引用之前。
我们在两个地方加载一个可能非空(即已初始化)的指针:通过加载操作,以及通过 compare_exchange 操作失败时。因此,如上所述,我们需要对加载内存排序和 compare_exchange 失败内存排序都使用 Acquire,以便能够与存储指针的操作同步。该存储操作发生在 compare_exchange 操作成功时,因此我们必须使用 Release 作为其成功排序。
图 3-5 展示了三个线程调用 get_data() 时操作和发生前关系的可视化。在这种情况下,线程 A 和 B 都观察到空指针,并都尝试初始化原子指针。线程 A 赢得了竞争,导致线程 B 的 compare_exchange 操作失败。线程 C 在线程 A 初始化原子指针后才观察到它。最终结果是所有三个线程最终都使用了线程 A 分配的 Box。

图 3-5. 三个线程调用 get_data() 时的操作和发生前关系
消费排序(Consume Ordering)
让我们仔细看看上一个例子中的内存排序。如果我们暂且抛开严格的内存模型,从更实际的角度来思考,可以说释放排序防止了数据的初始化与将指针共享给其他线程的存储操作被重排序。这很重要,因为否则其他线程可能在数据完全初始化之前就看到它。
类似地,我们可以将获取排序解释为防止了在指针加载之前访问数据的重排序。然而,有人可能会合理地怀疑,这在实践中是否有意义。在知道地址之前怎么可能访问数据呢?我们可能会得出结论:比获取排序更弱的某种排序可能就足够了。我们是对的:这种更弱的排序就叫做消费排序。
消费排序本质上是获取排序的一种轻量级——更高效——变体,其同步效果仅限于依赖于加载值的操作。
这意味着,如果你从一个原子变量中消费-加载一个释放-存储的值 x,那么,基本上,该存储操作发生在对诸如 *x、array[x] 或 table.lookup(x + 1) 这类依赖表达式的求值之前,但不必发生在独立操作之前,比如读取另一个我们不需要 x 值就能访问的变量。
现在有好消息也有坏消息。
好消息是——在所有现代处理器架构上——消费排序是通过与宽松排序完全相同的指令实现的。换句话说,消费排序可以是“免费”的,这至少在有些平台上对于获取排序来说并非如此。
坏消息是,没有编译器真正实现了消费排序。
事实证明,“依赖”求值这个概念不仅难以定义,而且在转换和优化程序时保持这种依赖关系完整更加困难。例如,编译器可能能够将 x + 2 - x 优化为 2,从而有效地取消了对 x 的依赖。如果编译器能够对 x 的可能值或数组元素做出任何逻辑推断,这种问题的更微妙变体也可能发生在像 array[x] 这样更实际的表达式中。当考虑到控制流时,比如 if 语句或函数调用,问题会变得更加复杂。
因此,为了安全起见,编译器将消费排序升级为获取排序。C++20 标准甚至明确不鼓励使用消费排序,并指出除了获取排序之外的其他实现方式被证明是不可行的。
未来可能会找到一种可行的消费排序定义和实现。但在那个时候到来之前,Rust 并未公开 Ordering::Consume。
顺序一致排序(Sequentially Consistent Ordering)
最强的内存排序是顺序一致排序:Ordering::SeqCst。它包含了获取排序(对于加载)和释放排序(对于存储)的所有保证,并且还保证了操作的全局一致性顺序。
这意味着程序中每个使用 SeqCst 排序的操作都是所有线程一致同意的单个总顺序的一部分。这个总顺序与每个独立变量的总修改顺序一致。
由于它严格强于获取和释放内存排序,一个顺序一致的加载或存储可以取代释放-获取对中的获取-加载或释放-存储,以形成发生前关系。换句话说,一个获取-加载不仅可以与一个释放-存储形成发生前关系,还可以与一个顺序一致的存储形成该关系,反之亦然。
只有当发生前关系的双方都使用 SeqCst 排序时,才能保证其与 SeqCst 操作的单一总顺序一致。
虽然顺序一致排序看起来可能是最容易推理的内存排序,但在实践中几乎不需要使用它。在几乎所有情况下,常规的获取和释放排序就足够了。
下面是一个依赖于顺序一致排序操作的例子:
use std::sync::atomic::Ordering::SeqCst;
static A: AtomicBool = AtomicBool::new(false);
static B: AtomicBool = AtomicBool::new(false);
static mut S: String = String::new();
fn main() {
let a = thread::spawn(|| {
A.store(true, SeqCst);
if !B.load(SeqCst) {
unsafe { S.push('!') };
}
});
let b = thread::spawn(|| {
B.store(true, SeqCst);
if !A.load(SeqCst) {
unsafe { S.push('!') };
}
});
a.join().unwrap();
b.join().unwrap();
}
两个线程都首先将自己的原子布尔值设置为 true,以警告另一个线程它们即将访问 S,然后检查对方的原子布尔值,以查看是否可以在不引起数据竞争的情况下安全地访问 S。
如果两个存储操作都发生在任何一个加载操作之前,那么可能两个线程最终都不会访问 S。但是,两个线程不可能都访问 S 并导致未定义行为,因为顺序一致排序保证了只有其中一个能赢得竞争。在每一个可能的单一总顺序中,第一个操作都将是存储操作,这阻止了另一个线程访问 S。
实际上,所有现实世界中 SeqCst 的使用都涉及一种类似的模式:一个存储操作必须在同一线程的后续加载之前全局可见。对于这些情况,一个可能更高效的替代方案是结合使用宽松操作和 SeqCst 栅栏,我们接下来将对此进行探讨。
内存栅栏(Fences)
除了对原子变量进行操作外,我们还可以将内存排序应用于另一种东西:原子栅栏。
std::sync::atomic::fence 函数代表一个原子栅栏,它可以是释放栅栏 (Release)、获取栅栏 (Acquire),或者两者都是 (AcqRel 或 SeqCst)。SeqCst 栅栏还额外参与顺序一致的总顺序。
原子栅栏允许你将内存排序与原子操作分离开来。如果你想将内存排序应用于多个操作,或者只想有条件地应用它,这会很有用。
本质上,一个释放-存储可以被拆分为一个释放栅栏后跟一个(宽松的)存储,而一个获取-加载可以被拆分为一个(宽松的)加载后跟一个获取栅栏:
释放-获取关系中的存储操作:
a.store(1, Release);可以用一个释放栅栏后跟一个宽松存储来替代:
fence(Release);
a.store(1, Relaxed);释放-获取关系中的加载操作:
a.load(Acquire);可以用一个宽松加载后跟一个获取栅栏来替代:
a.load(Relaxed);
fence(Acquire);
然而,使用单独的栅栏可能会导致额外的处理器指令,效率可能会稍低一些。
更重要的是,与释放-存储或获取-加载不同,栅栏并不绑定到任何单个原子变量。这意味着单个栅栏可以同时用于多个变量。
从形式上讲,如果在一个释放栅栏之后(在同一线程上)有任何原子操作存储了一个值,而这个值被我们正在同步的获取操作观察到,那么该释放栅栏可以取代发生前关系中的释放操作。类似地,如果一个获取栅栏之前(在同一线程上)有任何原子操作加载了释放操作所存储的值,那么该获取栅栏可以取代任何获取操作。
综合来看,这意味着如果在释放栅栏之后的任何存储操作被获取栅栏之前的任何加载操作观察到,那么在释放栅栏和获取栅栏之间就会创建发生前关系。
例如,假设有一个线程执行一个释放栅栏,然后是对不同变量的三个原子存储操作,而另一个线程执行从这些相同变量的三个加载操作,后跟一个获取栅栏,如下所示:
线程 1:
fence(Release);
A.store(1, Relaxed);
B.store(2, Relaxed);
C.store(3, Relaxed);
线程 2:
A.load(Relaxed);
B.load(Relaxed);
C.load(Relaxed);
fence(Acquire);
在这种情况下,如果线程 2 上的任何加载操作加载了来自线程 1 相应存储操作的值,那么线程 2 上的获取栅栏就发生在线程 1 上的释放栅栏之前。
栅栏不必直接紧接在原子操作之前或之后。中间可以发生任何其他事情,包括控制流。这可以用来使栅栏成为有条件的,类似于比较并交换操作既有成功排序也有失败排序。
例如,如果我们使用获取内存排序从一个原子变量加载指针,我们可以使用栅栏来仅在指针非空时应用获取排序:
使用获取-加载:
let p = PTR.load(Acquire);
if p.is_null() {
println!("no data");
} else {
println!("data = {}", unsafe { *p });
}使用有条件的获取栅栏:
let p = PTR.load(Relaxed);
if p.is_null() {
println!("no data");
} else {
fence(Acquire);
println!("data = {}", unsafe { *p });
}
如果指针预期经常为空,这可能是有益的,可以避免不必要的获取内存排序。
让我们看一个更复杂的释放和获取栅栏的使用案例:
use std::sync::atomic::fence;
static mut DATA: [u64; 10] = [0; 10];
const ATOMIC_FALSE: AtomicBool = AtomicBool::new(false);
static READY: [AtomicBool; 10] = [ATOMIC_FALSE; 10];
fn main() {
for i in 0..10 {
thread::spawn(move || {
let data = some_calculation(i);
unsafe { DATA[i] = data };
READY[i].store(true, Release);
});
}
thread::sleep(Duration::from_millis(500));
let ready: [bool; 10] = std::array::from_fn(|i| READY[i].load(Relaxed));
if ready.contains(&true) {
fence(Acquire);
for i in 0..10 {
if ready[i] {
println!("data{i} = {}", unsafe { DATA[i] });
}
}
}
}
std::array::from_fn是一种简单的方法,可以执行某件事一定次数并将结果收集到数组中。
在这个例子中,10 个线程进行一些计算,并将结果存储在一个(非原子的)共享变量中。每个线程设置一个原子布尔值,使用正常的释放-存储来指示数据已准备好供主线程读取。主线程等待半秒钟,检查所有 10 个布尔值以查看哪些线程已完成,并打印已就绪的结果。
主线程没有使用 10 个获取-加载操作来读取布尔值,而是使用了宽松操作和单个获取栅栏。它在读取数据之前执行栅栏,但仅在有待读取的数据时才这样做。
虽然在这个特定的例子中,花任何精力进行这样的优化可能完全没必要,但这种节省额外获取操作开销的模式在构建高效的并发数据结构时可能很重要。
SeqCst 栅栏既是释放栅栏又是获取栅栏(就像 AcqRel 一样),但也是顺序一致操作单一总顺序的一部分。然而,只有栅栏是总顺序的一部分,而不一定是它之前或之后的原子操作。这意味着与释放或获取操作不同,顺序一致操作不能被拆分为宽松操作和内存栅栏。
编译器栅栏
除了常规的原子栅栏,Rust 标准库还提供了一个编译器栅栏:
std::sync::atomic::compiler_fence。它的签名与我们上面讨论的常规fence()相同,但其效果仅限于编译器。与常规的原子栅栏不同,它不会阻止处理器进行例如指令重排序。在绝大多数栅栏的使用场景中,编译器栅栏是不够的。一个潜在的使用场景可能出现在实现 Unix 信号处理程序或嵌入式系统中的中断时。这些机制可以突然中断一个线程,在同一处理器核心上临时执行一个无关的函数。因为它发生在同一处理器核心上,处理器可能影响内存排序的常规方式并不适用。(更多内容见第7章。)在这种情况下,编译器栅栏可能就足够了,有可能节省一条指令,并有望提高性能。
另一个使用场景涉及进程范围的内存屏障。这种技术超出了 Rust 内存模型的范围,并且仅在某些操作系统中支持:在 Linux 上通过
membarrier系统调用,在 Windows 上使用FlushProcessWriteBuffers函数。它实际上允许一个线程强制性地将一个(顺序一致的)原子栅栏注入所有并发运行的线程中。这允许我们用轻量级的编译器栅栏一边和重量级的进程范围屏障另一边来替换两个匹配的栅栏。如果轻量级栅栏一侧的代码执行频率更高,这可以提高整体性能。(有关更多详细信息以及在 Rust 中使用此类屏障的跨平台方法,请参阅 crates.io 上 membarrier crate 的文档。)编译器栅栏也可以是一个有趣的工具,用于探索处理器对内存排序的影响。在第155页的“一个实验”中,我们将通过用编译器栅栏替换常规栅栏来故意破坏我们的代码。这将让我们体验到在使用错误的内存排序时,处理器可能带来的微妙但潜在灾难性的影响。
常见误解(Common Misconceptions)
关于内存排序存在很多误解。在结束本章之前,我们来梳理一下最常见的几种。
误解:我需要强内存排序来确保更改“立即”可见。
一个常见的误解是,使用像 Relaxed 这样的弱内存排序意味着对原子变量的更改可能永远不会到达另一个线程,或者只会延迟很久才到达。“宽松”这个名字可能听起来像是在某些硬件部件被强制唤醒并完成它本该完成的事情之前,什么都不会发生,而不是在“放松”。
事实是,内存模型根本不涉及时间问题。它只定义了某些事情发生的顺序;而不是你可能需要等待它们多久。一台假设需要数年时间才能将数据从一个线程传递到另一个线程的计算机虽然完全无法使用,但完全可以满足内存模型的要求。
在现实生活中,内存排序涉及的是诸如指令重排序之类的事情,这些通常发生在纳秒级别。更强的内存排序并不会让你的数据传播得更快;甚至可能减慢你的程序。
误解:禁用优化意味着我不需要关心内存排序。
编译器和处理器都在导致事情以我们可能意想不到的顺序发生方面起着作用。禁用编译器优化并不会禁用编译器所有可能的转换,也不会禁用导致指令重排序和类似潜在问题行为的处理器特性。
误解:使用不重排序指令的处理器意味着我不需要关心内存排序。
一些简单的处理器,例如小型微控制器中的处理器,只有一个核心,一次只按顺序执行一条指令。然而,虽然在这样的设备上,错误的内存排序导致实际问题的可能性确实显著降低,但编译器仍然可能基于错误的内存排序做出无效假设,从而破坏你的代码。除此之外,同样重要的是要认识到,即使处理器不乱序执行指令,它可能仍然具有其他与内存排序相关的特性。
误解:宽松操作是免费的。
这是否正确取决于你对“免费”的定义。确实,Relaxed 是最高效的内存排序,并且可能比其他排序快得多。甚至在所有现代平台上,宽松的加载和存储操作编译成的处理器指令与非原子读写操作相同。
如果一个原子变量仅由单个线程使用,那么与非常量变量之间的任何速度差异,很可能是由于编译器拥有更多自由并且更有效地优化非常量操作造成的。(编译器倾向于避免对原子变量进行大多数类型的优化。)
然而,从多个线程访问相同的内存通常比从单个线程访问要慢得多。一个持续向原子变量写入的线程,当其他线程开始重复读取该变量时,可能会经历明显的减速,因为处理器核心及其缓存现在必须开始协作。
我们将在第7章探讨这种效应。
误解:顺序一致内存排序是一个很好的默认选择,并且总是正确的。
暂且抛开性能考虑,顺序一致内存排序由于其强大的保证,通常被视为理想的默认内存排序类型。确实,如果任何其他内存排序是正确的,SeqCst 也是正确的。这听起来好像 SeqCst 总是正确的。然而,一个并发算法完全有可能本身就是不正确的,与内存排序无关。
更重要的是,在阅读代码时,SeqCst 基本上告诉读者:“此操作依赖于程序中每个 SeqCst 操作的总顺序”,这是一个影响极其深远的声明。如果可能的话,相同的代码如果使用较弱的内存排序,可能会更容易审查和验证。例如,Release 有效地告诉读者:“这与同一变量上的获取操作有关”,这在理解代码时需要考量的因素要少得多。
建议将 SeqCst 视为一个警示信号。在代码中看到它通常意味着要么正在进行复杂的事情,要么只是作者没有花时间分析他们与内存排序相关的假设,这两者都是需要额外仔细审查的理由。
误解:顺序一致内存排序可以用于“释放-加载”或“获取-存储”。
虽然 SeqCst 可以替代 Acquire 或 Release,但它并不是用来创建获取-存储或释放-加载的方法。那些仍然不存在。Release 只适用于存储操作,acquire 只适用于加载操作。
例如,一个 Release-存储操作不会与一个 SeqCst-存储操作形成任何释放-获取关系。如果你需要它们成为全局一致顺序的一部分,那么两个操作都必须使用 SeqCst。
总结(Summary)
- 所有原子操作可能不存在全局一致的顺序,因为从不同线程看,事情可能以不同的顺序发生。
- 然而,每个独立的原子变量都有自己的总修改顺序,无论内存排序如何,所有线程对此都达成一致。
- 操作顺序通过发生前关系形式化定义。
- 在单个线程内,每个操作之间都存在发生前关系。
- 创建一个线程发生在线程中所做的所有事情之前。
- 线程所做的所有事情发生在连接该线程之前。
- 解锁一个互斥锁发生在再次锁定该互斥锁之前。
- 从一个释放-存储操作获取-加载值会建立一个发生前关系。这个值可以被任意数量的获取-修改和比较并交换操作修改。
- 消费-加载(如果存在的话)将是获取-加载的轻量级版本。
- 顺序一致排序导致操作的全局一致顺序,但几乎从不需要,并且可能使代码审查更加复杂。
- 栅栏允许你将多个操作的内存排序结合起来,或者有条件地应用内存排序。