rust原子和锁 第二章翻译

第二章 原子性

原子 这个词来自希腊语ἄτομος, 意为“不可分割的”或者“不可分割之物”。在计算机领域,它被用来代表不能二次再分的操作,那些一旦开始就必须做完的事情。

在第一章中有提到,多线程对同一变量的读写会导致不可预期的行为。而原子操作能够安全的让多线程对变量进行读写。因为原子操作不能二次再分,所以这个操作必然不和其他操作冲突,这避免了不可预期的行为。在之后的第七章,我们将讨论硬件层面的实现。

原子操作是多线程的基础模块。对于其他的主要多线程的概念,比如说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::Oncestd::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)操作是最灵活、最通用的,也是进行任何其他原子操作的基础。
  • 一个弱的比较和交换操作可能会获得更好的性价比。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值