Rust 线程安全 II

常见的线程安全类型

技术
技术 Rust 标准库 编程语言特性 并发编程 多线程

2026-04-10

上篇文章简要介绍了 Rust 当中用于实现线程同步的互斥锁、读写锁和条件变量,以及在多线程中共享数据的原子引用计数。本篇将介绍“线程安全”版本的原生类型(primitive types)——原子类型。

初识原子类型

原子指的是一系列不可被 CPU 上下文交换的机器指令,这些指令组合在一起就形成了原子操作。在多核 CPU 下,当某个 CPU 核心开始运行原子操作时,会先暂停其它 CPU 内核对内存的操作,以保证原子操作不会被其它 CPU 内核所干扰。由于原子操作是通过指令提供的支持,因此它的性能相比锁和消息传递会好很多。相较于锁而言,原子类型不需要开发者处理加锁和释放锁的问题(无锁不代表无需等待!当大量的冲突发生时,该等待还是得等待),同时支持读写等操作,还具备较高的并发性能,几乎所有的语言都支持原子类型。

原子类型(atomic types)是定义在 core::sync::atomic 中的一系列线程安全类型,它们提供线程间共享内存通信的功能,并且被定义为部分 Rust 原生类型的“原子”版本。

原生类型 原子类型
bool AtomicBool
i8 AtomicI8
i16 AtomicI16
i32 AtomicI32
i64 AtomicI64
isize AtomicIsize
*mut T AtomicPtr<T>
u8 AtomicU8
u16 AtomicU16
u32 AtomicU32
u64 AtomicU64
usize AtomicUsize

下面是一个简单的例子,它用于对此时存活的线程进行全局计数:

use std::sync::atomic::{AtomicUsize, Ordering};

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

let old_thread_count = GLOBAL_THREAD_COUNT.fetch_add(1, Ordering::Relaxed);
// 要注意打印时此值不一定是真实的
// 因为可能有线程在这段时间间隔内修改了 static 值
println!("live threads: {}", old_thread_count + 1);

等等,这里面的 Ordering 是什么?

原子内存顺序

事实上 Ordering 是一种表示原子内存顺序(atomic memory ordering)的类型。Rust 从 C++20 继承了原子操作的内存模型,这其中也包括原子内存顺序。想理解这个内存模型,首先需要对它试图解决的问题有整体的认识。

三重期望的鸿沟

我们作为代码的编写者,都希望代码实际运行的顺序和我们认为它应该运行的顺序是一致的。但编译器会对代码动一些手脚,同时硬件层面的一些机制也会让运行结果变得令人困惑。要知道编译器这样做的初衷是好的——为了优化;硬件这样做也有它自己的道理。但是当我们的期望与编译器的期望、硬件的期望放在一起时,就会发现它们之间似乎存在着无法弥合的鸿沟。而 Rust 所采用的原子操作内存模型正是试图从这三者之间寻求平衡。

我们先介绍鸿沟从何而来,然后再讲述 Rust 如何使用内存模型来应对此问题。


考虑下面这个简单的程序:

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

很简单不是吗?我们清楚地知道这段代码运行起来将会发生什么。但到了编译阶段,编译器分析之后可能会得出结论——如果你的程序这样执行会更好:

x = 2;
y = 3;

嗯……虽然消除了一步还颠倒了执行顺序,但看起来好像也没问题,毕竟最终的结果都一样嘛!这里编译器所做的工作称为编译器重排序(compiler reordering)。在单线程中这没什么影响,而且我们甚至还希望它能进行这类优化;但到多线程环境立马就不一样了:可能其它线程会依赖 x = 1; 这一步的结果,编译器要是直接把这步“优化”掉,那我们肯定是不同意的。但问题是:编译器怎么知道何时能进行这类优化?

这样,我们就发现:一方面,我们希望编译器尽可能为我们优化性能;另一方面,我们也希望程序能够按照我们说的去做。

此为我们的期望与编译器期望之间的鸿沟。


即使编译器完全理解我们的意图并尊重我们的愿望,硬件仍可能给我们带来麻烦,这次问题出在 CPU 的内存层次结构。

我们知道,主内存空间是对所有 CPU 核心共享的,但是从每个核心的角度来看,直接访问主内存并不划算,因为实在是太慢了。相反,每当 CPU 需要读写数据时,它都会优先操作自己的缓存(因为自己的缓存当然自己用起来最顺手,共享的东西还需要忍受共享内存通信的痛苦),如果缓存里没有它想要的东西,CPU 核心才会不得已去操作主内存。这就是 CPU 的内存层次结构,这就是缓存存在的意义。

