理解Rust中的内存排序

文摘   2024-09-23 20:52   美国  

我正在阅读Mara Bos的 《Rust原子操作和锁》[1] 。第一遍阅读时,我并没有真正理解 内存排序[2] (Mara称之为"本书最复杂的主题")。所以这里是我尝试通过解释来理解。

为什么我们关心内存排序

当你的程序有多个线程处理相同的数据(如共享计数器或上下文对象)时,原子操作和内存排序就会发挥作用。

在单线程代码中,你可以从上到下阅读函数的语句,并理解它将要做什么。较早的语句会在较晚的语句之前发生。

在多线程代码中,情况并非如此简单。另一个线程可能正在以可能破坏程序预期行为的方式读取或写入你正在查看的函数中的相同变量。

多线程更难以推理,因为还有两个额外的因素:

  • 编译器优化 - 在编译时,编译器可以重新排序和组合指令以优化你的代码。我们通常很欣赏这些性能改进,但如果我们不小心,重新排序某些指令可能会破坏我们的多线程代码。
  • 处理器优化 - 在运行时,处理器也可能重新排序指令。例如,因为后面指令中的变量已经在处理器缓存中可用,而另一个变量需要从内存加载。与编译器优化类似,这些重新排序对单线程代码来说没问题,但可能会破坏多线程代码。

编译器和处理器优化很好,但它们没有考虑多个线程。

内存排序是我们告诉编译器和处理器哪些操作可以安全地重新排序,哪些不能的方式。(内存排序也用于实现更高级的多线程构建块,如通道、MutexRwLock。)

内存排序的三个类别

虽然std::sync::atomic::Ordering枚举有5个变体,但实际上有3类内存排序:

  1. Relaxed
  2. Release / Acquire (Acquire, Release, AcqRel)
  3. 顺序一致性 (SeqCst)

让我们深入研究这些类别,从"最强"的开始。

你可能不想要顺序一致性

这一点表达得非常清楚:

虽然顺序一致性内存排序似乎是最容易推理的,但在实践中几乎从不需要...

误解:顺序一致性内存排序是一个很好的默认选择,而且总是正确的。
撇开性能考虑不谈,顺序一致性内存排序通常被视为默认使用的完美内存排序类型,因为它提供了强有力的保证。确实,如果任何其他内存排序是正确的,那么SeqCst也是正确的。这可能会让人觉得SeqCst总是正确的。然而,并发算法完全有可能本身就是错误的,无论使用什么内存排序。... 建议将SeqCst视为一个警告信号。在实际中看到它通常意味着要么正在发生一些复杂的事情,要么作者simply没有花时间分析他们与内存排序相关的假设,这两种情况都需要额外的审查。

哎呀。我肯定犯过在过去写代码时使用SeqCst的错误,只是因为它看起来像是最安全的默认选择。

那么,我们还有什么其他选择呢?

放松一点

当你想对单个变量执行原子操作,但不关心跨线程同步不同操作时,Relaxed内存排序很有用。例如,如果你正在递增单个计数器,你可能不关心递增操作发生的顺序,只要每个操作都是原子的即可。

Mara给出了这段代码的例子,假设函数ab在不同的线程上运行:

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

在这个程序中:

  • 我们知道5会在10之前被加到X上。
  • 我们_不知道_b中不同的load操作会加载什么值。
  • 我们_确实知道_加载操作会按正确的顺序观察到加法操作(0 → 5 → 15)。
  • 同一程序的不同运行可能会以不同的方式交错fetch_addload操作。

这里是操作可能排序的4种不同方式的图示:

再次强调,Relaxed在以下情况下很有用:

  • 你有一个想要原子更新的单个变量。
  • 你不关心多个线程的更新发生的顺序。
  • 其他线程不立即观察到更新的效果是可以的。
  • 你有多个变量,但不关心它们之间更新的同步。

深入研究:ReleaseAcquire排序

现在我们已经看到我们可能不想要SeqCst,而Relaxed是用于不需要同步的原子操作,让我们把注意力转向ReleaseAcquire

首先要澄清一点:你不应该将ReleaseAcquire视为两种不同的"模式"。Release只与写入/存储操作相关。Acquire只与读取/加载操作相关。

Release标记程序整体时间线上的一个时刻,声明_在这个时刻或之前发生的所有写入操作在对同一变量进行Acquire之后都将可见_。

用更简单的话说,假设你有两个线程在做一些工作并读写数据:

生产者线程 消费者线程
🛠️ 工作
🛠️ 工作
✏️ 写入数据
✏️ 写入更多数据
🚩 Release
🚩 Acquire
📖 读取数据
📖 读取更多数据
🛠️ 工作

ReleaseAcquire同步线程,使得_在这个时刻或之前发生的所有写入操作在对同一变量进行Acquire之后都将可见_。

这种排序的用例包括:

  • 实现锁,如MutexRwLock
  • 实现生产者/消费者模式,如通道
  • 数据的延迟初始化
  • 在线程之间发信号通知任务完成

