• 作者:老汪软件技巧
  • 发表时间:2024-11-14 07:02
  • 浏览量:

在第 2 章中,我们简要提到了内存排序的概念。在本章中,我们将深入探讨这一主题,探索所有可用的内存排序选项,以及最重要的——何时使用哪一种。

重排序与优化

处理器和编译器会使用各种技巧来加快程序的运行速度。例如,处理器可能会判断程序中的两个连续指令不会互相影响,并且如果能够提高效率,就会以不同的顺序执行它们。例如,当某条指令在从主存中获取数据时短暂阻塞,处理器可以在此期间执行和完成几条后续的指令,只要这不会改变程序的行为。同样,编译器可能会决定重排或重写程序的某些部分,如果它认为这样可以使程序执行得更快。当然,这些优化前提是不会改变程序的行为。

让我们来看一个例子:

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 中可用的内存排序选项包括:

在 C++ 中,还有一种称为 消费排序(consume ordering) 的选项,但在 Rust 中有意被省略。不过,它也是一个值得讨论的概念。

内存模型

不同的内存排序选项有严格的形式定义,确保我们明确知道自己可以假设什么,同时也让编译器编写者知道需要为我们提供哪些保证。为了与特定处理器架构的细节解耦,内存排序是以抽象内存模型的形式来定义的。

Rust 的内存模型,主要借鉴了 C++,并不匹配任何现有的处理器架构,而是一个具有严格规则的抽象模型,试图代表所有当前和未来架构的最大公约数,同时也给编译器提供足够的自由来在分析和优化程序时做出有用的假设。

我们已经在《借用与数据竞争》一节中看到了内存模型的一部分实践,我们谈到数据竞争会导致未定义行为。Rust 的内存模型允许并发的原子存储操作,但将对同一变量的非原子并发存储视为数据竞争,导致未定义行为。

然而,在大多数处理器架构上,实际上原子存储和常规非原子存储之间并没有区别,我们将在第 7 章中看到这一点。有人可能会认为这个内存模型比实际需要的更加严格,但这些严格的规则使得编译器和程序员更容易推理程序行为,同时也为未来的发展留有空间。

先行发生关系(Happens-Before Relationship)

内存模型通过定义先行发生(happens-before)关系来描述操作的发生顺序。这意味着,作为一个抽象模型,它不涉及机器指令、缓存、缓冲区、时间、指令重排、编译器优化等具体细节,而只是定义了哪些情况可以保证某个操作在另一个操作之前发生,并将其他所有操作的顺序视为未定义。

基本的先行发生规则是,在同一个线程中发生的所有操作是有序的。如果一个线程正在执行 f(); g();,那么 f() 先行发生于 g()。

然而,在线程之间,先行发生关系只在一些特定情况下出现,比如生成和连接线程,解锁和锁定互斥锁,以及通过使用非松弛内存排序的原子操作。松弛内存排序是最基本(且最具性能)的内存排序,本身从不会导致跨线程的先行发生关系。

为了探索这意味着什么,让我们来看以下示例,假设 a 和 b 是由不同的线程并发执行的:

static X: AtomicI32 = AtomicI32::new(0);
static Y: AtomicI32 = AtomicI32::new(0);
fn a() {
    X.store(10, Relaxed); 
    Y.store(20, Relaxed); 
}
fn b() {
    let y = Y.load(Relaxed); 
    let x = X.load(Relaxed); 
    println!("{x} {y}");
}

如上所述,基本的先行发生规则是同一线程中的所有操作都是有序的。在这个例子中,X.store(10, Relaxed) 先行发生于 Y.store(20, Relaxed),let y = Y.load(Relaxed) 先行发生于 let x = X.load(Relaxed)。由于我们使用了松弛内存排序,在示例中没有其他的先行发生关系。

image.png

如果 a 或 b 中的任意一个在另一个开始前完成,则输出将是 0 0 或 10 20。如果 a 和 b 同时运行,很容易看到输出可能是 10 0。这可以通过以下顺序运行操作来实现:。

更有趣的是,输出还可能是 0 20,即使在四个操作中没有可能的全局一致顺序会导致这种结果。当执行 Y.load() 时,与 Y.store() 没有先行发生关系,这意味着它可以加载 0 或 20。当执行 X.load() 时,与 X.store() 没有先行发生关系,这意味着它可以加载 0 或 10。因此,输出 0 20 是有效的结果。

