我想谈论并发编程中一个特定且非常重要的部分,这是我长期以来误解的部分,也是我在阅读使用原子操作的 Rust 代码时发现普遍被误解和误用的部分,即内存序。

内存序是什么

大多数原子内存访问操作需要指定一个或多个内存顺序,这些顺序会修改访问的行为。根据你当前的知识,以下哪项是这些顺序的目的?

  1. 指定哪些原子访问必须先于其他访问

  2. 判断是否存在对原子访问的内存位置的修改的一致性顺序,无论哪个线程进行访问

  3. 要确定原子访问必须以何种优先级(即多“快”)执行

答案是以上全部是错误的。

对于选项(A),指定的内存顺序对在一个线程上执行的原子访问是否会在另一个线程上对同一内存执行的另一个原子访问之前或之后没有影响。

对于选项(B),仅仅在单个内存块上使用原子访问,即使只有 Relaxed 排序,也已经确保了只有一个被所有线程同意的"总修改顺序",针对该内存块。

对于选项©,排序再次没有任何效果——所有原子访问都以完全相同的优先级或"速度"(即"尽可能快")发生。一个 Relaxed 原子写入会以和 SeqCst 写入完全相同的速度传播到其他线程。

那么这些内存序的意义是什么呢?

内存序只做一件事。它们同步在一个原子值上进行的原子访问与对任何其他值(无论是否为原子)进行的内存访问之间的相对顺序。如果线程之间共享的唯一内容是一个原子值,那么内存 Ordering 对程序的指定行为没有任何影响。但这在实践中意味着什么?

考虑下面的程序:

use core::sync::atomic::AtomicUsize;
use core::sync::atomic::Ordering;

static FOO: AtomicUsize = AtomicUsize::new(0);

fn thread1() {
    FOO.store(1, Ordering::SeqCst);
}

fn thread2() {
    loop {
        if FOO.load(Ordering::SeqCst) == 1 {
            println!("Found 1!");
            break;
        }
    }
}

fn main() {
    let t1 = std::thread::spawn(thread1);
    let t2 = std::thread::spawn(thread2);
    t1.join().unwrap();
    t2.join().unwrap();
}

我们创建一个共享的 FOO ,静态初始化为 0 。然后以非同步的方式启动两个线程,并在每个线程完成时进行连接。 thread1 将 1 写入 FOO ,而 thread2 会旋转直到读取到 1 ,然后打印 "Found 1!"

在这个版本中, load 和 store 在 FOO 上都使用了最强的内存排序 SeqCst 。但这有必要吗?考虑以下版本(我们只改变了 Ordering 们):

static FOO: AtomicUsize = AtomicUsize::new(0);

fn thread1() {
    FOO.store(1, Ordering::Relaxed);
}

fn thread2() {
    loop {
        if FOO.load(Ordering::Relaxed) == 1 {
            println!("Found 1!");
            break;
        }
    }
}

fn main() {
    let t1 = std::thread::spawn(thread1);
    let t2 = std::thread::spawn(thread2);
    t1.join().unwrap();
    t2.join().unwrap();
}

这个程序的行为和之前有任何不同吗?

答案是,没有。我们只是在线程之间共享一个单一的原子值,所以 Ordering 没有任何影响。在两种情况下,我们都保证得到完全相同的结果:

我们的程序在打印 "Found 1!" 之前不会成功到达 main 的末尾

  1. 一旦线程 2 加载了 1 ,它将不会再加载 0

  2. 一旦线程 1 写入 1 ,它将不会再加载 0

  3. 实现会尽其所能,让线程 1 在 FOO 上的存储对线程 2 的加载"尽可能快"地可见

如果 Relaxed 的存储和加载即使遵循这一点的事实让你有点困惑,就像让我一样,你可能也是一个彻底被 GPU 思维影响的游戏程序员 >:)(或者也许注定要成为其中之一?👀)

是的,仅仅使用原子访问——即使是 Relaxed 的原子访问!——也迫使实现始终尽其所能地刷新存储,并尽快让它们对其他线程可见。然而,极其重要的是(预示!),它只迫使实现仅对被访问的那个特定的原子这样做。

好吧,让我们稍微提高一下示例程序的赌注

use core::sync::atomic::AtomicUsize;
use core::sync::atomic::Ordering;

static FOO: AtomicUsize = AtomicUsize::new(0);

fn thread1() {
    for i in 1..=2_000_000 {
        FOO.store(i, Ordering::SeqCst);
    }
}

fn thread2() {
    let mut i = 0;
    loop {
        let val = FOO.load(Ordering::SeqCst);
        if val >= 1_000_000 {
            println!("Found {val} after {i} iters!");
            break;
        }
        i = i + 1;
    }
}

fn main() {
    let t1 = std::thread::spawn(thread1);
    let t2 = std::thread::spawn(thread2);
    t1.join().unwrap();
    t2.join().unwrap();
}

我们有一个与之前非常相似的程序结构。不过这次,我们在 thread1 上从 1..=2_000_000 循环,并将那个值存储到 FOO ,而在 thread2 上,我们循环直到找到 FOO >= 1_000_000 ,并计算我们执行了多少次迭代才找到那个值。

