“原子”这个词源于希腊语“ἄτομος”,意为“不可分割的”,即不能被分割成更小的部分。在计算机科学中,这个词用于描述一种不可分割的操作:要么完全完成,要么尚未发生。
正如在“借用和数据竞争”中提到的,多个线程同时读取和修改同一变量通常会导致未定义行为。然而,原子操作允许不同线程安全地读取和修改同一变量。由于这种操作是不可分割的,它要么完全发生在另一个操作之前,要么完全发生在之后,从而避免了未定义行为。在第 7 章中,我们将看到它在硬件层面是如何工作的。
原子操作是涉及多线程的任何操作的主要构建模块。所有其他的并发原语,例如互斥锁(mutexes)和条件变量(condition variables),都是使用原子操作实现的。
在 Rust 中,原子操作作为标准原子类型的方法提供,这些类型位于 std::sync::atomic 模块中。它们的名称都以“Atomic”开头,例如 AtomicI32 或 AtomicUsize。可以使用哪些原子类型取决于硬件架构,有时也取决于操作系统,但几乎所有平台都至少提供指针大小以内的所有原子类型。
与大多数类型不同,原子类型允许通过共享引用(例如 &AtomicU8)进行修改。这可以通过内部可变性(interior mutability)实现,如“内部可变性”一节中讨论的。
每种可用的原子类型都有相同的接口,提供用于存储和加载的方法、用于原子“获取并修改”的方法,以及一些更高级的“比较并交换”(compare-and-exchange)方法。在本章的其余部分中,我们将详细讨论这些方法。
但在我们深入研究各种原子操作之前,我们需要简要介绍一个概念,称为内存顺序(memory ordering):
每个原子操作都接受一个类型为 std::sync::atomic::Ordering 的参数,该参数决定了我们对操作相对顺序的保证。最简单且保证最少的变体是 Relaxed。Relaxed 仍然保证单个原子变量的一致性,但不对不同变量的操作顺序做出任何承诺。
这意味着两个线程可能会看到不同变量的操作发生顺序不同。例如,如果一个线程首先对一个变量进行写操作,然后很快对另一个变量进行写操作,另一个线程可能会看到相反的顺序。
在本章中,我们只会讨论那些对操作顺序没有要求的用例,并在这些情况下简单地使用 Relaxed,而不会深入探讨更多细节。我们将在第 3 章详细讨论内存顺序以及其他可用的内存顺序。
原子加载和存储操作
我们将首先介绍最基本的两种原子操作:加载和存储。它们的函数签名如下,以 AtomicI32 为例:
impl AtomicI32 {
pub fn load(&self, ordering: Ordering) -> i32;
pub fn store(&self, value: i32, ordering: Ordering);
}
load 方法用于原子地加载存储在原子变量中的值,而 store 方法则用于原子地存储新值。请注意,尽管 store 方法会修改值,但它接受的是共享引用(&T)而不是独占引用(&mut T)。
让我们来看一些这两个方法的实际使用例子。
示例:停止标志
第一个例子使用 AtomicBool 作为停止标志。这种标志用于通知其他线程停止运行。
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering::Relaxed;
fn main() {
static STOP: AtomicBool = AtomicBool::new(false);
// 启动一个线程执行工作
let background_thread = thread::spawn(|| {
while !STOP.load(Relaxed) {
some_work();
}
});
// 主线程用于监听用户输入
for line in std::io::stdin().lines() {
match line.unwrap().as_str() {
"help" => println!("commands: help, stop"),
"stop" => break,
cmd => println!("unknown command: {cmd:?}"),
}
}
// 通知后台线程需要停止
STOP.store(true, Relaxed);
// 等待后台线程结束
background_thread.join().unwrap();
}
在这个例子中,后台线程不断地运行 some_work(),而主线程允许用户输入一些命令与程序交互。在这个简单的例子中,唯一有用的命令是 stop,用于停止程序。
为了让后台线程停止,原子的 STOP 布尔值被用于向后台线程传达这一条件。当前台线程读取到 stop 命令时,它将标志设置为 true,后台线程在每次新迭代前都会检查该标志。主线程使用 join 方法等待后台线程完成当前迭代。
这个简单的解决方案在后台线程定期检查标志的情况下运行得很好。如果后台线程在 some_work() 中卡住很长时间,则可能导致 stop 命令和程序退出之间产生无法接受的延迟。
示例:进度报告
在下一个例子中,我们在后台线程上逐个处理 100 个项目,同时主线程定期向用户报告进度:
use std::sync::atomic::AtomicUsize;
fn main() {
let num_done = AtomicUsize::new(0);
thread::scope(|s| {
// 一个后台线程处理所有 100 个项目
s.spawn(|| {
for i in 0..100 {
process_item(i); // 假设这个操作需要一些时间
num_done.store(i + 1, Relaxed);
}
});
// 主线程每秒显示一次状态更新
loop {
let n = num_done.load(Relaxed);
if n == 100 { break; }
println!("Working.. {n}/100 done");
thread::sleep(Duration::from_secs(1));
}
});
println!("Done!");
}
这次我们使用了受限线程(“Scoped Threads”),它会自动处理线程的连接,并允许我们借用局部变量。
每当后台线程处理完一个项目时,它都会将已处理项目的数量存储到一个 AtomicUsize 中。与此同时,主线程显示该数字以向用户告知进度,大约每秒一次。一旦主线程看到所有 100 个项目都已处理完毕,它将退出作用域,这会隐式连接后台线程,并通知用户所有操作都已完成。
同步
当处理完最后一个项目后,主线程可能需要长达一秒的时间才能知道这一情况,从而在结束时引入不必要的延迟。为了解决这个问题,我们可以使用线程停放(“Thread Parking”)来在有新信息时唤醒主线程。
以下是相同的例子,但现在使用 thread::park_timeout 而不是 thread::sleep:
fn main() {
let num_done = AtomicUsize::new(0);
let main_thread = thread::current();
thread::scope(|s| {
// 一个后台线程处理所有 100 个项目
s.spawn(|| {
for i in 0..100 {
process_item(i); // 假设这个操作需要一些时间
num_done.store(i + 1, Relaxed);
main_thread.unpark(); // 唤醒主线程
}
});
// 主线程显示状态更新
loop {
let n = num_done.load(Relaxed);
if n == 100 { break; }
println!("Working.. {n}/100 done");
thread::park_timeout(Duration::from_secs(1));
}
});
println!("Done!");
}
没有太大变化。我们通过 thread::current() 获取了主线程的句柄,现在后台线程在每次状态更新后使用它来唤醒主线程。主线程现在使用 park_timeout 而不是 sleep,这样它可以被打断。
现在,任何状态更新都会立即报告给用户,同时仍然每秒重复上次更新一次,以表明程序仍在运行。
示例:延迟初始化
在我们进入更高级的原子操作之前,最后一个例子是关于延迟初始化的。
假设有一个值 x,我们从文件中读取、从操作系统获取,或者通过某种方式计算得出,并且我们预计它在程序运行期间是恒定的。也许 x 是操作系统的版本、总内存量,或者 τ 的第 400 位数字。在这个例子中具体是什么并不重要。
因为我们不期望它变化,所以只需要在第一次需要它时进行请求或计算,并记住结果。第一个需要它的线程将计算这个值,并将其存储在一个原子静态变量中,以便于所有线程使用,包括自己以后再次需要它时。
让我们看一个例子。为了简化起见,我们假设 x 绝对不为零,因此可以使用零作为尚未计算出值的占位符。
use std::sync::atomic::AtomicU64;
fn get_x() -> u64 {
static X: AtomicU64 = AtomicU64::new(0);
let mut x = X.load(Relaxed);
if x == 0 {
x = calculate_x();
X.store(x, Relaxed);
}
x
}
第一个调用 get_x() 的线程将检查静态变量 X,发现它仍然是零,然后计算它的值,并将结果存储回静态变量中,以供将来使用。以后任何对 get_x() 的调用都会看到静态变量中的值为非零,并立即返回该值而无需再次计算。
然而,如果当第一个线程正在计算 x 时,第二个线程也调用了 get_x(),那么第二个线程也会看到零,并与第一个线程并行计算 x。其中一个线程最终会覆盖另一个线程的结果,这取决于哪个线程先完成。这被称为竞争(race)。它不是数据竞争(data race),因为数据竞争是未定义行为,在 Rust 中除非使用 unsafe 是不可能发生的,但仍然是一种竞争,其结果不可预测。
由于我们预计 x 是恒定的,所以谁赢得这场竞争并不重要,因为无论如何结果都是相同的。根据我们预期 calculate_x() 的耗时,这可能是一个非常好或者非常糟糕的策略。
如果 calculate_x() 预计会花费很长时间,最好在第一个线程初始化 X 时让其他线程等待,以避免不必要地浪费处理器时间。你可以使用条件变量或线程停放来实现这一点(“等待:停放和条件变量”),但对于一个小例子来说,这样做很快就会变得过于复杂。Rust 标准库通过 std::sync::Once 和 std::sync::OnceLock 提供了这种功能,因此通常不需要自己实现。
获取并修改操作
现在我们已经看过了一些基本加载和存储操作的使用案例,让我们继续了解更有趣的操作:获取并修改操作。这些操作修改原子变量,但也会加载(获取)原始值,作为单个原子操作。
最常用的操作是 fetch_add 和 fetch_sub,分别用于加法和减法。其他一些可用的操作包括用于按位运算的 fetch_or 和 fetch_and,以及用于保持运行中的最大值或最小值的 fetch_max 和 fetch_min。
它们的函数签名如下,以 AtomicI32 为例:
impl AtomicI32 {
pub fn fetch_add(&self, v: i32, ordering: Ordering) -> i32;
pub fn fetch_sub(&self, v: i32, ordering: Ordering) -> i32;
pub fn fetch_or(&self, v: i32, ordering: Ordering) -> i32;
pub fn fetch_and(&self, v: i32, ordering: Ordering) -> i32;
pub fn fetch_nand(&self, v: i32, ordering: Ordering) -> i32;
pub fn fetch_xor(&self, v: i32, ordering: Ordering) -> i32;
pub fn fetch_max(&self, v: i32, ordering: Ordering) -> i32;
pub fn fetch_min(&self, v: i32, ordering: Ordering) -> i32;
pub fn swap(&self, v: i32, ordering: Ordering) -> i32; // "fetch_store"
}
唯一的例外是仅存储一个新值的操作,而不考虑旧值。这个操作不是叫 fetch_store,而是叫 swap。
下面是一个快速演示,展示了 fetch_add 如何返回操作前的值:
use std::sync::atomic::AtomicI32;
let a = AtomicI32::new(100);
let b = a.fetch_add(23, Relaxed);
let c = a.load(Relaxed);
assert_eq!(b, 100);
assert_eq!(c, 123);
fetch_add 操作将 a 从 100 增加到 123,但返回给我们旧值 100。任何后续操作都会看到值为 123。
这些操作的返回值并不总是相关的。如果你只需要将操作应用于原子值,而对值本身不感兴趣,完全可以忽略返回值。
需要注意的一点是,fetch_add 和 fetch_sub 在溢出时实现了环绕行为。将值增加到可表示的最大值后,会环绕到可表示的最小值。这与常规整数上的加减操作行为不同,在调试模式中常规整数在溢出时会触发 panic。
在“比较和交换操作”中,我们将看到如何在溢出检查的情况下进行原子加法操作。
但首先,让我们看看这些方法的一些实际应用场景。
示例:多个线程的进度报告
在“进度报告示例”中,我们使用了 AtomicUsize 来报告后台线程的进度。如果我们将工作分配给四个线程,每个线程处理 25 个项目,我们就需要知道所有四个线程的进度。
我们可以为每个线程使用一个单独的 AtomicUsize,并在主线程中加载它们并将它们相加,但一个更简单的解决方案是使用一个 AtomicUsize 来跟踪所有线程处理的总项目数。
要实现这一点,我们不能再使用 store 方法,因为那样会覆盖其他线程的进度。相反,我们可以在每处理一个项目后使用原子加操作来增加计数器。
让我们将“进度报告示例”中的代码更新为将工作分配给四个线程:
fn main() {
let num_done = &AtomicUsize::new(0);
thread::scope(|s| {
// 四个后台线程,每个线程处理 25 个项目,共 100 个项目。
for t in 0..4 {
s.spawn(move || {
for i in 0..25 {
process_item(t * 25 + i); // 假设此操作需要一些时间。
num_done.fetch_add(1, Relaxed);
}
});
}
// 主线程每秒显示一次状态更新。
loop {
let n = num_done.load(Relaxed);
if n == 100 { break; }
println!("正在工作.. 完成 {n}/100");
thread::sleep(Duration::from_secs(1));
}
});
println!("完成!");
}
有几点发生了变化。最重要的是,我们现在生成四个后台线程,而不是一个,并使用 fetch_add 而不是 store 来修改 num_done 原子变量。
更微妙的是,我们现在为后台线程使用了 move 闭包,并且 num_done 现在是一个引用。这与使用 fetch_add 无关,而是与我们在循环中生成四个线程的方式有关。这个闭包捕获 t 来知道它是四个线程中的哪个,从而决定是从项目 0、25、50 还是 75 开始。如果没有 move 关键字,闭包会尝试通过引用捕获 t。这是不允许的,因为它只在循环期间短暂存在。
作为一个 move 闭包,它移动(或复制)捕获的值,而不是借用它们,从而为其提供 t 的副本。由于它还捕获了 num_done,我们将该变量改为引用,因为我们仍然希望借用同一个 AtomicUsize。请注意,原子类型没有实现 Copy 特性,因此如果我们尝试将它移动到多个线程中,我们会得到一个错误。
除了闭包捕获的细节外,在这里使用 fetch_add 的更改非常简单。我们不知道线程将以何种顺序增加 num_done,但由于加法是原子的,我们不必担心任何问题,并且可以确信所有线程完成后它将精确等于 100。
示例:统计信息
继续这个通过原子变量报告其他线程操作的概念,让我们扩展示例,收集和报告处理项目所需的时间的一些统计信息。
除了 num_done 之外,我们还添加了两个原子变量 total_time 和 max_time 来跟踪处理项目所花费的时间。我们将使用这些变量报告平均和峰值处理时间。
fn main() {
let num_done = &AtomicUsize::new(0);
let total_time = &AtomicU64::new(0);
let max_time = &AtomicU64::new(0);
thread::scope(|s| {
// 四个后台线程,每个线程处理 25 个项目,共 100 个项目。
for t in 0..4 {
s.spawn(move || {
for i in 0..25 {
let start = Instant::now();
process_item(t * 25 + i); // 假设此操作需要一些时间。
let time_taken = start.elapsed().as_micros() as u64;
num_done.fetch_add(1, Relaxed);
total_time.fetch_add(time_taken, Relaxed);
max_time.fetch_max(time_taken, Relaxed);
}
});
}
// 主线程每秒显示一次状态更新。
loop {
let total_time = Duration::from_micros(total_time.load(Relaxed));
let max_time = Duration::from_micros(max_time.load(Relaxed));
let n = num_done.load(Relaxed);
if n == 100 { break; }
if n == 0 {
println!("正在工作.. 目前还没有完成的项目。");
} else {
println!(
"正在工作.. 完成 {n}/100,平均时间:{:?},峰值时间:{:?}",
total_time / n as u32,
max_time,
);
}
thread::sleep(Duration::from_secs(1));
}
});
println!("完成!");
}
后台线程现在使用 Instant::now() 和 Instant::elapsed() 来测量它们在 process_item() 中花费的时间。原子加操作用于将微秒数添加到 total_time,原子最大值操作用于在 max_time 中跟踪最高测量值。
主线程通过处理项目数对总时间进行除法,以获得平均处理时间,然后将其与 max_time 中的峰值时间一起报告。
由于这三个原子变量是分别更新的,因此主线程可能在一个线程增加 num_done 之后,但在它更新 total_time 之前加载这些值,导致平均值的低估。更微妙的是,由于 Relaxed 内存排序不能保证从另一个线程看到的操作相对顺序,它甚至可能在短时间内看到 total_time 的新更新值,同时仍然看到 num_done 的旧值,从而导致平均值的高估。
在我们的示例中,这些都不是大问题。最坏的情况是向用户暂时报告不准确的平均值。
如果我们想避免这种情况,可以将这三个统计数据放在一个 Mutex 中。然后我们在更新这三个数字时短暂地锁住 Mutex,这样它们本身不必是原子的。这有效地将三个更新变成单个原子操作,代价是锁定和解锁 Mutex,并可能暂时阻塞线程。
示例:ID 分配
让我们来看一个需要使用 fetch_add 返回值的用例。
假设我们需要一个函数 allocate_new_id(),每次调用该函数时都会返回一个新的唯一编号。我们可以使用这些编号来标识程序中的任务或其他对象,这些对象需要由一个小的唯一标识符来表示,并且可以在多个线程之间轻松传递,例如一个整数。
使用 fetch_add 实现这个函数非常简单:
use std::sync::atomic::AtomicU32;
fn allocate_new_id() -> u32 {
static NEXT_ID: AtomicU32 = AtomicU32::new(0);
NEXT_ID.fetch_add(1, Relaxed)
}
我们简单地记录下一个要分配的编号,每次加载时将其递增。第一次调用会返回 0,第二次调用返回 1,以此类推。
唯一的问题是,当发生溢出时的行为。在第 4,294,967,296 次调用时,这个 32 位整数会发生溢出,导致下一次调用返回 0。
这是否会成为问题取决于具体的使用场景:这种函数被调用如此多次的可能性有多大?如果编号不唯一,最糟糕的情况是什么?尽管这似乎是一个巨大的数字,但现代计算机可以在几秒内执行我们这个函数如此多次。如果内存安全性依赖于这些编号的唯一性,那么上面的实现是不可接受的。
为了解决这个问题,我们可以尝试在函数被调用次数过多时让其发生 panic,代码如下:
// 此版本存在问题
fn allocate_new_id() -> u32 {
static NEXT_ID: AtomicU32 = AtomicU32::new(0);
let id = NEXT_ID.fetch_add(1, Relaxed);
assert!(id < 1000, "too many IDs!");
id
}
现在,assert 语句将在调用超过一千次时触发 panic。但是,这个判断是在原子加操作之后执行的,这意味着当触发 panic 时,NEXT_ID 已经增加到了 1001。如果此时有另一个线程调用这个函数,它将把 NEXT_ID 增加到 1002 然后再触发 panic。虽然可能需要更长的时间,但在 NEXT_ID 溢出回到 0 后,我们最终会遇到相同的问题。
解决这个问题的三种常见方法如下。
第一种方法是不进行 panic,而是直接中止整个进程。std::process::abort 函数会中止整个进程,从而杜绝任何继续调用该函数的可能性。虽然在中止进程的短暂时刻内,其他线程仍然可能调用此函数,但程序真正中止前发生数十亿次调用的可能性微乎其微。
事实上,这也是标准库中 Arc::clone() 的溢出检查的实现方式,以防您以某种方式克隆它 isize::MAX 次。如果 isize 是 64 位,这将需要数百年,但如果 isize 只有 32 位,那么在几秒内就可以实现。
第二种解决溢出的方法是使用 fetch_sub 在发生 panic 之前将计数器减回去,代码如下:
fn allocate_new_id() -> u32 {
static NEXT_ID: AtomicU32 = AtomicU32::new(0);
let id = NEXT_ID.fetch_add(1, Relaxed);
if id >= 1000 {
NEXT_ID.fetch_sub(1, Relaxed);
panic!("too many IDs!");
}
id
}
当多个线程同时执行此函数时,计数器仍然可能在短时间内超过 1000,但这种情况的次数受限于活动线程的数量。可以合理地假设,不会有数十亿个活动线程同时执行此函数,尤其是在 fetch_add 和 fetch_sub 之间的短暂时间内。
这也是标准库中 thread::scope 实现中用于处理正在运行线程数量溢出的方式。
第三种解决溢出的方法可以说是唯一真正正确的方法,因为它防止了在会发生溢出时进行加法运算。然而,我们无法用之前介绍的原子操作实现这种方法。为此,我们需要使用比较并交换(compare-and-exchange)操作,这将在下一节中探讨。
比较并交换操作
最先进且最灵活的原子操作是比较并交换操作。这个操作会检查原子值是否等于给定的值,只有在相等的情况下,它才会用新值替换该原子值,所有这些都是作为单个原子操作完成的。它将返回之前的值,并告诉我们是否成功替换。
它的函数签名比我们之前看到的要复杂一些。以 AtomicI32 为例,其函数签名如下:
impl AtomicI32 {
pub fn compare_exchange(
&self,
expected: i32,
new: i32,
success_order: Ordering,
failure_order: Ordering
) -> Result<i32, i32>;
}
暂时忽略内存排序,它的基本逻辑与以下实现相同,只是实际操作是以单个、不可分割的原子操作完成的:
impl AtomicI32 {
pub fn compare_exchange(&self, expected: i32, new: i32) -> Result<i32, i32> {
// 实际上,加载、比较和存储的操作
// 都作为单个原子操作完成。
let v = self.load();
if v == expected {
// 值与预期相符。
// 替换它并报告成功。
self.store(new);
Ok(v)
} else {
// 值与预期不符。
// 保持原样并报告失败。
Err(v)
}
}
}
通过这种方式,我们可以从原子变量中加载值,执行任意的计算,然后只有在原子变量未发生变化的情况下才将新计算的值存储回去。如果将这个过程放入一个循环中重试,就可以实现所有其他原子操作,因此这是最通用的一种操作。
为了演示如何使用 compare_exchange,我们可以在不使用 fetch_add 的情况下通过这个操作实现对 AtomicU32 的自增:
fn increment(a: &AtomicU32) {
let mut current = a.load(Relaxed);
loop {
let new = current + 1;
match a.compare_exchange(current, new, Relaxed, Relaxed) {
Ok(_) => return,
Err(v) => current = v,
}
}
}
- 首先,我们加载
a的当前值。 - 然后计算我们希望存储的新值,而不考虑其他线程可能的并发修改。
- 使用
compare_exchange来更新a的值,但前提是它的值仍然是我们之前加载的那个值。 - 如果
a确实仍然是之前的值,现在它会被替换为我们的新值,我们的工作就完成了。 - 如果
a已经不是之前的值,另一个线程必然在我们加载后的短暂时间内改变了它。compare_exchange操作会返回原子变量当前的值,我们会使用这个值再次尝试。由于加载和更新之间的时间非常短,通常这个循环不会重复多次。
注意事项: 如果原子变量的值在加载操作后但在 compare_exchange 操作之前,从某个值 A 变为 B 再变回 A,那么比较并交换操作仍会成功,即使在此期间原子变量被更改过。在很多情况下,例如自增的例子中,这并不是问题。然而,对于某些特定的算法(通常涉及原子指针),这可能是个问题。这就是所谓的 ABA 问题。
除了 compare_exchange 之外,还有一个类似的方法叫做 compare_exchange_weak。它与 compare_exchange 的区别在于,弱版本即使原子值与预期相符,有时也会返回 Err,保持原值不变。在某些平台上,这个方法的实现效率更高,应在误判的影响较小的情况下优先使用,例如上面的自增函数。在第 7 章中,我们将深入了解低级细节,以了解为什么弱版本效率更高。
示例:防止溢出的 ID 分配
现在,让我们回到“示例:ID 分配”中 allocate_new_id() 的溢出问题。
为了防止 NEXT_ID 增长超过某个上限并导致溢出,我们可以使用 compare_exchange 实现带上限的原子加法。使用这个思路,我们可以实现一个始终正确处理溢出的 allocate_new_id 版本,即使在几乎不可能的情况下也能正常工作:
fn allocate_new_id() -> u32 {
static NEXT_ID: AtomicU32 = AtomicU32::new(0);
let mut id = NEXT_ID.load(Relaxed);
loop {
assert!(id < 1000, "too many IDs!");
match NEXT_ID.compare_exchange_weak(id, id + 1, Relaxed, Relaxed) {
Ok(_) => return id,
Err(v) => id = v,
}
}
}
现在我们在修改 NEXT_ID 之前进行检查和异常处理,确保它永远不会超过 1000,从而使溢出变得不可能。如果我们希望,可以将上限从 1000 提高到 u32::MAX,而不必担心它会意外超过上限。
Fetch-Update
对于 compare-and-exchange 循环模式,原子类型提供了一个名为 fetch_update 的方便方法。它等效于先进行一次加载操作,然后循环重复计算并执行 compare_exchange_weak,就像我们上面做的一样。
使用这个方法,我们可以用一行代码实现我们的 allocate_new_id 函数:
NEXT_ID.fetch_update(Relaxed, Relaxed, |n| n.checked_add(1)).expect("too many IDs!")
请查阅方法的文档了解详细信息。
在本书中,我们不会使用 fetch_update 方法,而是专注于单个原子操作。
示例:懒加载的一次性初始化
在“示例:懒加载”中,我们讨论了一个懒加载常量值的例子。我们编写了一个函数,该函数在第一次调用时延迟初始化值,但在之后的调用中重用它。当多个线程在第一次调用时并发运行该函数时,可能会有多个线程执行初始化操作,并且它们将以不可预测的顺序覆盖彼此的结果。
对于我们期望是常量的值,或者我们不在乎值的改变,这种情况是可以接受的。然而,还有一些使用场景,这些值在每次初始化时可能会有所不同,但我们需要在程序运行期间的每次调用中返回相同的值。
例如,设想一个函数 get_key(),它返回一个每次程序运行时唯一生成的随机密钥。它可能是用于与程序通信的加密密钥,每次程序运行时都需要唯一,但在进程中保持不变。
这意味着我们不能简单地在生成密钥后使用存储操作,因为这可能会覆盖刚才由另一个线程生成的密钥,导致两个线程使用不同的密钥。相反,我们可以使用 compare_exchange 确保只有在没有其他线程已经存储密钥的情况下才存储密钥,否则就丢弃我们生成的密钥,并改用存储的密钥。
以下是该想法的实现:
fn get_key() -> u64 {
static KEY: AtomicU64 = AtomicU64::new(0);
let key = KEY.load(Relaxed);
if key == 0 {
let new_key = generate_random_key();
match KEY.compare_exchange(0, new_key, Relaxed, Relaxed) {
Ok(_) => new_key,
Err(k) => k,
}
} else {
key
}
}
- 只有在
KEY尚未初始化时才生成新的密钥。 - 用我们新生成的密钥替换
KEY,但前提是它仍然是零。 - 如果我们成功交换了零为我们的新密钥,我们就返回新生成的密钥。后续对
get_key()的调用将返回现在存储在KEY中的新密钥。 - 如果我们在初始化
KEY的竞赛中输给了另一个线程,我们会忘掉我们新生成的密钥,改用KEY中的密钥。
这是一个 compare_exchange 比其弱版本更合适的典型例子。我们不在循环中运行比较并交换操作,并且我们不希望在操作偶然失败时返回零。
如“示例:懒加载”中提到的,如果 generate_random_key() 花费大量时间,那么在初始化期间阻塞线程可能更有意义,以避免可能生成未使用的密钥。Rust 标准库通过 std::sync::Once 和 std::sync::OnceLock 提供了此类功能。
总结
- 原子操作是不可分割的:要么完全完成,要么尚未发生。
- 在 Rust 中,原子操作是通过
std::sync::atomic中的原子类型完成的,例如AtomicI32。 - 并非所有平台都支持所有原子类型。
- 当涉及多个变量时,原子操作的相对顺序变得非常棘手。更多内容将在第 3 章中讨论。
- 简单的加载和存储操作对于非常基础的线程间通信非常有用,例如停止标志和状态报告。
- 懒加载可以通过竞争完成,而不会引起数据竞争。
- “取并修改”操作允许进行一小部分基本的原子修改,特别适用于多个线程修改同一个原子变量的情况。
- 原子加法和减法在溢出时会默默地环绕回到最小值。
- 比较并交换操作是最灵活和通用的,是构建其他任何原子操作的基础。
- 弱比较并交换操作在某些情况下可以稍微更高效。