重要且直观上反常的是,要理解操作 Y.load() 加载值 20 并不会导致与 Y.store() 形成先行发生关系,即使该值是由 Y.store() 存储的。当事情不一定按照全局一致的顺序发生时(如涉及指令重排时),我们对“之前”的直观理解就会崩溃。

一种更实际、更直观但不那么形式化的理解是,从执行 b 的线程的角度来看,操作 X.store() 和 Y.store() 可能以相反的顺序发生。

生成和连接线程(Spawning and Joining)

生成一个线程会在 spawn() 调用之前发生的事情与新线程之间形成先行发生关系。类似地,连接一个线程会在被连接的线程与 join() 调用之后发生的事情之间形成先行发生关系。

为了演示以下内容,下面示例中的断言不可能失败:

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);
}

由于连接和生成操作形成的先行发生关系,我们可以确定从 X 加载的操作发生在第一次存储之后,但在最后一次存储之前,如图 3-2 所示。然而,它是否会在第二次存储前或后观察到该值是不可预测的。换句话说,它可能加载 1 或 2,但不可能加载 0 或 3。

image.png

松散顺序(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" 是不可能的。

即使对一个原子变量存在多个可能的修改顺序,所有线程也会一致认为只有一个顺序。

让我们将 a 替换为两个独立的函数 a1 和 a2,假设它们分别由不同线程执行:

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,反之亦然。

在第二章中,我们看到了几个使用这些单变量修改顺序保证的例子,使得松散内存顺序足够了。然而,如果我们尝试超出这些例子进行更高级的操作,很快就会发现我们需要比松散内存顺序更强的保证。

空中生成的值(Out-of-Thin-Air Values)

松散内存顺序缺乏排序保证,可能导致某些理论上的复杂情况,特别是在操作之间存在循环依赖时。

为了演示这一点,以下是一个人为的例子,其中两个线程从一个原子变量中加载值并将其存储到另一个变量中:

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,这是因为我们从 Y 中加载了 37 而存储到 X。

幸运的是,这种“空中生成的值”的可能性被普遍认为是理论模型中的一个漏洞,而不是在实践中需要考虑的问题。如何在模型中正式化松散内存顺序而不允许此类异常是一个未解决的问题。虽然这对形式验证来说是一个令人头疼的问题,让许多理论家难以入睡,但我们其余人可以放心地忽略这一点,因为它在实践中不会发生。

释放和获取顺序(Release and Acquire Ordering)

释放和获取内存顺序是成对使用的,用于在线程之间形成“先行发生”关系。释放内存顺序应用于存储操作,而获取内存顺序应用于加载操作。

当获取加载操作观察到释放存储操作的结果时,就会形成“先行发生”关系。在这种情况下,存储操作及其之前发生的所有操作,先于加载操作及其之后的所有操作发生。

在使用获取顺序进行“获取并修改”(fetch-and-modify)或“比较并交换”(compare-and-exchange)操作时,它仅适用于加载值的部分。同样,释放顺序仅适用于操作中存储的部分。而 AcqRel 则表示获取和释放的结合,使得加载使用获取顺序,存储使用释放顺序。

让我们通过一个例子来看看如何在实践中使用它们。在下面的例子中,我们将一个 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。

image.png

如果我们在这个示例中对所有操作都使用了松弛(Relaxed)内存顺序,主线程可能会看到 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,而不是第一个,这意味着我们现在与线程一之间形成了“先行发生”关系,而不是与线程二之间。

还有一个额外的细节,即释放存储的值可能会被任意数量的“获取并修改”(fetch-and-modify)和“比较并交换”(compare-and-exchange)操作所修改,但仍然可以与读取最终结果的获取加载形成“先行发生”关系。

例如,假设一个原子变量的总修改顺序如下:

初始化为 0释放存储 7松弛获取并加 1,将 7 改为 8释放获取并加 1,将 8 改为 9释放存储 7松弛交换 10,将 7 改为 10

现在,如果我们从该变量中获取加载一个 9,我们不仅与第四个操作(存储该值)形成了“先行发生”关系,还与第二个操作(存储了 7)形成了关系,即使第三个操作使用的是松弛内存顺序。

同样地,如果我们从该变量中获取加载了一个 10,该值是由一个松弛操作写入的,我们仍然与第五个操作(存储了 7)形成了“先行发生”关系。由于这是一个普通的存储操作(不是“获取并修改”或“比较并交换”操作),它中断了链条:我们无法与任何其他操作形成“先行发生”关系。

示例:锁机制

互斥锁是最常见的使用释放和获取顺序的情况(参见“锁机制:互斥锁和读写锁”)。在锁定时,它们使用原子操作检查是否已解锁,使用获取顺序,同时(原子地)将状态更改为“锁定”。在解锁时,它们将状态设置回“解锁”状态,使用释放顺序。这意味着解锁互斥锁和随后锁定它之间会形成一个“先行发生”关系。

下面是这种模式的演示:

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 操作相匹配。

注意

细心的读者可能已经注意到,compare_exchange 操作也可以使用交换操作来实现,因为在已锁定的情况下将 true 替换为 true 并不会改变代码的正确性:

// 这也可行。
if LOCKED.swap(true, Acquire) == false {
    …
}

由于获取和释放的内存顺序,我们可以确定没有两个线程可以同时访问 DATA。如图 3-4 所示,对 DATA 的任何先前访问都会在随后对 LOCKED 进行释放存储(将其设为 false)之前发生,而这一存储操作又在下一次获取比较并交换(或获取交换)操作更改 false 为 true 之前发生,最后这次操作发生在对 DATA 的下一次访问之前。

image.png

示例:使用间接的惰性初始化

在“示例:惰性一次性初始化”中,我们使用比较并交换操作实现了全局变量的惰性初始化,以处理多个线程竞争初始化值的情况。由于该值是一个非零的 64 位整数,我们能够使用 AtomicU64 来存储它,使用零作为初始化之前的占位符。

要对一个更大但无法放入单个原子变量的数据类型执行相同操作,我们需要寻找替代方法。

对于这个示例,假设我们希望保持非阻塞行为,使线程不需要等待其他线程,而是竞争并获取由第一个完成初始化的线程设置的值。这意味着我们仍然需要能够通过单个原子操作从“未初始化”变为“完全初始化”。

软件工程的基本定理告诉我们,计算机科学中的每个问题都可以通过添加另一层间接来解决,这个问题也不例外。由于我们无法将数据放入单个原子变量中,因此我们可以使用原子变量来存储指向数据的指针。

AtomicPtr 是 *mut T 的原子版本:即指向 T 的指针。我们可以使用空指针作为初始状态的占位符,使用比较并交换操作将其原子地替换为指向新分配的、完全初始化的 T 的指针,其他线程随后可以读取该指针。

由于我们不仅共享了包含指针的原子变量,还共享了它指向的数据,因此我们不能像第 2 章中那样使用放松的内存顺序。我们需要确保数据的分配和初始化不会与读取它的操作竞争。换句话说,我们需要在存储和加载操作中使用释放和获取顺序,以确保编译器和处理器不会通过重新排序指针存储和数据初始化等方式破坏我们的代码。

这就引出了以下对于某个任意数据类型 Data 的实现:

use std::sync::atomic::AtomicPtr;
fn get_data() -> &'static Data {
    static PTR: AtomicPtr = 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 中。如果另一个线程在初始化竞争中获胜,compare_exchange 将失败,因为 PTR 不再为空。如果发生这种情况,我们将原始指针转换回 Box 并通过 drop 释放它,从而避免内存泄漏,然后继续使用另一个线程存储在 PTR 中的指针。

在最后一个 unsafe 块上的安全注释说明了我们假设指向的数据已经初始化的前提条件。请注意,这包含了对操作顺序的假设。为了确保我们的假设成立,我们使用释放和获取的内存顺序来确保初始化数据实际上在创建引用之前已经发生。

我们在两个地方加载潜在的非空(即已初始化的)指针:通过加载操作和 compare_exchange 操作失败时。因此,如上所述,我们需要对加载的内存顺序和 compare_exchange 失败的内存顺序使用 Acquire,以能够与存储指针的操作同步。这种存储操作发生在 compare_exchange 成功时,因此我们必须使用 Release 作为其成功顺序。

图 3-5 显示了多个线程调用 get_data() 的操作和发生先行关系的可视化。在这种情况下,线程 A 和 B 都观察到空指针并尝试初始化原子指针。线程 A 在竞争中获胜,导致线程 B 的 compare_exchange 操作失败。线程 C 只在原子指针被线程 A 初始化后观察到它。最终结果是,三个线程都使用了由线程 A 分配的那个 Box。

image.png

消费排序(Consume Ordering)

让我们更仔细地看一下上一个示例中的内存排序。如果我们抛开严格的内存模型,以更实际的术语来看,可以说释放排序(Release Ordering)防止了数据的初始化与共享指针的存储操作重新排序。这一点很重要,因为否则其他线程可能会在数据完全初始化之前看到它。

类似地,我们可以解释获取排序(Acquire Ordering),它可以防止指针加载之前对数据进行访问。然而,这在实践中是否有意义可能会引发疑问:在知道地址之前,数据如何被访问呢?我们可以得出结论,某种比获取排序更弱的排序就足够了。这种较弱的排序就是消费排序(Consume Ordering)。

消费排序本质上是获取排序的轻量化、更高效的变体,其同步效果仅限于依赖于加载值的操作。

这意味着,如果您从一个原子变量中消费加载一个释放存储的值 x,那么基本上该存储操作发生在对依赖表达式(例如 *x、array[x] 或 table.lookup(x + 1))进行求值之前,但不一定发生在不依赖于 x 的独立操作之前。

现在有好消息也有坏消息。

好消息是,在所有现代处理器架构上,消费排序可以通过与放松排序相同的指令来实现。换句话说,消费排序可以是“免费的”,这在某些平台上并非获取排序的情况。

坏消息是,没有编译器实际上实现了消费排序。

事实证明,不仅“依赖”求值这个概念难以定义,在对程序进行转换和优化时保持这些依赖性也更加困难。例如,编译器可能会将 x + 2 - x 优化为仅为 2,从而有效地消除了对 x 的依赖。类似的、更微妙的问题可能出现在更复杂的表达式中,如 array[x],如果编译器能够对 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 栅栏(fence),我们将在下一部分中进行探讨。

内存栅栏(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 中的相应存储操作中加载了值,则线程 1 上的释放栅栏发生在线程 2 上的获取栅栏之前。

栅栏不必直接紧跟在原子操作之前或之后。其他任何操作都可以发生在它们之间,包括控制流。这样可以使栅栏是有条件的,类似于比较并交换操作具有成功和失败排序。

例如,如果我们从一个原子变量中使用获取内存排序加载指针,可以使用栅栏在指针不为空时才应用获取排序:

使用获取加载:

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 });
}

