Rust原子和锁:第二张、原子操作Atomics

608 阅读21分钟

2 . 1、 Atomic加载和存储操作****

我们首先讨论的是两个最基本的原子操作:加载和存储。它们的函数签名如下(以AtomicI32为例):

impl AtomicI32 {

    pub fn load(&self, ordering: Ordering) -> i32;

    pub fn store(&self, value: i32, ordering: Ordering);

}

load方法原子地加载存储在原子变量中的值,而store方法原子地在其中存储一个新值。注意store方法如何接受共享引用(&T)而不是独占引用(&mut),尽管它修改了值。

让我们看一下这两个方法的一些实际用例。

2 . 1 . 1、 示例 Stop标记****

第一个示例使用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命令时,它将标志设置为true,在每次新的迭代之前由后台线程检查。主线程等待后台线程使用join方法完成当前迭代。

只要后台线程定期检查标志,这个简单的解决方案就能很好地工作。如果它在some_work()中停留很长时间,就会导致停止命令和程序退出之间出现不可接受的延迟。

2 . 1 . 2、 示例 进度上报****

在下一个例子中,我们在后台线程中逐一处理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!");

}

这一次,我们使用了一个作用域线程(第一章中的“作用域线程”),它将自动为我们处理线程的连接,并允许我们借用局部变量。

每当后台线程处理完成一项时,它将已处理项的数量存储在一个AtomicUsize中。与此同时,主线程向用户显示该数字,以告知他们进度,大约每秒一次。一旦主线程看到所有100项都已处理完毕,它就退出作用域,隐式地加入后台线程,并通知用户一切都已完成。

2 . 1 . 3、 同步****

一旦处理完最后一项,主线程可能需要整整一秒钟才能知道,在结束时引入不必要的延迟。为了解决这个问题,我们可以使用线程暂停(在第一章中“线程暂停”),在主线程可能感兴趣的新信息出现时将主线程从休眠状态中唤醒。

下面是同样的例子,但现在使用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()获得了主线程的句柄,现在后台线程在每次状态更新后使用它来解除主线程。主线程现在使用park_timeout而不是sleep,这样它就可以被中断。

现在,任何状态更新都会立即报告给用户,同时仍然每秒钟重复上一次更新,以显示程序仍在运行。

 

2 . 1 . 4、 示例:惰性初始化****

在我们转向更高级的原子操作之前的最后一个例子是关于延迟初始化的。

假设有一个值x,我们从文件中读取,从操作系统中获取,或者以其他方式计算,我们希望在程序运行期间该值为常数。也许x是操作系统的版本,或者是内存总量,或者是tau的第400位数字。对于这个例子来说,这并不重要。

因为我们不期望它改变,所以我们可以只在第一次需要它时请求或计算它,并记住结果。第一个需要它的线程必须计算该值,但它可以将它存储在原子static中,以便所有线程都可以使用它,如果以后再次需要它,也包括它自己。

让我们看一个这样的例子。为了简单起见,我们假设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并查看它仍然为零,计算它的值,并将结果存储回静态X中以供将来使用。稍后,任何对get_x()的调用都将看到静态对象中的值是非零,并立即返回该值而无需再次计算。

但是,如果第二个线程在第一个线程仍在计算x时调用get_x(),第二个线程也会看到一个0并并行计算x。其中一个线程最终会覆盖另一个线程的结果,这取决于哪个线程先完成。这就是所谓的比赛。不是数据竞赛,这是一种未定义的行为,在Rust中不可能不使用不安全的方法,但仍然是一场赢家不可预测的竞赛。

 

因为我们期望x是常数,所以谁赢得比赛并不重要,因为无论如何结果都是一样的。这可能是一个非常好的策略,也可能是一个非常糟糕的策略,这取决于我们期望calculate_x()花费的时间。

如果calculate_x()预计会花费很长时间,那么线程最好在第一个线程仍在初始化X时等待,以避免不必要的处理器时间浪费。你可以使用一个条件变量或线程暂停(详见第一章“等待:停车和条件变量“)来实现这一点,但是对于一个小例子来说,这很快就变得太复杂了。Rust标准库通过std::sync::Once和std::sync::OnceLock提供了这个功能,所以通常不需要自己实现这些。

2 . 2、 Fetch-and-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。接下来的任何操作都将看到123的值。

这些操作的返回值并不总是相关的。如果只需要将操作应用于原子值,而对值本身不感兴趣,则完全可以忽略返回值。

要记住的一件重要的事情是fetch_add和fetch_sub为溢出实现了包装行为。将一个值增加到最大可表示值之后,将会产生最小可表示值。这与常规整数上的加减号操作符的行为不同,后者在调试模式下溢出时会出现恐慌。