信号示例

让我们看看Mara使用AtomicBool来表示数据何时准备好的例子:

use std::sync::atomic::{AtomicBool, AtomicU64};
use std::sync::atomic::Ordering::{Acquire, Relaxed, Release};

static DATA: AtomicU64 = AtomicU64::new(0);
static READY: AtomicBool = AtomicBool::new(false);

fn main() {
    std::thread::spawn(|| {
        DATA.store(123, Relaxed);
        READY.store(true, Release); // 在这个存储之前的所有内容 ..
    });
    while !READY.load(Acquire) { // .. 在这里加载为`true`之后可见。
        std::thread::sleep(std::time::Duration::from_millis(100));
        println!("waiting...");
    }
    println!("{}", DATA.load(Relaxed));
}

主线程正在等待DATA准备就绪,每次检查之间睡眠100毫秒。

关键是,READY.store(true, Release);这一行确保了_在这个时刻或之前发生的所有写入操作在对同一变量进行Acquire之后都将可见_。注意DATA是在Release时刻_之前_写入的,并且只使用了Relaxed排序。

当主线程最终观察到READY.load(Acquire)true时,我们跳出while循环并最终读取值。即使DATA.load(Relaxed)使用Relaxed,它也保证能看到该值。写入发生在_READY变量的Release时刻之前_,而这个load发生在READY对应的Acquire之后。

这是此代码唯一可能的序列:

锁定示例

Mara关于Release / Acquire排序的下一个例子是一个非常基本的锁,它使用AtomicBool来保护对String的访问。这里是该示例的一个稍微修改的版本:

use std::sync::atomic::Ordering::{Acquire, Relaxed, Release};
use std::sync::atomic::AtomicBool;

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() {
    std::thread::scope(|s| {
        for _ in 0..100 {
            s.spawn(f);
        }
    });
    println!("{}", unsafe { &DATA });
}

两个关键行是:

  1. if LOCKED.compare_exchange(false, true, Acquire, Relaxed).is_ok() {
  2. LOCKED.store(false, Release);

第一行使用Acquire排序原子地读取LOCKED,如果值为false,则使用Relaxed排序将值设置为true

第二行使用ReleaseLOCKED设置回false。正如我们之前看到的,Release意味着_在这个时刻或之前发生的所有写入操作在对同一变量进行Acquire之后都将可见_。

结合这两行保证了对DATAunsafe写入和对READYRelease写入都将在另一个线程使用Acquire排序观察到LOCKEDfalse之前完成。

DIY同步原语

Mara的书中接下来的几章很好地介绍了如何使用我们在这里讨论的内存排序原则构建 自旋锁[3] 、 通道[4] 和 原子引用计数指针([5] 。我不会在这里详细介绍,但我建议你自己阅读这些内容。

如果你想进一步深入研究,你还可以阅读关于 原子栅栏[6] 的内容。这些实际上将我们一直在讨论的存储/Release和加载/Acquire操作分解为单独的栅栏和存储或加载操作,这对于一次将栅栏应用于多个变量很有用。

结论

内存排序可能很难理解,但这里有几个要记住的要点:

  • SeqCst并不像你想象的那样是一个好的默认选择,在大多数情况下应该避免使用。
  • 当你想对单个变量应用原子操作,但不关心同步多个变量的读写时,Relaxed很好用。例如,递增简单计数器或收集统计信息。
  • Release / Acquire用于当你想在多个线程之间同步多个变量的读写时。使用Release将值存储到给定变量保证_在这个时刻或之前发生的所有写入操作在使用Acquire加载同一变量后都将可见_。Release / Acquire对于构建各种同步原语很有用,包括锁、通道和信号。可以将Release视为向其他线程释放一些更改(或锁),而Acquire获取这些更改(或锁)。


感谢Mara Bos撰写了清晰易懂的 《Rust原子操作和锁》[0] 解释,以及Simon Rassmussen对本文草稿的反馈。


在 r/rust[7] 、 Lobsters[8] 或 Hacker News[9] 上讨论。

#rust[10]

参考链接

  1. 《Rust原子操作和锁》: https://marabos.nl/atomics/
  2. 内存排序: https://marabos.nl/atomics/memory-ordering.html
  3. 自旋锁: https://marabos.nl/atomics/building-spinlock.html
  4. 通道: https://marabos.nl/atomics/building-channels.html
  5. 原子引用计数指针(: https://marabos.nl/atomics/building-arc.html
  6. 原子栅栏: https://marabos.nl/atomics/memory-ordering.html#fences
  7. r/rust: https://www.reddit.com/r/rust/comments/1fj9eog/understanding_memory_ordering_in_rust/
  8. Lobsters: https://lobste.rs/s/zofsan/understanding_memory_ordering_rust
  9. Hacker News: https://news.ycombinator.com/item?id=41572070
  10. #rust: https://emschwartz.me/blog/?q=rust

幻想发生器
图解技术本质
 最新文章