如果指针通常为 null,这样可以避免在不必要时应用获取内存排序。

让我们来看一个更复杂的释放和获取栅栏的使用示例:

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] });
            }
        }
    }
}

在这个示例中,10 个线程进行一些计算并将其结果存储在一个(非原子的)共享变量中。每个线程通过设置原子布尔值来指示数据已准备好供主线程读取,使用的是常规的释放存储。主线程等待半秒钟,检查所有 10 个布尔值,查看哪些线程完成了计算,并打印出已准备好的结果。

主线程使用放松操作和一个获取栅栏,而不是使用 10 个获取加载操作来读取布尔值。它在读取数据之前执行栅栏,但仅当有数据可读取时才执行。

虽然在这个特定示例中,对这些优化进行任何努力可能完全没有必要,但当构建高效的并发数据结构时,这种节省额外获取操作的模式可能非常重要。

顺序一致性栅栏(SeqCst Fence)

顺序一致性栅栏既是释放栅栏也是获取栅栏(类似于 AcqRel),并且也是顺序一致操作的单一总顺序的一部分。然而,只有栅栏是总顺序的一部分,而不一定是栅栏前后的原子操作。这意味着与释放或获取操作不同,顺序一致性操作不能分解为放松操作和内存栅栏。

编译器栅栏(Compiler Fences)

