Rust原子和锁——原子

246 阅读26分钟

“原子”这个词源于希腊语“ἄτομος”,意为“不可分割的”,即不能被分割成更小的部分。在计算机科学中,这个词用于描述一种不可分割的操作:要么完全完成,要么尚未发生。

正如在“借用和数据竞争”中提到的,多个线程同时读取和修改同一变量通常会导致未定义行为。然而,原子操作允许不同线程安全地读取和修改同一变量。由于这种操作是不可分割的,它要么完全发生在另一个操作之前,要么完全发生在之后,从而避免了未定义行为。在第 7 章中,我们将看到它在硬件层面是如何工作的。

原子操作是涉及多线程的任何操作的主要构建模块。所有其他的并发原语,例如互斥锁(mutexes)和条件变量(condition variables),都是使用原子操作实现的。

在 Rust 中,原子操作作为标准原子类型的方法提供,这些类型位于 std::sync::atomic 模块中。它们的名称都以“Atomic”开头,例如 AtomicI32AtomicUsize。可以使用哪些原子类型取决于硬件架构,有时也取决于操作系统,但几乎所有平台都至少提供指针大小以内的所有原子类型。

与大多数类型不同,原子类型允许通过共享引用(例如 &AtomicU8)进行修改。这可以通过内部可变性(interior mutability)实现,如“内部可变性”一节中讨论的。

每种可用的原子类型都有相同的接口,提供用于存储和加载的方法、用于原子“获取并修改”的方法,以及一些更高级的“比较并交换”(compare-and-exchange)方法。在本章的其余部分中,我们将详细讨论这些方法。

但在我们深入研究各种原子操作之前,我们需要简要介绍一个概念,称为内存顺序(memory ordering):

每个原子操作都接受一个类型为 std::sync::atomic::Ordering 的参数,该参数决定了我们对操作相对顺序的保证。最简单且保证最少的变体是 RelaxedRelaxed 仍然保证单个原子变量的一致性,但不对不同变量的操作顺序做出任何承诺。

这意味着两个线程可能会看到不同变量的操作发生顺序不同。例如,如果一个线程首先对一个变量进行写操作,然后很快对另一个变量进行写操作,另一个线程可能会看到相反的顺序。

在本章中,我们只会讨论那些对操作顺序没有要求的用例,并在这些情况下简单地使用 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::Oncestd::sync::OnceLock 提供了这种功能,因此通常不需要自己实现。

获取并修改操作

现在我们已经看过了一些基本加载和存储操作的使用案例,让我们继续了解更有趣的操作:获取并修改操作。这些操作修改原子变量,但也会加载(获取)原始值,作为单个原子操作。

最常用的操作是 fetch_addfetch_sub,分别用于加法和减法。其他一些可用的操作包括用于按位运算的 fetch_orfetch_and,以及用于保持运行中的最大值或最小值的 fetch_maxfetch_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_addfetch_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_timemax_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_addfetch_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, 
        }
    }
}
  1. 首先,我们加载 a 的当前值。
  2. 然后计算我们希望存储的新值,而不考虑其他线程可能的并发修改。
  3. 使用 compare_exchange 来更新 a 的值,但前提是它的值仍然是我们之前加载的那个值。
  4. 如果 a 确实仍然是之前的值,现在它会被替换为我们的新值,我们的工作就完成了。
  5. 如果 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
    }
}
  1. 只有在 KEY 尚未初始化时才生成新的密钥。
  2. 用我们新生成的密钥替换 KEY,但前提是它仍然是零。
  3. 如果我们成功交换了零为我们的新密钥,我们就返回新生成的密钥。后续对 get_key() 的调用将返回现在存储在 KEY 中的新密钥。
  4. 如果我们在初始化 KEY 的竞赛中输给了另一个线程,我们会忘掉我们新生成的密钥,改用 KEY 中的密钥。

这是一个 compare_exchange 比其弱版本更合适的典型例子。我们不在循环中运行比较并交换操作,并且我们不希望在操作偶然失败时返回零。

如“示例:懒加载”中提到的,如果 generate_random_key() 花费大量时间,那么在初始化期间阻塞线程可能更有意义,以避免可能生成未使用的密钥。Rust 标准库通过 std::sync::Oncestd::sync::OnceLock 提供了此类功能。

总结

  • 原子操作是不可分割的:要么完全完成,要么尚未发生。
  • 在 Rust 中,原子操作是通过 std::sync::atomic 中的原子类型完成的,例如 AtomicI32
  • 并非所有平台都支持所有原子类型。
  • 当涉及多个变量时,原子操作的相对顺序变得非常棘手。更多内容将在第 3 章中讨论。
  • 简单的加载和存储操作对于非常基础的线程间通信非常有用,例如停止标志和状态报告。
  • 懒加载可以通过竞争完成,而不会引起数据竞争。
  • “取并修改”操作允许进行一小部分基本的原子修改,特别适用于多个线程修改同一个原子变量的情况。
  • 原子加法和减法在溢出时会默默地环绕回到最小值。
  • 比较并交换操作是最灵活和通用的,是构建其他任何原子操作的基础。
  • 弱比较并交换操作在某些情况下可以稍微更高效。