第二章 原子性
原子 这个词来自希腊语ἄτομος, 意为“不可分割的”或者“不可分割之物”。在计算机领域,它被用来代表不能二次再分的操作,那些一旦开始就必须做完的事情。
在第一章中有提到,多线程对同一变量的读写会导致不可预期的行为。而原子操作能够安全的让多线程对变量进行读写。因为原子操作不能二次再分,所以这个操作必然不和其他操作冲突,这避免了不可预期的行为。在之后的第七章,我们将讨论硬件层面的实现。
原子操作是多线程的基础模块。对于其他的主要多线程的概念,比如说mutexes,条件变量,都是使用原子操作实现的。
在rust的标准库中,原子类std::sync::atomic可以实现原子操作。这些类型以Atomic开头,例如AtomicI32或者AtomicUsize。操作系统和硬件架构共同决定了哪些原子类型可以使用,通常而言,比系统架构指针小或者一样大的原子类型都是被支持的。
相比普通类型,通过内部可变性,原子操作可以通过共享引用修改内部数据,比如说&AtomicU8。参见第一章。
每个原子类型都有一样的接口,存入和加载的方法,原子“获取和修改”操作的方法以及一些更高级的“比较和交换”方法。我们将在本章的其余部分详细讨论它们。
但是在讨论不同的原子操作之前,我们先要初步讨论内存排序的概念。
每个原子操作都需要一个std::sync::atomic::Ordering类型的参数,这决定了操作的相对顺序。保证最少最简单的情况是Relaxed。Relaxed保证单个原子变量的一致性,但是对于不同变量之间的相对操作顺序没有任何承诺。
在多线程编程中,两个线程可能会以不同的顺序看到对不同变量的操作。举个例子,假设一个线程首先向变量A写入一个值,然后很快地写入变量B,另一个线程可能会看到这些操作的顺序相反。
在现代计算机系统中,线程的执行是在处理器上进行的,并且处理器通常具有多级缓存来提高执行效率。每个处理器核心都有自己的缓存,并且线程在执行时会从缓存中读取和写入数据。
当线程A写入变量B时,它可能会将这个写入操作先存储在自己的处理器缓存中,而不是立即写入到主内存中。这是因为处理器的缓存是为了提高性能而设计的,可以减少与主内存的频繁通信。所以,线程A在写入变量A之前,可能会先将写入变量B的操作保存在自己的缓存中。
当线程B读取变量B时,它可能会先从自己的处理器缓存中读取数据,而不是从主内存中获取最新的值。这是因为处理器会优先从自己的缓存中获取数据,以提高访问速度。所以,线程B在读取变量B时,可能会看到线程A写入的值,即使这个写入操作还没有同步到主内存。
然后,当线程B接下来读取变量A时,由于处理器缓存中没有变量A的最新值,它会从主内存中获取最新的值。此时,线程B可能会看到线程A写入变量A的操作,从而观察到了线程A写入变量B和变量A的顺序。
需要注意的是,这种行为是由处理器的内存模型和缓存一致性机制决定的,可能会因不同的计算机体系结构和处理器架构而有所差异。为了确保多线程程序的正确性,需要使用适当的同步机制来保证对共享变量的访问顺序和一致性。
From chatgpt
在这一章中,我们会使用Relaxed设定约束,选择不会产生上述问题的例子。本章中不会那么细致的讨论内存顺序,更多细节和其他内存顺序将在第三章讨论。
原子性操作:加载(load)与存储(store)
首先要介绍的两个原子性操作是最基础的两个:加载(load)与存储(store)。函数签名以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);
// Spawn a thread to do the work.
let background_thread = thread::spawn(|| {
while !STOP.load(Relaxed) {
some_work();
}
});
// Use the main thread to listen for user input.
for line in std::io::stdin().lines() {
match line.unwrap().as_str() {
"help" => println!("commands: help, stop"),
"stop" => break,
cmd => println!("unknown command: {cmd:?}"),
}
}
// Inform the background thread it needs to stop.
STOP.store(true, Relaxed);
// Wait until the background thread finishes.
background_thread.join().unwrap();
}
在这个例子中,后台线程正在重复运行some_work(),而主线程允许用户输入一些命令与程序进行交互。在这个简单的例子中,唯一有用的命令是stop,使程序停止。
原子量STOP用来停止后台的线程,它会告知后台线程当前状态。当前台线程接收到用户输入的stop指令,前台线程将把STOP置为true。后台线程在每次新的循环时会检查该标准。通过join,主线程会一直等到后端线程完成当前循环。
只要后台线程定期检查该标志,这个简单的解决方案就非常有效。如果它在some_work()中卡了很久,那就会导致停止命令和程序退出之间出现不可接受的延迟。
例子: 进度报告
第二个例子中,我们将在后台逐一处理100个项目,同时主线程定期给用户提供最新进展。
use std::sync::atomic::AtomicUsize;
fn main() {
let num_done = AtomicUsize::new(0);
thread::scope(|s| {
// A background thread to process all 100 items.
s.spawn(|| {
for i in 0..100 {
process_item(i); // Assuming this takes some time.
num_done.store(i + 1, Relaxed);
}
});
// The main thread shows status updates, every second.
loop {
let n = num_done.load(Relaxed);
if n == 100 { break; }
println!("Working.. {n}/100 done");
thread::sleep(Duration::from_secs(1));
}
});
println!("Done!");
}
此处我们使用了作用域线程(见第一章),它自动处理线程的join控制,以及move等局部变量借用。
每当后台线程处理完一个项目,它递增AtomicUsize
类型的原子量num_done
。这样每一面,主线程就可以给用户更新进度。一旦主线程看到所有100个项目都完成,作用域线程隐式结束后台线程后,通知用户一切完成。
同步完成
在最后一个后台线程执行完成后,主线程可能要等待秒后才能知道,这产生了一个不必要的延迟。为了消除延迟,我们可以使用线程停放threadparking
(见第一章),当有特定消息发出时,提前唤醒主线程。
以下程序用thread::park_timeout来替代thread::sleep。
fn main() {
let num_done = AtomicUsize::new(0);
let main_thread = thread::current();
thread::scope(|s| {
// A background thread to process all 100 items.
s.spawn(|| {
for i in 0..100 {
process_item(i); // Assuming this takes some time.
num_done.store(i + 1, Relaxed);
main_thread.unpark(); // Wake up the main thread.
}
});
// The main thread shows status updates.
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()获得了前台线程的句柄,现在后台线程每次状态更新后都会解除主线程的暂停,同时使用thread::park_timeout()用来唤醒主线程。
现在任何状态更新都会立刻报告个用户,同时每秒也会刷新来显示程序仍然继续运行。
例子:惰性初始化(Lazy Initialization)
在我们讨论更加高级的原子操作之前,最后一个例子是关于惰性初始化。
想象一下,有一个值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,看到它是0时,进行计算,并将结果保存到静态量x中,以便之后使用。之后,对get_x()的调用都会看到静态中的值为非零,直接返回x的值。
由于第二个线程在第一个线程还在计算的时候可能调用get_x(),此时x并未更新,所以可能会有几个线程都出现调用并计算。其中一个线程最终会覆盖另一个线程的结果,这取决于哪个线程先更新x。该状态也被认为是一种竞态。但是在rust中特殊的点在于:这不是数值竞态(需要使用unsafe),而是一种不能预测哪个线程是最终赢家的竟态。
x是静态变量,所以哪个线程先完成运算和更新并不重要,因为结果一样。不过这个方法的好坏受calculate_x()所需的时间影响。
如果预计calculate_x()要很久,那么第一个线程初始化X时,其他线程应该等待,而不是浪费处理器时间。条件变量或线程停放都能实现这一点(第一章中的 “等待:停放和条件变量”),但对于简单例子来说,这就太复杂了。Rust标准库提供std::sync::Once
和std::sync::OnceLock
,所以通常不用自行实现。
原子操作:获取(fetch)并修改(modify)
在了解基本加载与存储的用例后,我们开始学习获取fetch和修改modify。这些操作在原子状态下,修改原子量,同时返回原子量的原值。
最常用的功能是加(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"
}
长得不一样的函数被称为交换(swap),因为它用新值取代了原值。如果按其他一样的规则,它会被称为“fetch_store"。
举个可以体现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作为返回值。在之后的其他操作中,a将变成123。
返回的行为可能和你无关。如果只需要这个原子操作,那返回值可以直接忽略。
这里要注意一点,fetch_add和fetch_sub对溢出进行了封装。在出现溢出的时候,会替换结果为舍弃最高位的计算结果。与普通的加减法会导致溢出不同。
在“比较与交换”这一小结,将讨论如何坐带有溢出检查的原子量加法。
但首先我们来看一些实际用例。
例子:多线程进度报告
在:"例子: 进度报告"中,我们使用了一个AtomicUsize来报告后台线程的进度。如果分割工作量到4个线程,那么每个线程是25个任务。我们想知道4个线程各自的进度。
我们可以为每个线程使用单独的AtomicUsize,并在主线程中加载它们,然后将它们相加,但一个更简单的解决方案是使用一个AtomicUsize来跟踪所有线程中处理的项目总数。
fn main() {
let num_done = &AtomicUsize::new(0);
thread::scope(|s| {
// 四个后台线程处理100个任务,每个线程25个。
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!("Working.. {n}/100 done");
thread::sleep(Duration::from_secs(1));
}
});
println!("Done!");
}
有几点变化,最明显的是后台生成四个线程,同时用fetch_add替换store来修改原子量num_done。
从细节来说,我们在后台线程闭包中使用了move关键字,num_done改为一个引用,用来在循环体内使用外部变量创建线程。这些改变和fetch_add无关。没有move关键字,内部就获取不到t,每个线程的起始值。不使用move,闭包将试图获取t的引用。由于闭包可能会存活超过当前函数中t的生命周期,所以编译器会报错。
带move的闭包会移动(或复制)它需要的t变量,而不是借用t。它也捕获了num_done。我们把num_done变为引用是为了在多线程中使用这个num_done,但是原子类型并没有实现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| {
// Four background threads to process all 100 items, 25 each.
for t in 0..4 {
s.spawn(move || {
for i in 0..25 {
let start = Instant::now();
process_item(t * 25 + i); // Assuming this takes some time.
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);
}
});
}
// The main thread shows status updates, every second.
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!("Working.. nothing done yet.");
} else {
println!(
"Working.. {n}/100 done, {:?} average, {:?} peak",
total_time / n as u32,
max_time,
);
}
thread::sleep(Duration::from_secs(1));
}
});
println!("Done!");
}
后台线程用 Instant::now() 和 Instant::elapsed()测量process_item()所用时间。一个原子加法被用来把所需微秒数加到total_time中,同时原子操作max_time被用来跟踪单次最长用时。
主线程将总时间除以所处理的项目数量,得到平均处理时间与max_time一起输出。
由于这三个原子变量是分开更新的,所以主线程有可能在一个线程增加了num_done之后,但在更新total_time之前加载这些值,从而导致低估了平均数。更微妙的是,由于宽松的内存排序不能保证从另一个线程看到的操作的相对顺序,它甚至可能短暂地看到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。
这是否变成一个问题取决于用例:它有多大可能被如此频繁地调用,如果数字不唯一,最坏的情况是什么?虽然这可能看起来是一个巨大的数字,但现代计算机可以很容易地在几秒钟内执行我们的函数这么多遍。如果内存安全依赖于这些数字的唯一性,那么我们上面的实现是不可接受的。
为了解决这个问题,我们可以尝试让函数在被调用太多次时弹出恐慌,就像这样:
// This version is problematic.
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
}
现在,断言语句将在一千次调用后出现恐慌。然而,这发生在原子添加操作已经发生之后,这意味着当我们恐慌时,NEXT_ID已经被递增到1001。如果另一个线程再调用该函数,以此类推,它将在恐慌前将其增加到1002,以此类推。虽然这可能需要更长的时间,但在4,294,966,296次恐慌之后,我们会遇到同样的问题,此时NEXT_ID将再次溢出到0。
对于溢出的问题,有三种方法来解决,第一种是不再使用恐慌panic,转而在溢出时中止程序。std::process::abort函数将中止整个进程,排除了任何继续调用我们函数的可能性。中止进程可能需要一个短暂的时间,在这个时间里函数仍然可以被其他线程调用,但在程序真正被中止之前调用上亿次的概率可以忽略不计。
事实上,标准库中Arc::clone()的溢出检查就是这样实现的,以防你以某种方式成功地克隆了isize::MAX次。这在64位的计算机上需要几百年的时间,但如果isize只有32位,确实可能几秒就越界了。
处理溢出的第二个方法是在恐慌之前使用fetch_sub再次递减计数器,像这样:
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与交换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 {
// Value is as expected.
// Replace it and report success.
self.store(new);
Ok(v)
} else {
// 这个值不符合预期,不要动v直接报错。
Err(v)
}
}
}
利用这一点,我们可以从一个原子量中加载一个值,进行任何我们喜欢的计算,然后在原子量没有改变的情况下存储新计算的值。我们可以把它放在一个循环中判断是否发生了变化,来实现所有其他的原子操作,使它成为最通用的原子操作。
举个例子,在不使用fetch_add的情况下将AtomicU32递增1,观察compare_exchange的应用:
fn increment(a: &AtomicU32) {
//首先加载线程变量a
let mut current = a.load(Relaxed);
loop {
//计算需要存入的值,先不考虑其他线程的影响。
let new = current + 1;
//如果a是原值,用compare_exchange更新a
match a.compare_exchange(current, new, Relaxed, Relaxed) {
//如果a还是原值,替换a并正常返回
Ok(_) => return,
//如果a是新值,说明加载它之后的短时间内,它被其他线程修改了。
//compare_echange返回了a当前值,就使用这个新值进行尝试。
//加载和更新操作很快,所以这个很快可以结束这个循环。
Err(v) => current = v,
}
}
}
🔥
如果原子量在加载操作之后,compare_exchange操作之前,从某个值A变成了B,然后又回到了A,那么函数仍然会成功,尽管在此期间原子变量被改变了(又变回来)。在很多情况下,就像我们的增量例子一样,这并不是一个问题。然而,有一些算法,通常涉及到原子指针,对于这些算法,这可能是一个问题。这就是所谓的ABA问题。
除了compare_exchange,有一个类似的方法叫compare_exchange_weak。不同点在于,尽管原子值与预期值相匹配,弱版本有时可能无法该值并返回报错。在某些平台上,这种方法实现起来更有效,特别在产生的“比较和交换”假性失败影响不大时,应该优先选择这种方法,比如上面的增量函数。在第7章中,我们将深入研究低级别的细节,以找出为什么弱版本会更有效率。
例子:无溢出
现在,回顾allocate_new_id()中的溢出问题"Example: ID Allocation"。
为了防止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,
}
}
}
现在我们先检查或者恐慌panic,再修改NEXT_ID确保不会越界到1000以上,并且避免越界。进一步,可以将上限从1000提高到u32::MAX,不用担心它的增量回超过极值。
获取-更新操作(Fetch-Update)
原子类型有一个方便的方法fetch_update用于比较和交换的循环模式。它相当于一个先加载后重复计算和compare_exchange_weak的循环,相当于上面做的那样。
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 {
//只在KEY还没有被初始化的情况下生成一个新的密钥。
let new_key = generate_random_key();
//用新生成的密钥替换KEY,但前提是它仍然为零。
match KEY.compare_exchange(0, new_key, Relaxed, Relaxed) {
//如果把零换成了我们的密钥,返回新生成的钥匙。
//再调用的get_key()时将返回存储在KEY中的同一密钥。
Ok(_) => new_key, 3
//如果竞争中输给了其他初始化KEY的线程,
//忽略新生成的密钥,使用KEY中保存的密钥。
Err(k) => k, 4
}
} else {
key
}
}
这是个很好的例子说明这种情况compare_exchange比它的弱变体更合适。我们不会在一个循环中运行我们的比较和交换操作,而且我们不希望在操作虚假失败时返回0。
正如在 "例子:懒惰的初始化 "中提到的,如果generate_random_key()需要很多时间,那么在初始化过程中阻塞线程可能更有意义,以避免可能花费时间生成不会被使用的密钥。Rust标准库通过std::sync::Once和std::sync::OnceLock提供了此类功能。
总结
- 原子操作是不可分割的;它们要么已经完全完成,要么还没有发生。
- Rust中的原子操作是通过std::sync::atomic中的原子类型完成的,比如AtomicI32。
- 不是所有的原子类型在所有平台上都可用。
- 当涉及到多个变量时,原子操作的相对顺序是很棘手的。更多内容见第三章。
- 简单的加载和存储对于非常基本的线程间通信是很好的,比如停止标志和状态报告。
- 惰性初始化可以作为一种竞态,而不会引起数据竞赛。
- 获取-修改(Fetch-and-modify)操作允许小部分基础原子修改,当多个线程修改同一个原子变量的时候,这些修改特别有用。
- 原子加法和减法在溢出时会自动封装并忽略进位。
- 比较和交换(Compare-and-exchange)操作是最灵活、最通用的,也是进行任何其他原子操作的基础。
- 一个弱的比较和交换操作可能会获得更好的性价比。