在“比较和交换操作”中,我们将看到如何使用溢出检查进行原子加法。

但首先,让我们看看这些方法的一些实际用例。

2 . 2 . 1、 示例:来自多线程的进度报告****

在“示例:进度报告”中,我们使用AtomicUsize来报告后台线程的进度。例如,如果我们将工作分成四个线程,每个线程处理25个项目,那么我们需要知道所有四个线程的进度。

我们可以为每个线程使用单独的AtomicUsize,并将它们全部加载到主线程中并将它们相加,但更简单的解决方案是使用单个AtomicUsize来跟踪所有线程中处理的项的总数。

为此,我们不能再使用store方法,因为那样会覆盖来自其他线程的进度。相反,我们可以使用原子添加操作在每个处理项之后递增计数器。

让我们更新“示例:进度报告”中的示例,将工作分成四个线程:

fn main() {

    let num_done = &AtomicUsize::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 {

                    process_item(t * 25 + i); // Assuming this takes some time.

                    num_done.fetch_add(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!");

}

有些事情已经改变了。最重要的是,我们现在生成了四个而不是一个后台线程,并使用fetch_add而不是store来修改num_done原子变量。

更微妙的是,我们现在为后台线程使用一个move闭包,num_done现在是一个引用。这与我们使用fetch_add无关,而是与如何在循环中生成四个线程有关。这个闭包捕获t,以了解它是四个线程中的哪一个,从而确定是从项目0、25、50或75开始。如果没有move关键字,闭包将尝试通过引用捕获t。这是不允许的,因为它只在循环期间短暂存在。

作为移动闭包,它移动(或复制)其捕获而不是借用它们,从而为其提供 t的副本。因为它也捕获了num_done,所以我们将该变量更改为引用,因为我们仍然想借用相同的 AtomicUsize。请注意,原子类型不实现 Copy 特征,因此如果我们尝试将一个线程移动到多个线程中,我们就会出错。

撇开闭包捕获的微妙之处不谈,这里使用fetch_add的更改非常简单。我们不知道线程将以什么顺序增加num_done,但由于这个增加是原子的,所以我们不必担心任何事情,并且可以确保当所有线程都完成时,它将恰好是100。

2 . 2 . 2、 例如:统计数据****

继续这个通过原子报告其他线程正在做什么的概念,让我们扩展示例,同时收集和报告处理一个项所花费的时间的一些统计数据。

在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之前加载这些值,从而导致低估平均值。更微妙的是,因为relax内存排序不能保证从另一个线程看到的操作的相对顺序,它甚至可能会短暂地看到total_time的新更新值,同时仍然看到num_done的旧值,从而导致高估平均值。

在我们的例子中,这两个都不是大问题。可能发生的最坏情况是向用户简要报告了一个不准确的平均值。

如果我们想避免这种情况,我们可以把这三个统计数据放在一个互斥锁中。然后,在更新三个数字时,我们会简单地锁定互斥量,这三个数字不再需要单独作为原子。这有效地将三个更新转换为单个原子操作,代价是锁定和解锁互斥量,并可能暂时阻塞线程。

2 . 2 . 3、 举例 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,就像这样:

// 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

}

现在,assert语句将在1000次调用后出现恐慌。但是,这发生在原子添加操作已经发生之后,这意味着当我们恐慌时,NEXT_ID已经被增加到1001。如果另一个线程调用该函数,它会在恐慌之前将其增加到1002,依此类推。尽管这可能需要更长的时间,但在出现4,294,966,296次恐慌后,当NEXT_ID再次溢出为零时,我们将遇到相同的问题。

这个问题有三种常见的解决方案。第一个方法是不要panic,而是在溢出时完全中止该过程。std::process::abort函数将中止整个进程,排除任何东西继续调用我们函数的可能性。虽然中止进程可能需要很短的时间,在此期间函数仍然可以被其他线程调用,但在程序真正中止之前发生数十亿次这种情况的可能性是可以忽略不计的。

事实上,这就是标准库中Arc::clone()中的溢出检查是如何实现的,以防你以某种方式将它克隆为::MAX次。这在64位计算机上需要几百年的时间,但如果size只有32位,那么几秒钟就可以实现。

处理溢出的第二种方法是在panic之前使用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实现中处理运行线程数量的溢出。

 

第三种处理溢出的方法可以说是唯一真正正确的方法,因为它可以在溢出的情况下阻止添加操作的发生。然而,我们不能用迄今为止看到的原子操作来实现这一点。为此,我们将需要比较和交换操作,我们将在接下来探讨。

2.3、 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> {

        // In reality, the load, comparison and store,

        // all happen as a single atomic operation.

        let v = self.load();

        if v == expected {

            // Value is as expected.

            // Replace it and report success.

            self.store(new);

            Ok(v)

        } else {

            // The value was not as expected.

            // Leave it untouched and report failure.

            Err(v)

        }

    }

}