┌───────────────┬───────────────┐
│   CPU Core    │    CPU Core   │
│       │       │       │       │
│  ┌────▼────┐  │  ┌────▼────┐  │
└──│  Cache  │──┴──│  Cache  │──┘
   └────┬────┘     └────┬────┘
        │               │
     ┌──▼───────────────▼──┐
     │        DRAM         │
     └─────────────────────┘

但这种硬件层面的设计会带来什么问题?看下面这个例子:初始状态 x y 两个变量的值分别是 01,然后在两个线程中分别对它们进行操作。这里假设编译器完全按照我们的想法生成机器指令,并且两线程所在 CPU 核心的缓存都还没有写入这两个变量。

──────────────────────────
      x = 0   y = 1

──Thread─1──┬──Thread─2───
            │
  y = 3;    │ if x == 1 {
  x = 1;    │     y *= 2;
            │ }

理想情况下,上面的代码会有三种运行结果:

  1. y 值为 3。因为线程 2 在 x 还是 0 时就进行了检查,所以 y *= 2 根本没执行,最终 y 值为线程 1 写入的 3
  2. y 值为 6。因为线程 1 完全执行完毕后,线程 2 才检查内存中的 x,此时内存中 x = 1y = 3,所以 y 被翻倍为 6
  3. y 值为 2

前两种情况都能理解,第三种情况是这样的:首先线程 1 执行了它的两条语句,并把新的 x y 变量值写入自己的缓存中;然后线程 1 会开始向内存传播这两项变更,但这里就有问题了:先传播 x = 1 还是 y = 3

由于硬件架构的某些特性,数据同步到各线程的顺序不一致,从而导致结果上的差异,这种现象叫做硬件重排序(hardware reordering)。由于硬件重排序的存在,我们本来期望会出现情况一或二,但实际发生的是情况三。

这就是我们的期望与硬件的期望之间的鸿沟。

弥合鸿沟的措施

Rust 采用的原子操作内存模型是如何弥合三者之间的鸿沟的呢?

首先,它认为程序中存在着因果关系,并且这种关系还会体现在程序和运行程序的线程之间。我们在代码中可以手动标记出这种关系,这样的话,硬件和编译器会在没有建立此种关系的地方进行更为激进的优化,而在建立此种关系的地方需要对优化保持谨慎。

其次,它将程序对内存的读/写操作分为两类:

大多数情况下程序中发生的都是非原子操作,这种方式便捷、对硬件友好,编译器可以尽情地优化和重排,硬件也能自由地将数据访问所做的更改传播到其他线程。但正如我们上面所见,这样的代码在实现线程同步上一塌糊涂,换言之,仅使用非原子操作,字面上就不可能编写出正确的同步代码。为此,原子操作应运而生。它告诉硬件和编译器“这里的程序是多线程的”。每个原子访问都可以标记一个顺序(ordering),指定它与其他访问建立何种类型的关系,并告诉编译器和硬件某些它们不能做的事情。对于编译器,不能做的事情主要是指令重排序;对于硬件,主要围绕写入如何传播到其他线程。

在 C++20 推出的内存模型当中,原子操作被抽象描述为“原子操作行为 + 内存序修饰符”,之所以这样是为了隐藏“内存屏障汇编指令”的技术细节:下图左侧是 ARM 架构的汇编伪代码片段;而右侧是对应的 Rust 代码片段。

内存屏障汇编指令
内存屏障汇编指令

蓝框中的代码描述了一次内存同步写操作;红框中的代码描述了一次内存同步读操作。左侧蓝框与红框重叠部分就是 DMB 内存屏障汇编指令,它虽是独立的语句,也需搭配完成具体功能的读写指令才能起效。

因此,在 Rust 当中,我们通过将具体的操作与原子内存顺序组合成一条语句,来表示一次完整且有序的原子操作。

原子类型的具体操作

使用 new 来创建原子类型。

load 会将内部值作为原生类型的值传回,这相当于对内存的操作;store 会用原生类型值替换掉内部值,相当于对内存的操作。

除了普通的读写操作外,还有一种“读-改-写”操作。例如 fetch_addcompare_exchangeswap。它们的共同点是会在一个原子操作内对同一原子变量连续且不可中断地完成“读-改-写”三个处理动作,并将第一次“读”处理的执行结果作为整个原子操作的返回值。

原子类型当中,除了 newinto_inner,以及 get 和 from 这种类型转换的方法以外,其它几乎所有方法最后的参数都是 Ordering 类型,也就是原子操作对应的内存顺序。需要注意不同的用法对允许的 Ordering 参数有着不同的限制,有的 Ordering 不能应用在某些方法上。

原子内存顺序

Rust 中有以下四种原子内存顺序:

这四种顺序从上至下,对访问顺序的约束也从强到弱。

顺序一致 SeqCst

顺序一致SeqCst)是所有顺序中约束最强的,它隐含了所有其他顺序的限制。一个顺序一致的操作不能被重排序:在一个线程中,发生在 SeqCst 访问之前的所有访问,都会保持在其之前;发生在 SeqCst 访问之后的所有访问,都会保持在其之后。当然天下没有免费的午餐,对访问顺序的严格限制,带来的代价就是最严重的性能损耗。

实际上,顺序一致很少是程序正确性所必需的。然而如果你对其他内存顺序心里没底,顺序一致绝对是正确的选择。让程序运行得稍慢一点,总比让它运行错误要好吧!

获取顺序 Acquire 和释放顺序 Release

AcquireRelease 主要是成对使用的。名称暗示了它们非常适合用于获取和释放锁,并确保临界区不产生重叠。

获取顺序Acquire)的访问确保其后的每个访问都保持在其后。然而,发生在 Acquire 之前的操作可以自由地被重排序到其之后。loadAcquire 组合的原子操作会保证发生后于该读操作的原子或非原子操作都不会被重排至此读操作之前执行。此外,

类似地,释放顺序Release)的访问确保其前的每个访问都保持在其前。然而,发生在 Release 之后的操作可以自由地被重排序到其之前。storeRelease 组合的原子操作会保证发生先于该写操作的原子或非原子操作都不会被重排至此写操作之后执行。此外,

当线程 A 释放一个内存位置,然后线程 B 获取同一个内存位置时,因果关系就建立了。在 A 的 Release 之前发生的每次写入(包括非原子和宽松的原子写入)都将在 B 的 Acquire 之后被观察到。然而,这不会与其他任何线程建立因果关系。类似地,如果 A 和 B 访问不同的内存位置,也不会建立因果关系。这里的“同一个内存位置”换句话说就是同一个原子变量。

Release-Acquire 的组合用法
Release-Acquire 的组合用法

因此,AcquireRelease 这一对的基本使用很简单:你获取一个内存位置以开始临界区,然后释放该位置以结束它。比如下面这个简单的自旋锁:

use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};

fn main() {
    // 这个锁的内部值表示当前的上锁状态
    let lock = Arc::new(AtomicBool::new(false));

    /* 把锁分配给其它线程的操作 */

    // 通过将内部值设置为 `true` 来获取锁
    // 
    // `compare_exchange` 会读取原子变量的当前值
    // 如果等于 `current`,则用 `success` 原子操作将其改为 `new`,返回 `Ok(旧值)`
    // 如果不等于 `current`,则不修改任何值,并返回 `Err(实际读到的值)`
    while lock.compare_exchange(false,              // current
                                true,               // new
                                Ordering::Acquire,  // success
                                Ordering::Relaxed)  // failure
              .is_err() {
        /* 锁被其他线程持有,继续重试 */
    }
    // 跳出循环,表明此时已经获取到了锁
    // 进入临界区

    /* 临界区 */

    // 将内部值设为 `false` 来释放锁,保证临界区内的修改对其他线程可见
    lock.store(false, Ordering::Release);
}

获取-释放 AcqRel

AcqRel 同时具有 AcquireRelease 的效果。

“读-改-写”原子操作与 AcqRel 的组合会使“读-改-写”中的读操作有 Acquire 语义,写操作有 Release 语义。此外,

宽松顺序 Relaxed

Relaxed 只保证操作本身是原子的(不会被中断),但编译器和硬件可以自由重排序该操作与其他内存访问的顺序。

宽松顺序适用于不需要同步、仅需要原子性——也就是“不关心过程正确,只注重结果正确”——的场景。下面就是一个固定步长多线程计数器,它采用了 Relaxed 顺序与读-改-写操作结合的方式。

use std::sync::{
    Arc,
    atomic::{AtomicI32, Ordering},
};
use std::thread;

fn main() {
    let counter = Arc::new(AtomicI32::new(0));
    let mut handles = Vec::new();
    for _ in 0..110 {
        let counter = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            // 因为每个线程的每次计数计算仅恒定地累加 1
            // 所以谁先执行谁后执行并不重要
            counter.fetch_add(1, Ordering::Relaxed);
        }));
    }
    for (index, join_handle) in handles.into_iter().enumerate() {
        join_handle
            .join()
            .expect(&format!("第 {} 个线程提前崩溃了", index)[..]);
    }
    println!(
        "最终的计数结果是:{},它总是正确的",
        counter.load(Ordering::Relaxed)
    );
    // 最终的计数结果是:110,它总是正确的
}

参考资料