这次,将那些 Ordering::SeqCst 改为 Relaxed 会改变我们程序的指定行为吗?

再一次,不会!(抱歉,我保证现在不再诱你了……)

我们仍然只有一个原子变量在多个线程间共享,所以,和之前一样,内存 Ordering 将没有任何效果。我想展示这个例子,但是,因为它展示了另一个我见过的一些常见误解,即“使用 SeqCst 内存顺序会阻止优化器移除这些原子存储”这一概念。

如果真是那样,编译器将不得不保留 for 循环在 thread1 中,并实际按顺序对 FOO 执行 200 万次存储。实际上根本不是这样,编译器可以自由地将 fn thread1() 改为仅存储 2_000_000 一次:

fn thread1() {
    FOO.store(2_000_000, Ordering::SeqCst);
}

这个转换可以通过以下思想实验来证明,其结构在编写通用异步代码时极其有用且重要:

是否存在原始程序的任何有效执行会导致转换后的程序行为,即在其他线程在 for 循环的第一次迭代和最终迭代之间没有观察到 FOO 的值,只留下 2_000_000 的最终值被观察到?是的,这确实是原始程序的一个非常有效的可能执行。因此,这个转换是合理的。

这种思考方式,即“是否存在程序执行的某种情况,使得以下情况发生……”是非常有用的模式。它还促使我们思考关于我的程序行为的实际保证是什么,哪些是未定义的?

改变内存顺序可能会改变特定实现中此类程序的观察行为,但实际观察到的行为必须是内存模型允许行为的一个有效子集。这意味着即使根据指定为正确的标准来看程序行为不正确,你也可能观察到程序的正确行为,这可能导致非常隐蔽的 bug,这些 bug 只有在编译器改进或你将程序运行在不同的硬件架构上(其中实际实现存在差异)时才会出现。例如,目前 Rust 和 C/C++ 编译器的当前实现,在原子访问上的实际优化与内存模型规则允许它们进行的优化相比非常保守,因此如果你运行上述示例,它们实际上会在 thread1 上执行完整的循环。再加上 x86 以硬件级别免费提供"Acquire-Release"语义这一事实,这意味着可以编写非常不正确的原子同步代码,这些代码在 x86 主机上可以"正常工作",但在 aarch64 主机上完全失效。 这也意味着,关于"速度"的某些我之前所说的内容在实际中可能并不成立,如果编译器和硬件方便以不同的方式实现它,而这种方式仍然满足规范。

内存序何时重要

好吧,让我们终于来看一个内存排序有实际意义的例子。假设我们想在某个线程上加载数据,将其放入一个与另一个线程共享的缓冲区,然后告诉那个线程我们已经完成加载,它现在可以读取那些数据。如果我们以最原始的形式来做这件事,可能会写出如下代码:

#![feature(sync_unsafe_cell)]
use core::sync::atomic::AtomicBool;
use core::sync::atomic::Ordering;
use core::cell::SyncUnsafeCell;

static DONE_LOADING: AtomicBool = AtomicBool::new(false);
static BUFFER: SyncUnsafeCell<Vec<u8>> = SyncUnsafeCell::new(Vec::new());

fn thread1() {
    {
        // SAFETY: nobody else accesses the buffer until we say we're done loading
        let buffer = unsafe { &mut* BUFFER.get() };
        *buffer = vec![10; 256];
    }
    DONE_LOADING.store(true, Ordering::Relaxed);
}

fn thread2() {
    loop {
        if DONE_LOADING.load(Ordering::Relaxed) {
            break;
        }
    }
    // SAFETY: thread1 is done accessing the buffer since we loaded a true!
    let buffer = unsafe { &* BUFFER.get() };
    println!("Loaded buffer: {:?}", buffer)
}

fn main() {
    let t1 = std::thread::spawn(thread1);
    let t2 = std::thread::spawn(thread2);
    t1.join().unwrap();
    t2.join().unwrap();
}

目前这样写的实现是合理的吗?

不,不是这样的!我们在不同线程中对 BUFFER 进行了非同步的读写访问,这是一个数据竞争,因此是未定义行为!

但这怎么可能呢?我们不是用了 DONE_LOADING 原子操作来同步访问的吗?如果我们在线程 thread2 上加载了 true ,那么线程 thread1 必须已经写入了缓冲区,因为我们只会在完成写入后才会写 true ,甚至确保我们的临时引用 buffer 已经超出了作用域!

事实上,我们使用 DONE_LOADING 原子并没有正确同步我们的访问,因为我们只使用了 Ordering::Relaxed 。

这是内存排序的目的,也是为什么它们被称为内存排序的原因!它们告诉实现如何要求对其他内存位置的操作相对于原子操作进行排序。

被编译器挫败

当我们使用 Ordering::Relaxed 时,我们告诉语言我们完全不在乎对其他内存位置的操作相对于原子操作何时发生。事实上,如果编译器想要这样做,改变 fn thread1() 为以下内容将是程序的一个完全有效的转换(注意存储和加载相对于向缓冲区写入的顺序!)