使用这个函数,我们可以从原子变量中加载一个值,执行我们想要的任何计算,然后仅在原子变量在此期间没有改变的情况下存储新计算的值。如果我们把它放在一个循环中,以便在它确实发生变化时重试,我们可以使用它来实现所有其他原子操作,使其成为最通用的操作。

为了演示,让我们在不使用fetch_add的情况下将AtomicU32增加1,只是为了看看compare_exchange在实践中是如何使用的:

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的当前值。

②我们计算要存储在a中的新值,而不考虑其他线程对a的潜在并发修改。

③我们使用compare_exchange来更新a的值,但前提是它的值仍然与之前加载的值相

④如果a确实仍然和以前一样,那么它现在就被我们的新值所取代,我们就完成了。

⑤如果a与之前不一样,那么在我们加载它之后的短暂时间内,另一个线程一定已经改变了它。compare_exchange操作为我们提供了a所拥有的更改后的值,我们将再次尝试使用该值。加载和更新之间的短暂时间非常短,以至于循环不太可能超过几次迭代。

如果原子变量在加载操作之后,但在compare_exchange操作之前从某个值A更改为B,然后再返回到A,则仍然会成功,即使在此期间原子变量已更改(并更改回来)。在许多情况下,就像我们的增量示例一样,这不是问题。然而,对于某些算法(通常涉及原子指针),这可能是一个问题。这就是所谓的ABA问题。

在compare_exchange旁边,有一个类似的方法,名为compare_exchange_weak。不同之处在于,弱版本有时仍然可能保持值不变并返回Err,即使原子值与预期值匹配。在某些平台上,这种方法可以更有效地实现,并且在虚假比较和交换失败的后果不显著的情况下应该是首选方法,例如在上面的增量函数中。在第7章中,我们将深入研究底层细节,找出弱版本为什么更有效。

2 . 3 . 1、 示例 ID分配不溢出****

现在,回到“示例:ID分配”中的alloate_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

原子类型有一个方便的方法,称为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方法,因此我们可以专注于单个原子操作。

2 . 3 . 2、 示例:惰性一次性初始化****

在“示例:惰性初始化”中,我们看了一个常量值的惰性初始化示例。我们创建了一个函数,它在第一次调用时惰性地初始化一个值,但在以后的调用中重用它。当多个线程在第一次调用期间并发运行该函数时,可能会有多个线程执行初始化,并且它们将以不可预知的顺序覆盖彼此的结果。

对于我们期望的值是常数,或者当我们不关心值的变化时,这很好。然而,也有一些情况下,这样的值每次都被初始化为不同的值,即使我们需要在程序的一次运行中对函数的每次调用都返回相同的值。

例如,假设函数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替换KEY,但前提是它仍然为零。

③如果我们将0替换为新键,则返回新生成的键。get_key()的新调用将返回与现在存储在key中的相同的新键。

④如果我们输给了另一个在我们之前初始化KEY的线程,我们就会忘记新生成的KEY,而使用KEY中的KEY。

这是一个很好的例子,说明compare_exchange比它的弱变体更合适。我们不会在循环中运行比较-交换操作,如果操作错误地失败,我们也不希望返回0。

正如在“示例:惰性初始化”中提到的,如果generate_random_key()需要大量的时间,那么在初始化过程中阻塞线程可能更有意义,以避免潜在地花费时间生成不会使用的键。Rust标准库通过std::sync::Once和std::sync::OnceLock提供了这样的功能。

2 . 4、 总结****

l 原子操作是不可分割的;它们要么已经完全完成,要么还没有发生。

l Rust中的原子操作是通过std::sync:: Atomic中的原子类型完成的,例如AtomicI32。

l 并不是所有的原子类型都可以在所有平台上使用。

l 当涉及多个变量时,原子操作的相对顺序很棘手。详见第三章。

l 简单的加载和存储对于非常基本的线程间通信是很好的,比如停止标志和状态报告。

l 延迟初始化可以作为一种竞争来完成,而不会导致数据竞争。

l 获取和修改操作允许进行一小组基本的原子修改,当多个线程修改同一个原子变量时,这些修改尤其有用。

l 原子加法和减法在溢出时无声地环绕。

比较和交换操作是最灵活和通用的,是进行任何其他原子操作的基础。