除了常规的原子栅栏之外,Rust 标准库还提供了一个编译器栅栏:std::sync::atomic::compiler_fence。它的签名与我们上面讨论的常规栅栏相同,但其效果仅限于编译器。与常规原子栅栏不同,它不会阻止处理器重新排序指令等。

在绝大多数使用栅栏的情况下,编译器栅栏并不够用。

常见误区

关于内存排序存在许多误解。在本章结束之前,让我们来看看最常见的一些误区。

误区:我需要强内存排序来确保更改“立即”可见

一个常见的误解是,使用像 Relaxed 这样的弱内存排序意味着对原子变量的更改可能永远不会到达另一个线程,或者只会在经过显著延迟后到达。Relaxed 这个名字可能让人觉得,直到某些硬件部分被强制唤醒并做它本应做的事情前,什么都不会发生。

事实是,内存模型根本不讨论时间问题。它只定义某些事情发生的顺序,而不是你可能需要等待多长时间才能看到结果。在现实中,一个假设的计算机如果需要几年才能让一个线程的数据传递到另一个线程,它仍然可以完全符合内存模型,但显然是不太实用的。

在现实生活中,内存排序主要涉及指令重排等内容,通常发生在纳秒级别的时间尺度上。更强的内存排序不会让你的数据传输得更快,反而可能会减慢程序的速度。