fn thread1() {
    DONE_LOADING.store(true, Ordering::Relaxed);

    {
        let buffer = unsafe { &mut* BUFFER.get() };
        *buffer = vec![10; 256];
    }
}

如果 BUFFER 不会在多个线程间共享,这种转换对程序的最终结果不会有任何影响,因此编译器可以自由地进行这种优化。确实,通过使用 Ordering::Relaxed ,我们已经明确告诉编译器我们不会使用这些原子访问来同步其他内存访问,所以编译器可以随意地相对于其他内存访问重新排序它们。

被硬件挫败

除了编译器,即使它不执行上述优化,我们还需要考虑硬件实现本身。许多,但并非所有,关于 Rust(C++)并发内存模型的设计考虑都受到语言将要运行在的硬件需求的影响。

mem weak@2x

任何硬件实现都希望尽量减少无效化缓存内存的需求。每当我们跨线程共享内存时,都必须确保两个线程都能看到它们共享内存的相同"视图",即使硬件中每个线程的内存是完全独立的缓存副本。

为了确保我们依赖的实现能够保证在程序某个时刻所有对其他共享内存的写入确实被更新(从而使程序看起来正确),我们需要使用能够创建内存同步的内存序。我们可以使用 Acquire 和 Release 来实现这一点,如下所示(注意这和原始程序相同,仅修改了 Ordering 。

#![feature(sync_unsafe_cell)]
use core::sync::atomic::AtomicBool;
use core::sync::atomic::Ordering;
use core::cell::SyncUnsafeCell;

static DONE_LOADING: AtomicBool = AtomicBool::new(false);
static BUFFER: SyncUnsafeCell<Vec<u8>> = SyncUnsafeCell::new(Vec::new());

fn thread1() {
    {
        // SAFETY: nobody else accesses the buffer until we say we're done loading
        let buffer = unsafe { &mut* BUFFER.get() };
        *buffer = vec![10; 256];
    }
    let _ = DONE_LOADING.store(true, Ordering::Release);
}

fn thread2() {
    loop {
        if DONE_LOADING.load(Ordering::Acquire) {
            break;
        }
    }
    // SAFETY: thread1 is done accessing the buffer since we loaded a true!
    let buffer = unsafe { &* BUFFER.get() };
    println!("Loaded buffer: {:?}", buffer)
}

fn main() {
    let t1 = std::thread::spawn(thread1);
    let t2 = std::thread::spawn(thread2);
    t1.join().unwrap();
    t2.join().unwrap();
}

我们已经告诉编译器,线程 1 上发生在" Release -store"之前的任何源序访问,都必须发生在线程 2 上后续的" Acquire -load"存储值之前。这正是我们需要确保程序有效的条件。

关于特定、棘手的情况,需要某些内存顺序来确保程序正确行为,还有更多话可说,我不会在这里一一列举。相反,我想在思考内存顺序时,在你头脑中种下这个核心种子: Ordering 是关于我们期望在原子操作被访问时发生的事情,而不是关于原子操作本身。有了这个前提,我认为你处于一个绝佳的位置去继续阅读 Mara 关于内存顺序的章节(以及这本书的其余部分)。那里列出的常见误解也是这篇帖子的一个很好的补充。

但是为什么呢

这有点跑题,但我想谈谈我认为非常重要的事情。此时此刻,许多读者可能正在想类似这样的话:"唉,编译器工程师为什么要搞得这么复杂?!为什么程序不能像我写的那样运行呢?"

在我看来,至少有三个重要原因。

  1. 在并发和内存排序的情况下,问题本身就极其复杂,即使仅从硬件角度来看也是如此。Rust 和其他语言所采用的 C++20 内存模型并非万能药,但……

  2. 你希望你的程序运行得快,并且希望编译器能帮助你让它变快。承认吧。真的,你确实希望如此。

  3. 你希望你的程序能在多个目标上运行(快),具有不同的操作系统、硬件架构等。

  4. 你希望能够编写那些以你知道是正确的方式进行优化的程序,即使编译器无法证明这一点。

进入未定义行为。与某种普遍观点相反,未定义行为并不是“语言中设计者懒得指定行为而产生的漏洞”(至少,当它们做得好的时候不是这样……但这并不一定成立)。相反,这些是精心设计的、匠人般的漏洞,有意留下的空白。这些空白让编译器能够以一种满足上述要求的方式来进行实现。没有未定义行为,编译器将无法提供我们所期望的灵活性、可移植性和速度的组合。我强烈推荐阅读 Ralf Jung 关于这个话题的博客文章《未定义行为值得更好的声誉》。

话虽如此,未定义行为是一把双刃剑。在语言设计中谨慎使用它非常有帮助,但过度使用导致了它目前的糟糕声誉。只有通过使用 unsafe Rust 才能触发未定义行为,这是一项超能力。务必小心使用(并使用正确的内存 Ordering )!

Last moify: 2022-12-04 15:11:33
Build time:2025-07-18 09:41:42
Powered By asphinx