误区:禁用优化意味着我不需要关心内存排序

编译器和处理器都可能导致事情按不同于预期的顺序发生。禁用编译器优化并不会禁用编译器中的所有可能转换,也不会禁用处理器的指令重排和其他类似可能导致问题的行为。

误区:使用不重排指令的处理器意味着我不需要关心内存排序

一些简单的处理器(例如用于小型微控制器中的处理器)只有一个核心,只能一次执行一条指令,并且严格按照顺序执行。然而,即使在这些设备上,因不正确的内存排序导致实际问题的可能性大大降低,但编译器仍可能基于不正确的内存排序做出无效假设,从而破坏代码。此外,即使处理器不按顺序执行指令,它可能仍具有其他与内存排序相关的特性。

误区:放松操作是免费的

这是否正确取决于你对“免费”的定义。确实,Relaxed 是最有效的内存排序,并且比其他排序快得多。确实,在所有现代平台上,放松加载和存储操作编译后与非原子读取和写入的处理器指令相同。

如果一个原子变量仅由一个线程使用,那么与非原子变量相比,任何速度上的差异很可能是因为编译器在优化非原子操作时更有自由度和更有效(编译器往往避免对原子变量进行大多数类型的优化)。

然而,从多个线程访问相同的内存通常比从单个线程访问慢得多。一个持续向原子变量写入数据的线程,当其他线程开始反复读取该变量时,可能会经历明显的减速,因为处理器核心及其缓存现在必须开始协作。

我们将在第 7 章中探讨这一效应。

误区:顺序一致性内存排序是一个很好的默认值,并且总是正确的

暂且不考虑性能问题,顺序一致性内存排序通常被视为默认选择的理想类型,因为它提供了强有力的保证。确实,如果其他任何内存排序是正确的,那么 SeqCst 也是正确的。这可能会让人觉得 SeqCst 总是正确的。然而,完全有可能某个并发算法本身就是不正确的,无论使用什么内存排序。

更重要的是,在阅读代码时,SeqCst 基本上告诉读者:“该操作依赖于程序中每一个 SeqCst 操作的总顺序”,这是一种非常深远的声明。如果使用更弱的内存排序,代码可能会更容易阅读和验证。例如,Release 有效地告诉读者:“这与同一变量上的 Acquire 操作相关”,这样在理解代码时需要考虑的内容要少得多。

建议将 SeqCst 视为一个警示标志。看到它时,通常意味着要么发生了复杂的事情,要么是代码作者没有花时间分析与内存排序相关的假设,这两种情况都需要更加仔细地审查。

误区:顺序一致性内存排序可以用于“释放加载”或“获取存储”

虽然 SeqCst 可以替代 Acquire 或 Release,但它并不能用于创建获取存储或释放加载。它们依然不存在。Release 只适用于存储操作,而 Acquire 只适用于加载操作。

例如,一个 Release 存储并不会与一个 SeqCst 存储形成任何释放-获取关系。如果你需要它们成为全局一致顺序的一部分,那么两个操作都必须使用 SeqCst。

总结


上一条查看详情 +前端组件化开发指南(二)
下一条 查看详情 +没有了