第一章:Rust 并发基础

英文版本

早在多核处理器司空见惯之前,操作系统就允许一台计算机运行多个程序。其通过在进程之间反复地切换来完成的,这允许每个进程逐个地逐次取得一点进展。现在,几乎所有的电脑,甚至手机和手表都有着多核处理器,可以真正并行执行多个程序。

操作系统尽可能的将进程之间隔离,允许程序完全意识不到其他线程在做什么的情况下做自己的事情。例如,在不先询问操作系统内核的情况下,一个进程通常不能获取其他进程的内存,或者以任意方式与之通信。

然而,一个程序可以产生额外的执行线程作为进程的一部分。同一进程中的线程不会相互隔离。线程共享内存并且可以通过内存相互交互。

这一章节将阐述在 Rust 中如何产生线程,并且关于它们的所有基本概念,例如如何安全地在多个线程之间共享数据。本章中解释的概念是本书其余部分的基础。

如果你已经熟悉 Rust 中的这些部分,你可以随时跳过。然而,在你继续下一章节之前,请确保你对线程、内部可变性、Send 和 Sync 有一个好的理解,以及知道什么是互斥锁1、条件变量2以及线程阻塞(park)3

Rust 中的线程

英文版本

每个程序都从一个线程开始:主(main)线程。该线程将执行你的 main 函数,并且如果你需要,可以用它产生更多线程。

在 Rust 中,新线程使用来自标准库的 std::thread::spawn 函数产生。它接受一个参数:新线程执行的函数。一旦该函数返回,线程就会停止。

让我们看一个示例:

use std::thread;

fn main() {
    thread::spawn(f);
    thread::spawn(f);

    println!("Hello from the main thread.");
}

fn f() {
    println!("Hello from another thread!");

    let id = thread::current().id();
    println!("This is my thread id: {id:?}");
}

我们产生两个线程,它们都将执行 f 作为它们的主函数。这两个线程将输出一个信息并且展示它们的线程 id,主线程也将输出它自己的信息。

Thread ID

Rust 标准库为每个线程分配一个唯一的标识符。此标识符可以通过 Thread::id() 访问并且拥有 ThreadId 类型。除了复制 ThreadId 以及检查它们是否相等外,你什么也做不了。不能保证这些 ID 将会连续分配,并且每个线程的 ID 都会有所不同。

如果你运行几次我们上面的示例,你可能注意到输出在运行之间有所不同。一次在机器上特定运行的输出:

Hello from the main thread.
Hello from another thread!
This is my thread id:

惊讶的是,部分输出似乎丢失了。

这里发生的情况是:新的线程完成其函数的执行之前,主线程完成了主函数的执行。

从主函数返回将退出整个程序,即使其它线程仍然在运行。

在这个特定的示例中,在程序被主线程关闭之前,其中一个新的线程只有够到达第二条消息一半的时间。

如果我们想要线程在主函数返回之前完成执行,我们可以通过 join 它们来等待。为此,我们使用 spawn 函数返回的 JoinHandle

fn main() {
    let t1 = thread::spawn(f);
    let t2 = thread::spawn(f);

    println!("Hello from the main thread.");

    t1.join().unwrap();
    t2.join().unwrap();
}

.join() 方法会等待直到线程结束执行并且返回 std::thread::Result。如果线程由于 panic 不能成功地完成它的函数,这将包含 panic 消息。我们试图去处理这种情况,或者为 join panic 的线程调用 .unwrap() 去 panic。

运行我们程序的这个版本,将不再导致输出被截断:

Hello from the main thread.
Hello from another thread!
This is my thread id: ThreadId(3)
Hello from another thread!
This is my thread id: ThreadId(2)

唯一仍然改变的是消息的打印顺序:

Hello from the main thread.
Hello from another thread!
Hello from another thread!
This is my thread id: ThreadId(2)
This is my thread id: ThreadId(3)

输出锁定

println 宏使用 std::io::Stdout::lock() 去确保输出没有被中断。println!() 表达式将等待直到任意并发的表达式运行完成后,再写入输出。如果不是这样,我们可能得到更多的交错输出:


Hello fromHello from another thread!
another This is my threthreadHello fromthread id: ThreadId!
( the main thread.
2)This is my thread
id: ThreadId(3)

与其将函数的名称传递给 std::thread::spawn(像我们上面的示例那样),不如传递一个闭包。这允许我们捕获并移动值到新的线程:

#![allow(unused)]
fn main() {
let numbers = vec![1, 2, 3];

thread::spawn(move || {
    for n in &numbers {
        println!("{n}");
    }
}).join().unwrap();
}

在这里,因为我们使用了 move 闭包,numbers 的所有权被转移到新产生的线程。如果我们没有使用 move 关键字,闭包将会通过引用捕获 numbers。这将导致一个编译错误,因为新的线程可能比变量的生命周期更长。

由于线程可能运行直到程序执行结束,因此产生的线程在它的参数类型上有 'static 生命周期绑定。换句话说,它只接受永久保留的函数。闭包通过引用捕获局部变量不能够永久保留,因为当局部变量不存在时,引用将变得无效。

从线程中取回一个值,是从闭包中返回值来完成的。该返回值可以通过 join 方法返回的 Result 中获取:

#![allow(unused)]
fn main() {
let numbers = Vec::from_iter(0..=1000);

let t = thread::spawn(move || {
    let len = numbers.len();
    let sum = numbers.iter().sum::();
    sum / len  // 1
});

let average = t.join().unwrap(); // 2

println!("average: {average}");
}

在这里,线程闭包(1)返回的值通过 join 方法发送回主线程。

如果 numbers 是空的,当它尝试去除以 0 时(2),线程将发生 panic,而 join 将会发生 panic 消息,将由于 unwarp 导致主线程也 panic。

Thread Builder

std::thread::spawn 函数事实上仅是 std::thread::Builder::new().spawn().unwrap() 的简写。

std::thread::Builder 允许你在产生线程之前为新线程做一些配置。你可以使用它为新线程配置栈大小并给新线程一个名字。线程的名字是可以通过 std::thread::current().name() 获得,这将在 panic 消息中可用,并在监控和大多数调试工具中可见。

此外,Builder 的产生函数返回一个 std::io::Result,允许你处理产生新线程失败的情况。如果操作系统内存不足,或者资源限制已经应用于你的程序,这是可能发生的。如果 std::thread::spawn 函数不能去产生一个新线程,它就会 panic。

作用域内的线程

英文版本

如果我们确信生成的线程不会比某个作用域存活更久,那么线程可以安全地借用那些不会一直存在的东西,例如局部变量,只要它们比该范围活得更久。

Rust 标准库提供了 std::thread::scope 去产生此类作用域内的线程。它允许我们产生不超过我们传递给该函数闭包的范围的线程,这使它可能安全地借用局部变量。

它的工作原理最好使用一个示例来展示:

#![allow(unused)]
fn main() {
let numbers = vec![1, 2, 3];

thread::scope(|s| { // 1
    s.spawn(|| { // 2
        println!("length: {}", numbers.len());
    });
    s.spawn(|| { // 2
        for n in &numbers {
            println!("{n}");
        }
    });
}); // 3
}
  1. 我们使用闭包调用 std::thread::scope 函数。我们的闭包是直接执行,并得到一个参数,s,表示作用域。
  2. 我们使用 s 去产生线程。该闭包可以借用本地变量,例如 numbers。
  3. 当作用域结束,所有仍没有 join 的线程都会自动 join。

这种模式保证了,在作用域产生的线程没有会比作用域更长的生命周期。因此,作用域中的 spawn 方法在它的参数类型中没有 'static 约束,允许我们去引用任何东西,只要它比作用域有更长的生命周期,例如 numbers。

在以上示例中,这两个线程并发地获取 numbers。这是没问题的,因为它们其中的任何一个(或者主线程)都没有修改它。如果我们改变第一个线程去修改 numbers,正如下面展示的,编译器将不允许我们也产生另一个也使用数字的线程:

#![allow(unused)]
fn main() {
let mut numbers = vec![1, 2, 3];

thread::scope(|s| {
    s.spawn(|| {
        numbers.push(1);
    });
    s.spawn(|| {
        numbers.push(2); // 报错!
    });
});
}

确切的错误信息取决于 Rust 编译器版本,因为它会在不断改进中以产生更好的诊断,但是试图去编译以上代码将导致以下问题:

error[E0499]: cannot borrow `numbers` as mutable more than once at a time
 --> example.rs:7:13
  |
4 |     s.spawn(|| {
  |             -- first mutable borrow occurs here
5 |         numbers.push(1);
  |         ------- first borrow occurs due to use of `numbers` in closure
  |
7 |     s.spawn(|| {
  |             ^^ second mutable borrow occurs here
8 |         numbers.push(2);
  |         ------- second borrow occurs due to use of `numbers` in closure

泄漏启示录

在 Rust 1.0 之前,标准库有一个函数叫做 std::thread::scoped,它将直接产生一个线程,就像 std::thread::spawn。它允许无 'static 的捕获,因为它返回的不是 JoinHandle,而是当被丢弃时 join 到线程的 JoinGuard。任意的借用数据仅需要比这个 JoinGuard 存活得更久。只要 JoinGuard 在某个时候被丢弃,这似乎就是安全的。

就在 Rust 1.0 发布之前,人们慢慢发现它似乎不能保证某些东西一定被丢弃。有很多种方式不能丢弃它,例如创建一个引用计数节点的循环,可以忘记某些东西或者泄漏它。

最终,在一些人提及的“泄漏启示录”中得到结论,(安全)接口的设计不能依赖假设对象总是在它们的生命周期结束后丢弃。泄漏一个对象可能会导致泄漏更多对象(例如,泄漏一个 Vec 将也导致泄漏它的元素),但它并不会导致未定义行为(undefind behavior)。因此,std::thread::scoped 将不再视为安全的并从标准库移除。此外,std::mem::forget 从一个不安全的函数升级到安全的函数,以强调忘记(或泄漏)总是一种可能性。

直到后来,在 Rust 1.63 中,添加了一个新的 std::thread::scope 功能,其新设计不依赖 Drop 来获得正确性。

共享所有权以及引用计数

英文版本

目前,我们已经使用了 move 闭包(“Rust 中的线程”)将值的所有权转移到线程并从生命周期较长的父线程借用数据(作用域内的线程)。当两个线程之间共享数据,它们之间的任何一个线程都不能保证比另一个线程的生命周期长,那么它们都不能称为该数据的所有者。它们之间共享的任何数据都需要与最长生命周期的线程一样长。

静态值(static)

英文版本

有几种方式去创建不属于单线程的东西。最简单的方式是静态值,它由整个程序“拥有”,而不是单个线程。在以下示例中,这两个线程都可以获取 X,但是它们并不拥有它:

#![allow(unused)]
fn main() {
static X: [i32; 3] = [1, 2, 3];

thread::spawn(|| dbg!(&X));
thread::spawn(|| dbg!(&X));
}

静态值一般由一个常量初始化,它从不会被丢弃,并且甚至在程序的主线程开始之前就已经存在。每个线程都可以借用它,因为可以保证它它总是存在。

泄漏(Leak)

英文版本

另一种方式是通过泄漏内存分配的方式共享所有权。使用 Box::leak,人们可以释放 Box 的所有权,保证永远不会丢弃它。从那时起,Box 将永远存在,没有所有者,只要程序运行,任意线程都可以借用它。

#![allow(unused)]
fn main() {
let x: &'static [i32; 3] = Box::leak(Box::new([1, 2, 3]));

thread::spawn(move || dbg!(x));
thread::spawn(move || dbg!(x));
}

move 闭包可能会让它看起来像我们移动所有权进入线程,但仔细观察 x 的类型就会发现,我们只是给线程一个对数据的引用

引用是 Copy 的,这意味着当你“移动”(move)它们的时候,原始内容仍然存在,这就像整数或者布尔内容一样。

注意,'static 生命周期并不意味着该值自程序开始时就存在,而只是意味着它一直存在到程序的结束。过去并不重要。

泄漏 Box 的缺点是我们正在泄漏内存。我们获取一些内存,但是从未丢弃和释放它。如果仅发生有限的次数,这就可以了。但是如果我们继续这样做,程序将慢慢地耗尽内存。

引用计数

英文版本

为了确保共享数据能够丢弃和释放内存,我们不能完全放弃它的所有权。相反,我们可以分享所有权。通过跟踪所有者的数量,我们确保仅当没有所有者时,才会丢弃该值。

Rust 标准库通过 std::rc::Rc 类型提供了该功能,它是“引用计数”(reference counted)的缩写。它与 Box 非常类似,唯一的区别是克隆它将不会分配任何新内存,而是递增存储在包含值旁边的计数器。原始的 Rc 和克隆的 Rc 将引用相同的内存分配;它们共享所有权

#![allow(unused)]
fn main() {
use std::rc::Rc;

let a = Rc::new([1, 2, 3]);
let b = a.clone();

assert_eq!(a.as_ptr(), b.as_ptr()); // 相同内存分配!
}

丢弃一个 Rc 将递减计数。只有最后一个 Rc,计数器下降到 0,才会丢弃且释放内存分配中所包含的数据。

如果我们尝试去发送一个 Rc 到另一个线程,然而,我们将造成以下的编译错误:

error[E0277]: `Rc` cannot be sent between threads safely
    |
8   |     thread::spawn(move || dbg!(b));
    |                   ^^^^^^^^^^^^^^^

事实证明,Rc 不是线程安全的(详见,线程安全:Send 和 Sync)。如果多个线程有相同内存分配的 Rc,那么它们可能尝试并发修改引用计数,这可能产生不可预测的结果。

然而,我们可以使用 std::sync::Arc,它代表“原子引用计数”。它与 Rc 相同,只是它保证了对引用计数的修改是不可分割的原子操作,因此可以安全地与多个线程使用。(详见第二章。)

#![allow(unused)]
fn main() {
use std::sync::Arc;

let a = Arc::new([1, 2, 3]); // 1
let b = a.clone(); // 2

thread::spawn(move || dbg!(a)); // 3
thread::spawn(move || dbg!(b)); // 3
}
  1. 我们在新的内存分配中放置了一个数组,以及从一开始的引用计数器。
  2. 克隆 Arc 递增引用计数到 2,并为我们提供第二个指向相同内存分配的 Arc。
  3. 两个线程通过各自的 Arc 访问共享的数组。当它们丢弃 Arc 时,两者都会递减引用计数。最后一个丢弃 Arc 的线程将看见计数器递减到 0,并且将丢弃和回收数组的内存。

命名克隆

如果给每个 Arc 的克隆取一个不同的名称,这可能使得代码变得混乱难以追踪。尽管每个 Arc 的克隆都是一个独立的对象,而给每个克隆赋予不同的名称也并不能很好地反映这一点。

Rust 允许(并且鼓励)你通过定义有着新的名称的相同变量去遮蔽变量。如果你在同一作用域这么做,则无法再命名原始变量。但是通过打开一个新的作用域,可以使用类似 let a = a.clone(); 的语句在该作用域内重用相同的名称,同时在作用于外保留原始变量的可用性。

通过在新的作用域(使用 {})中封装闭包,我们可以在将变量移动到闭包中之前,进行克隆,而不重新命名它们。

  ```

let a = Arc::new([1, 2, 3]); let b = a.clone(); thread::spawn(move || { dbg!(b); }); dbg!(a);

    Arc 克隆存活在同一作用域内。每个线程都有自己的克隆,只是名称不同。
    
    
      ```

let a = Arc::new([1, 2, 3]);
thread::spawn({
    let a = a.clone();
    move || {
        dbg!(a);
    }
});
dbg!(a);
      
Arc 的克隆存活在不同的作用域内。我们可以在每个线程使用相同的名称。

因为所有权是共享的,引用计数指针(RcArc)与共享引用(&T)有着相同的限制。它们并不能让你对它们包含的值进行可变访问,因为该值在同一时间,可能被其它代码借用。

例如,如果我们尝试去排序 Arc 中整数的切片,编译器将阻止我们这么做,告诉我们不允许改变数据:

error[E0596]: cannot borrow data in an `Arc` as mutable
  |
6 |     a.sort();
  |     ^^^^^^^^

借用和数据竞争

英文版本

在 Rust 中,可以使用两种方式借用值。

  • 不可变借用
    • 使用 & 借用会得到一个不可变借用。这样的引用可以被复制。对于它引用访问的数据在所有引用副本之间是共享的。顾名思义,编译器通常不允许你通过这样的引用改变数据,因为那可能会影响当前引用相同数据的其它代码。
  • 可变借用
    • 使用 &mut 借用会得到一个可变引用。可变借用保证了它是该数据的唯一激活的借用。这确保了可变的数据将不会改变任何其它代码正在查看的数据。

这两个概念一起,完全阻止了数据竞争:一个线程正在改变数据,而另一个线程正在并发地访问数据的情况。数据竞争通常是未定义行为4,这意味着编译器不需要考虑这些情况。它只是假设它们并不会发生。

为了清晰地表达这个意思,让我们来看一看编译器可以使用借用规则作出有用假设的示例:

#![allow(unused)]
fn main() {
fn f(a: &i32, b: &mut i32) {
    let before = *a;
    *b += 1;
    let after = *a;
    if before != after {
        x(); // 从不发生
    }
}
}

这里,我们得到一个整数的不可变引用,并在递增 b 所引用的整数之前和之后存储整数的值。编译器可以自由地假设关于借用和数据竞争的基本规则得到了遵守,这意味着 b 不可能引用与 a 相同的整数。实际上,在对 a 进行引用时,整个程序中没有任何地方对 a 借用的整数进行可变借用。因此,编译器可以轻松地推断 *a 不会发生变化,并且 if 语句将永远不是 true,并且可以作为优化完全地删除 x 调用。

除了使用不安全的块(unsafe)禁止一些编译器的安全检查之外,不可能写出打破编译器假设的 Rust 程序。

未定义行为

类似 C、C++ 和 Rust 都有一套需要遵守的规则,以避免未定义行为。例如,Rust 的规则之一是,对任何对象的可变引用永远不可能超过一个。

在 Rust 中,仅当使用 unsafe 代码块才能打破这些规则。“unsafe”并不意味着代码是错误的或者不安全的,而是编译器并没有为你验证你的代码是安全的。如果代码确实违法了这些规则,则称为不健全的(unsound)。

允许编译器在不检查的情况下假设这些规则从未破坏。当破坏是,这将导致叫做未定义行为的问题,我们需要不惜一切代价去避免。如果我们允许编译器作出与实际不符的假设,那么它可能很容易导致关于代码不同部分更错误的结论,影响你整个程序。

作为一个具体的例子,让我们看看在切片上使用 get_unchecked 方法的小片段:

let a = [123, 456, 789];
let b = unsafe { a.get_unchecked(index) };

get_unchecked 方法给我们一个给定索引的切片元素,就像 a[index],但是允许编译器假设索引总是在边界,没有任何检查。

这意味着,在代码片段中,由于 a 的长度是 3,编译器可能假设索引小于 3。这使我们确保其假设成立。

如果我们破坏了这个假设,例如,我们以等于 3 的索引运行,任何事情都可能发生。它可能导致读取 a 之后存储的任何内存内容。这可能导致程序崩溃。它可能会执行程序中完全无关的部分。它可能会引起各种糟糕的情况。

或许令人惊讶的是,未定义行为甚至可以“时间回溯”,导致之前的代码出问题。要理解这种情况是如何发生的,想象我们上面的片段有一个 match 语句,如下:

match index {
 0 => x(),
 1 => y(),
 _ => z(index),
}

let a = [123, 456, 789];
let b = unsafe { a.get_unchecked(index) };

由于不安全的代码,编译器被允许假设 index 只有 0、1 或 2。它可能会逻辑的得出结论,我们的 match 语句的最后分支仅会匹配到 2,因此 z 仅会调用为 z(2)。这个结论不仅可以优化匹配,还可以优化 z 本身。这可能包括丢弃代码中未使用的部分。

如果我们以 3 为 index 执行此设置,我们的程序可能会尝试执行被优化的部分,导致在我们到达最后一行的 unsafe 块之前就出现不可预测的行为。就像这样,未定义行为通过整个程序向后或者向前传播,而这往往是以非常出乎意料的方式发生。

当调用任何的不安全函数时,仔细阅读其文档,确保你完全理解它的安全要求:作为调用者,你需要维持约定或前提条件,以避免未定义行为。

内部可变性

英文版本

上一节介绍的借用规则可能非常有限——尤其涉及多个线程时。遵循这些规则在线程之间通信极其有限,并且是不可能的,因为多个线程访问的数据都无法改变。

幸运的是,有一个逃生方式:内部可变性。有着内部可变性的数据类型略微改变了借用规则。在某些情况下,这些类型可以使用“不可变”的引用进行可变。

“引用计数”中,我们已经看到一个设计内部可变性的微妙示例。在 RcArc 都变为引用计数器,即使可能有多个克隆都使用相同的引用计数器。

一旦设计内部可变性类型,称“不可变”和“可变”将变得混乱和不准确,因为一些类型可以通过两者变得可变。更准确的称呼是“共享”和“独占”:共享引用(&T)可以被复制以及与其它引用共享,然而独占引用&mut T)保证了仅有一个对 T 的独占借用。对于大多数类型,共享引用并不允许可变,但有一些例外。由于本书我们将主要处理这些例外情况,我们将在这本书的剩余内容中使用更准确的术语。

请记住,内部可变性仅会影响共享借用的规则,以便在共享时允许可变。它不能改变任意关于独占借用的规则。独占借用仍然保证没有任意激活的借用。导致超过一个活动的独占引用的不安全代码总是涉及未定义行为,不管内部可变性如何。

让我们看一看有着内部可变性的一些示例,以及如何通过共享引用允许可变性而不导致未定义行为。

Cell

英文版本

std::cell::Cell 仅是包装了 T,但允许通过共享引用进行可变。为避免未定义行为,它仅允许你将值复制出来(如果 T 实现 Copy)或者将其替换为另一个整体值。此外,它仅用于单个线程。

让我们看一看与上一节相似的示例,但是这一次使用 Cell 而不是 i32

#![allow(unused)]
fn main() {
use std::cell::Cell;

fn f(a: &Cell, b: &Cell) {
    let before = a.get();
    b.set(b.get() + 1);
    let after = a.get();
    if before != after {
        x(); // 可能发生
    }
}
}

与上次不同,现在 if 条件有可能为真。因为 Cell 是内部可变的,只要我们有对它的共享引用,编译器不再假设它的值不再改变。a 和 b 可能引用相同的值,通过 b 也可能影响 a。然而,它可能假设没有其它线程并发获取 cell。

对 Cell 的限制并不总是容易处理的。因为它不能直接让我们借用它所持有的值,我们需要将值移动出去(让一些东西替换它的位置),修改它,然后将它放回去,以改变它的内容:

#![allow(unused)]
fn main() {
fn f(v: &Cell>) {
    let mut v2 = v.take(); // 使用空的 Vec 替换 Cell 中的内容
    v2.push(1);
    v.set(v2); // 将修改的 Vec 返回
}
}

RefCell

英文版本

与常规的 Cell 不同的是,std::cell::RefCell 允许你以很小的运行时花费,去借用它的内容。RefCell 不仅持有 T,同时也持跟踪任何未解除的借用。如果你在已经存在可变借用的情况下尝试借用它(反之亦然),会引发 panic,以避免出现未定义行为。就像 Cell,RefCell 只能在单个线程中使用。

借用 RefCell 的内容通过调用 borrow 或者 borrow_mut 完成:

#![allow(unused)]
fn main() {
use std::cell::RefCell;

fn f(v: &RefCell>) {
    v.borrow_mut().push(1); // 我们可以直接修改 `Vec`。
}
}

尽管 Cell 和 RefCell 有时是非常有用的,但是当我们使用多线程的时候,它们会变得无用。所以让我们继续讨论与并发相关的类型。

互斥锁和读写锁

英文版本

读写锁(RwLock)5RefCell 的并发版本。RwLock 持有 T 并且跟踪任意未解除的借用。然而,与 RefCell 不同,它在冲突的借用中不会 panic。相反,它会阻塞当前线程——使它进入睡眠——直到冲突的借用消失才会唤醒。在其它线程完成后,我们仅需要耐心的等待轮到我们处理数据。

借用 RwLock 的内容称为。通过锁定它,我们临时阻塞并发的冲突借用,这允许我们没有导致数据竞争的借用它。

Mutex6 与其是非常相似的,但是概念上相对简单的。它不像 RwLock 跟踪共享借用和独占借用的数量,它仅允许独占借用。

我们将在“锁:互斥锁和读写锁”更详细地介绍这些类型。

Atomic

英文版本

原子类型表示 Cell 的并发版本,是第 2 章和第 3 章的主题。与 Cell 相同,它们通过将整个值进行复制来避免未定义行为,而不直接让我们借用内容。

与 Cell 不同的是,它们不能是任意大小的。因此,任何 T 都没有通用的 Atomic 类型,但仅有特定的原子类型,例如 AtomicU32AtomicPtr。因为它们需要处理器的支持来避免数据竞争,所以哪些类型可用具体取决于平台。(我们将在第七章研究这个问题。)

因为它们的大小非常有限,原子类型通常不直接在线程之间共享所需的信息。相反,它们通常用作工具,是线程之间共享其它(通常是更大的)东西作为可能。当原子用于表示其它数据时,情况可能变得令人意外地复杂。

UnsafeCell

英文版本

UnsafeCell 是内部可变性的原始基石。

UnsafeCell 包装 T,但是没有附带任何条件和限制来避免未定义行为。相反,它的 get() 方法仅是给出了它包装值的原始指针,该值仅可以在 unsafe 块中使用。它以用户不会导致任何未定义行为的方式使用它。

更常见的是,不会直接使用 UnsafeCell,而是将它包装在另一个类型,通过限制接口提供安全,例如 CellMutex。所有有着内部可变性的类型——包括所有以上讨论的类型都建立在 UnsafeCell 之上。

线程安全:Send 和 Sync

英文版本

在这一章节中,我们已经看见一个不是线程安全的类型,这些类型仅用于一个单线程,例如 RcCell 以及其它。由于需要这些限制来避免未定义行为,所以编译器需要理解并为你检查这个限制,这样你就可以在不使用 unsafe 块的情况下使用这些类型。

该语言使用两种特殊的 trait 以跟踪这些类型可以安全地用作交叉线程:

  • Send
    • 一个类型如果可以发送到另一个线程,则其是 Send 类型。换句话说,如果一个类型值的所有权可以转移到另一个线程,那么该类型就是 Send。例如,ArcSend,而 Rc 不是。
  • Sync
    • 一个类型如果可以共享到另一个线程,则其是 Sync 类型。换句话说,当且仅当对该类型(T)的共享引用 &TSend 的时候,这个类型 T 才是 Sync。例如,i32 是 Sync,而 Cell 就不是。(然而 CellSend,但并非 Sync。)

原始类型,例如 i32、bool 以及 str 都是 SendSync

这两个 trait 会自动地为你实现该 trait,这意味着它们会基于各自的字段为你的类型自动地实现。如果结构体的所有字段都实现 SendSync,那结构体本身也将实现 SendSync

选择退出其中任何一种的方式是去增加没有实现该 trait 的字段到你的类型。为此,特殊的 std::marker::PhantomData 类型经常派上用场。实际上它在运行时并不存在,它会被被编译器视为 T。它是零开销类型,不占用任何空间。

让我们来看看以下的结构体:

#![allow(unused)]
fn main() {
use std::marker::PhantomData;

struct X {
    handle: i32,
    _not_sync: PhantomData>,
}
}

在这个示例中,如果 handle 是它唯一的字段,X 将是 SendSync。然而,我们增加一个零开销的 PhantomData> 字段,该字段被视为 Cell。因为 Cell 字段不是 Sync,X 也将不是。但它仍然是 Send,因为所有字段都实现了 Send。

原始指针(*const T*mut T)既不是 Send 也不是 Sync,因为编译器不了解他们表示什么。

选择任意 trait 的方式和使用任意其它 trait 相同;使用一个 impl 为你的类型实现 trait:

#![allow(unused)]
fn main() {
struct X {
    p: *mut i32,
}

unsafe impl Send for X {}
unsafe impl Sync for X {}
}

注意,实现这些 trait 需要 unsafe 关键字,因为编译器不能为你检查它是否正确。这是你对编译器作出的承诺,你不得不信任它。

如果你尝试去移动一些未实现 Send 的值进入另一个线程,编译器将阻止你这样做。用一个小的示例去演示:

fn main() {
    let a = Rc::new(123);
    thread::spawn(move || { // 报错!
        dbg!(a);
    });
}

这里,我们尝试去发送 Rc 到一个新线程,但是 RcArc 不同,因为它没有实现 Send。

如果我们尝试去编译以上示例,我们将面临一个类似这样的错误:

error[E0277]: `Rc` cannot be sent between threads safely
   --> src/main.rs:3:5
    |
3   |     thread::spawn(move || {
    |     ^^^^^^^^^^^^^ `Rc` cannot be sent between threads safely
    |
    = help: within `[closure]`, the trait `Send` is not implemented for `Rc`
note: required because it's used within this closure
   --> src/main.rs:3:19
    |
3   |     thread::spawn(move || {
    |                   ^^^^^^^
note: required by a bound in `spawn`

thread::spawn 函数需要它的参数实现 Send,并且只有当其所有的捕获都是 Send,闭包才是 Send。如果我们尝试捕获未实现 Send,就会捕捉我们的错误,保护我们避免未定义行为的影响。

锁:互斥锁和读写锁

英文版本

在线程之间共享(可变)数据更常规的有用工具是 mutex,它是“互斥”(mutual exclusion)的缩写。mutex 的工作是通过暂时阻塞其它试图同时访问某些数据的线程,来确保线程对某些数据进行独占访问。

概念上,mutex 仅有两个状态:解锁和锁定。当线程锁定一个未上锁的 mutex,mutex 被标记为锁定,线程可以立即继续。当线程尝试锁定一个已上锁的 mutex,操作将阻塞。当线程等待 mutex 解锁时,其会置入睡眠状态。解锁操作仅能在已上锁的 mutex 上进行,并且应当由锁定它的同一线程完成。如果其它线程正在等待锁定 mutex,解锁将导致唤醒其中一个线程,因此它可以尝试再次锁定 mutex 并且继续它的进程。

使用 mutex 保护数据仅是所有线程之间的约定,当它们持有 mutex 锁时,它们才能获取数据。这种方式,没有两个线程可以并发地获取数据和导致数据竞争。

Rust 的互斥锁

英文版本

Rust 的标准库通过 std::sync::Mutex 提供这个功能。它对类型 T 进行范型化,该类型 T 是 mutex 所保护的数据类型。通过将 T 作为 mutex 的一部分,该数据仅可以通过 mutex 获取,从而提供一个安全的接口,以保证所有线程都遵守这个约定。

为确保已上锁的 mutex 仅通过锁定它的线程解锁,所以它没有 unlock() 方法。然而,它的 lock() 方法返回一个称为 MutexGuard 的特殊类型。该 guard 表示保证我们已经锁定 mutex。它通过 DerefMut trait 行为表现像一个独占引用,使我们能够独占访问互斥体保护的数据。解锁 mutex 通过丢弃 guard 完成。当我们丢弃 guard 时,我们我们放弃了获取数据的能力,并且 guard 的 Drop 实现将解锁 mutex。

让我们看一个示例,实践中的 mutex:

use std::sync::Mutex;

fn main() {
    let n = Mutex::new(0);
    thread::scope(|s| {
        for _ in 0..10 {
            s.spawn(|| {
                let mut guard = n.lock().unwrap();
                for _ in 0..100 {
                    *guard += 1;
                }
            });
        }
    });
    assert_eq!(n.into_inner().unwrap(), 1000);
}

在这里,我们有一个 Mutex,一个保护整数的 mutex,并且我们启动了十个线程,每个线程会递增这个整数 100 次。每个线程将首先锁定 mutex 去获取 MutexGuard,并且然后使用 guard 去获取整数并修改它。当该变量超出作用域后,guard 会立即隐式丢弃。

线程完成后,我们可以通过 into_inner() 安全地从整数中移除保护。into_inner 方法获取 mutex 的所有权,这确保了没有其它东西可以引用 mutex,从而使 mutex 变得不再必要。

尽管递增是逐步地的,但是线程仅能够看见 100 的倍数,因为它只能在 mutex 解锁时查看整数。实际上,由于 mutex 的存在,这一百次递增称为了一个单一不可分割的原子操作。

为了清晰地看见 mutex 的效果,我们可以让每个线程在解锁 mutex 之前等待一秒:

use std::time::Duration;

fn main() {
    let n = Mutex::new(0);
    thread::scope(|s| {
        for _ in 0..10 {
            s.spawn(|| {
                let mut guard = n.lock().unwrap();
                for _ in 0..100 {
                    *guard += 1;
                }
                thread::sleep(Duration::from_secs(1)); // 新增!
            });
        }
    });
    assert_eq!(n.into_inner().unwrap(), 1000);
}

当你现在运行程序,你将看见大约需要花费 10s 才能完成。每个线程仅等待 1s,但是 mutex 确保一次仅有一个线程这么做。

如果我们在睡眠 1s 之前丢弃 guard,并且因此解锁 mutex,我们将看到并行发生:

fn main() {
    let n = Mutex::new(0);
    thread::scope(|s| {
        for _ in 0..10 {
            s.spawn(|| {
                let mut guard = n.lock().unwrap();
                for _ in 0..100 {
                    *guard += 1;
                }
                drop(guard); // 新增:在睡眠之前丢弃 guard!
                thread::sleep(Duration::from_secs(1));
            });
        }
    });
    assert_eq!(n.into_inner().unwrap(), 1000);
}

有了这些变化,这个程序大约仅需要 1s,因为 10 个线程现在可以同时执行 1s 的睡眠。这表明了 mutex 锁定时间保持尽可能短的重要性。将 mutex 锁定时间超过必要时间可能会完全抵消并行带来的好处,实际上会强制所有操作按顺序执行。

锁中毒(poison)

英文版本

上述示例中 unwarp() 调用和锁中毒有关。

当线程在持有锁时 panic,Rust 中的 mutex 将被标记为中毒。当这种情况发生时,Mutex 将不再被锁定,但调用它的 lock 方法将导致 Err,以表明它已经中毒。

这是一个防止由 mutex 保护的数据处于不一致状态的机制。在我们上面的示例中,如果一个线程在整数递增到 100 之前崩溃,mutex 将解锁并且整数将处于一个意外的状态,它不再是 100 的倍数,这可能打破其它线程的假设。在这种情况下,自动标记 mutex 中毒,强制用户处理这种可能。

在中毒的 mutex 上调用 lock() 仍然可能锁定 mutex。由 lock() 返回的 Err 包含 MutexGuard,允许我们在必要时纠正不一致的状态。

虽然锁中毒是一种强大的机制,在实践中,从潜在的不一致状态恢复并不常见。如果锁中毒,大多数代码要么忽略了中毒或者使用 unwrap() 去 panic,这有效地将 panic 传递给使用 mutex 的所有用户。

MutexGuard 的生命周期

尽管隐式丢弃 guard 解锁 mutex 很方便,但是它有时会导致微妙的意外。如果我们使用 let 语句授任 guard 一个名字(正如我们上面的示例),看它什么时候会被丢弃相对简单,因为局部变量定义在它们作用域的末尾。然而,正如上述示例所示,不明确地丢弃 guard 可能导致 mutex 锁定的时间超过所需时间。

在不给它指定名称的情况下使用 guard 也是可能的,并且有时非常方便。因为 MutexGuard 保护数据的行为像独占引用,我们可以直接使用它,而无需首先为他授任一个名称。例如,你有一个 Mutex<Vec<i32>>,你可以在单个语句中锁定 mutex,将项推入 Vec,并且再次锁定 mutex:

list.lock().unwrap().push(1);

任何更大表达式产生的临时值,例如通过 lock() 返回的 guard,将在语句结束后被丢弃。尽管这似乎显而易见,但它导致了一个常见的问题,这通常涉及 matchif let 以及 while let 语句。以下是遇到该陷阱的示例:

if let Some(item) = list.lock().unwrap().pop() {
  process_item(item);
}

如果我们的旨意就是锁定 list、弹出 item、解锁 list 然后在解锁 list 后处理 item,我们在这里犯了一个微妙而严重的错误。临时的 guard 直到完整的 if let 语句结束后才能被丢弃,这意味着我们在处理 item 时不必要地持有锁。

或许,意外地是,对于类似地 if 语句,这并不会发生,例如以下示例:

if list.lock().unwrap().pop() == Some(1) {
  do_something();
}

在这里,临时的 guard 在 if 语句的主体执行之前就已经丢弃了。该原因是,通常 if 语句的条件总是一个布尔值,它并不能借用任何东西。没有理由将临时的生命周期从条件开始延长到语句的结尾。对于 if let 语句,情况可能并非如此。例如,如果我们使用 front(),而不是 pop(),项将会从 list 中借用,因此有必要保持 guard 存在。因为借用检查实际上只是一种检查,它并不会影响何时以及什么顺序丢弃,所以即使我们使用了 pop(),情况仍然是相同的,尽管那并不是必须的。

我们可以通过将弹出操作移动到单独的 let 语句来避免这种情况。然后在该语句的末尾放下 guard,在 if let 之前:

let item = list.lock().unwrap().pop();
if let Some(item) = item {
  process_item(item);
}

读写锁

英文版本

互斥锁仅涉及独占访问。MutexGuard 将提供受保护数据的一个独占引用(&mut T),即使我们仅想要查看数据,并且共享引用(&T)就足够了。

读写锁是一个略微更复杂的 mutex 版本,它能够区分独占访问和共享访问的区别,并且可以提供两种访问方式。它有三种状态:解锁、由单个 writer 锁定(用于独占访问)以及由任意数量的 reader 锁定(用于共享访问)。它通常用于通常由多个线程读取的数据,但只是偶尔一次。

Rust 标准库通过 std::sync::RwLock 类型提供该锁。它与标准库的 Mutex 工作类似,只是它的接口大多是分成两个部分。然而,单个 lock() 方法,它有 read()write() 方法,用于为 reader 或 writer 进行锁定。它还附带了两种守卫类型,一种用于 reader,一种用于 writer:RwLockReadGuard 和 RwLockWriteGuard。前者只实现了 Deref,其行为像受保护数据共享引用,后者还实现了 DerefMut,其行为像独占引用。

它实际上是 RefCell 的多线程版本,动态地跟踪引用的数量以确保借用规则得到维护。

MutexRwLock 都需要 T 是 Send,因为它们可能发送 T 到另一个线程。除此之外,RwLock 也需要 T 实现 Sync,因为它允许多个线程对受保护的数据持有共享引用(&T)。(严格地说,你可以创建一个并没有实现这些需求 T 的锁,但是你不能在线程之间共享它,因为锁本身并没有实现 Sync)。

Rust 标准库仅提供一种通用的 RwLock 类型,但它的实现依赖于操作系统。读写锁之间有很多细微差别。当有 writer 等待时,即使当锁已经读锁定的,很多实现将阻塞新的 reader。这样做是为了防止 writer 挨饿,在这种情况下,很多 reader 将集体阻止锁解锁,从而不允许任何 writer 更新数据。

在其他语言中的互斥锁

Rust 标准的 Mutex 和 RwLock 类型与你在其它语言(例如 C、C++)发现的看起来有一点不同。

最大的区别是,Rust 的 Mutex<T> 数据包含它正在保护的数据。例如,在 C++ 中,std::mutex 并不包含着它保护的数据,甚至不知道它在保护什么。这意味着,用户有指责记住哪些数据由 mutex 保护,并且确保每次访问“受保护”的数据都锁定正确的 mutex。注意,当读取其它语言涉及到 mutex 的代码,或者与不熟悉 Rust 程序员沟通时,非常有用。Rust 程序员可能讨论关于“数据在 mutex 之中”,或者说“mutex 中包装数据”这类话,这可能让只熟悉其它语言 mutex 的程序员感到困惑。

如果你真的需要一个不包含任何内容的独立 mutex,例如,保护一些外部硬件,你可以使用 Mutex<()>。但即使是这种情况,你最好定义一个(可能 0 大小开销)的类型来与该硬件对接,并将其包装在 Mutex 之中。这样,在与硬件交互之前,你仍然可以强制锁定 mutex。

等待: 阻塞(Park)和条件变量

英文版本

当数据由多个线程更改时,在许多情况下,它们需要等待一些事件,以便管有数据的某些条件变为真。例如,如果我们有一个保护 Vec 的 mutex,我们可能想要等待直到它包含任何东西。

尽管 mutex 允许线程等待直到它解锁,但它不提供等待任何其它条件的功能。如果我们只拥有一个 mutex,我们不得不持有锁定的 mutex,以反复检查 Vec 中是否有任意东西。

线程阻塞

英文版本

一种方式是去等待来自另一个线程的通知,其被称为线程阻塞。一个线程可以阻塞它自己,将它置入睡眠状态,阻止它消耗任意 CPU 周期。然后,另一个线程可以解锁阻塞的线程,将其从睡眠中唤醒。

线程阻塞可以通过 std::thread::park() 函数获得。对于解锁,你可以在 Thread 对象中调用 unpark() 函数表示你想要解锁该线程。这样的对象可以通过 spawn 返回的 join 句柄获得,或者也可以通过 std::thread::current() 从线程本身中获得。

让我们深入研究在线程之间使用 mutex 共享队列的示例。在以下示例中,一个新产生的线程将消费来自队列的项,尽管主线程将每秒插入新的项到队列。线程阻塞被用于在队列为空时使消费线程等待。

use std::collections::VecDeque;

fn main() {
    let queue = Mutex::new(VecDeque::new());

    thread::scope(|s| {
        // 消费线程
        let t = s.spawn(|| loop {
            let item = queue.lock().unwrap().pop_front();
            if let Some(item) = item {
                dbg!(item);
            } else {
                thread::park();
            }
        });

        // 产生线程
        for i in 0.. {
            queue.lock().unwrap().push_back(i);
            t.thread().unpark();
            thread::sleep(Duration::from_secs(1));
        }
    });
}

消费线程运行一个无限循环,它将项弹出队列,使用 dbg 宏展示它们。当队列为空的时候,它停止并且使用 park() 函数进行睡眠。如果它得到解锁,park() 调用将返回,循环继续,再次从队列中弹出项,直到它是空的。等等。

生产线程将其推入队列,每秒产生一个新的数字。每次递增一个项时,它都会在 Thread 对象上使用 unpark() 方法,该方法引用消费线程来解锁它。这样,消费线程就会被唤醒处理新的元素。

需要注意的一点是,即使我们移除阻塞,这个程序在理论上仍然是正确的,尽管效率低下。这是重要的,因为 park() 不能保证它将由于匹配 unpark() 而返回。尽管有些罕见,但它很可能会有虚假唤醒。我们的示例处理得很好,因为消费线程会锁定队列,可以看到它是空的,然后直接解锁它并再次阻塞。

线程阻塞的一个重要属性是,在线程自己进入阻塞之前,对 unpark() 的调用不会丢失。对 unpark 的请求仍然被记录下来,并且下次线程尝试挂起自己的时候,它会清除该请求并且直接继续执行,实际上并不会进入睡眠状态。为了理解这对于正确操作的关键性,让我们来看一下程序可能执行步骤的顺序:

  1. 消费线程(让我们称之为 C)锁定队列。
  2. C 尝试去从队列中弹出项,但是它是空的,导致 None。
  3. C 解锁队列。
  4. 生产线程(我们将称为 P)锁定队列。
  5. P 推入一个新的项进入队列。
  6. P 再次解锁队列。
  7. P 调用 unpark() 去通知 C,有一些新的项。
  8. C 调用 park() 去睡眠,以等待更多的项。

虽然在步骤 3 解锁队列和在步骤 8 阻塞之间很可能仅有一个很短的时间,但第 4 步和第 7 步可能在线程阻塞自己之前发生。如果 unpark() 在线程没有挂起时不执行任何操作,那么通知将会丢失。即使队列中有项,消费线程仍然在等待。由于 unpark() 请求被保存,以供将来调用 park() 时使用,我们不必担心这个问题。

然而,unpark 请求并不会堆起来。先调用两次 unpark(),然后再调用两次 park(),线程仍然会进入睡眠状态。第一次 park() 清除请求并直接返回,但第二次调用通常让它进入睡眠。

这意味着,在我们上面的示例中,重要的是我们看见队列为空的时候,我们仅会阻塞线程,而不是在处理每个项之后将其阻塞。然而由于巨长的(1s)睡眠,这种情况在本示例中几乎不可能发生,但多个 unpark() 调用仅能唤醒单个 park() 调用。

不幸的是,这确实意味着,如果在 park() 返回后,立即调用 unpark(),但是在队列得到锁定并清空之前,unpark() 调用是不必要的,但仍然会导致下一个 park() 调用立即返回。这导致(空的)队列多次被锁定并解锁。虽然这不会影响程序的正确性,但这确实会影响它的效率和性能。

这种机制在简单的情况下是好的,比如我们的示例,但是当东西变得复杂,情况可能会很糟糕。例如,如果我们有多个消费线程从相同的队列获取项时,生产线程将不会知道有哪些消费者实际上在等待以及应该被唤醒。生产者将必须知道消费者正在等待的时间以及正在等待的条件。

条件变量

英文版本

条件变量是一个更通用的选项,用于等待受 mutex 保护的数据发生变化。它有两种基本操作:等待和通知。线程可以在条件变量上等待,然后在另一个线程通知相同条件变量时被唤醒。多个线程可以在同样的条件变量上等待,通知可以发送给一个等待线程或者所有等待线程。

这意味着我们可以为我们感兴趣的事件或条件创建一个条件变量,例如,队列是非空的,并且在该条件下等待。任意导致事件或条件发生的线程都会通知条件变量,无需知道哪个或有多个线程对该通知感兴趣。

为了避免在解锁 mutex 和等待条件变量的短暂时间失去通知的问题,条件变量提供了一种原子地解锁 mutex 和开始等待的方式。这意味着根本没有通知丢失的时刻。

Rust 标准库提供了 std::sync::Condvar 作为条件变量。它的等待方法接收 MutexGuard,以保证我们已经锁定 mutex。它首先解锁 mutex 并进入睡眠。稍后,当唤醒时,它重新锁定 mutex 并且返回一个新的 MutexGuard(这证明了 mutex 再次被锁定)。

它有两个通知方法:notify_one 仅唤醒一个线程(如果有),和 notify_all 去唤醒所有线程。

让我们改用 Condvar 修改我们用于线程阻塞的示例:

#![allow(unused)]
fn main() {
use std::sync::Condvar;

let queue = Mutex::new(VecDeque::new());
let not_empty = Condvar::new();

thread::scope(|s| {
    s.spawn(|| {
        loop {
            let mut q = queue.lock().unwrap();
            let item = loop {
                if let Some(item) = q.pop_front() {
                    break item;
                } else {
                    q = not_empty.wait(q).unwrap();
                }
            };
            drop(q);
            dbg!(item);
        }
    });

    for i in 0.. {
        queue.lock().unwrap().push_back(i);
        not_empty.notify_one();
        thread::sleep(Duration::from_secs(1));
    }
});
}
  • 我们必须改变一些事情:
    • 我们现在不仅有一个包含队列的 Mutex,同时有一个 Condvar 去通信“不为空”的条件。
    • 我们不再需要知道要唤醒哪个线程,因此我们不再存储 spawn 的返回值。而是,我们通过使用 notify_one 方法的条件变量通知消费者。
    • 解锁、等待以及重新锁定都是通过 wait 方法完成的。我们不得不稍微重组控制流,以便传递 guard 到 wait 方法,同时在处理项之前仍然丢弃它。

现在,我们可以根据自己的需求生成尽可能多的消费线程,甚至稍后生成更多线程,而无需更改任何东西。条件变量会负责将通知传递给任何感兴趣的线程。

如果我们有个更加复杂的系统,其线程对不同条件感兴趣,我们可以为每个条件定义一个 Condvar。例如,我们能定义一个来指示队列是非空的条件,并且另一个指示队列是空的条件。然后,每个线程将等待与它们正在做的事情相关的条件。

通常,Condvar 仅能与单个 Mutex 一起使用。如果两个线程尝试使用两个不同的 mutex 去并发地等待条件变量,它可能导致 panic。

Condvar 的缺点是,它仅能与 Mutex 一起工作,对于大多数用例是没问题的,因为已经在保护数据时使用了 mutex。

thread::park()Condvar::wait() 也都有一个有时间限制的变体:thread::park_timeout()Condvar::wait_timeout()。它们接受一个额外的参数 Duration,表示在多长时间后放弃等待通知并无条件地唤醒。

总结

英文版本

  • 多线程可以并发地运行在相同程序并且可以在任意时间生成。

  • 当主线程结束,主程序结束。

  • 数据竞争是未定义行为,它会由 Rust 的类型系统完全地阻止(在安全的代码中)。

  • 常规的线程可以像程序运行一样长时间,并且因此只能借用 'static 数据。例如静态值和泄漏内存分配。

  • 引用计数(Arc)可以用于共享所有权,以确保只要有一个线程使用它,数据就会存在。

  • 作用域线程用于限制线程的生命周期,以允许其借用非 'static 数据,例如作用域变量。

  • &T共享引用&mut T独占引用。常规类型不允许通过共享引用可变。

  • 一些类型有着内部可变性,这要归功于 UnsafeCell,它允许通过共享引用改变。

  • Cell 和 RefCell 是单线程内部可变性的标准类型。Atomic、Mutex 以及 RwLock 是它们多线程等价物。

  • Cell 和原子类型仅允许作为整体替换值,而 RefCell、Mutex 和 RwLock 允许你通过动态执行访问规则直接替换值。

  • 线程阻塞可以是等待某种条件的便捷方式。

  • 当条件是关于由 Mutex 保护的数据时,使用 Condvar 时更方便,并且比线程阻塞更有效。

    下一篇,第二章:Atomic

2
1
3
6
5
4

第二章:Atomic

英文版本

原子(atomic)这个单词来自于希腊语 ἄτομος,意味着不可分割的,不能被切割成更小的块。在计算机科学中,它被用于描述一个不可分割的操作:它要么完全完成,要么还没发生。

正如在第一章“借用和数据竞争”中提及的,多线程并发地读取和修改相同的变量会导致未定义行为。然而,原子操作确实允许不同线程去安全地读取和修改相同的变量。因为该操作是不可分割的,它要么完全地在一个操作之前完成,要么在另一个操作之后完成,从而避免未定义行为。稍后,在第七章,我们将在硬件层面查看它们是如何工作的。

原子操作是任何涉及多线程的主要基石。所有其它的并发原语,例如互斥锁,条件变量都使用原子操作实现。

在 Rust 中,原子操作可以作为 std::sync::atomic 标准原子类型的方法使用。它们的名称都以 Atomic 开头,例如 AtomicI32 或 AtomicUsize。可用的原子类型取决于硬件架构和一些操作系统,但几乎所有的平台都提供了指针大小的所有原子类型。

与大多数类型不同,它们允许通过共享引用进行修改(例如,&AtomicU8)。正如第一章“内部可变性”讨论的那样,这都要归功于它。

每一个可用的原子类型都有着相同的接口,包括存储(store)和加载(load)、原子“获取并修改(fetch-and-modify)”操作方法、和一些更高级的“比较并交换”(compare-and-exchange)1方法。我们将在这章节的后半部分讨论它们。

但是,在我们研究不同原子操作之前,我们需要简要谈谈叫做内存排序2的概念:

每一个原子操作都接收 std::sync::atomic::Ordering 类型的参数,这决定了我们对操作相对排序的保证。保证最少的简单变体是 RelaxedRelaxed 只保证在单个原子变量中的一致性,但是在不同变量的相对操作顺序没有任何保证。

这意味着两个线程可能看到不同变量的操作以不同的顺序下发生。例如,如果一个线程首先写入一个变量,然后非常快速的写入第二个变量,另一个线程可能看见这以相反的顺序发生。

在这章节,我们将仅关注不会出现这种问题的使用情况,并且在所有地方都简单地使用 Relaxed,而不深入讨论更多细节。我们将在第三章讨论内存排序的所有细节以及其它可用内存排序。

Atomic 的加载和存储操作

英文版本

我们将查看的前两个原子操作是最基本的:load 和 store。它们的函数签名如下,使用 AtomicI32 作为示例:

#![allow(unused)]
fn main() {
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!");
}

这次,我们使用一个作用域内的线程,它将自动地为我们处理线程的 join,并且也允许我们借用局部变量。

每次后台线程完成处理元素时,它都会将处理的元素数量存储在 AtomicUsize 中。与此同时,主线程向用户显示该数字,告知该进度,大约每秒一次。一旦主线程看见所有 100 个元素已经被处理,它就会退出作用域,它会隐式地 join 后台线程,并且告知用户所有都完成。

同步

英文版本

一旦最后一个元素处理完成,主线程可能需要整整一秒才知道,这在最后引入了不必要的延迟。为了解决这个问题,我们在每当有新的消息有用时,使用线程阻塞(第一章“线程阻塞”)去唤醒睡眠中的主线程。

以下是相同的示例,但是现在使用 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 是操作系统的版本、内存的总数或者 tau 的第 400 位。对于这个示例,这不重要。

因为我们不期待它去发生变化,我们仅在第一次请求或计算时需要它,并且记住它的结果。需要它的第一个线程必须计算值,但它可以存储它到一个 static 的原子中,使所有线程可用,包括稍后的自己。

让我们看看这个示例。为了使这些简单,我们将假设 x 永远不会是 0,这样我们就可以在计算之前使用 0 作为占位符。

#![allow(unused)]
fn main() {
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() 将检查 static X 并看见它仍然是 0,计算它的值并且存回结果到 static X 中,使它在未来可用。稍后,任意对 get_x() 的调用都将看见静态值不是 0,并立即返回,没有立即计算。

然而,如果第二个线程调用 get_x(),而第一个线程仍然正在计算 x,第二个线程将也看见 0,并且也在并行的计算 x。其中一个线程将最后复写另一个线程的结果,这取决于哪一个线程先完成。这被叫做竞争。并不是数据竞争(这是一个未定义行为),在 Rust 中不使用 unsafe 的情况下是不可能发生的,但这仍然有一个不可预测的赢者的竞争。

因为我们期待 x 是常量,那么谁赢得比赛并不重要,因为无论如何结果都是一样。依赖于 calculate_x() 会花费多少时间,这可能非常好或者很糟糕。

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

获取并修改操作

英文版本

注意,我们已经看见基础 load 和 store 操作的一些用例,让我们继续更有趣的操作:获取并修改(fetch-and-modify)操作。这些操作修改原子变量,但也加载(获取)原始值,作为一个单原子操作。

最常用的是 fetch_addfetch_sub,它们分别执行加和减运算。一些其他可获得的操作是位操作 fetch_orfetch_and,以及用于比较最大值和最小值的 fetch_maxfetch_min

它们的函数签名如下,使用 AtomicI32 作为示例:

#![allow(unused)]
fn main() {
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 如何在操作之前返回值:

#![allow(unused)]
fn main() {
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 操作从 100 递增到 123,但是返回给我们还是旧值 100。任意下一个操作将看见 123。

来自这些操作的返回值并不总是相关的。如果你仅需要将操作用于原子值,但是值本身并没有用,那么你可以忽略该返回值。

需要记住的一个重要的事情是,fetch_add 和 fetch_sub 为溢出实现了环绕(wrapping)行为。将值递增超过最大可表示值将导致环绕到最小可表示值。这与常规整数上的递增和递减行为是不同的,后者在调试模式下的溢出将 panic。

“「比较并交换」操作”中,我们将看见如何使用溢出检查进行原子加运算。

但首先,让我们看看这些方法的现实使用示例。

示例:来自多线程的进度报道

英文版本

“示例:进度报道”中,我们使用一个 AtomicUsize 去报道后台线程的进度。如果我们把工作分开,例如,四个线程,每个处理 25 个元素,我们将需要知道所有 4 个线程的进度。

我们可以为每个线程使用单独的 AtomicUsize 并且在主线程加载它们并进行汇总,但是更简单的解决方案是,使用单个 AtomicUsize 去跟踪所有线程处理元素的总数。

为了使其工作,我们不再使用 store 方法,因为这会覆盖其他线程的进度。相反,我们可以使用原子自增操作在每个处理元素之后递增计数器。

fn main() {
    let num_done = &AtomicUsize::new(0);

    thread::scope(|s| {
        // 4 个后台线程,去处理所有 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!");
}

一些东西已经改变。更重要地是,我们现在产生了 4 个后台线程而不是 1 个,并且使用 fetch_add 而不是 store 去修改 num_done 原子变量。

更巧妙的是,我们现在对后台线程使用一个 move 闭包,并且 num_done 现在是一个引用。这与我们使用 fetch_add 无关,而是我们如何在循环中产生 4 个线程有关。此闭包捕获 t,以了解它是 4 个线程中的哪一个,从而确定是从元素 0、25、50 还是 75 开始。没有 move 关键字,闭包将尝试通过引用捕获 t。这是不允许的,因为它仅在循环期间短暂地存在。

由于 move 闭包,它移动(或复制)它的捕获而不是借用它们,这使它得到 t 的复制。因为它也捕获 num_done,我们已经改变该变量为一个引用,因为我们仍然想要借用相同的 AtomicUsize。注意,原子类型并没有实现 Copy trait,所以如果我们尝试移动一个原子类型的变量到多个线程,我们将得到错误。

撇开闭包的微妙不谈,在这里使用 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| {
        /// 4 个后台线程,去处理所有 100 个元素,每个 25 次。
        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!("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 操作用于跟踪 max_time 中的最高测量值。

主线程将总时间除以处理器元素的数量以获取平均处理时间,然后将它与 max_time 的峰值时间一起报道。

由于三个原子变量是单独更新的,因此主线程可能在线程递增 num_done 后而在更新 total_time 之前加载值,导致低估了平均值。更微妙的是,因为 Relaxed 内存排序不能保证从另一个线程中看到操作的相对顺序,它甚至可能看到 total_time 新更新的值,同时仍然看到 num_done 的旧值,导致平均值的高估。

在我们的示例中,这两个都不是大问题。最糟糕的情况是向用户提交了不准确的平均值。

如果我们想要避免这个,我们可以把这三个统计数据放到一个 Mutex 中。然后,在更新三个数字时,我们短暂地锁定 mutex,这三个数字本身就不再是原子的。这有效地转变三次更新为单个原子操作,代价是锁定和解锁 mutex 的开销,并且可能临时地阻塞线程。

示例:ID 分配

英文版本

让我们转到一个用例,我们实际上需要 fetch_add 的返回值。

假设我们需要一些函数,allocate_new_id(),每次调用它时,都会给出新的唯一的数字。我们可能使用这些数字标识程序中的任务或其它事情;需要一个小而易于存储和在线程之间传递的东西来唯一标识事物,例如整数。

使用 fetch_add 实现此函数是轻松的:

#![allow(unused)]
fn main() {
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,例如这样:

#![allow(unused)]
fn main() {
// 这个版本是有问题的。
fn allocate_new_id() -> u32 {
    static NEXT_ID: AtomicU32 = AtomicU32::new(0);
    let id = NEXT_ID.fetch_add(1, Relaxed);
    assert!(id 尽管终止进程可能需要短暂的时间,但是函数仍然可能通过其它线程调用,但在程序真正的终止之前,发生数十亿次调用的可能性几乎可以忽略不计。

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

处理溢出的第二种方法是使用 fetch_sub 在 panic 之前再次递减计数器,就像这样:

```rust
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
}
}

当多个线程在相同时间执行这个函数,计数器仍然有可能在非常短暂的时间超过 100,但这受到活动线程数量的限制。合理地假设是永远不会有数十亿个激活的线程,并非所有线程都在 fetch_add 和 fetch_sub 之间的短暂时间内同时执行相同的函数。

这就是标准库 thread::scope 实现中处理运行线程数量溢出的方式。

第三种处理溢出的方式可以说是唯一正确的方式,因为如果它溢出,它完全可以阻止加运算发生。然而,我们不能使用迄今为止看到的原子操作实现这一点。为此,我们需要「比较并交换」操作,接下来我们将探索。

比较并交换操作

英文版本

更加高级和灵活的原子操作是「比较并交换」操作。这个操作检查是否原子值等同于给定的值,只有在这种情况下,它才以原子地方式使用新值替换它,作为单个操作完成。它会返回先前的值,并告诉我们是否进行了替换。

它的签名比我们到目前为止看到的要复杂一些。以 AtomicI32 为例,它看起来像这样:

#![allow(unused)]
fn main() {
impl AtomicI32 {
    pub fn compare_exchange(
        &self,
        expected: i32,
        new: i32,
        success_order: Ordering,
        failure_order: Ordering
    ) -> Result;
}
}

暂时忽略内存排序,它基本上与以下实现相同,只是这一切都发生在单个不可分割原子操作上:

#![allow(unused)]
fn main() {
impl AtomicI32 {
    pub fn compare_exchange(&self, expected: i32, new: i32) -> Result {
        // 在实际中,加载、比较以及存储操作,
        // 这些所有都是以单个原子操作发生的。
        let v = self.load();
        if v == expected {
            // 值符合预期
            // 替换它并报道成功。
            self.store(new);
            Ok(v)
        } else {
            // 值不符合预期。
            // 保持不变并报道失败。
            Err(v)
        }
    }
}
}

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

为了演示,让我们在不使用 fetch_add 的情况下将 AtomicU32 递增 1,仅是为了看看 compare_exchange 是如何使用的:

#![allow(unused)]
fn main() {
fn increment(a: &AtomicU32) {
    let mut current = a.load(Relaxed); // 1
    loop {
        let new = current + 1; // 2
        match a.compare_exchange(current, new, Relaxed, Relaxed) { // 3
            Ok(_) => return, // 4
            Err(v) => current = v, // 5
        }
    }
}
}
  1. 首先,我们加载 a 的当前值。
  2. 我们计算我们想要存储在 a 的新值,而不考虑其他线程的并发修改。
  3. 我们使用 compare_exchange 去更新 a 的值,但当它的值仍然与我们之前加载的值相同时。
  4. 如果 a 确实和之前一样,它现在被我们的新值所取代,我们就完成了。
  5. 如果 a 并不和之前相同,那么自从我们加载它以来,它一定短时间被另一个线程改变了。compare_exchange 操作给我们提供了 a 的改变值,并且我们将再次尝试使用该值。加载和更新之间的时间非常短暂,它不可能循环超过几次迭代。

如果原子变量从某个值 A 更改到 B,但在 load 操作之后和 compare_exchange 操作之前又变回 A,即使原子变量在此期间发生了变化(并且回变),compare_exchange 操作也会成功。在很多示例中,就像在我们的递增示例中一样,这并不是问题。然而,有几种算法,通常涉及原子指针,这样的情况就会产生问题。这就是所谓的 ABA 问题。

compare_exchange 旁边,有一个名为 compare_exchange_weak 的类似方法。区别是 weak 版本有时可能仍然保留不改变值并且返回 Err,即使原子值匹配期待值。在某些平台,这个方法可以更有效地实现,并且对于虚假的「比较并交换」失败的后果不重要的情况下,比如上面的递增函数,应该优先使用它。在第七章节,我们将深入研究底层细节,以找出为什么 weak 版本会更有效。

示例:没有溢出的 ID 分配

英文版本

现在,从“示例:ID 分配”中回到 allocate_new_id() 的溢出问题。

为了停止递增 NEXT_ID 超过某个限制以阻止溢出,我们可以使用 compare_exchange 去实现具有上限的原子操作加。使用这个想法,让我们制作一个始终正确处理溢出 allocate_new_id 的版本,即使在几乎不可能的情况下也是如此:

#![allow(unused)]
fn main() {
fn allocate_new_id() -> u32 {
    static NEXT_ID: AtomicU32 = AtomicU32::new(0);
    let mut id = NEXT_ID.load(Relaxed);
    loop {
        assert!(id  return id,
            Err(v) => id = v,
        }
    }
}
}

现在,我们在修改 NEXT_ID 之前,检查并 panic,保证它将从不递增超出 1000,使溢出变得不可能。如果我们愿意,我们现在可以将上限从 1000 提高到 u32::MAX,而不必担心它可能会超过极限的边缘情况。

Fetch-Update

原子类型有一个名为 fetch_update 的简写方法,用于「比较并交换」循环模式。它相当于 load 操作,然后就是重复计算和 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(),它返回一个随机生成的密钥,该密钥仅在程序每次运行时生成。它可能是用于与程序通信的加密密钥,该密钥每次运行程序时都需要是唯一的,但在进程中保持不变。

这意味着我们不能在生成密钥之后,简单地使用一个 store 操作,因为这可能仅在片刻之后由其他线程复写这个生成的密钥,导致两个线程使用不同的密钥。相反,我们可以使用 compare_exchange 去确保我们仅当没有其他线程完成该操作,去存储这个密钥,否则,扔掉我们的密钥,改用存储的密钥。

#![allow(unused)]
fn main() {
fn get_key() -> u64 {
    static KEY: AtomicU64 = AtomicU64::new(0);
    let key = KEY.load(Relaxed);
    if key == 0 {
        let new_key = generate_random_key(); // 1
        match KEY.compare_exchange(0, new_key, Relaxed, Relaxed) { // 2
            Ok(_) => new_key, // 3
            Err(k) => k, // 4
        }
    } else {
        key
    }
}
}
  1. 我们仅在 KEY 仍然没有初始化时,生成一个新的密钥。
  2. 我们用新生成的密钥替换 KEY,但前提是它仍然是 0。
  3. 如果我们将 0 换成新密钥,我们将返回新生成的密钥。get_key() 的新调用将返回现在存储在 KEY 中的相同新密钥。
  4. 如果我们输给了另一个初始化 KEY 的线程,我们忘记我们的新密钥,而是使用来自 KEY 的密钥。

这是一个很好的例子,在这里 compare_exchangeweak 变体更合适。我们不会在循环中运行「比较并交换」操作,如果操作虚假失败,我们不想返回 0。

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

总结

英文版本

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

  • 在 Rust 中的原子操作是通过 std::sync::atomic 原子类型完成的,例如 AtomicI32

  • 不是所有原子类型在所有平台都是可获得的。

  • 当涉及多个变量时,原子操作的相对顺序是棘手的。更多细节,请看第三章

  • 简单的 load 和 store 操作非常适合非常简单的基本线程间通信,例如停止标识和状态报道。

  • 我们可以使用竞争条件3来惰性始化,而不会引发数据竞争4

  • 「获取并修改」操作允许进行一小组基本的原子修改操作,当多个线程同时修改同一个原子变量时,非常有用。

  • 原子加和减运算在溢出时会默默地进行环绕(wrap around)操作。

  • 「比较并交换」操作是最灵活和通用的,并且是任意其它原子操作的基石。

  • weak 版本「比较并交换」稍微更有效。

    下一篇,第三章:内存排序

2
3

竞争条件是指多个线程并发访问和修改共享数据时,其最终结果依赖于线程执行的具体顺序。在某些情况下,我们可以利用竞争条件来实现延迟初始化。也就是说,多个线程可以同时尝试对共享资源进行初始化,但只有第一个成功的线程会完成初始化,而其他线程会放弃初始化操作。

4

数据竞争是指多个线程同时访问共享数据,并且至少有一个线程进行写操作,而没有适当的同步机制来保证正确的访问顺序。

1

第三章:内存排序1

英文版本

第二章,我们简要地谈到了内存排序的概念。在该章节,我们将研究这个主题,并探索所有可用的内存排序选项,并且,更重要地是,我们将学习如何使用它们。

重排和优化

英文版本

处理器和编译器执行各种优化,以便使你的程序运行地尽可能地快。例如,处理器可能会确定你程序中的两个连续指令不会相互影响,如果顺序执行更快就这样执行,否则按不正常顺序执行。当一个指令在从主存中获取一些数据被短暂地阻塞了,只要这不会更改你程序的行为,几个后续地指令可能在第一个指令结束之前被执行和完成。类似地,编译器可能会决定重排或者重写你程序的部分代码,如果它有理由相信这可能会导致更快地执行。但是,同样,仅有在不更改你程序行为的情况下。

让我们来看看以下这个例子:

#![allow(unused)]
fn main() {
fn f(a: &mut i32, b: &mut i32) {
    *a += 1;
    *b += 1;
    *a += 1;
}
}

这里,编译器肯定会明白,操作的顺序并不重要,因为在这三个加运算之间没有发生任何依赖于 *a*b 的操作(假设溢出检查被禁用)。因此,编译器可能会重新排序第二个和第三个操作,然后将前两个操作合并为单个加运算:

#![allow(unused)]
fn main() {
fn f(a: &mut i32, b: &mut i32) {
    *a += 2;
    *b += 1;
}
}

稍后,在执行优化编译程序的函数时,由于各种原因,处理器可能最终在执行第一次加运算之前,执行第二次加运算,可能是 *b 在缓存中可用,而 *a 在主内存可获取。

无论这些优化如何,结果都是相同的:*a 加上 2,*b 加上 1。它们执行加运算的顺序对于你程序的其余部分完全不可见。

验证特定的重新排序或者其他优化并不影响程序的行为的逻辑并不需要考虑其他线程。在我们上面的示例中,这是极好的,因为独占引用(&mut i32)保证没有其他线程可以访问这个值。出现问题的唯一情况是,当共享的数据在线程之间发生改变。或者,换句话说,当使用原子操作时。这就是为什么,我们必须明确地告诉编译器和处理器,它们可以和不能使用我们的原子操作做什么,因为它们通常的逻辑忽略了线程之间的交互,并且可能允许的优化,会导致我们程序的结果改变。

有趣的问题是我们如何告诉它们。如果我们想要准确地阐明什么是可以接受的,什么是不可以接受的,并发程序将变得非常冗长并很容易出错,并且可能特定于架构:

#![allow(unused)]
fn main() {
let x = a.fetch_add(1,
    Dear compiler and processor,
    Feel free to reorder this with operations on b,
    but if there's another thread concurrently executing f,
    please don't reorder this with operations on c!
    Also, processor, don't forget to flush your store buffer!
    If b is zero, though, it doesn't matter.
    In that case, feel free to do whatever is fastest.
    Thanks~  **凭空出现的值**
>
  在使用 Relaxed 内存排序时,由于缺乏顺序保证,当操作在循环方式下相互依赖时,可能会导致理论上的复杂情况。

  为了演示,这里有一个人为的例子,两个线程从一个原子加载一个值,并将其存储在另一个原子中:

}

static X: AtomicI32 = AtomicI32::new(0); static Y: AtomicI32 = AtomicI32::new(0);

fn main() { let a = thread::spawn(|| { let x = X.load(Relaxed); Y.store(x, Relaxed); }); let b = thread::spawn(|| { let y = Y.load(Relaxed); X.store(y, Relaxed); }); a.join().unwrap(); b.join().unwrap(); assert_eq!(X.load(Relaxed), 0); // 可能失败? assert_eq!(Y.load(Relaxed), 0); // 可能失败? }


  似乎很容易得出 X 和 Y 的值不会是除 0 以外的任何东西的结论,因为 store 操作仅从这项相同的原子中加载值,而这些原子仅是 0。

  然而,如果我们严格遵循理论内存模型,我们必须面对循环推理,并得出可怕的结论,我们可能错了。事实上,内存模型在技术上允许出现这样的结果,即最终 X 和 Y 都是 37,或者任意其它的值,导致断言失败。

  由于缺乏顺序保证,这两个线程的 load 操作可能都看到另一个线程 store 操作的结果,允许按操作顺序循环:我们在 Y 中存储 37,因为我们从 X 加载了 37,X 存储到 X,因为我们从 Y 加载了 37,这是我们在 Y 中存储的值。

  幸运的是,这种*凭空捏造*值的可能性在理论模型中被普遍认为是一个 bug,而不需要你在实践中考虑。如何在不允许这种异常情况的情况下形式化 relaxed 内存排序还是一个未解决的问题。尽管这对于形式化验证来说可能是一个问题,让许多理论家夜不能寐,但是我们其他人可以放心地使用 relaxed,因为在实践中不会发生这种情况。


## Release 和 Acquire 排序

([英文版本](https://marabos.nl/atomics/memory-ordering.html#release-and-acquire-ordering))

*Release* 和 *Acquire* 内存排序通常成对使用,它们用于形成线程之间的 happens-before 关系。`Release` 内存排序适用于 store 操作,而 `Acquire` 内存排序适用于 load 操作。

当 acquire-load 操作观察 release-store 操作的结果时,就会形成 happens-before 关系。在这种情况下,store 操作及其之前的所有操作在时间上先于 load 操作和之后的所有操作。

当使用 Acquire 进行「获取并修改」或者「比较并交换」操作时,它仅适用于操作的 load 部分。类似地,Release 仅适用于操作的 store 部分。`AcqRel` 用于表示 Acquire 和 Release 的组合,这既能使 load 使用 Acquire,也能使 store 使用 Release。

让我们回顾一个示例,看看我们在实践中如何使用它们。在以下示例中,我们将一个 64 位整数从产生的线程发送到主线程。我们使用一个额外的原子布尔类型以指示主线程,整数已经被存储并且已经可以读取:

```rust
use std::sync::atomic::Ordering::{Acquire, Release};

static DATA: AtomicU64 = AtomicU64::new(0);
static READY: AtomicBool = AtomicBool::new(false);

fn main() {
    thread::spawn(|| {
        DATA.store(123, Relaxed);
        READY.store(true, Release); // 在这个 store 之前的所有操作都会在这个 store 操作之后可见
    });
    while !READY.load(Acquire) { // READY 的值变为 true,表示前面的 load 操作对于其他线程可见
        thread::sleep(Duration::from_millis(100));
        println!("waiting...");
    }
    println!("{}", DATA.load(Relaxed));
}

当产生的线程完成数据存储时,它使用 release-store 去设置 READY 标识为真。当主线程通过它的 acquire-load 操作观察到时,会在这两个线程之间建立一个 happens-before 关系,正如图 3-3 所示。此时,我们肯定知道在 release-store 到 READY 之前的所有操作对 acquire-load 之后的所有操作都可见。具体而言,当主线程从 DATA 加载时,我们可以肯定它将加载由后台线程存储的值。该程序在最后一行只有一种输出结果:123。

图 3-3。示例代码中原子操作之间的 happens-before 关系,展示了通过 acquire 和 release 操作形成的跨线程关系。

如果我们在这个示例为所有操作使用 relaxed 内存排序,主线程可能会看到 READY 翻转为 true,而之后仍然从 DATA 中加载 0。

“Release”和“Acquire”的名称基于它们最基本用例:一个线程通过原子地存储一些值到原子变量来发布数据,而另一个线程通过原子地加载这个值来获取数据。这正是当我们解锁(释放)互斥体并随后在另一个线程上锁定(获取)它时发生的情况。

在我们的示例中,来自 READY 标识的 happens-before 关系保证了 DATA 的 store 和 load 操作不能并发地发生。这意味着我们实际上不需要这些操作是原子的。

然而,如果我们仅是为我们的数据变量尝试去使用常规的非原子类型,编译器将拒绝我们的程序,因为当另一个线程也在借用它们,Rust 的类型系统不允许我们修改它们。类型系统不会理解我们在这里创建的 happens-before 关系。一些不安全的代码是必要的,以向编译器承诺我们已经仔细考虑过这个问题,我们确信我们没有违反任何规则,如下所示:

static mut DATA: u64 = 0;
static READY: AtomicBool = AtomicBool::new(false);

fn main() {
    thread::spawn(|| {
        // 安全性:没有其他东西正在访问 DATA,
        // 因为我们还没有设置 READY 标识。
        unsafe { DATA = 123 };
        READY.store(true, Release); // 在这个 store 之前的所有操作都会在这个 store 操作之后可见
    });
    while !READY.load(Acquire) { // READY 的值变为 true,表示前面的 store 操作对于其他线程可见
        thread::sleep(Duration::from_millis(100));
        println!("waiting...");
    }
    // 安全地:没有其他东西修改 DATA,因为 READY 已设置。
    println!("{}", unsafe { DATA });
}

更正式地

当 acquire-load 操作观察 release-store 操作的结果时,就会形成 happens-before 关系。但那是什么意思?

想象一下,两个线程都将一个 7 release-store 到相同的原子变量中,第三个线程从该变量中加载 7。第三个线程和第一个或者第二个线程有一个 happens-before 关系吗?这取决于它加载“哪个 7”:线程一还是线程二的。(或许一个不相关的 7)。这使我们得出的结论是,尽管 7 等于 7,但两个 7 与两个线程有一些不同。

思考这个问题的方式是我们在“Relaxed 排序”中讨论的总修改顺序:发生在原子变量上的所有修改的有序列表。即使将相同的值多次写入相同的变量,这些操作中的每一个都以该变量的总修改顺序代表一个单独的事件。当我们加载一个值,加载的值与每个变量“时间线”上的特定点相匹配,这告诉我们我们可能会同步哪个操作。

例如,如果原子总修改顺序是

  1. 初始化为 0

  2. Release-store 7(来自线程二)

  3. Release-store 6

  4. Release-store 7(来自线程一)

然后,acquire-load 7 将与第二个线程的 release-store 或者最后一个事件的 release-store 同步。然而,如果我们之前(就 happens-before 关系而言)见过 6,我们知道我们看到的是最后一个 7,而不是第一个 7,这意味着我们现在与线程一有 happens-before 的关系,而不是线程二。

还有一个额外的细节,即 release-stored 的值可以经过被任意数量的「获取并修改」和「比较并交换」操作修改,但仍会导致与读取最终结果的 acquire-load 操作建立 happens-before 关系。

例如,想象一个具有以下总修改顺序的原子变量:

  1. 初始化为 0

  2. Release-store 7

  3. Relaxed-fetch-and-add 1,改变 7 到 8

  4. Relaxed-fetch-and-add 1,改变 8 到 9

  5. Release-store 7

  6. Relaxed-swap 10,改变 7 到 10

现在,如果我们在这个变量上执行 acquire-load 到 9,我们不仅与第四个操作(存储此值)建立了一个 happens-before 关系,同时也与第二个操作(存储 7)建立了该关系,即使第三个操作使用了 Relaxed 内存排序。

相似地,如果我们在这个变量上执行 acquire-load 到 10,而该值是由一个 relaxed 操作写入的,我们仍然建立了与第五个操作(存储 7)的 happens-before 关系。因为它只是一个普通的 store 操作(不是「获取并修改」或「比较并交换」操作),它打破了规则链:我们没有与其他操作建立 happens-before 关系。

示例:锁定

英文版本

互斥锁是 release 和 acquire 排序的最常见用例(参见第一章的“锁:互斥锁和读写锁”)。当锁定时,它们使用 acquire 排序的原子操作来检查是否它已解锁,同时也(原子地)改变状态到“锁定”。当解锁时,它们使用 release 排序设置状态到“解锁”。这意味着,在解锁 mutex 和随后锁定它有一个 happens-before 关系。

以下是这种模式的演示:

static mut DATA: String = String::new();
static LOCKED: AtomicBool = AtomicBool::new(false);

fn f() {
    if LOCKED.compare_exchange(false, true, Acquire, Relaxed).is_ok() {
        // 安全地:我们持有独占的锁,所以没有其他东西访问 DATA。
        unsafe { DATA.push('!') };
        LOCKED.store(false, Release);
    }
}

fn main() {
    thread::scope(|s| {
        for _ in 0..100 {
            s.spawn(f);
        }
    });
}

正如我们在第二章“「比较并交换」操作”简要地所见,「比较并交换」接收两个内存排序参数:一个用于比较成功且 store 发生的情况,一个用于比较失败且 store 没有发生的情况。在 f 中,我们试图去改变 LOCKED 的值从 false 到 true,并且只有在成功的情况下才能访问 DATA。所以,我们仅关心成功的内存排序。如果 compare_exchange 操作失败,那一定是因为 LOCKED 已经设置为 true,在这种情况下 f 不会做任何事情。这与常规 mutex 上的 try_lock 操作相匹配。

观察力强的读者可能已经注意到,「比较并交换」操作也可能是 swap 操作,因为在已锁定时将 true 替换为 true 不会改变代码的正确性:

#![allow(unused)]
fn main() {
// 这也有效。
if LOCKED.swap(true, Acquire) == false {
   // …
}
}

归功于 acquire 和 release 内存排序,我们肯定没有两个线程能并发地访问数据。正如在图 3-4 展示的,对 DATA 的任何先前访问都在随后使用 release-store 操作将 false 存储到 LOCKED 之前发生,然后在下一个 acquire-compare-exchange(或 acquire-swap)操作中将 false 更改为 true,然后在下一次访问 DATA 之前发生。

 图 3-4。锁定示例中原子操作之间的 happens-before 关系,显示了两个线程按顺序锁定和解锁。

第四章,我们将把这个概念变成一个可重复使用的类型:自旋锁。

示例:使用间接的方式惰性初始化

英文版本

第二章的“示例:惰性一次性初始化”中,我们实现一个全局变量的惰性初始化,使用「比较并交换」操作去处理多个线程竞争同时初始化值的情况。由于该值是非零的 64 位整数,我们能够使用 AtomicU64 来存储它,在初始化之前使用零作为占位符。

要对不适合单个原子变量的更大的数据类型做同样的事情,我们需要寻找替代方案。

在这个例子中,假设我们想保持非阻塞行为,这样线程就不会等待另一个线程,而是从第一个线程中竞争并获取值来完成初始化。这意味着我们仍然需要能够在单个原子操作中从“未初始化”到“完全初始化”。

正如软件工程的基本定理告诉我们的那样,计算机科学中的每个问题都可以通过添加另一层间接来解决,这个问题也不例外。由于我们无法将数据放入单个原子变量中,因此我们可以使用原子变量来存储指向数据的指针

AtomicPtr*mut T 的原子版本:指向 T 的指针。我们可以使用空指针作为初始状态的占位符,并使用「比较并交换」操作将其原子地替换为指向新分配的、完全初始化的 T 的指针,然后可以由其他线程读取。

由于我们不仅共享包含指针的原子变量,还共享它所指向的数据,因此我们不能再像第 2 章那样使用 Relaxed 的内存排序。我们需要确保数据的分配和初始化不会与读取数据竞争。换句话说,我们需要在 store 和 load 操作上使用 release 和 acquire 排序,以确保编译器和处理器不会通过(例如)重新排序指针的存储和数据本身的初始化来破坏我们的代码。

对于一些名为 Data 的任意数据类型,这引出了以下实现:

#![allow(unused)]
fn main() {
use std::sync::atomic::AtomicPtr;

fn get_data() -> &'static Data {
    static PTR: AtomicPtr = AtomicPtr::new(std::ptr::null_mut());

    let mut p = PTR.load(Acquire);

    if p.is_null() {
        p = Box::into_raw(Box::new(generate_data()));
        if let Err(e) = PTR.compare_exchange(
            std::ptr::null_mut(), p, Release, Acquire
        ) {
            // 安全性:p 来自上方的 Box::into_raw,
            // 并且不能与其他线程共享
            drop(unsafe { Box::from_raw(p) });
            p = e;
        }
    }

    // 安全性:p 不是 null,并且指向一个正确初始化的值。
    unsafe { &*p }
}
}

如果我们以 acquire-load 操作从 PTR 得到的指针是非空的,我们假设它指向已初始化的数据,并构建对该数据的引用。

然而,如果它仍然为空,我们会生成新数据,并使用 Box::new 将其存储在新的内存分配中。然后,我们使用 Box::into_raw 将此 Box 转换为原始指针,因此我们可以尝试使用「比较并交换」操作将其存储到 PTR 中。如果另一个线程赢得初始化竞争,compare_exchange 将失败,因为 PTR 不再是空的。如果发生这种情况,我们将原始指针转回 Box,使用 drop 来释放内存分配,避免内存泄漏,并继续使用另一个线程存储在 PTR 中的指针。

在最后的不安全块中,关于安全性的注释表明我们的假设是指它指向的数据已经被初始化。注意,这包括对事情发生顺序的假设。为了确保我们的假设成立,我们使用 release 和 acquire 内存排序来确保初始化数据实际上在创建对它引用之前已经发生。

我们在两个地方加载一个可能的非空(即初始化)指针:通过 load 操作和当 compare_exchange 失败时的该操作。因此,如上所述,我们需要在 load 内存排序和 compare_exchange 失败内存排序上都使用 Acquire,以便能够与存储指针的操作进行同步。当 compare_exchange 操作成功时,会发生 store 操作,因此我们必须使用 Release 作为其成功的内存排序。

图 3-5 显示了三个线程调用 get_data() 的情况的操作和发生前关系的可视化。在这种情况下,线程 A 和 B 都观察到一个空指针并都试图去初始化原子指针。线程 A 赢得竞争,导致线程 B 的 compare_exchange 调用失败。线程 C 在通过线程 A 初始化之后观察原子指针。最终结果是,所有三个线程最终都使用由线程 A 分配的 box。

 图3-5。调用 get_data() 的三个线程之间的操作和发生前关系。

Consume 排序

英文版本

让我们仔细看看上一个示例中的内存排序。如果我们把严格的模型放在一边,从更实际的方面来思考它,我们可以说 release 排序阻止了数据的初始化与共享指针的 store 操作重新排序。这一点非常重要,因为否则其它线程可能会在数据完全初始化之前就能看到它。

类似地,我们可以说 acquire 排序为防止重新排序,使得数据在加载指针之前被访问。然而,人们可能合理地质疑,在实践中是否有意义。在数据的地址被加载之前,如何访问数据?我们可能会得出结论:比 acquire 排序更弱的内存排序可能就足够了。我们的结论是正确的:这种较弱的内存排序被称为 consume 排序。

consume 排序是 acquire 排序的一个轻量级、更高效的变体,其同步效果仅限于那些依赖于被加载值的操作。

这意味着如果你用 consume-load 从一个原子变量中加载一个通过 release 存储的值 x,那么基本上,这个 store 操作发生在依赖 x 的表达式(如 *xarray[x]table.lookup(x + 1))的求值之前,但不一定发生在不相关的操作(如读取另一个与 x 无关的变量)之前。

现在有好消息和坏消息。

好消息是,在所有现代处理器架构上,consume 排序是通过与 relaxed 排序完全相同的指令实现的。换句话说,consume 排序可以是“免费的”,而 acquire 内存排序在某些平台可能不是这样。

坏消息是,没有编译器真正实现 consume 排序。

事实证明,这种“依赖性”评估的概念不仅难以定义,而且在转换和优化程序时保持这些依赖性也很难。例如,编译器能够优化 x + 2 - x 为 2,有效地消除了对 x 的依赖。对于更复杂的表达式,如 array[x],如果编译器能够对 x 或数组元素的可能值进行逻辑推断,那么可能会出现更微妙的变化。当考虑控制流,如 if 语句或函数调用时,问题将变得更加复杂。

因此,为了安全起见,编译器将 consume 排序升级到 acquire 排序。C++20 标准甚至明确地不鼓励使用 consume 排序,并指出,除了 acquire 排序之外,其他实现被证明是不可行的。

将来可能找到一个 consume 排序的有效定义和实现。然而,直到这一天到来之前,Rust 都不会暴露 Ordering::Consume

顺序一致性排序

英文版本

最强的内存排序是顺序一致性排序:Ordering::SeqCst。它包含了 acquire 排序(对于 load 操作)以及 release 排序(对于 store 操作)的所有保证,并且保证了操作的全局一致排序。

这意味着在程序中使用 SeqCst 排序的每个操作是所有线程都同意的单独全序2(single total order,译注:可以理解为在该顺序关系中,每个操作都与其它操作有单独的顺序关系)的一部分。该全序3(total order,译注:该原子变量的顺序关系)与每个单独变量的总修改顺序(total modification order)一致。

由于它严格强于 acquire 和 release 内存排序,因此顺序一致性 load 或者 store 操作可以取代一对 release-acquire 中的 acquire-load 或 release-store 操作,形成 happens-before 关系。换句话说,acquire-load 不仅可以与 release-store 形成 happens-before 关系,同时也可以和顺序一致的 store 形成 happens-before 关系,同样 release-store 也是如此。

仅有当 happens-before 关系的双方都使用 SeqCst 时,才能保证与 SeqCst 操作的单独全序一致。

虽然这似乎是最容易推理的内存排序,但 SeqCst 排序在实践中几乎从来都没有必要。在几乎所有情况下,通常 acquire 和 release 排序就足够了。

以下是一个依赖于顺序一致的有序操作的示例:

use std::sync::atomic::Ordering::SeqCst;

static A: AtomicBool = AtomicBool::new(false);
static B: AtomicBool = AtomicBool::new(false);

static mut S: String = String::new();

fn main() {
    let a = thread::spawn(|| {
        A.store(true, SeqCst);
        if !B.load(SeqCst) {
            unsafe { S.push('!') };
        }
    });

    let b = thread::spawn(|| {
        B.store(true, SeqCst);
        if !A.load(SeqCst) {
            unsafe { S.push('!') };
        }
    });

    a.join().unwrap();
    b.join().unwrap();
}

两个线程首先设置它们自己的原子布尔值到 true,以告知另一个线程它们正在获取 S,并且然后检查其它的原子变量布尔值,是否它们可以安全地获取 S,而不会导致数据竞争。

如果两个 store 操作都发生在任一 load 操作之前,则两个线程最终都无法访问 S。然而,两个线程都不可能访问 S 并导致未定义的行为,因为顺序一致的顺序保证了其中只有一个线程可以赢得竞争。在每个可能的单独全序中,第一个操作将是 store 操作,这阻止其他线程访问 S。

在实际情况中,几乎所有对 SeqCst 的使用都涉及一种类似的存储模式, store 操作在随后同一线程上的 load 操作之前必须成为全局可见的。对于这些情况,一个潜在的更有效的替代方案是将 relaxed 的操作与 SeqCst 屏障结合使用,我们接下来将探索。

屏障(Fence)4

英文版本

除了对原子变量的额外操作,我们还可以将内存排序应用于:原子屏障。

std::sync::atomic::fence 函数表示一个原子屏障,它可以是一个 Release 屏障、一个 Acquire 屏障,或者两者都是(AcqRel 或 SeqCst)。SeqCst 屏障还参与到顺序一致性的全序中。

原子屏障允许你从原子操作中分离内存排序。如果你想要应用内存排序到多个操作这可能是有用的,或者你想要有条件地应用内存排序。

本质上,release-store 可以拆分成 release 屏障,然后是(relaxed)store,并且 acquire-load 可以拆分成(relaxed)load,然后是 acquire 屏障:

release-acquire 关系的 store 操作,
```

a.store(1, Release);
    可以由 release 屏障和随后的 relaxed store 组成:
    ```

    fence(Release);
    a.store(1, Relaxed);
release-acquire 关系的 load 操作,
  ```

  a.load(Acquire);
    可以由 relaxed load 和随后的 acquire 屏障组成:
    ```

    a.load(Relaxed);
    fence(Acquire);

不过,使用单独的屏障可能会导致额外的处理器指令,这可能会略微降低效率。

更重要的是,与 release-store 或者 acquire-load 不同,屏障不会与任意单个原子变量捆绑。这意味着单个屏障可以立刻用于多个变量。

从形式上讲,如果 release 屏障在同一线程上的位置紧跟着任何一个原子操作,而该原子操作存储的值被我们要同步的 acquire 操作观察到,那么该 release 屏障可以代替 release 操作,并建立一个 happens-before 关系。同样地,如果一个 acquire 屏障在同一线程上的位置紧接着之前的任何一个原子操作,而该原子操作加载的值是由 release 操作存储的,那么该 acquire 屏障可以替代任何一个 acquire 操作。

综上所述,这意味着如果在 release 屏障之后的任何 store 操作被在 acquire 屏障之前的任何 load 操作所观察,那么在 release 屏障和 acquire 屏障之间将建立 happens-before 关系。

例如,假设我们有一个线程执行一个 release 屏障,然后对不同的变量执行三个原子 store 操作,另一个线程从这些相同的变量执行三个 load 操作,然后是一个 acquire 屏障,如下所示:

线程 1:
```

fence(Release);
A.store(1, Relaxed);
B.store(2, Relaxed);
C.store(3, Relaxed);
  
  
    线程 2:
    ```

    A.load(Relaxed);
    B.load(Relaxed);
    C.load(Relaxed);
    fence(Acquire);

在这种情况下,如果线程 2 上的任意 load 操作从线程 1 上的相应 store 操作加载值,那么线程 1 上的 release 屏障和线程 2 上的 acquire 屏障之间将建立 happens-before 关系。

屏障不必直接在原子操作之前或者之后。屏障和原子操作之间可以进行任何事,包括控制流。这可以用于使屏障具有条件性,类似于「比较并交换」操作具有成功和失败的排序。

例如,如果我们从使用 acquire 内存排序的原子变量中加载指针,我们可以使用屏障仅当指针不是空的时候应用 acquire 内存排序:

使用 acquire-load:
```

let p = PTR.load(Acquire); if p.is_null() { println!("no data"); } else { println!("data = {}", unsafe { *p }); }

  
  
    使用条件的 acquire 屏障:
    ```
let p = PTR.load(Relaxed);
if p.is_null() {
    println!("no data");
} else {
    fence(Acquire);
    println!("data = {}", unsafe {*p });
}

如果指针通常为空,这可能是有益的,在不需要时避免 acquire 内存排序。

让我们来看看一个更复杂的 release 和 acquire 屏障的用例:

use std::sync::atomic::fence;

static mut DATA: [u64; 10] = [0; 10];

const ATOMIC_FALSE: AtomicBool = AtomicBool::new(false);
static READY: [AtomicBool; 10] = [ATOMIC_FALSE; 10];

fn main() {
    for i in 0..10 {
        thread::spawn(move || {
            let data = some_calculation(i);
            unsafe { DATA[i] = data };
            READY[i].store(true, Release);
        });
    }
    thread::sleep(Duration::from_millis(500));
    let ready: [bool; 10] = std::array::from_fn(|i| READY[i].load(Relaxed));
    if ready.contains(&true) {
        fence(Acquire);
        for i in 0..10 {
            if ready[i] {
                println!("data{i} = {}", unsafe { DATA[i] });
            }
        }
    }
}

std::array::from_fn 是一种执行一定次数并将结果收集到数组中的简单方法。

在这个示例中,10 个线程做了一些计算,并存储它们的结果到一个(非原子)共享变量中。每个线程设置一个原子布尔值,以指示数据已经通过主线程准备好读取,使用一个普通的 release-store。主线程等待半秒,检查所有 10 个布尔值以查看哪些线程已完成,并打印任何准备好的结果。

主线程不使用 10 个 acquire-load 操作来读取布尔值,而是使用 relaxed 的操作和单个 acquire 屏障。它在读取数据之前执行屏障,但前提是有数据要读取。

虽然在这个特定的例子中,投入任何精力进行此类优化可能完全没有必要,但在构建高效的并发数据结构时,这种节省额外获取操作开销的模式可能很重要。

SeqCst 屏障既是 release 屏障也是 acquire 屏障(就像 AcqRel),同时也是顺序一致性操作中单独全序的一部分。然而,只有屏障是全序的一部分,但它之前或之后的原子操作不必是总顺序的一部分。这意味着,与 release 或 acquire 操作不同,顺序一致性的操作不能拆分为 relaxed 操作和内存屏障。

编译器屏障

除了常规的原子屏障,Rust 标准库还提供了编译器屏障std::sync::atomic::compiler_fence。它的签名与我们上面讨论的这些常规 fence() 不同,但它的效果仅限于编译器。与原子屏障不同,例如,它并不会阻止处理器重排指令。在绝大多数屏障的用例中,编译器屏障是不够的。

在实现 Unix 信号处理程序或嵌入式系统上的中断时,可能会出现的用例。这些机制可以突然中断一个线程,暂时在同一处理器内核上执行一个不相关的函数。由于它发生在同一处理器内核上,处理器可能影响内存排序的常规方式不适用。(更多细节请参考第七章)在这种情况下,编译器屏障可能阻隔,这样可以节省一条指令并且希望提高性能。

另一用例涉及进程级内存屏障。这种技术超出了 Rust 内存模型的范畴,并且仅在某些操作系统上受支持:在 Linux 上通过 membarrier 系统调用,在 Windows 上使用 FlushProcessWriterBuffers 函数。它有效地允许一个线程强制向所有并发运行的线程注入(顺序一致性)原子屏障。这使得我们可以使用轻量级的编译器屏障和重型的进程级屏障替换两个匹配的屏障。如果轻量级屏障一侧的代码执行效率更高,这可以提高整体性能。(请参阅 crates.io 上的 membarrier crate 文档,了解更多详细信息和在 Rust 中使用这种屏障的跨平台方法。)

编译器屏障也可以是一个有趣的工具,用于探索处理器对内存排序的影响。

第七章“一个实验”中,我们将故意使用编译器屏障替换常规屏障。这将让我们在使用错误的内存排序时体验到处理器的微妙但潜在的灾难性影响。

常见的误解

英文版本

围绕内存排序有很多误解。在我们结束本章之前,让我们回顾一下最常见的误解。

误区:我需要强大的内存排序,以确保更改“立即”可见。

一个常见的误解是,使用像 Relaxed 这样的弱内存排序意味着对原子变量的更改可能永远不会到达另一个线程,或者只有在显著延迟之后才会到达。“Relaxed”这个名字可能会让它听起来像什么都没发生,直到有什么东西迫使硬件被唤醒并执行本该执行的操作。

事实是,内存模型并没有说任何关于时机的事情。它仅定义了某些事情发生的顺序;而不是你要等待多久。假设一台计算机需要数年时间才能将数据从一个线程传输到另一个线程,这完全无法使用,但可以完美地满足内存模型。

在现实生活中,内存排序是关于重新排序指令等事情,这通常以纳秒规模发生。更强的内存排序不会使你的数据传输速度更快;它甚至可能会减慢你的程序速度。

误区:禁用优化意味着我不需要关心内存排序。

编译器和处理器在使事情按照我们预期的顺序发生方面起着作用。禁用编译器优化并不能禁用编译器中的每种可能的转换,也不能禁用处理器的功能,这些功能导致指令重新排序和类似的潜在问题行为。

误区:使用不重新排序指令的处理器意味着我不需要关心内存排序。

一些简单的处理器,比如小型微控制器中的处理器,只有一个核心,每次只能执行一条指令,并且按顺序执行。然而,虽然在这类设备上,出现错误的内存排序导致实际问题的可能性较低,但编译器仍然可能基于错误的内存排序做出无效的假设,导致代码出错。此外,还需要认识到,即使处理器不会乱序执行指令,它仍可能具有其他与内存排序相关的特性。

误区:Relaxed 的操作是免费的。

这是否成立取决于对“免费”一词的定义。的确,Relaxed 是最高效的内存排序,相比其他内存排序可以显著提升性能。事实上,对于所有现代平台,Relaxed load 和 store 操作编译成与非原子读写相同的处理器指令。

如果原子变量只在单个线程中使用,与非原子变量相比,速度上的差异很可能是因为编译器对非原子操作具有更大的自由度并更有效地进行优化。(编译器通常避免对原子变量进行大部分类型的优化。)

然而,从多个线程访问相同的内存通常比从单个线程访问要慢得多。当其他线程开始重复读取该变量时,持续写入原子变量的线程可能会遇到明显的减速,因为处理器核心和它们的缓存现在必须开始协作。

我们将在第 7 章中探讨这种效应。

误区:顺序一致的内存排序是一个很好的默认值,并且总是正确的。

抛开性能问题,顺序一致性内存排序通常被视为默认选择的理想内存排序类型,因为它具有强大的保证。确实,如果任何其他内存排序是正确的,那么 SeqCst 也是正确的。这可能让人觉得 SeqCst 总是正确的。然而,可能并发算法本身就是不正确的,不论使用哪种内存排序。

更重要的是,在阅读代码时,SeqCst 基本上告诉读者:“该操作依赖于程序中每个单独 SeqCst 操作的全序”,这是一个极其广泛的声明。如果可能的话,使用较弱的内存排序往往会使相同的代码更容易进行审查和验证。例如,Release 明确告诉读者:“这与同一变量的 acquire 操作相关”,在形成对代码的理解时涉及的考虑要少得多。

建议将 SeqCst 看作是一个警示标识。在实际代码中看到它通常意味着要么涉及到复杂的情况,要么简单地说是作者没有花时间分析其与内存排序相关的假设,这两种情况都需要额外的审查。

误区:顺序一致的内存排序可用于“release-store”或“acquire-load”。

虽然顺序一致性内存排序可以替代 Acquire 或 Release,但它并不能以某种方式创建 acquire-store 或 release-load。这些仍然是不存在的。Release 仅适用于 store 操作,而 acquire 仅适用于 load 操作。

例如,Release-store 与 SeqCst-store 不会形成任何 release-acquire 关系。如果你希望它们成为全局一致顺序的一部分,两个操作都必须使用 SeqCst。

总结

英文版本

  • 所有的原子操作可能没有全局一致的顺序,因为不同的线程视角可能会以不同的顺序发生。

  • 然而,每个单独的原子变量都有它自己的总修改顺序,不管内存排序如何,所有线程都会达成一致意见。

  • 操作顺序是通过 happens-before 关系来定义的。

  • 在单个线程中,每个操作之间都会有一个 happens-before 关系。

  • 创建一个线程的操作在顺序上发生在该线程的所有操作之前。

  • 线程做的任何事情都会在 join 这个线程之前发生。

  • 解锁 mutex 的操作在顺序上发生在再次锁定 mutex 的操作之前。

  • 从 release 存储中以 acquire 加载值建立了一个 happens-before 关系。该值可以通过任意数量的「获取并修改」以及「比较并交换」操作修改。

  • 如果存在 consume-load,这将是 acquire-load 的轻量级版本。

  • 顺序一致的排序导致全局一致的操作顺序,但几乎从来都没有必要,并且会使代码审查更加复杂。

  • 屏障允许你组合多个操作的内存排序或有条件地应用内存排序。

    下一篇,第四章:构建我们自己的自旋锁

1
4
2

摘自 CPP 原子变量的内存排序翻译,请参考参见

3

摘自 CPP 原子变量的内存排序翻译,请参考参见

参见:

第四章:构建我们自己的自旋锁

英文版本

对普通互斥锁(参见第一章中的“锁:互斥锁和读写锁”)进行加锁时,如果互斥锁已经被锁定,线程将被置于睡眠状态。这避免在等待锁被释放时浪费资源。如果一个锁只会被短暂地持有,并且锁定它的线程可以在不同的处理器核心并发地运行,那么线程最好反复尝试锁定它而不真正进入睡眠态。

自旋锁是能够做到这一点的 mutex。试图锁定一个已经锁定的 mutex 将导致忙碌循环或者自旋:一遍又一遍的尝试。直到它成功。这可能浪费处理器周期,但有时在锁定时可以使延迟更低。

在某些平台上,许多现实世界中的 mutex 实现,包括 std::sync::Mutex,在告诉操作系统将线程置于睡眠状态之前,短暂地表现得像一个自旋锁。这是为了将两者的优点结合起来,具体情况是否有益完全取决于特定的用例。

在该章节中,我们将建造我们自己的 SpinLock 类型,应用我们已经在第 2 章和第 3 章学习的,并且了解如何使用 Rust 的类型系统为我们的 SpinLock 用户提供安全且有用的接口。

一个最小实现

英文版本

让我们从头实现这样的自旋锁。

最小的版本非常简单,如下:

#![allow(unused)]
fn main() {
pub struct SpinLock {
    locked: AtomicBool,
}
}

我们需要的只是一个布尔值,指示自旋锁是否已锁定。我们使用一个原子布尔值,因为我们希望多个线程能够同时与它交互。

然后,我们只需要一个构造函数,以及锁定和解锁的方法:

#![allow(unused)]
fn main() {
impl SpinLock {
    pub const fn new() -> Self {
        Self { locked: AtomicBool::new(false) }
    }

    pub fn lock(&self) {
        while self.locked.swap(true, Acquire) {
            std::hint::spin_loop();
        }
    }

    pub fn unlock(&self) {
        self.locked.store(false, Release);
    }
}
}

locked 的布尔值是从 false 开始的,lock 方法会将其替换为 true,如果它已经是 true,那么它将继续尝试,并且 unlock 方法仅将它设回 false。

与其使用 swap 操作,我们也可以使用「比较并交换」操作去原子地检查布尔值是否是 false,如果是这种情况,将它设置为 true:

#![allow(unused)]
fn main() {
while self.locked.compare_exchange_weak(
           false, true, Acquire, Relaxed).is_err()
}

这可能有点冗长,但是根据你的思维,这可能会更容易理解,因为它更容易地表述了可能失败和可能成功的情况。然而,它也导致了稍微不同的指令,正如我们将在第七章所看到的那样。

在 while 循环中,我们使用一个自旋循环提示,它告诉处理器我们正在自旋等待某些变化。在大多数平台上,该自旋导致处理器核心采取优化行为以应对这种情况。例如,它可能暂时地降低速度或优先处理其它有用的任务。然而,与 thread::sleep 或者 thread::park 等阻塞操作不同,自旋循环提示并不会调用操作系统的调用,将你的线程置于睡眠状态以便执行其它线程。

总的来说,在自旋循环中包含这样的提示是一个好的主意。根据情况,在尝试再次访问原子变量之前,最好多次执行此提示。如果你关心最后几纳秒的性能并且想要找到最优的策略,你将不得不为你特定用例编写基准测试。不幸的是,正如我们将在第 7 章中看到的那样,此类基准测试的结果可能在很大程度上取决于硬件。

我们可以使用 acquire 和 release 内存排序去确保每个 unlock() 调用和随后的 lock() 调用都建立了一个 happens-before 关系。换句话说,为了确保锁定它后,我们可以安全地假设上次锁定期间的任何事情已经发生。这是 acquire 和 release 最经典的使用案列:获取和释放一个锁。

图 4-1 展示了使用 SpinLock 来保护对一些共享数据的访问情况,其中两个线程同时尝试获取锁。请注意,第一个线程上的解锁操作与第二个线程上的锁定操作形成 happens-before 关系,这确保了线程不能并发地访问数据。

 图 4-1。在使用 SpinLock 保护对某些共享数据访问的两个线程之间的 happens-before 关系。

一个不安全的自旋锁

英文版本

我们上面实现的 SpinLock 类型有一个完全安全地接口,它并不会引起任何未定义行为。然而,在大多数的使用案列中,它将被用于保护共享变量的可变性,这意味着用于将仍然使用一个不安全的、未检查的代码。

为了提供一个简单的接口,我们可以改变 lock 方法为直接提供受锁保护数据的独占的引用(&mut T),因为在大多数情况下,锁操作保证了可以安全地假设具有独占访问权限。

为了能够做到这一点,我们必须将类型更改为更加通用,而不是受保护的数据类型,并且添加一个字段持有数据。因为即使自旋锁是共享的,数据也是可变的(或者独占访问),我们需要去使用内部可变性(参见第 1 章中的“内部可变性”),为此我们将使用 UnsafeCell

#![allow(unused)]
fn main() {
use std::cell::UnsafeCell;

pub struct SpinLock {
    locked: AtomicBool,
    value: UnsafeCell,
}
}

作为一种预防措施,UnsafeCell 没有实现 Sync,这意味着我们的类型现在不再可以在线程之间共享,使其变得毫无用处。为了修复它,我们需要向编译器保证我们的类型实际上可以在线程之间共享是安全的。然而,因为锁可以用于在线程之间发送类型为 T 的值,我们将这个承诺限制为哪些类型可以在线程之间安全发送。因此,我们(不安全地)为所有实现 Send 的 T 实现 SpinLockSync,如下所示:

#![allow(unused)]
fn main() {
unsafe impl Sync for SpinLock where T: Send {}
}

注意,我们并不需要去要求 T 是 Sync,由于我们的 SpinLock 一次仅允许一个线程访问它保护的 T。只有当我们同时允许多个线程访问时,就像读写锁对 reader 所做的那样,我们(另外)才需要 T: Sync

下一步,现在我们的新函数需要接收一个 T 类型的值来初始化 UnsafeCell

#![allow(unused)]
fn main() {
impl SpinLock {
    pub const fn new(value: T) -> Self {
        Self {
            locked: AtomicBool::new(false),
            value: UnsafeCell::new(value),
        }
    }

    // …
}
}

然后我们进入有趣的部分:锁定和解锁。我们做这一切的原因,是为了能够从 lock() 中返回 &mut T,例如,这样用户在使用我们的锁来保护它们的数据时,并不要求写不安全、未检查的代码。这意味着,我们现在的 lock 实现必须使用一个不安全的代码。UnsafeCell 可以通过其 get() 方法向我们提供指向其内容(*mut T)的原始指针,我们可以使用不安全块转换到一个引用,如下所示:

#![allow(unused)]
fn main() {
    pub fn lock(&self) -> &mut T {
        while self.locked.swap(true, Acquire) {
            std::hint::spin_loop();
        }
        unsafe { &mut *self.value.get() }
    }
}

由于 lock 函数的函数签名在其输入和输出都包含引用,&self&mut T 的生命周期都已经被省略并假定为相同的生命周期。(参见《Rust Book》中的“Chapter 10: Generic Types, Traits, and Lifetimes”的“Lifetime Elision”一节)。我们可以通过手动书写来明确这些生命周期,如下所示:

#![allow(unused)]
fn main() {
 pub fn lock(&'a self) -> &'a mut T { … }
}

这清楚的表明,返回引用的生命周期与 &self 的生命周期相同。这意味着我们已经声称,只要锁本身存在,返回的引用就是有效的。

如果我们假装 unlock() 不存在,这将是完全安全和健全的接口。SpinLock 可以被锁定,导致一个 &mut T,并且然后不再被再次锁定,这保证了这个独占引用确实是独占的。

然而,如果我们尝试重新引入 unlock() 方法,我们需要一种方式去限制返回引用的生命周期,直到下一次调用 unlock()。如果编译器理解英语,或者它应该这样工作:

#![allow(unused)]
fn main() {
pub fn lock(&self) -> &'a mut T
    where
        'a ends at the next call to unlock() on self,
        even if that's done by another thread.
        Oh, and it also ends when self is dropped, of course.
        (Thanks!)
    { … }
}

不幸的是,这并不是有效的 Rust。我们必须努力向用户解释这个限制,而不是向编译器解释。为了将责任转移到用户身上,我们将 unlock 函数标记为不安全,并给他们留下一张纸条,解释他们需要做什么来保持健全:

#![allow(unused)]
fn main() {
/// 安全性:来自 lock() 的 &mut T 必须消失
/// (并且通过引用该 T 周围的字段来防止欺骗!)
pub unsafe fn unlock(&self) {
    self.locked.store(false, Release);
}
}

使用锁守卫的安全接口

英文版本

为了能够提供一个完全安全地接口,我们需要将解锁操作绑定到 &mut T 的末尾。我们可以通过将此引用包装成我们自己的类型来做到这一点,该类型的行为类似于引用,但也实现了 Drop trait,以便在它被丢弃时做一些事情。

这一类型通常被称为守卫(guard),因为它有效地守卫了锁的状态,并且对该状态负责,直到它被丢弃。

我们的 Guard 类型将仅包含对 SpinLock 的引用,以便它既可以访问 UnsafeCell,也可以稍后重置 AtomicBool:

#![allow(unused)]
fn main() {
pub struct Guard {
    lock: &SpinLock,
}
}

然而,如果我们尝试编译它,编译器将告诉我们:

error[E0106]: missing lifetime specifier
   --> src/lib.rs
    |
    |         lock: &SpinLock,
    |               ^ expected named lifetime parameter
    |
help: consider introducing a named lifetime parameter
    |
    ~     pub struct Guard {
    |                      ^^^
    ~         lock: &'a SpinLock,
    |                ^^
    |

显然,这不是一个可以淘汰生命周期的地方。我们必须明确表示,引用的生命周期有限,正如编译器所建议的那样:

#![allow(unused)]
fn main() {
pub struct Guard {
    lock: &'a SpinLock,
}
}

这保证了 Guard 不能超出 SpinLock 的生命周期。

下一步,我们在我们的 SpinLock 上改变 lock 方法,以返回 Guard:

#![allow(unused)]
fn main() {
pub fn lock(&self) -> Guard {
    while self.locked.swap(true, Acquire) {
        std::hint::spin_loop();
    }
    Guard { lock: self }
}
}

我们的 Guard 类型没有构造函数,其字段是私有的,因此这是用户获得 Guard 的唯一方法。因此,我们可以有把握地假设 Guard 的存在意味着 SpinLock 已被锁定。

为了使 Guard 行为类似一个(独占)引用,透明地允许访问 T,我们必须实现以下特殊的 Deref 和 DerefMut trait:

#![allow(unused)]
fn main() {
use std::ops::{Deref, DerefMut};

impl Deref for Guard {
    type Target = T;
    fn deref(&self) -> &T {
        // 安全性:Guard 的 存在
        // 保证了我们已经独占地锁定这个锁
        unsafe { &*self.lock.value.get() }
    }
}

impl DerefMut for Guard {
    fn deref_mut(&mut self) -> &mut T {
        // 安全性:Guard 的存在
        // 保证了我们已经独占地锁定这个锁
        unsafe { &mut *self.lock.value.get() }
    }
}
}

最后一步,我们为 Guard 实现 Drop,允许我们完全地引出不安全的 unlock() 方法:

#![allow(unused)]
fn main() {
impl Drop for Guard {
    fn drop(&mut self) {
        self.lock.locked.store(false, Release);
    }
}
}

就这样,通过 Drop 和 Rust 类型系统的魔力,我们为我们的 SpinLock 类型提供了一个完全安全(和有用的)接口。

让我们尝试使用它:

fn main() {
    let x = SpinLock::new(Vec::new());
    thread::scope(|s| {
        s.spawn(|| x.lock().push(1));
        s.spawn(|| {
            let mut g = x.lock();
            g.push(2);
            g.push(2);
        });
    });
    let g = x.lock();
    assert!(g.as_slice() == [1, 2, 2] || g.as_slice() == [2, 2, 1]);
}

上面的程序展示了我们的 SpinLock 是多么容易使用。多亏了 DerefDerefMut,我们可以直接在 guard 上调用 Vec::push 方法。多亏了 Drop,我们不必担心解锁。

通过调用 drop(g) 来丢弃 guard,也可以明确地解锁。如果你尝试过早地解锁,你将看见 guard 正在做它的工作时,发生编译器错误。例如,如果你在两个 push(2) 行之间插入 drop(g);,第二个 push 将无法编译,因为你此时已经丢弃 g 了:

error[E0382]: borrow of moved value: `g`
   --> src/lib.rs
    |
    |     drop(g);
    |          - value moved here
    |     g.push(2);
    |     ^^^^^^^^^ value borrowed here after move

多亏了 Rust 的类型系统,我们可以放心,在我们运行程序之前,这样的错误就已经被发现了。

总结

英文版本

  • 自旋锁是在等待时忙碌循环或自旋的 mutex。

  • 自旋可以减少延迟,但也可能浪费时钟周期并降低性能。

  • 自旋循环提示(spin::hint::spin_loop())可以用于通知处理器自旋循环,这可能增加它的效率。

  • SpinLock 只需使用 AtomicBoolUnsafeCell 即可实现,后者是内部可变性所必需的(见第 1 章中的“内部可变性”)。

  • 在解锁和锁定之间的 happens-before 关系是防止数据竞争的必要条件,否则会导致未定义行为。

  • AcquireRelease 内存排序对这个用例是极合适的。

  • 当做出必要的未检查的假设以避免未定义的行为时,可以通过将函数标记为不安全来将责任转移到调用者。

  • DerefDerefMut trait 可用于使类型像引用一样,透明地提供对另一个对象的访问。

  • Drop trait 可以用于在对象被丢弃时,做一些事情,例如当它超出作用域或者它被传递给 drop()

  • 锁守卫是一种特殊类型的有用设计模式,它被用于表示对锁定的锁的(安全)访问。由于 Deref trait,这种类型通常与引用的行为相似,并通过 Drop trait 实现自动解锁。

    下一篇,第五章:构建我们自己的 Channel

第五章:构建我们自己的 Channel

英文版本

Channel 可以被用于在线程之间发送数据,并且它有很多变体。一些 channel 仅能在一个发送者和一个接收者之间使用,而另一些可以在任意数量的线程之间发送,或者甚至允许多个接收者。一些 channel 是阻塞的,这意味着接收(有时也包括发送)是一个阻塞操作,这会使线程进入睡眠状态,直到你的操作完成。一些 channel 针对吞吐量进行优化,而另一些针对低延迟进行优化。

这些变体是无穷尽的,没有一种通用版本在所有场景都适合的。

在该章节,我们将实现一个相对简单的 channel,不仅可以探索更多的原子应用,同时也可以了解如何在 Rust 类型系统中捕获我们的需求和假设。

一个简单的以 mutex 为基础的 Channel

英文版本

一个基础的 channel 实现并不需要任何关于原子的知识。我们可以接收 VecDeque,它根本上是一个 Vec,允许在两端高效地添加和移除元素,并使用 Mutex 保护它,以允许多个线程访问。然后,我们使用 VecDeque 作为已发送但尚未接受数据的消息队列。任何想要发送消息的线程只需要将其添加到队列的末尾,而任何想要接受消息的线程只需从队列的前端删除一个消息。

还有一点需要补充,用于将接收操作阻塞的 Condvar(参见第一章“条件变量”),当有新的消息,它会通知正在等待的接收者。

这样做的实现可能非常简短且相对直接,如下所示:

#![allow(unused)]
fn main() {
pub struct Channel {
    queue: Mutex>,
    item_ready: Condvar,
}

impl Channel {
    pub fn new() -> Self {
        Self {
            queue: Mutex::new(VecDeque::new()),
            item_ready: Condvar::new(),
        }
    }

    pub fn send(&self, message: T) {
        self.queue.lock().unwrap().push_back(message);
        self.item_ready.notify_one();
    }

    pub fn receive(&self) -> T {
        let mut b = self.queue.lock().unwrap();
        loop {
            if let Some(message) = b.pop_front() {
                return message;
            }
            b = self.item_ready.wait(b).unwrap();
        }
    }
}
}

注意,我们并没有使用任意的原子操作或者不安全代码,也不需要考虑 Send 或者 Sync。编译器理解 Mutex 的接口以及保证该提供什么类型,并且会隐式地理解,如果 Mutex 和 Condvar 都可以在线程之间安全共享,那么我们的 Channel 也可以这么做。

我们的 send 函数锁定 mutex,然后从队列的末尾推入消息,并且使用条件变量在解锁队列后直接通知可能等待的接收者。

receive 函数也锁定 mutex,然后从队列的首部弹出消息,但如果仍然没有可获得的消息,则会使用条件变量去等待。

记住,Condvar::wait 方法将在等待时解锁 Mutex,并在返回之前重新锁定它。因此,我们的 receive 函数将不会在等待时锁定 mutex。

尽管这个 channel 在使用上是非常灵活的,因为它允许任意数量的发送和接收线程,但在很多情况下,它的实现远非最佳。即使有大量的消息准备好被接收,任意的发送或者接收操作将短暂地阻塞任意其它的发送或者接收操作,因为它们必须都锁定相同的 mutex。如果 VecDeque::push 必须增加 VecDeque 的容量时,所有的发送和接收线程将不得不等待该线程完成重新分配容量,这在某些情况下是不可接受的。

另一个可能不可取的属性是,该 channel 的队列可能会无限制地增长。没有什么能阻止发送者以比接收者更高的速度持续发送新消息。

一个不安全的一次性 Channel

英文版本

channel 的各种用例几乎是无止尽的。然而,在本章的剩余部分,我们将专注于一种特定类型的用例:恰好从一个线程向另一个线程发送一条消息。为此类用例设计的 channel 通常被称为 一次性(one-shot)channel。

我们采用上述基于 Mutex 的实现,并且将 VecDeque 替换为 Option,从而将队列的容量减小到恰好一个消息。这样可以避免内存浪费,但仍然会存在使用 Mutex 的一些缺点。我们可以通过使用原子操作从头构建我们自己的一次性 channel 来避免这个问题。

首先,让我们构建一个最小化的一次性 channel 实现,不需要考虑它的接口。在本章的稍后,我们将探索如何改进其接口以及如何与 Rust 类型相结合,为 channel 的用于提供愉快的体验。

我们需要开始的工具基本上与我们在第四章使用的 SpinLock 基本相同:一个用于存储的 UnsafeCell 和用于指示状态的 AtomicBool。在该示例中,我们使用原子布尔值去指示消息是否准备好用于消费。

在发送消息之前,channel 是“空的”并且不包含任何类型为 T 的消息。我们可以在 cell 中使用 Option,以允许 T 缺失。然而,这可能会浪费宝贵的内存空间,因为我们的原子布尔值已经告诉我们是否有消息。相反,我们可以使用 std::mem::MaybeUninit,它本质上是裸露的 Option 的不安全版本:它要求用户手动跟踪其是否已初始化,几乎整个接口都是不安全的,因为它不能执行自己的检查。

综合来看,我们从这个结构体定义开始我们的第一次尝试:

#![allow(unused)]
fn main() {
use std::mem::MaybeUninit;

pub struct Channel {
    message: UnsafeCell>,
    ready: AtomicBool,
}
}

就像我们的 SpinLock 一样,我们需要告诉编译器,我们的 channel 在线程之间共享是安全的,或者至少只要 T 是 Send 的:

#![allow(unused)]
fn main() {
unsafe impl Sync for Channel where T: Send {}
}

一个新的 channel 是空的,将 ready 设置为 false,并且消息仍然没有初始化:

#![allow(unused)]
fn main() {
impl Channel {
    pub const fn new() -> Self {
        Self {
            message: UnsafeCell::new(MaybeUninit::uninit()),
            ready: AtomicBool::new(false),
        }
    }

    // …
}
}

要发送消息,它首先需要存储在 cell 中,之后我们可以通过将 ready 标识设置为 true 来将其释放给接收者。试图做这个超过一次是危险的,因为设置 ready 标识后,接收者可能在任意时刻读取消息,这可能会与第二次发送消息产生数据竞争。目前,我们通过使方法不安全并为它们留下备注,将此作为用户的责任:

#![allow(unused)]
fn main() {
    /// 安全性:仅能调用一次!
    pub unsafe fn send(&self, message: T) {
        (*self.message.get()).write(message);
        self.ready.store(true, Release);
    }
}

在上面这个片段中,我们使用 UnsafeCell::get 方法去获取指向 MaybeUninit 的指针,并且通过不安全地解引用它来调用 MaybeUninit::write 进行初始化。当错误使用时,这可能导致未定义行为,但我们将这个责任转移到了调用方身上。

对于内存排序,我们需要使用 release 排序,因为原子的存储有效地将消息释放给接收者。这确保了如果接收线程从 self.ready 以 acquire 排序加载 true,则消息的初始化将从接受线程的角度完成。

对于接收,我们暂时不会提供阻塞的接口。相反,我们将提供两个方法:一个用于检查是否有可用消息,另一个用于接收消息。我们将让我们的 channel 用户决定是否使用线程阻塞的方法来阻塞。

以下是完成此版本我们 channel 的最后两种方法:

#![allow(unused)]
fn main() {
    pub fn is_ready(&self) -> bool {
        self.ready.load(Acquire)
    }

    /// 安全性:仅能调用一次,
    /// 并且仅在 is_ready() 返回 true 之后调用!
    pub unsafe fn receive(&self) -> T {
        (*self.message.get()).assume_init_read()
    }
}

虽然 is_ready 方法可以始终地安全调用,但是 receive 方法使用了 MaybeUninit::assume_init_read(),这不安全地假设它已经被初始化,且不会用于生成非 Copy 对象的多个副本。就像 send 方法一样,我们只需通过将函数本身标记为不安全来将这个问题交给用户解决。

结果是一个在技术上可用的 channel,但它用起来不便并且通常令人失望。如果正确使用,它会按预期进行操作,但有很多微妙的方式去错误地使用它。

多次调用 send 可能会导致数据竞争,因为第二个发送者在接收者尝试读取第一条消息时可能正在覆盖数据。即使接收操作得到了正确的同步,从多个线程调用 send 可能会导致两个线程尝试并发地写入 cell,再次导致数据竞争。此外,多次调用 receive 会导致获取两个消息的副本,即使 T 不实现 Copy 并且因此不能安全地进行复制。

更微妙的问题是我们的 Channel 缺乏 Drop 实现。MaybeUninit 类型不会跟踪它是否已经初始化,因此它在被丢弃时不会自动丢弃其内容。这意味着如果发送了一条消息但从未被接收,该消息将永远不会被释放。这并不是不正确的,但仍然是要避免。在 Rust 中,泄漏被普遍认为是安全的,但通常只有作为另一个泄漏的后果才是可接受的。例如,泄漏 Vec 的内存也会泄漏其内容,但正常使用 Vec 不会导致任何内存泄漏。

由于我们让用户对一切负责,不幸的事故只是时间问题。

通过运行时检查来达到安全

英文版本

为了提供更安全的接口,我们可以增加一些检查,以确保误用会导致 panic 并显示清晰的错误信息,这比未定义行为要好得多。

让我们在消息准备好之前调用 receive 方法的问题开始处理。这个问题很容易解决,我们只需要在尝试读消息之前让 receive 方法验证 ready 标识即可:

#![allow(unused)]
fn main() {
    /// 如果仍然没有消息可获得,panic。
    ///
    /// 提示,首先使用 `is_ready` 检查。
    ///
    /// 安全地:仅能调用一次。
    pub unsafe fn receive(&self) -> T {
        if !self.ready.load(Acquire) {
            panic!("no message available!");
        }
        (*self.message.get()).assume_init_read()
    }
}

该函数仍然是不安全的,因为用户仍然需要确保只调用一次,但未能首先检查 is_ready() 不再导致未定义行为。

因为我们现在在 receive 方法里有一个 ready 标识的 acquire-load 操作,其提供了必要的同步,我们可以在 is_ready 中使用 Relaxed 内存排序,因为该操作现在仅用于指示目的:

#![allow(unused)]
fn main() {
    pub fn is_ready(&self) -> bool {
        self.ready.load(Relaxed)
    }
}

记住,ready 上的总修改顺序(参见第三章的“Relaxed 排序”)保证了从 is_ready 加载 true 之后,receive 也能看到 true。无论 is_ready 使用的内存排序如何,都不会出现 is_ready 返回 true,receive() 仍然出现 panic 的情况。

下一个要解决的问题是,当调用 receive 不止一次时会发生什么。通过在接收方法中将 ready 标识设置回 false,我们也可以很容易地导致 panic,例如:

#![allow(unused)]
fn main() {
    /// 如果仍然没有消息可获得,
    /// 或者消息已经被消费 panic。
    ///
    /// 提示,首先使用 `is_ready` 检查。
    pub fn receive(&self) -> T {
        if !self.ready.swap(false, Acquire) {
            panic!("no message available!");
        }
        // Safety: We've just checked (and reset) the ready flag.
        unsafe { (*self.message.get()).assume_init_read() }
    }
}

我们仅是将 load 操作更改为 swap 操作(交换的值为 false),突然之间,receive 方法在任何情况下都可以安全地调用。该函数不再标记为不安全。我们现在承担了不安全代码的责任,而不是让用户负责一切,从而减轻了用户的压力。

对于 send,事情稍微复杂一点。为了阻止多个 send 调用同时访问 cell,我们需要知道是否另一个 send 调用已经开始。ready 标识仅告诉我们是否另一个 send 调用已经完成,所以这还不够。

让我们增加第二个标识,命名为 in_use,以指示该 channel 是否已经在使用:

#![allow(unused)]
fn main() {
pub struct Channel {
    message: UnsafeCell>,
    in_use: AtomicBool, // 新增!
    ready: AtomicBool,
}

impl Channel {
    pub const fn new() -> Self {
        Self {
            message: UnsafeCell::new(MaybeUninit::uninit()),
            in_use: AtomicBool::new(false), // 新增!
            ready: AtomicBool::new(false),
        }
    }

    //…
}
}

现在我们需要做的就是在访问 cell 之前,在 send 方法中,将 in_use 设置为 true,如果它已经由另一个线程设置,则 panic:

#![allow(unused)]
fn main() {
    /// 当尝试发送不止一次消息时,Panic。
    pub fn send(&self, message: T) {
        if self.in_use.swap(true, Relaxed) {
            panic!("can't send more than one message!");
        }
        unsafe { (*self.message.get()).write(message) };
        self.ready.store(true, Release);
    }
}

我们可以为原子 swap 操作使用 relaxed 内存排序,因为 in_use总修改顺序(参见第三章“Relaxed 排序”)保证了在 in_use 上只会有一个 swap 操作返回的 false,而这是 send 方法尝试访问 cell 的唯一情况。

现在我们拥有了一个完全安全的接口,尽管还有一个问题未解决。最后一个问题出现在发送一个永远不会被接收的消息时:它将从不会被丢弃。虽然这不会导致未定义行为,并且在安全代码中是允许的,但确实应该避免这种情况。

由于我们在 receive 方法中重置了 ready 标识,修复这个问题很容易:ready 标识指示是否在 cell 中尚未接受的消息需要被丢弃。

在我们的 Channel 的 Drop 实现中,我们不需要使用一个原子操作去检查原子 ready 标识,因为只有对象完全被正在丢弃它的线程所拥有的时候,且没有任何未解除借用的情况下,才能丢弃一个对象。这意味着,我们可以使用 AtomicBool::get_mut 方法,它接受一个独占引用(&mut self),以证明原子访问是不必要的。对于 UnsafeCell 也是一样,通过 UnsafeCell::get_mut 方法来来获取独占引用。

使用它,这是我们完全安全且不泄漏的 channel 的最后一部分:

#![allow(unused)]
fn main() {
impl Drop for Channel {
    fn drop(&mut self) {
        if *self.ready.get_mut() {
            unsafe { self.message.get_mut().assume_init_drop() }
        }
    }
}
}

我们试试吧!

由于我们的 channel 仍没有提供一个阻塞的接口,我们将手动地使用线程阻塞去等待消息。只要没有消息准备好,接收线程将 park() 自身,并且发送线程将在发送东西后,立刻 unpark() 接收者。

这里是一个完整的测试程序,通过我们的 Channel 从第二个线程发送字符串字面量“hello world”到主线程:

fn main() {
    let channel = Channel::new();
    let t = thread::current();
    thread::scope(|s| {
        s.spawn(|| {
            channel.send("hello world!");
            t.unpark();
        });
        while !channel.is_ready() {
            thread::park();
        }
        assert_eq!(channel.receive(), "hello world!");
    });
}

该程序编译、运行和干净地退出,表明我们的 Channel 正常工作。

如果我们复制了 send 行,我们也可以在运行中看到我们的安全检查,当运行程序时,产生以下 panic:

thread '' panicked at 'can't send more than one message!', src/main.rs

尽管 panic 程序并不出色,但是程序可靠的 panic 比可能的未定义行为错误好太多。

为 Channel 状态使用单原子

如果你对 channel 实现还不满意,这里有一个微妙的变体,可以节省一字节的内存。

我们使用单个原子 AtomicU8 表示所有 4 个状态,而不是使用两个分开的布尔值去表示 channel 的状态。我们必须使用 compare_exchange 来原子地检查 channel 是否处于预期状态,并将其更改为另一个状态,而不是原子交换布尔值。

const EMPTY: u8 = 0;
const WRITING: u8 = 1;
const READY: u8 = 2;
const READING: u8 = 3;

pub struct Channel<T> {
  message: UnsafeCell<MaybeUninit<T>>,
  state: AtomicU8,
}

unsafe impl<T: Send> Sync for Channel<T> {}

impl<T> Channel<T> {
  pub const fn new() -> Self {
      Self {
          message: UnsafeCell::new(MaybeUninit::uninit()),
          state: AtomicU8::new(EMPTY),
      }
  }

  pub fn send(&self, message: T) {
      if self.state.compare_exchange(
          EMPTY, WRITING, Relaxed, Relaxed
      ).is_err() {
          panic!("can't send more than one message!");
      }
      unsafe { (*self.message.get()).write(message) };
      self.state.store(READY, Release);
  }

  pub fn is_ready(&self) -> bool {
      self.state.load(Relaxed) == READY
  }

  pub fn receive(&self) -> T {
      if self.state.compare_exchange(
          READY, READING, Acquire, Relaxed
      ).is_err() {
          panic!("no message available!");
      }
      unsafe { (*self.message.get()).assume_init_read() }
  }
}

impl<T> Drop for Channel<T> {
  fn drop(&mut self) {
      if *self.state.get_mut() == READY {
          unsafe { self.message.get_mut().assume_init_drop() }
      }
  }
}

通过类型来达到安全

英文版本

尽管我们已经成功地保护了我们 Channel 的用户免受未定义行为的问题,但是如果它们偶尔地不正确使用它,它们仍然有 panic 的风险。理想情况下,编译器将在程序运行之前检查正确的用法并指出滥用。

让我们来看看调用 send 或 receive 不止一次的问题。

为了防止函数被多次调用,我们可以让它按值接受参数,对于非 Copy 类型,这将消耗对象。对象被消耗或移动后,它会从调用者那里消失,防止它再次被使用。

通过将调用 send 或 receive 表示的能力作为单独的(非 Copy)类型,并在执行操作时消费对象,我们可以确保每个操作只能发生一次。

这给我们带来了以下接口设计,而不是单个 Channel 类型,一个 channel 由一对 SenderReceiver 表示,它们各自都有以值接收 self 的方法:

#![allow(unused)]
fn main() {
pub fn channel() -> (Sender, Receiver) { … }

pub struct Sender { … }
pub struct Receiver { … }

impl Sender {
    pub fn send(self, message: T) { … }
}

impl Receiver {
    pub fn is_ready(&self) -> bool { … }
    pub fn receive(self) -> T { … }
}
}

用户可以通过调用 channel() 创建一个 channel,这将给他们一个 Sender 和一个 Receiver。它们可以自由地传递每个对象,将它们移动到另一个线程,等等。然而,它们最终不能获得其中任何一个的多个副本,这保证了 send 和 receive 仅被调用一次。

为了实现这一点,我们需要为我们的 UnsafeCell 和 AtomicBool 找到一个位置。之前,我们仅有一个具有这些字段的结构体,但是现在我们有两个单独的结构体,每个结构体都可能存在更长的时间。

因为 sender 和 receiver 将需要共享这些变量的所有权,我们将使用 Arc(第一章“引用计数”)为我们提供引用计数共享内存分配,我们将在其中存储共享的 Channel 对象。正如以下展示的,Channel 类型不必是公共的,因为它的存在是与用户无关的细节。

#![allow(unused)]
fn main() {
pub struct Sender {
    channel: Arc>,
}

pub struct Receiver {
    channel: Arc>,
}

struct Channel { // 不再 `pub`
    message: UnsafeCell>,
    ready: AtomicBool,
}

unsafe impl Sync for Channel where T: Send {}
}

就像之前一样,我们在 T 是 Send 的情况下为 Channel 实现了 Sync,以允许它跨线程使用。

注意,我们不再像我们之前 channel 实现中的那样,需要 in_use 原子布尔值。它仅通过 send 来检查它有没有被调用超过一次,现在通过类型系统静态地保证。

channel 函数去创建一个 channel 和一对发送者和接收者,它与我们之前的 Channel::new 函数类似,除了将 Channel 包装在 Arc 中,也将该 Arc 和其克隆包装在 Sender 和 Receiver 类型中:

#![allow(unused)]
fn main() {
pub fn channel() -> (Sender, Receiver) {
    let a = Arc::new(Channel {
        message: UnsafeCell::new(MaybeUninit::uninit()),
        ready: AtomicBool::new(false),
    });
    (Sender { channel: a.clone() }, Receiver { channel: a })
}
}

sendis_readyreceive 方法与我们之前实现的方法基本相同,但有一些区别:

  • 它们现在被移动到它们各自的类型中,因此只有(单个)发送者可以发送,并且只有(单个)接收者可以接收。
  • 发送和接收现在通过值而不是引用来接收 self,以确保它们每个只能被调用一次。
  • 发送不再 panic,因为它的先决条件(只被调用一次)现在被静态保证。

所以,他们现在看起来像这样:

#![allow(unused)]
fn main() {
impl Sender {
    /// 从不会 panic :)
    pub fn send(self, message: T) {
        unsafe { (*self.channel.message.get()).write(message) };
        self.channel.ready.store(true, Release);
    }
}

impl Receiver {
    pub fn is_ready(&self) -> bool {
        self.channel.ready.load(Relaxed)
    }

    pub fn receive(self) -> T {
        if !self.channel.ready.swap(false, Acquire) {
            panic!("no message available!");
        }
        unsafe { (*self.channel.message.get()).assume_init_read() }
    }
}
}

receive 函数仍然可以 panic,因为用户可能仍然会在 is_ready() 返回 true 之前调用它。它仍然使用 swap 将 ready 标识设置回 false(而不仅仅是 load 操作),以便 Channel 的 Drop 实现知道是否有需要删除的未读消息。

该 Drop 实现与我们之前实现的完全相同:

#![allow(unused)]
fn main() {
impl Drop for Channel {
    fn drop(&mut self) {
        if *self.ready.get_mut() {
            unsafe { self.message.get_mut().assume_init_drop() }
        }
    }
}
}

Sender 或者 Receiver 被丢弃时,Arc> 的 Drop 实现将递减对共享内存分配的引用计数。当丢弃到第二个时,计数达到 0,并且 Channel 自身被丢弃。这将调用我们上面的 Drop 实现,如果已发送但未收到消息,我们将丢弃该消息。

让我们尝试它:

fn main() {
    thread::scope(|s| {
        let (sender, receiver) = channel();
        let t = thread::current();
        s.spawn(move || {
            sender.send("hello world!");
            t.unpark();
        });
        while !receiver.is_ready() {
            thread::park();
        }
        assert_eq!(receiver.receive(), "hello world!");
    });
}

有一点不方便的是,我们仍然得手动地使用线程阻塞去等待一个消息,但是我们稍后将处理这个问题。

目前,我们的目标是在编译时使至少一种形式的滥用变得不可能。与过去不同,试图发送两次不会导致程序 Panic,相反,根本不会导致有效的程序。如果我们向上述工作程序增加另一个 send 调用,编译器现在捕捉问题并可能告知我们错误信息:

error[E0382]: use of moved value: `sender`
  --> src/main.rs
   |
   |             sender.send("hello world!");
   |                    --------------------
   |                     `sender` moved due to this method call
   |
   |             sender.send("second message");
   |             ^^^^^^ value used here after move
   |
note: this function takes ownership of the receiver `self`, which moves `sender`
  --> src/lib.rs
   |
   |     pub fn send(self, message: T) {
   |                 ^^^^
   = note: move occurs because `sender` has type `Sender`,
           which does not implement the `Copy` trait

根据情况,设计一个在编译时捕捉错误的接口可能非常棘手。如果这种情况确实适合这样的接口,它不仅可以为用户带来更多的便利,还可以减少运行时检查的数量,因为这些检查在静态上已经得到保证。例如,我们不再需要 in_use 标识,并从发送者法中移除了交换和检查步骤。

不幸的是,可能会出现新的问题,这可能导致运行时开销。在这种情况下,问题是拆分所有权,我们不得不使用 Arc 并承受 Arc 的代价。

不得不在安全性、便利性、灵活性、简单性和性能之间进行权衡是不幸的,但有时是不可避免的。Rust 通常致力于在这些方面取得最佳表现,但有时为了最大化某个方面的优势,我们需要在其中做出一些妥协。

借用以避免内存分配

英文版本

我们刚刚基于 Arc 的 channel 实现的设计可以非常方便的使用——代价是一些性能,因为它得内存分配。如果我们想要优化效率,我们可以通过用户对共享的 Channel 对象负责来获取一些性能。我们可以强制用户去创建一个通过可以由 Sender 和 Receiver 借用的 Channel,而不是在幕后处理 Channel 内存分配和所有权。这样,它们可以选择简单地放置 Channel 在局部变量中,从而避免内存分配的开销。

我们将也在一定程度上牺牲简洁性,因为我们现在不得不处理借用和生命周期。

因此,这三种类型现在看起来如下,Channel 再次公开,Sender 和 Receiver 借用它一段时间。

#![allow(unused)]
fn main() {
pub struct Channel {
    message: UnsafeCell>,
    ready: AtomicBool,
}

unsafe impl Sync for Channel where T: Send {}

pub struct Sender {
    channel: &'a Channel,
}

pub struct Receiver {
    channel: &'a Channel,
}
}

我们没有使用 channel() 函数来创建一对 Sender 和 Receiver,而是回到本章节使用的 Channel::new,这允许用户为此类对象创建局部变量。

此外,我们需要一种方法,让用户创建将借用 Channel 的 Sender 和 Receiver 对象。这将需要是一个独占借用(&mut Channel),以确保同一 channel 不能有多个发送者或接收者。通过同时提供 Sender 和 Receiver,我们可以将独占引用分成两个共享借用,这样发送者和接收者都可以引用 channel,同时防止其他任何东西接触 channel。

这导致我们实现以下内容:

#![allow(unused)]
fn main() {
impl Channel {
    pub const fn new() -> Self {
        Self {
            message: UnsafeCell::new(MaybeUninit::uninit()),
            ready: AtomicBool::new(false),
        }
    }

    pub fn split(&'a mut self) -> (Sender, Receiver) {
        *self = Self::new();
        (Sender { channel: self }, Receiver { channel: self })
    }
}
}

split 方法使用一个极其复杂的签名,值得好好观察。它通过一个独占引用独占地借用 self,但它分成了两个共享引用,包装在 Sender 和 Receiver 类型中。'a 生命周期清楚地表明,这两个对象借用了有限的生命周期的东西;在这种情况下,是 Channel 本身的生命周期。由于 Channel 是独占地借用,只要 Sender 或 Receiver 对象存在,调用者不能去借用或者移动它。

然而,一旦这些对象都不再存在,可变的借用就会过期,编译器会愉快地让 Channel 对象通过第二次调用 split() 再次被借用。尽管我们可以假设在 Sender 和 Receiver 存在时,不能再次调用 split(),我们不能阻止在这些对象被丢弃或者遗忘后再次调用 split()。我们需要确保我们不能偶然地在 channel 已经有它的 ready 标识设置的情况下创建新的 Sender 或 Receiver 对象,因为这将打包阻止未定义行为的假设。

通过在 split() 中用新的空 channel 覆盖 *self,我们确保它在创建 Sender 和 Receiver 状态时处于预期状态。这也会在旧的 *self 上调用 Drop 实现,它将负责丢弃之前发送但从未接收的消息。

由于 split 的签名的生命周期来自 self,它可以被省略。上面片段的 split 签名与这个不太冗长的版本相同

#![allow(unused)]
fn main() {
pub fn split(&mut self) -> (Sender, Receiver) { … }
}

虽然此版本没有明确显示返回的对象借用了 self,但编译器仍然与更冗长的版本完全一样检查生命周期的正确使用情况。

其余的方法和 Drop 实现与我们基于 Arc 的实现相同,除了 Sender 和 Receiver 类型的额外 '_ 生命周期参数。(如果你忘记了这些,编译器会建议添加它们。)

为了完全起效,以下是剩余的代码:

#![allow(unused)]
fn main() {
impl Sender {
    pub fn send(self, message: T) {
        unsafe { (*self.channel.message.get()).write(message) };
        self.channel.ready.store(true, Release);
    }
}

impl Receiver {
    pub fn is_ready(&self) -> bool {
        self.channel.ready.load(Relaxed)
    }

    pub fn receive(self) -> T {
        if !self.channel.ready.swap(false, Acquire) {
            panic!("no message available!");
        }
        unsafe { (*self.channel.message.get()).assume_init_read() }
    }
}

impl Drop for Channel {
    fn drop(&mut self) {
        if *self.ready.get_mut() {
            unsafe { self.message.get_mut().assume_init_drop() }
        }
    }
}
}

让我们来测试它!

fn main() {
    let mut channel = Channel::new();
    thread::scope(|s| {
        let (sender, receiver) = channel.split();
        let t = thread::current();
        s.spawn(move || {
            sender.send("hello world!");
            t.unpark();
        });
        while !receiver.is_ready() {
            thread::park();
        }
        assert_eq!(receiver.receive(), "hello world!");
    });
}

与基于 Arc 的版本相比,便利性的减少非常小:我们只需要多一行代码来手动创建一个 Channel 对象。然而,请注意,channel 必须在作用域之前创建,以向编译器证明其存在超过 Sender 和 Receiver 的时间。

要查看编译器的借用检查器的实际操作,请尝试在各个地方添加对 channel.split() 的第二次调用。你将看到,在线程作用域内第二次调用它会导致错误,而在作用域之后调用它是可以接受的。即使在作用域之前调用 split() 也没问题,只要你在作用域开始之前停止使用返回的 Sender 和 Receiver 。

阻塞

英文版本

让我们最终处理一下我们 Channel 最后留下的最大不便,阻塞接口的缺乏。我们测试一个新的 channel 变体,每次都使用线程阻塞函数。将这种模式本身整合到 channel 应该不是太难。

为了能够释放接收者,发送者需要知道去释放哪个线程。std::thread::Thread 类型表示线程的句柄,正是我们调用 unpark() 所需要的。我们将把句柄存储到 Sender 对象内的接收线程,如下所示:

#![allow(unused)]
fn main() {
use std::thread::Thread;

pub struct Sender {
    channel: &'a Channel,
    receiving_thread: Thread, // 新增!
}
}

然而,如果 Receiver 对象在线程之间发送,该句柄将引用错误的线程。Sender 将不会意识到这个,并且仍然会参考最初持有 Receiver 的线程。

我们可以通过使 Receiver 更具限制性,不再允许它在线程之间发送来处理这个问题。正如第 1 章“线程安全:Send 和 Sync”中所讨论的,我们可以使用特殊的 PhantomData 标记类型将此限制添加到我们的结构中。PhantomData 将完成这项工作,因为原始指针,如 *const (),没有实现 Send:

#![allow(unused)]
fn main() {
pub struct Receiver {
    channel: &'a Channel,
    _no_send: PhantomData, // 新增!
}
}

接下来,我们必须修改 Channel::split 方法来填充新字段,例如:

#![allow(unused)]
fn main() {
    pub fn split(&'a mut self) -> (Sender, Receiver) {
        *self = Self::new();
        (
            Sender {
                channel: self,
                receiving_thread: thread::current(), // 新增!
            },
            Receiver {
                channel: self,
                _no_send: PhantomData, // 新增!
            }
        )
    }
}

我们使用当前线程的句柄来填充 receiving_thread 字段,因为我们返回的 Receiver 对象将保留在当前线程上。

正如以下展示的,send 方法并不做改变。我们仅在 receiving_thread 字段上调用 unpark() 去唤醒接收者,以防止它正在等待:

#![allow(unused)]
fn main() {
impl Sender {
    pub fn send(self, message: T) {
        unsafe { (*self.channel.message.get()).write(message) };
        self.channel.ready.store(true, Release);
        self.receiving_thread.unpark(); // 新增!
    }
}
}

receive 函数发生的变化稍大。如果它仍然没有消息,新版本不会 panic,而是使用 thread::park() 等待消息并再次尝试,并根据需要多次重试。

#![allow(unused)]
fn main() {
impl Receiver {
    pub fn receive(self) -> T {
        while !self.channel.ready.swap(false, Acquire) {
            thread::park();
        }
        unsafe { (*self.channel.message.get()).assume_init_read() }
    }
}
}

请记住,thread::park() 可能会虚假返回。(或者因为除了我们的 send 方法以外的其它原因调用了 unpark()。)这意味着我们不能假设 park() 返回时已经设置了 ready 标识。因此,我们需要使用一个循环,在唤醒后再次检查 ready 标识。

Channel 结构体、它的 Sync 实现、它的 new 函数以及它的 Drop 实现保持不变。

让我们尝试它!

fn main() {
    let mut channel = Channel::new();
    thread::scope(|s| {
        let (sender, receiver) = channel.split();
        s.spawn(move || {
            sender.send("hello world!");
        });
        assert_eq!(receiver.receive(), "hello world!");
    });
}

显然,这个 Channel 比上一个 Channel 更方便使用,至少在这个简单的测试程序中是这样。我们不得不牺牲一些灵活性来创造这种便利性:只有调用 split() 的线程才能调用 receive()。如果你交换 send 和 receive 行,此程序将不再编译。根据用例,这可能完全没问题、有用或非常不方便。

确实,有许多方法解决这个问题,其中有很多会增加一些额外的复杂度并影响一些性能。总的来说,我们可以继续探索的变种和权衡是无穷无尽的。

我们很容易花费大量的时间实现 20 个一次性 channel 不同的变体,每个变体都具有不同的属性,适用于每个可以想象到的用例甚至更多。尽管这听起来很有趣,但是我们应该避免陷入这个歧途,并在事情失控之前结束本章。

总结

英文版本

  • channel 用于在线程之间发送消息

  • 一个简单、灵活但可能效率低下的 channel,只需一个 MutexCondvar 就很容易实现。

  • 一次性(one-shot)channel 是一个被设计仅发送一次信息的 channel。

  • MaybeUninit 类型可用于表示可能尚未初始化的 T。其接口大多不安全,使用户负责跟踪其是否已初始化,不要复制非 Copy 数据,并在必要时删除其内容。

  • 不丢弃对象(也称为泄漏或者遗忘)是安全的,但如果没有充分理由而这样做,会被视为不良的做法。

  • panic 是创建安全接口的重要工具。

  • 按值获取一个非 Copy 对象可以用于阻止某个操作被重复执行。

  • 独占借用和拆分借用是确保正确性的强大工具。

  • 我们可以确保对象的类型不实现 Send,确保它在同一个线程,这可以通过 PhantomData 标记实现。

  • 每个设计和实施决定都涉及权衡,最好在考虑特定用例的情况下做出。

  • 在没有用例的情况下设计一些东西可能是有趣的和有教育意义的,但是这可能是一个无止境的任务。

    下一篇,第六章:构建我们自己的“Arc”

第六章:构建我们自己的“Arc”

英文版本

第一章“引用计数”中,我们了解了 std::sync::Arc 类型允许通过引用计数共享所有权。Arc::new 函数创建一个新的内存分配,就像 Box::new。然而,与 Box 不同的是,克隆 Arc 将共享原始的内存分配,而不是创建一个新的。只有当 Arc 和所有其他的克隆被丢弃,共享的内存分配才会被丢弃。

这种类型的实现所涉及的内存排序可能是非常有趣的。在本章中,我们将通过实现我们自己的 Arc 将更多理论付诸实践。我们将开始一个基础的版本,然后将其扩展到支持循环结构的 weak 指针,并且最终将其优化为一个与标准库差不多的实现结束本章。

基础的引用计数

英文版本

我们的第一个版本将使用单个 AtomicUsize 去计数 Arc 对象共享分配的数量。让我们开始使用一个持有计数器和 T 对象的结构体:

#![allow(unused)]
fn main() {
struct ArcData {
    ref_count: AtomicUsize,
    data: T,
}
}

注意,该结构体不是公共的。它是我们 Arc 实现的内部实现细节。

接下来是 Arc 结构体本身,它实际上仅是一个指向(共享的)ArcData 的指针。

使用 Box> 作为包装器,并使用标准的 Box 来处理 ArcData 的内存分配可能很诱人。然而,Box 表示独占所有权,并不是共享所有权。我们不能使用引用,因为我们不仅要借用其他所有权的数据,并且它的生命周期(“直到此 Arc 的最后一个克隆被丢弃”)无法直接表示为 Rust 的生命周期。

相反,我们将不得不使用指针,并手动处理内存分配以及所有权的概念。我们将使用 std::ptr::NonNull,而不是 *mut T*const T,它表示一个永远不会为空的指向 T 的指针。这样,使用 None 的空指针表示 Option>Arc 的大小相同。

#![allow(unused)]
fn main() {
use std::ptr::NonNull;

pub struct Arc {
    ptr: NonNull>,
}
}

使用一个引用或者 Box,编译器会自动地理解它会为哪个 T 实现 Send 和 Sync。然而,当使用原始指针或者 NonNull,除非我们明确告知,否则它会保守地认为它永远不会 Send 或 Sync。

发送 Arc 跨线程会导致 T 对象被共享,这时要求 T 实现 Sync。类似地,将 Arc 跨线程发送可能导致另一个线程丢弃该对象,从而将它转移到其他线程,这里要求 T 实现 Send。换句话说,如果 T 既是 Send 又是 Sync,那么 Arc 应该是 Send。对于 Sync 来说也是完全相同的,因为一个共享的 &Arc 可以克隆为一个新的 Arc

#![allow(unused)]
fn main() {
unsafe impl Send for Arc {}
unsafe impl Sync for Arc {}
}

对于 Arc::new,我们必须使用引用计数为 1 的 ArcData 创建一个新的内存分配。我们将使用 Box::new 创建新的内存分配,使用 Box::leak 放弃我们对此内存分配的独占所有权,以及使用 NonNull::from 将其转换为指针:

#![allow(unused)]
fn main() {
impl Arc {
    pub fn new(data: T) -> Arc {
        Arc {
            ptr: NonNull::from(Box::leak(Box::new(ArcData {
                ref_count: AtomicUsize::new(1),
                data,
            }))),
        }
    }

    // …
}
}

我们知道只要 Arc 对象存在,指针将总是指向一个有效的 ArcData。然而,这不是编译器知道的或为我们检查的内容,所以通过指针访问 ArcData 需要不安全的代码。我们将添加一个私有的辅助函数去从 Arc 获取ArcData,因为这是我们将执行多次的操作:

#![allow(unused)]
fn main() {
    fn data(&self) -> &ArcData {
        unsafe { self.ptr.as_ref() }
    }
}

使用它,我们现在可以实现 Deref trait 以使得我们的 Arc 能够像 T 的引用一样透明地操作:

#![allow(unused)]
fn main() {
impl Deref for Arc {
    type Target = T;

    fn deref(&self) -> &T {
        &self.data().data
    }
}
}

注意,我们并没有实现 DerefMut。因为 Arc 表示共享所有权,我们不能无条件地提供 &mut T

接下来:实现 Clone。在增加引用计数后,克隆的 Arc 将使用相同的指针:

#![allow(unused)]
fn main() {
impl Deref for Arc {
    type Target = T;

    fn deref(&self) -> &T {
        &self.data().data
    }
}
}

我们可以使用 Relaxed 内存排序去递增引用计数,因为没有对其他变量的操作在此原子操作之前或者之后严格地发生。在此操作之前,我们已经可以访问 Arc 包含的 T(通过原始的 Arc),在此操作之后,访问仍然没有改变(但现在至少有两个相同 Arc 对象可以访问数据)。

一个 Arc 需要被克隆多次才有可能导致引用计数溢出,但是在循环中运行 std::men::forget() 可以实现这一点。我们可以使用在第二章的“示例:ID 分配”“示例:没有溢出的 ID 分配”中讨论的任意技术来处理这个问题。

为了在正常(非溢出)情况下保持尽可能高效,我们将保留原始的 fetch_add,并在接近溢出时简单地中止整个过程:

#![allow(unused)]
fn main() {
        if self.data().ref_count.fetch_add(1, Relaxed) > usize::MAX / 2 {
            std::process::abort();
        }
}

并不会立刻中止进程,在一段时间内,另一个线程也可以调用 Arc::clone,进一步增加引用计数器。因此,仅仅检查 usize::MAX - 1 将是不够的。然而,使用 usize::MAX / 2 限制工作是好的:假设每个线程在内存中至少需要几字节的空间,那么并发存在 usize::MAX / 2 数量的线程是不可能的。

就像我们在克隆时递增计数器一样,我们在丢弃 Arc 时需要递减计数器。线程看到计数器从 1 到 0,这意味着该线程丢弃了最后一个 Arc,并负责丢弃和释放 ArcData

我们将使用 Box::from_raw 去重新获得内存的独占所有权,然后立即使用 drop() 将其丢弃:

#![allow(unused)]
fn main() {
impl Drop for Arc {
    fn drop(&mut self) {
        // TODO:内存排序
        if self.data().ref_count.fetch_sub(1, …) == 1 {
            unsafe {
                drop(Box::from_raw(self.ptr.as_ptr()));
            }
        }
    }
}
}

对于这个操作,我们不能使用 Relaxed 排序,因为我们需要确保当我们丢弃它时,没有任何东西仍然在访问数据。换句话说,每个之前的 Arc 丢弃操作都必须发生在最终丢弃之前。因此,最后的 fetch_sub 必须与之前的 fetch_sub 操作建立一个 happens-before 关系,我们可以使用 release 和 acquire 排序来实现这一点:例如,从 2 递减到 1 可以有效地“释放”数据,而从 1 递减到 0 则“获取”了对它的所有权。

我们可以使用 AcqRel 内存排序来覆盖这两种情况,但只有最后一个递减到 0 才需要 Acquire,而其他情况只需要 Release。为了提高效率,我们将在 Release 用于 fetch_sub 操作,并且仅在必要时使用单独的 Acquire 屏障:

#![allow(unused)]
fn main() {
        if self.data().ref_count.fetch_sub(1, Release) == 1 {
            fence(Acquire);
            unsafe {
                drop(Box::from_raw(self.ptr.as_ptr()));
            }
        }
}

测试它

英文版本

为了测试我们的 Arc 是否按预期运行,我们可以编写一个单元测试,创建一个包含特殊对象的 Arc,让我们知道何时它被丢弃时:

#![allow(unused)]
fn main() {
#[test]
fn test() {
    static NUM_DROPS: AtomicUsize = AtomicUsize::new(0);

    struct DetectDrop;

    impl Drop for DetectDrop {
        fn drop(&mut self) {
            NUM_DROPS.fetch_add(1, Relaxed);
        }
    }

    // 创建两个 Arc,共享一个对象,包含一个字符串
    // 和一个 DetectDrop,以当它被丢弃时去检测。
    let x = Arc::new(("hello", DetectDrop));
    let y = x.clone();

    // 发送 x 到另一个线程,并在那里使用它。
    let t = std::thread::spawn(move || {
        assert_eq!(x.0, "hello");
    });

    // 这是并行的,y 应该仍然在这里可用。
    assert_eq!(y.0, "hello");

    // 等待线程完成。
    t.join().unwrap();

    // Arc,x 现在应该被丢弃。
    // 我们仍然有 y,因此对象仍然还没有被丢弃。
    assert_eq!(NUM_DROPS.load(Relaxed), 0);

    // 丢弃剩余的 `Arc`。
    drop(y);

    // 现在,`y` 也被丢弃,
    // 对象应该也被丢弃。
    assert_eq!(NUM_DROPS.load(Relaxed), 1);
}
}

编译并运行良好,因此我们的 Arc 似乎按预期运行!虽然这令人兴奋,但并不能证明实现是完全正确的。建议使用涉及多个线程的长压力测试,以获得更多的信心。

Miri

使用 Miri 运行测试是非常有用的。Miri 是一个实验性,但非常有用并且强力的工具,用于检查各种未定义形式的不安全代码。

Miri 是 Rust 编译器中间级别中间表示的解释器。这意味着它不会将你的代码编译成本机处理器指令,而是通过当像类型和生命周期是仍然可用时进行解释。因此,Miri 运行程序的速度比正常运行速度慢很多,但能够检测许多导致未定义行为的错误。

它包括检测数据竞争的实验性支持,这允许它检测内存排序问题。

有关更多使用 Miri 的细节和指导,请参见它的 GitHub 页面

可变性

英文版本

正如之前提及的,我们不能为我们的 Arc 实现 DerefMut。我们不能无条件地承诺对数据的独占访问(&mut T),因为它能够通过其他 Arc 对象访问。

然而,我们可以有条件地允许独占访问。我们可以创建一个方法,如果引用计数为 1,则提供 &mut T,这证明没有其他 Arc 对象可以用来访问相同的数据。

该函数我们将称它为 get_mut,它必须接受一个 &mut Self 以确保没有其他的相同的东西使用 Arc 获取 T。如果这个 Arc 仍然可以共享,知道只有一个 Arc 对象是没有意义的。

我们需要使用 acquire 内存排序去确保之前拥有 Arc 克隆的线程不再访问数据。我们需要与导致引用计数为 1 的每个单独的 drop 建立一个 happens-before 关系。

这仅在引用计数实际为 1 时才重要:如果引用计数高,我们将不再提供一个 &mut T,并且内存排序是无关紧要。因此,我们可以使用 relaxed load 操作,随后跟条件行的 acquire 屏障,如下所示:

#![allow(unused)]
fn main() {
    pub fn get_mut(arc: &mut Self) -> Option {
        if arc.data().ref_count.load(Relaxed) == 1 {
            fence(Acquire);
            // 安全性:没有任何其他东西可以访问 data,因为
            // 只有一个 Arc,我们拥有独占访问的权限。
            unsafe { Some(&mut arc.ptr.as_mut().data) }
        } else {
            None
        }
    }
}

该函数并不接收 self 参数,而是接受一个常规的参数(名称 arc)。这意味着,它仅可以 Arc::get_mut(&mut a) 这样的方式调用,而不以 a.get_mut() 方式调用。对于实现了 Deref 的类型来说,这是可取的,以避免与底层类型 T 上的同名方法产生歧义。

返回的可以引用会隐式地从参数重借用生命周期,这意味着只要返回 &mut T 仍然存在,原始的 Arc 就不能被其他代码使用,从而允许安全的可变性操作。

&mut T 的生命周期过期后,Arc 可以在此被使用以及与其他线程共享。也许有人可能会想知道,在之后访问数据的线程是否需要关注内存排序。然而,这是用于与其他线程共享 Arc(或着新克隆)的机制负责的。(例如 mutex、channel 或者产生的新线程。)

Weak 指针

英文版本

当表示在内存中多个对象组成的结构时,引用计数非常有用。例如,在树结构中的每个节点可以包含对其子节点的 Arc 引用。这样,当我们丢弃一个节点时,不再使用的孩子节点也会被(递归地)丢弃。

然而,对于循环结构来说,这会失效。如果一个子节点也包含对它父节点的 Arc 引用,那么当所有 Arc 引用都不存在时,两者都不会被丢弃,因为始终存至少有一个 Arc 引用仍然指向它们。

标准库的 Arc 提供了解决这个问题的办法:WeakWeak(也被称为 weak 指针),行为有点像 Arc,但是并不会阻止对象被丢弃。T 可以在多个 ArcWeak 对象之间共享,但是当所有 Arc 对象都消失时,不管是否还有 Weak 对象,T 都会被丢弃。

这意味着 Weak 可以没有 T 而存在,因此无法像 Arc 那样无条件地提供 &T。然而,为了获取给定 Weak 中的 T,可以通过 Arcupgrade() 方法来升级。这个方法返回一个 Option>,如果 T 已经被丢弃,则返回 None。

在基于 Arc 的结构中,可以使用 Weak 打破循环引用。例如,在树结构中的子节点使用 Weak,而不是使用 Arc 来引用它们的父节点。然后,尽管子节点存在,也不会阻止父节点被丢弃。

让我们来实现这个功能。

与之前一样,当 Arc 对象的数量到达 0 时,我们可以丢弃包含 T 的对象。然而,我们仍然不能丢弃和释放 ArcData,因为可能仍然有 weak 指针指向它。只有当最后一个 Weak 指针也不存在时,我们才能丢弃和释放 ArcData。

因此,我们将使用两个计数器:一个计算“引用 T 对象的数量”,另一个计算“引用 ArcData 对象的数量”。换句话说,第一个计数器与之前相同:它计算 Arc 对象的数量,而第二个计数器计算 Arc 和 Weak 对象的数量。

我们还需要一种在 ArcData 被 weak 指针使用时,允许我们丢弃包含的对象(T)的机制。我们将使用 Option,这样当数据被丢弃时可以使用 None,并将其包装在 UnsafeCell 中进行内部可变性(在第一章“内部可变性”中),以允许在 ArcData 不是独占所有权时发生这种情况:

#![allow(unused)]
fn main() {
struct ArcData {
    /// `Arc` 的数量
    data_ref_count: AtomicUsize,
    /// `Arc` 和 `Weak` 总共的数量。
    alloc_ref_count: AtomicUsize,
    /// 持有的数据。如果仅剩下 weak 指针,则是 `None`。
    data: UnsafeCell>,
}
}

如果我们认为 Weak 是保持 ArcData 存活的对象,那么将 Arc 实现为包含 Weak 的结构体可能是有意义的,因为 Arc 需要做相同的事情,而且还有更多的功能。

#![allow(unused)]
fn main() {
pub struct Arc {
    weak: Weak,
}

pub struct Weak {
    ptr: NonNull>,
}

unsafe impl Send for Weak {}
unsafe impl Sync for Weak {}
}

新函数与之前的基本相同,除了它现在有两个计数器可以同时初始化:

#![allow(unused)]
fn main() {
impl Arc {
    pub fn new(data: T) -> Arc {
        Arc {
            weak: Weak {
                ptr: NonNull::from(Box::leak(Box::new(ArcData {
                    alloc_ref_count: AtomicUsize::new(1),
                    data_ref_count: AtomicUsize::new(1),
                    data: UnsafeCell::new(Some(data)),
                }))),
            },
        }
    }

    //…
}
}

就像之前一样,我们假设 ptr 字段总是指向有效的 ArcData。这一次,我们将在 Weak 上将该假设编码为私有 data() 辅助方法:

#![allow(unused)]
fn main() {
impl Weak {
    fn data(&self) -> &ArcData {
        unsafe { self.ptr.as_ref() }
    }

    // …
}
}

Arc 的 Deref 实现中,我们现在不得不使用 UnsafeCell::get() 拉取得到 cell 内容的指针,并使用不安全的代码去承诺它此时可以共享。我们也需要 as_ref().unwrap() 去获取 Option 引用。我们不必担心引发 panic,因为只有在没有 Arc 对象时 Option 才会为 None。

#![allow(unused)]
fn main() {
impl Deref for Arc {
    type Target = T;

    fn deref(&self) -> &T {
        let ptr = self.weak.data().data.get();
        // 安全性:由于 Arc 包装 data,
        // data 存在并可以共享。
        unsafe { (*ptr).as_ref().unwrap() }
    }
}
}

Weak 的克隆实现非常简单;它与我们之前 Arc 的克隆实现几乎相同:

#![allow(unused)]
fn main() {
impl Clone for Weak {
    fn clone(&self) -> Self {
        if self.data().alloc_ref_count.fetch_add(1, Relaxed) > usize::MAX / 2 {
            std::process::abort();
        }
        Weak { ptr: self.ptr }
    }
}
}

在我们新 Arc 的克隆实现中,我们需要同时递增两个计数器。我们将简单地使用 self.weak.clone() 为第一个计数器重用上面的代码,因此我们只需要手动递增第二个计数器:

#![allow(unused)]
fn main() {
impl Clone for Arc {
    fn clone(&self) -> Self {
        let weak = self.weak.clone();
        if weak.data().data_ref_count.fetch_add(1, Relaxed) > usize::MAX / 2 {
            std::process::abort();
        }
        Arc { weak }
    }
}
}

当计数器从 1 到 0 时,丢弃 Weak 应该递减它的计数,以及丢弃和释放 ArcData。这与我们之前 Arc 的 Drop 实现相同。

#![allow(unused)]
fn main() {
impl Drop for Weak {
    fn drop(&mut self) {
        if self.data().alloc_ref_count.fetch_sub(1, Release) == 1 {
            fence(Acquire);
            unsafe {
                drop(Box::from_raw(self.ptr.as_ptr()));
            }
        }
    }
}
}

丢弃 Arc 应该同时递减两个计数器。注意,其中一个计数器已经被自动地处理,因为每个 Arc 都包含一个 Weak,因此删除 Arc 也会删除一个 Weak。我们仅需要处理另一个计数器:

#![allow(unused)]
fn main() {
impl Drop for Arc {
    fn drop(&mut self) {
        if self.weak.data().data_ref_count.fetch_sub(1, Release) == 1 {
            fence(Acquire);
            let ptr = self.weak.data().data.get();
            // 安全性:data 引用计数是 0,
            // 因此没有任何东西可以访问它。
            unsafe {
                (*ptr) = None;
            }
        }
    }
}
}

在 Rust 中丢弃一个对象将首先运行它的 Drop::drop 函数(如果它实现了 Drop),然后递归地逐个地丢弃它的所有字段。

get_mut 方法中的检查基本上保持不变,除了现在需要考虑 weak 指针。看起来似乎可以在检查独占性时忽略 weak 指针,但是 Weak 可以随时升级为 Arc。因此,在给出 &mut T 之前,get_mut 必须检查是否还有其他 Arc 或者 Weak 指针:

#![allow(unused)]
fn main() {
impl Arc {
    // …

    pub fn get_mut(arc: &mut Self) -> Option {
        if arc.weak.data().alloc_ref_count.load(Relaxed) == 1 {
            fence(Acquire);
            // 安全性:没有任何东西可以访问 data,因为
            // 仅有一个 Arc,并且我们拥有独占访问权限,
            // 也没有 Weak 指针
            let arcdata = unsafe { arc.weak.ptr.as_mut() };
            let option = arcdata.data.get_mut();
            // 我们知道 data 是仍然可获得的,因为我们
            // 有一个 Arc 去包裹它,因此不会 panic。
            let data = option.as_mut().unwrap();
            Some(data)
        } else {
            None
        }
    }

    // …
}
}

接下来:是升级 Weak 指针。当数据仍然存在时,才能升级 Weak 到 Arc。如果仅剩下 Weak 指针,则没有数据通过 Arc 共享了。因此,我们需要递增 Arc 的计数器,但只能在计数器不为 0 时才能这样做。我们将使用「比较并交换」循环(第二章的“比较并交换操作”)来做这些。

与之前一样,对于递增引用计数,relaxed 内存排序是好的。在这个原子操作之前或之后,没有其他变量的操作需要严格执行。

#![allow(unused)]
fn main() {
impl Weak {
    //…

    pub fn upgrade(&self) -> Option> {
        let mut n = self.data().data_ref_count.load(Relaxed);
        loop {
            if n == 0 {
                return None;
            }
            assert!(n ` 获得 `Weak` 要简单得多:

```rust
impl Arc {
    // …

    pub fn downgrade(arc: &Self) -> Weak {
        arc.weak.clone()
    }
}
}

测试它2

英文版本

为了快速测试我们创建的内容,我们将修改之前的单元测试,以使用 weak 指针,并验证它们是否可以在预期的情况下升级:

#![allow(unused)]
fn main() {
#[test]
fn test() {
    static NUM_DROPS: AtomicUsize = AtomicUsize::new(0);

    struct DetectDrop;

    impl Drop for DetectDrop {
        fn drop(&mut self) {
            NUM_DROPS.fetch_add(1, Relaxed);
        }
    }

    // 创建一个 Arc,同时也创建两个 weak 指针。
    let x = Arc::new(("hello", DetectDrop));
    let y = Arc::downgrade(&x);
    let z = Arc::downgrade(&x);

    let t = std::thread::spawn(move || {
        // 此刻,Weak 指针应该被升级。
        let y = y.upgrade().unwrap();
        assert_eq!(y.0, "hello");
    });
    assert_eq!(x.0, "hello");
    t.join().unwrap();

    // data 仍然不应该被丢弃,
    // 并且 weak 指针应该被升级。
    assert_eq!(NUM_DROPS.load(Relaxed), 0);
    assert!(z.upgrade().is_some());

    drop(x);

    // 现在,data 已经被丢弃,并且
    // weak 指针应该不再被升级。
    assert_eq!(NUM_DROPS.load(Relaxed), 1);
    assert!(z.upgrade().is_none());
}
}

这也毫无问题地编译和运行,这给我们留下了一个非常可用的手工 Arc 实现。

优化

英文版本

虽然 weak 指针是可用的,但 Arc 类型通常用于没有任何 weak 的情况下。我们上次实现的缺点是,克隆和丢弃 Arc 现在都需要两个原子操作,因为它们不得不递增或递减两个计数器。这使得 Arc 用于丢弃 weak 指针的开销增大,即使它们没有使用 weak 指针。

似乎解决的方案是分别计算 ArcWeak 指针的计数,但那样我们将无法原子地检测这两个计数器是否为 0。为了理解这个问题,想象我们有一个线程执行以下令人恼火的函数:

#![allow(unused)]
fn main() {
fn annoying(mut arc: Arc) {
    loop {
        let weak = Arc::downgrade(&arc);
        drop(arc);
        println!("I have no Arc!"); // 1
        arc = weak.upgrade().unwrap();
        drop(weak);
        println!("I have no Weak!"); // 2
    }
}
}

该线程不断降级和升级一个 Arc,以至于它反复地循环未持有 Arc(1)和未持有 Weak(2)的片刻。如果我们同时检查两个计数器,查看是否还有线程仍然使用的内存,如果我们不幸地在它的第一个输出语句(1)期间检查 Arc 计数,但在第二个输出语句(2)期间检查 Weak 计数器,该线程能够隐藏它的存在。

在我们上次实现中,我们通过将每个 Arc 也计为 Weak 来解决了这个问题。一种更微妙的解决是将所有的 Arc 指针合并为一个单独的 Weak 指针进行计数。这样,只要周围仍然有一个 Arc 对象,weak 指针计数器(alloc_ref_count)将从不会到达 0,就像在我们上次实现中一样,但是克隆的 Arc 不需要触及该计数器。只有当最后一个 Arc 被丢弃时,weak 指针计数才会递减。

让我们尝试它。

这次,我们不能简单地将 Arc 实现为对 Weak 的包装,所以两者都将包装一个非空指针到内存分配中:

#![allow(unused)]
fn main() {
pub struct Arc {
    ptr: NonNull>,
}

unsafe impl Send for Arc {}
unsafe impl Sync for Arc {}

pub struct Weak {
    ptr: NonNull>,
}

unsafe impl Send for Weak {}
unsafe impl Sync for Weak {}
}

因为我们正在优化我们的实现,我们也能通过使用 std::mem::ManuallyDrop 来稍微减小 ArcData 的大小。我们使用 Option 是为了能够在丢弃数据时,将 Some(T) 替换为 None,但实际上我们并不需要单独的 None 状态去告诉我们数据消失了,因为 Arc 的存在或者缺失已经告诉我们这一点。ManuallyDrop 占用了与 T 相同的数量的空间,但是这允许我们任意时刻通过不安全地调用 ManuallyDrop::drop() 来手动丢弃 T:

#![allow(unused)]
fn main() {
use std::mem::ManuallyDrop;

struct ArcData {
    /// `Arc` 的数量
    data_ref_count: AtomicUsize,
    /// `Weak` 的数量,如果有任意的 `Arc` 的数量,加上 1。
    alloc_ref_count: AtomicUsize,
    /// 持有的数据。如果仅剩下 weak 指针,就丢弃它。
    data: UnsafeCell>,
}
}

Arc::new() 函数几乎持不变,像之前一样初始化两个计数器,但是现在使用 ManuallyDrop::new(),而不是 Some()

#![allow(unused)]
fn main() {
impl Arc {
    pub fn new(data: T) -> Arc {
        Arc {
            ptr: NonNull::from(Box::leak(Box::new(ArcData {
                alloc_ref_count: AtomicUsize::new(1),
                data_ref_count: AtomicUsize::new(1),
                data: UnsafeCell::new(ManuallyDrop::new(data)),
            }))),
        }
    }

    // …
}
}

Deref 的实现不能再在 Weak 类型上使用私有数据方法,因此我们将在 Arc 上添加相同的私有辅助函数:

#![allow(unused)]
fn main() {
impl Arc {
    // …

    fn data(&self) -> &ArcData {
        unsafe { self.ptr.as_ref() }
    }

    // …
}

impl Deref for Arc {
    type Target = T;

    fn deref(&self) -> &T {
        // 安全性:因为有一个 Arc 包裹 data,
        // data 存在,并且可能被共享。
        unsafe { &*self.data().data.get() }
    }
}
}

Weak 的克隆和 Drop 实现与我们上次实现完全相同。包括私有的 Weak::data 辅助函数,这里是为了完整性:

#![allow(unused)]
fn main() {
impl Weak {
    fn data(&self) -> &ArcData {
        unsafe { self.ptr.as_ref() }
    }

    // …
}

impl Clone for Weak {
    fn clone(&self) -> Self {
        if self.data().alloc_ref_count.fetch_add(1, Relaxed) > usize::MAX / 2 {
            std::process::abort();
        }
        Weak { ptr: self.ptr }
    }
}

impl Drop for Weak {
    fn drop(&mut self) {
        if self.data().alloc_ref_count.fetch_sub(1, Release) == 1 {
            fence(Acquire);
            unsafe {
                drop(Box::from_raw(self.ptr.as_ptr()));
            }
        }
    }
}
}

现在我终于来到这个新的优化实现的重点内容——克隆 Arc 现在只需要操作一个计数器:

#![allow(unused)]
fn main() {
impl Clone for Arc {
    fn clone(&self) -> Self {
        if self.data().data_ref_count.fetch_add(1, Relaxed) > usize::MAX / 2 {
            std::process::abort();
        }
        Arc { ptr: self.ptr }
    }
}
}

类似地,丢弃 Arc 现在也只需要递减一个计数器,除了看到最后一个 drop 操作会将计数器从 1 递减到 0。在这中情况下,weak 指针计数也需要递减,以便在没有 weak 指针时到达 0。我们通过简单地创建一个无关紧要的 Weak,然后立即丢弃它来实现这一点:

#![allow(unused)]
fn main() {
impl Drop for Arc {
    fn drop(&mut self) {
        if self.data().data_ref_count.fetch_sub(1, Release) == 1 {
            fence(Acquire);
            // 安全性:data 引用计数是 0,
            // 所以没有东西再访问 data。
            unsafe {
                ManuallyDrop::drop(&mut *self.data().data.get());
            }
            // 现在,没有 `Arc` 了,
            // 丢弃所有表示 `Arc` 的隐式 weak 指针。
            drop(Weak { ptr: self.ptr });
        }
    }
}
}

Weak 上的 upgrade 方法基本保持不变,只是不再克隆 weak 指针,因为它不再需要递增 weak 计数器。仅当内存分配中至少有一个 Arc 才会成功,这意味着 Arc 对象已经计入了 weak 计数器。

#![allow(unused)]
fn main() {
impl Weak {
    // …

    pub fn upgrade(&self) -> Option> {
        let mut n = self.data().data_ref_count.load(Relaxed);
        loop {
            if n == 0 {
                return None;
            }
            assert!(n ` 以及没有`Weak`,因为一个 weak 指针计数现在可以表示多个 `Arc` 指针。读取计数器是发生在(稍微)不同时间的两个分开的操作,所以我们不得不非常小心,以确保不会错过任何并发的 downgrade,就像我们在[“优化”](#优化)一节示例的开头所见。

如果我们首先检查 `data_ref_count` 是 1,那么我们在检查另一个计数器之前,可能错过随后的 `upgrade()`。但是,如果我们首先检查 `alloc_ref_count` 是 1,那么在检查另一个计数器之前,可能错过随后的 `downgrade()`。

摆脱这个困境的方法是通过“锁定”weak 指针计数器来暂时阻塞 `downgrade()` 操作。为此,我们不需要像 mutex 那样的东西。我们可以使用一个特殊的值,如  `usize::MAX`,来表示 weak 指针计数器的特殊“锁定”状态。它只会在加载另一个计数器之前很短暂地被锁定,因此 downgrade 方法只需在它解锁之前自旋,以防止在正好与 `get_mut` 并发运行的极端情况下出现问题。

因此,在 `get_mut` 方法中,我们首先需要检查 `alloc_ref_count` 是否为 1,并在确实为 1 的情况下将其替换为 `usize::MAX`。这是 compare_exchange 的任务。

然后,我们需要检查其他计数器是否也为 1,之后我们可以立即解锁 weak 指针计数器。如果第二个计数器也为 1,我们就能知道我们有独占访问内存分配和数据的权限,可以返回一个 `&mut T`。

```rust
    pub fn get_mut(arc: &mut Self) -> Option {
        // Acquire 与 Weak::drop 的 Release 递减操作匹配,以确保任意的
        // 指针升级在下一个 data_ref_count.load 中可见。
        if arc.data().alloc_ref_count.compare_exchange(
            1, usize::MAX, Acquire, Relaxed
        ).is_err() {
            return None;
        }
        let is_unique = arc.data().data_ref_count.load(Relaxed) == 1;
        // Release 与 `downgrade` 中的 Acquire 操作匹配,以确保任意
        // 在 `downgrade` 之后对 data_ref_count 的改变都不会
        // 改变以上 is_unique 的结果。
        arc.data().alloc_ref_count.store(1, Release);
        if !is_unique {
            return None;
        }
        // Acquire 去匹配 Arc::drop 的 Release 递减操作,以确保没有
        // 其他东西正在访问 data。
        fence(Acquire);
        unsafe { Some(&mut *arc.data().data.get()) }
    }
}

正如你所预期的那样,锁定操作(compare_exchange)将使用 Acquire 内存排序,而解锁操作(store)将使用 Release 内存排序。

如果我们为 compare_exchange 操作使用 Relaxed 内存排序,那么在从 data_ref_count 加载时,可能无法看到新升级的 Weak 指针的新值,尽管 compare_exchange 已经确认每个 Weak 指针都已经被丢弃。

如果我们为 store 操作使用 Relaxed 内存排序,那么之前的 load 操作可能会观察到未来的 Arc::drop 结果,而该 Arc 仍然可以降级。

Acquire 屏障与之前相同:它与 Arc::Drop 中的 release-decrement 操作同步,以确保通过之前的 Arc 克隆的每次访问都发生在新的独占访问之前。

最后一部分是 downgrade 方法,它将检查特殊的 usize::MAX 值,以查看 weak 指针计数器是否被锁定,并在解锁之前自旋等待。就像在 upgrade 实现中一样,我们将在递增之前使用「比较并交换」循环来检查特殊值和溢出:

#![allow(unused)]
fn main() {
    pub fn downgrade(arc: &Self) -> Weak {
        let mut n = arc.data().alloc_ref_count.load(Relaxed);
        loop {
            if n == usize::MAX {
                std::hint::spin_loop();
                n = arc.data().alloc_ref_count.load(Relaxed);
                continue;
            }
            assert!(n 
我们为 `compare_exchange_weak` 操作使用 `acquire` 内存排序,它与 `get_mut` 函数中的 `release-store` 同步。否则,可能会出现在 `get_mut` 函数解锁计数器之前,后续的 `Arc::drop` 操作的效果对正在运行 `get_mut` 的线程可见。

换句话说,在这里,acquire 的「比较并交换」操作有效地“锁定”了 get_mut,阻止其成功。后续的 `Weak::drop` 操作可以使用 `release` 内存排序将计数器递减回 1,从而有效地“解锁”。

> 我们刚刚制作的 `Arc` 和 `Weak` 的优化实现与 Rust 标准库中包含的实现几乎相同。

如果我们运行与以前完全相同的测试([“测试它”](#测试它2)),我们看到这个优化的实现也会编译并通过我们的测试。

> 如果你觉得为这个优化的实现做出正确的内存排序决定很困难,请不要担心。许多并发数据结构比这个更容易正确地实现。本章的 Arc 实现,特别是因为它在内存排序方面具有棘手的微妙之处。

# 总结

([英文版本](https://marabos.nl/atomics/building-arc.html#summary))

* `Arc` 提供一个引用计数分配的共享所有权。
* 通过检查引用计数是否确实是一个 `Arc`,可以有条件地提供独占访问(`&mut T`)。
* 递增原子引用计数可以使用 relaxed 操作,但是最终的递减必须与之前的递减同步。
* *weak 指针*(`Weak`)可以用于避免循环。
* `NonNull` 类型表示一个指向 T 的指针,但是从不为空。
* `ManuallyDrop` 类型可以用于使用不安全代码时,手动决定何时丢弃 T。
* 一旦涉及一个以上的原子变量,事情就会变得更加复杂。
* 实现特定的(自旋)锁有时可能是同时对多个原子变量进行操作的有效策略。


  [下一篇,第七章:理解处理器](./7_Understanding_the_Processor.html)

}

第七章:理解处理器

英文版本

虽然第 2 章和第 3 章的全部理论,已经可以让我们去编写正确的并发代码,但是更进一步,大概了解一下在处理器层面,一切都是怎么运行的,这对我们也会非常有帮助。在这一章节,我们将会探索原子操作最终编译后的机器码是什么样、不同架构的处理器之间的差异、为什么会有一个弱版本的 compare_exchange 存在、内存排序在单个指令的最低级含义,并且缓存是如何和这一切联系的。

这一章的目标不是为了理解每种处理器架构的相关细节。如果这样的话,就需要我们去查阅大量大量的书籍,其中有些内容或许还没被著成书,或者根本没被公开。相反,本章节的目标是让大家对于原子操作在处理器层面是如何工作的这一点,有一个大概的认知,并且在实现或者优化涉及到关于原子的代码的时候,可以做出更多有依据的决策。当然,也仅是满足我们对处理器幕后如何工作的好奇心——暂时从抽象的理论中休息一下吧。

为了尽可能的具体化,我们只关注两种特定的处理器架构:

  • X86-64

    Intel 和 AMD 实现的 64 位 x86 架构处理器主要用于笔记本、台式机、服务器和一些游戏机主机。最初的 x86 架构的 16 位版本和之后非常流行的 32 位版本是 Intel 开发的,而 64 位的版本,也就是我们今天称作 x86-64 版本,最初是 AMD 开发的一种扩展版本,因此也常叫作 AMD64。Intel 也开发了自己的 64 位架构:IA-64,但最终采用了更为流行的 AMD 的 x86 扩展版本(名称为 IA-32E、EM64T 以及稍后的 64)。

  • ARM64

    ARM 架构的 64 位版本用于几乎所有的现代移动设备、高性能的嵌入式系统、现代化的笔记本电脑和台式机中。它也被称为 AArch64,并被被引入作为 ARMv8 的一部分。ARM 的早期版本(32 位)在很多方面也是类似地,广泛地使用在各种应用中。可以想象在各种嵌入式系统中,从汽车到电子 COVID 测试,这些流行的微控制器都是基于 ARMv6 和 ARMv7。

这两种架构在许多方面都是不同的。最重要的是,他们以不同的方式去实现原子化。理解这两种架构中原子化如何工作的,将会给我们一些更加普遍的理解,并且这些也可以适用于许多其他的处理器架构。

处理器指令

英文版本

我们可以通过仔细观察编译器的输出,以及处理器将执行的确切指令,来大致了解处理器级别的工作方式。

汇编语言简介

当编译任何以编译语言(比如 rust 或者 C)编写的软件的时候,你的代码将会翻译成可以被处理器执行的机器指令,最终处理器将会以此执行你的程序。这些指令高度特定于你编译程序的处理器架构。

这些机器指令也被称作机器码,是以二进制格式编码的,对于人类来说,完全可读。汇编是人类可阅读的代表。每一个指令用一行文本表示,通常以一个单词或者缩写表示指令,后面再跟上它的参数或者操作数。汇编器(assembler)将一段汇编文本转换成二进制表示,反汇编器(disassembler)则相反。

像 Rust 语言,编译之后,源代码的大部分结构将会丢失。依据组织结构的层级,函数与函数调用或许仍然能被识别出来。但是,像结构体或者枚举这些类型会被简化为字节和地址,循环和条件处理会被转变为基本跳转或者分支指令的扁平结构。

这里有一个示例,展示汇编长什么模样的一小段代码片段,用于某个假设的架构里面的:


ldr x, 1234 // 从内存地址 1234 加载数据到寄存器 x
li y, 0     // 将寄存器 y 设置为零
inc x       // 将寄存器 x 增加一
add y, x    // 将寄存器 x 的值加到寄存器 y
mul x, 3    // 将寄存器 x 的值乘以 3
cmp y, 10   // 比较寄存器 y 和 10
jne -5      // 如果不相等,向前跳转五条指令
str 1234, x // 将寄存器 x 的值存储到内存地址 1234

这个示例中,x 和 y 是寄存器(register)。寄存器是处理器的一部分,不属于主内存,通常保存单个数值或者内存地址。在 64 位的架构中,它们一般是 64 位的大小。在不同的架构中,寄存器的个数可能是不一样的,但通常个数也是非常有限的。寄存器基本用于计算过程中的临时存储,是将数据写回内存前存放中间结果的地方。

对于特定内存地址的常量,例如上面示例中的 -5 和 1234,经常是以人类更容易阅读的标签来代替。在将汇编转换成机器码的时候,汇编器会自动将他们替换为实际的地址。

使用标签的话,上面的示例可能是这样的:


         ldr x, SOME_VAR
         li y, 0
my_loop: inc x
         add y, x
         mul x, 3
         cmp y, 10
         jne my_loop
         str SOME_VAR, x

由于标签名只是汇编的一部分,并不是二进制机器码的,所以反汇编器不知道原始的标签名是什么,将机器码转换到汇编的时候,极大可能地只是生成一个无意义的标签名,比如 label1var2

对于所有不同架构下的完整汇编学习课程,并不在本书的范围内,也不是阅读本章的必备条件。常规的理解已经足够去弄懂这些示例代码了,我们只阅读汇编,不去写它。每个示例中相关的指令已经解释的足够详细,对于先前没有汇编经验的人来说,也可以跟得上。

为了去看 Rust 编译器产生的确切机器码,我们有几个选项。我们可以像平常一样编译我们的代码,然后使用反汇编器(比如 objdump),将生成的二进制文件转成汇编。利用编译阶段编译器产生的调试信息,反汇编器可以生成与 Rust 程序源码中原始函数名字对应的标签。这种方式有一个缺点,就是需要一个支持不同处理器架构的反汇编器。虽然 Rust 的编译器支持许多处理器架构,但是很多反汇编器只支持它所被编译的某一种处理器架构。

一种更为直接的方式是使用 rustc--emit=asm 命令行参数,让编译器直接生成汇编代码,而不是生成二进制代码。这种方式的缺点是会产生很多不相关的输出信息,包含一些我们不需要的反汇编器和调试工具的信息。

也有一些很棒的工具,比如 cargo-show-asm,它可以与 cargo 互操,并且在使用了对应的命令行参数后,会自动编译你的代码,找出你感兴趣的函数对应的汇编,并且将包含的具体指令行进行高亮处理。

对于一些相对比较小的代码片段,最简单最推荐的方式是使用 web 服务,比如特别棒的 Compiler Explorer by Matt Godbolt。这个网站上可以使用包含 Rust 在内的多种语言编写代码,并且很直观地可以看到使用选择的编译器版本所产生的汇编代码。该网站甚至使用颜色展示出哪一行 Rust 代码对应哪一行汇编代码,即使是代码被优化过,这种对应关系仍然存在。

由于我们想要观察在不同处理器架构下的汇编代码,因此需要指定 Rust 编译器想要编译的目标架构。我们将使用 x86-64 的 x86_64-unknown-linux-musl 和 ARM64 的 aarch64-unknown-linux-musl。这两个在网站 Compiler Explorer 上是直接被支持的。如果你想要在本地编译,比如使用上面提到的 cargo-show-asm 或者其他的方式,你必须确保在目标机器上,已经安装了 Rust 标准库,这些标准库通常使用 rustup 的 target 进行添加。

在任何情况下,使用编译器标识 --target 来选择编译的目标机器架构,比如 --target=aarch64-unknown-linux-musl。如果你不指定任何目标架构,编译器将会基于你当前的平台进行自动选择。(在网站 Compiler Explorer 的例子中,机器平台是这个网站所在的服务器,当前是 x86_64-unknown-linux-gnu)。

此外,建议使用 -O 标识以开启优化(或者当使用 Cargo 时使用 --release),因为这样启用优化并禁止溢出检查,这可以显著地减少我们将查看较小函数产生的汇编代码。

让我门尝试一下,看看以下函数在 x86-64 和 ARM64 上的汇编代码是什么样的:

#![allow(unused)]
fn main() {
pub fn add_ten(num: &mut i32) {
    *num += 10;
}
}

使用 -O --target=aarch64-unknown-linux-musl 作为上述任何方式的编译参数,我们将得到 ARM64 架构下的汇编代码:

add_ten:
    ldr w8, [x0]
    add w8, w8, #10
    str w8, [x0]
    ret

x0 寄存器包含我们函数的参数,即增加 10 的 i32 内存地址。首先,ldr 指令从内存地址加载 32 位值到 w8 寄存器。然后,add 指令增加 10 到 w8 并且将结果存回至 w8。随后,str 指令将 w8 指令存回到相同的内存地址。最后,ret 指令标记函数结束,并导致处理器跳回并继续执行调用 add_ten 的函数。

如果我们为 x86_64-unknown-linux-musl 编译完全相同的代码,我们将得到这样的东西:

add_ten:
    add dword ptr [rdi], 10
    ret

这次,叫做 rdi 的寄存器用于 num 参数。更有趣地是,在 x86_64,一个单独的 add 指令可以完成 ARM64 上需要 3 个指令才能完成的操作:加载、加和存储值。

复杂指令集计算机(CISC)架构上的情况,例如 x86。这种架构上的指令通常有很多变体,例如操作寄存器或直接操作特定大小的内存。(汇编指令中的 dword 指定 32 位操作。)

相对地,精简指令集计算机(RISC)架构上通常有非常少变体的精简指令集,例如 ARM。大多数指令仅能操作寄存器,并且加载和存储到内存需要单独的指令。这允许更简单的处理器,这可以降低成本,有时甚至提高性能。

这种差异在原子性的取值和修改指令中尤为突出,我们很快就会看到。

虽然编译器通常非常聪明,但它们并不总是生成最优的汇编,尤其当涉及原子操作时。如果你正在尝试并发现你对汇编中看似不必要的复杂性感到困惑的情况,这通常只是意味着编译器的未来版本有更多的优化空间。

加载和存储操作

英文版本

在我们深入研究更高级的内容之前,让我们首先看看最基本的原子操作:加载和存储指令。

通过 &mut i32 进行的采用的常规非原子指令,在 x86-64ARM64 上只需要一个指令,如下所示:

Rust 源码
```

pub fn a(x: &mut i32) { *x = 0; }

  
  
    编译的 x86-64
    ```
a:
    mov dword ptr [rdi], 0
    ret
编译的 ARM64
```

a: str wzr, [x0] ret

  


在 x86-64 上,非常通用的 mov 指令用于将数据从一个地方复制(“移动”)到另一个地方;在这个示例下,是从 0 常数复制到内存。在 ARM64 上,`str`(存储寄存器)指令将一个 32 位寄存器存储到内存中。在这个示例下,使用的是特殊的 wzr 寄存器,它总是包含 0。

如果我们更改代码,而是使用 relaxed 排序的原子操作,我们将得到以下结果:


  
    Rust 源码
    ```
pub fn a(x: &AtomicI32) {
    x.store(0, Relaxed);
}
编译的 x86-64
```

a: mov dword ptr [rdi], 0 ret

  
  
    编译的 ARM64
    ```
a:
    str wzr, [x0]
    ret

或许令人惊讶的是,它的汇编与非原子版本完全相同。事实证明,mov 和 str 指令已经是原子的。它们要么发生,要么它们完全不会发生。显然,在 &mut i32&AtomicI32 之间的任何差异仅对编译器检查和优化有关,但对于处理器是没有意外的——至少对于这两种架构上的 relaxed store 操作。

当我们查看 relaxed load 操作时,也是相同的:

Rust 源码
```

pub fn a(x: &i32) -> i32 { *x }

pub fn a(x: &AtomicI32) -> i32 { x.load(Relaxed) }

  
  
    编译的 x86-64
    ```
a:
    mov eax, dword ptr [rdi]
    ret
```

a: mov eax, dword ptr [rdi] ret

  
  
    编译的 ARM64
    ```
a:
    ldr w0, [x0]
    ret
```

a: ldr w0, [x0] ret

  


在 x86-64 上,mov 指令被再次使用,这次用于从内存中的值复制到 32 位 eax 寄存器。在 ARM64 上,使用 ldr(加载寄存器)指令将内存中的值加载到 w0 寄存器。

> 32 位的 eax 和 w0 寄存器用于返回函数的 32 位返回值。(对于 64 位值,使用 64 位的 rax 和 x0 寄存器。)

尽管处理器没有明显地区别原子和非原子的 store 和 load 操作之间的不同,但是在我们的 Rust 代码中不能安全地忽视这些区别。如果我们使用一个 `&mut i32`,Rust 编译器可能假设没有其他线程可以并发地访问相同的 i32 类型,并且可能决定以某种方式去转换或者优化代码,使得 store 操作不再导致单个相应的 store 指令。例如,通过使用两个单独的 16 位指令进行非原子的 32 位 load 或 store 操作时非常正确的,尽管有些不寻常。

### 读并修改并写操作

([英文版本](https://marabos.nl/atomics/hardware.html#rmw-ops)

对于*读并修改并写操作*来说,事情将变得更加有趣。正如本章早期讨论的那样,在类似 ARM64 的 RISC 架构中,非原子的读并修改并写操作通常编译为三个分开的指令(读、修改以及写),但在类似 x86-64 的 CISC 架构中,通常编译为一个单独的指令。这个简短的示例如下:


  
    Rust 源码
    ```
pub fn a(x: &mut i32) {
    *x += 10;
}
编译的 x86-64
```

a: add dword ptr [rdi], 10 ret

  
  
    编译的 ARM64
    ```
a:
    ldr w8, [x0]
    add w8, w8, #10
    str w8, [x0]
    ret

我们甚至可以在查看相应的原子操作之前,合理的假设这次会看到一个非原子和原子版本之间的区别。ARM64 版本显然不是原子的,因为它的 load 和 store 操作发生在不同的步骤中。

尽管不能从汇编本身直接明显地看出,但 x86-64 版本并不是原子的。add 指令将由处理器在幕后分割成几个指令,分别的步骤为加载值和存储结果。在单核计算机中,这是无关紧要的,因为在指令之间切换处理器核心仅在线程之间发生。然而,当多个核心并行执行指令,我们在不考虑执行单个指令设计的多个步骤的情况下,就不能假设所有指令都以原子地方式发生。

x86 lock 前缀

英文版本

为了支持多核系统,英特尔引入了一个称为 lock 的处理器前缀。它被用于像 add 这样的指令修饰符,以使它们的操作变得原子化。

lock 前缀最初是会导致处理器在指令执行期间,临时阻止所有其他核心访问内存。尽管这是一个简单和有效的方式,可以使在其他的核心看来像原子操作,但每次原子操作都停止其他活动(stop the world)是非常低效的。新的处理器对 lock 前缀的处理方式有更先进的 lock 前缀实现,它们并不会停止其他核心处理不相关的内存,并且允许核心在等待某块内存变得可获得期间,继续做有用的事情。

lock 前缀只能应用于非常有限数量的指令,包括 add、sub、and、or 以及 xor,这些都是非常有用的、能够以原子方式完成的操作。xchg(exchange)指令对应于原子交换操作,有一个隐式的 lock 前缀:无论 lock 前缀如何,它的行为都像 lock xchg。

让我们通过改变我们的最后一个示例来操作 AtomicI32,看看 lock add 的操作:

Rust 源码
```

pub fn a(x: &AtomicI32) { x.fetch_add(10, Relaxed); }

  
  
    编译的 x86-64
    ```
a:
    lock add dword ptr [rdi], 10
    ret

正如预期的那样,与非原子版本唯一的区别就是 lock 前缀。

在上面的示例中,我们忽略了 fetch_add 返回的值,也就是操作之前 x 的值。然而,如果我们使用了这个值,add 指令就不够用了。add 指令可以向之后的指令提供一点有用的信息,比如更新后的值是否为零或负数,但它并未提供完整的(原始或更新的)值。相反,可以使用另一个指令:xadd(“交换并添加”),它将原来加载的值放入一个寄存器。

我们可以通过对我们的代码做一个小修改,使其返回 fetch_add 返回的值,来看到它的实际效果:

Rust 源码
```

pub fn a(x: &AtomicI32) -> i32 { x.fetch_add(10, Relaxed) }

  
  
    编译的 x86-64
    ```
a:
    mov eax, 10
    lock xadd dword ptr [rdi], eax
    ret

现在使用一个包含 10 的寄存器,而不是常量 10。xadd 指令将重用该寄存器以存储旧值。

不幸的是,除了 xadd 和 xchg,其他可以加 lock 前缀的指令(例如 sub、and 以及 or)都没有这样的变体。例如,没有 xsub 指令。对于减法,这不是问题,因为 xadd 可以使用负数值。然而,对于 and 和 or 没有这样的替代方案。

对于 and、or 以及 xor 操作,只影响单个比特位,如 fetch_or(1) 或者 fetch_and(1),可以使用 bts(比特测试和设置)、btr(比特测试和复位)以及 btc(比特测试和补码)指令。这些指令也允许一个 lock 前缀,只改变一个比特位,并且使该比特位的前一个值对后续的指令可用,如条件跳转。

当这些操作影响多个比特位,它们不能由单个 x86-64 指令表示。类似地,fetch_max 和 fetch_min 操作也没有相应的 x86-64 指令。对于这些操作,我们需要一个不同与简单 lock 前缀的策略。

x86 比较并交换指令

英文版本

第二章“比较并交换操作”中,我们看到任何原子「获取并修改」操作都可以实现为一个「比较并交换」循环。对于由单个 x86-64 指令表示的操作,编译器可以使用这种方式,因为该架构确实包含一个(lock 前缀)的 cmpxcchg(比较并交换)指令。

我们可以通过将最后一个示例从 fetch_add 更改为 fetch_or 来在操作中看到这一点:

Rust 源码
```

pub fn a(x: &AtomicI32) -> i32 { x.fetch_or(10, Relaxed) }

  
  
    编译的 x86-64
    ```
a:
    mov eax, dword ptr [rdi]
.L1:
    mov ecx, eax
    or ecx, 10
    lock cmpxchg dword ptr [rdi], ecx
    jne .L1
    ret

第一条 mov 指令从原子变量加载值到 eax 寄存器。以下 mov 和 or 指令是将该值复制到 ecx 和应用二进制或操作,以至于 eax 包含旧值,ecx 包含新值。紧随之后的 cmpxchg 指令行为完全类似于 Rust 中 compare_exchange 方法。它的第一个参数是要操作的内存地址(原子变量),第二个参数(ecx)是新值,期望的值隐式地从 eax 取出,并且返回值隐式地存储在 eax 中。它还设置了一个状态标识,后续的指令可以用根据操作是否成功,来有条件的进行分支跳转。在这种情况下,使用 jne(如果与期待值不等则跳转)指令跳回 .L1 标签,在失败时再次尝试。

以下是 Rust 中等效的「比较并交换」循环的样子,就像我们在第2章的“比较并交换操作”中看到的那样:

#![allow(unused)]
fn main() {
pub fn a(x: &AtomicI32) -> i32 {
    let mut current = x.load(Relaxed);
    loop {
        let new = current | 10;
        match x.compare_exchange(current, new, Relaxed, Relaxed) {
            Ok(v) => return v,
            Err(v) => current = v,
        }
    }
}
}

编译此代码会导致与 fetch_or 版本完全相同的汇编。这表明,至少在 x86-64 上,它们在各方面确实都是相等的。

在 x86_64,在 compare_exchange 和 compare_exchange_weak 之间是有没有区别的。两者都编译为 lock cmpxchg 指令。

LL 和 SC 指令

英文版本

在 RISC 架构上最接近「比较并交换」循环的是 load-linked/store-conditional(LL/SC)循环。它包括两个特殊的指令,这两个指令成对出现:LL 指令的行为更像常规的 load 指令;SC 指令,其行为更像常规的 store 指令。它们都成对的使用,两个指令都针对同一个内存地址。与常规的 load 和 store 指令的主要区别是 store 是有条件的:如果自从 LL 指令以来任何其他线程已经覆盖了该内存,它就会拒绝存储到内存。

这两条指令允许我们从内存加载一个值,修改它,并只有在仅在没有人从我们加载它以来覆盖该值的情况下,才将新值存回。如果失败,我们可以简单地重试。一旦成功,我们可以安全地假装整个操作是原子的,因为它没有被打断。

使这些指令可行且高效的关键有两点:(1)一次只能追踪一个内存地址(每个核心),(2)存储条件允许有伪阴性1,意味着即使没有任何东西改变这个特定的内存片段,它也可能失败存储。

这使得可以在跟踪内存更改时不那么精确,但可能需要通过 LL/SC 循环额外花几个周期。访问内存的跟踪可以不按字节,而是按 64 字节的分块,或者按千字节,甚至整个内存作为一个整体。不够精确的内存跟踪导致更多不必要的 LL/SC 循环,显著降低性能,但也降低了实现的复杂性。

采用一个极端的想法,一个基本的、假设的单核系统可以使用一种策略,即完全不跟踪对内存的写入。相反,它可以跟踪中断或上下文切换,这些事件可以导致处理器切换到另一个线程。如果在一个没有任何并行性的系统中,没有发生这样的事件,它可以安全地假设没有其他线程可能触及到内存。如果发生了这样的事件,它可以仅假设最糟糕的情况,拒绝存储,并希望在循环的下一次迭代中有更好的运气。

ARM 的 ldxr 和 stxr 指令

英文版本

在 ARM64 中,或者至少是 ARMv8 的第一个版本,没有任何原子「获取并修改」或者「比较并交换」操作可以通过单个指令表示。对于 RISC 的性质,load 和 store 步骤与计算和比较是分离的。

ARM64 的 LL 和 SC 指令被称为 ldxr(加载独占寄存器)和 stxr(存储独占寄存器)。此外,clrex(清理独占)指令可以用作停止跟踪对内存的写入,而不存储任何东西的 stxr 替代。

为了看到它们的实际效果,让我们看看在 ARM64 上进行原子加时会发生什么:

Rust 源码
```

pub fn a(x: &AtomicI32) { x.fetch_add(10, Relaxed); }

  
  
    编译的 ARM64
    ```
a:
    mov eax, dword ptr [rdi]
.L1:
    ldxr w8, [x0]
    add w9, w8, #10
    stxr w10, w9, [x0]
    cbnz w10, .L1
    ret

我们得到的东西看起来非常类似于我们之前得到的非原子版本(在“读并修改并写操作”):一个 load 指令、一个 add 指令以及一个 store 指令。load 和 store 指令已经被替换为它们的“独占”LL/SC 版本,并且出现一个新的 cbnz(非 0 比较和分支)指令。如果成功,stxr 指令在 w10 存储一个 0,如果失败,则存储 1。cbnz 指令利用这点,如果操作失败,则重启整个操作。

注意,与 x86-64 上的 lock add 不同,我们不需要做任何特殊的处理检索旧值。在以上示例中,操作成功后,旧值将仍然在寄存器 w8 可获得,所以并不需要相 xadd 这样的特殊指令。

这钟 LL/SC 模式是非常灵活的:它不仅可以用于像 add 和 or 这样有限的操作集,而是可以用于几乎任何操作。我们可以通过在 ldxr 和 stxr 指令之间放入相应的指令,轻松地实现原子 fetch_divide 或 fetch_shift_left 操作。然而,如果它们之间有太多的指令,中断的几率就会越来越高,导致额外的周期。通常,编译器会尝试在 LL/SC 模式中的指令数量尽可能少,防止 LL/SC 循环频繁失败可能从不成功,并且以及可能无限自旋的情况。

ARMv8.1 原子指令

ARMv8.1 的后续版本 ARM64,还包括新的 CISC 风格指令,用于常见的原子操作。例如,新的 ldadd(加载并添加)指令等效于一个原子 fetch_add 操作,无需使用 LL/SC 循环。它甚至包括像 fetch_max 这样的操作指令,这在 x86-64 上并不存在。

它还包括一个与 com⁠pare_​exchange 相对应的 cas(比较并交换)指令。当使用此指令时,compare_exchangecompare_exchange_weak 之间没有差别,就像在 x86-64 上一样。

尽管 LL/SC 模式非常灵活,并且很好地适应了一般的 RISC 模式,但这些新指令可以更高效,因为它们可以通过特定的硬件进行更轻松的优化。

ARM 的「比较并交换」操作

英文版本

compare_exchange 操作通过使用条件分支指令在比较失败时跳过 store 指令,这与 LL/LC 模式的映射非常恰当。让我们来看看生成汇编的代码:

Rust 源码
```

pub fn a(x: &AtomicI32) { x.compare_exchange_weak(5, 6, Relaxed, Relaxed); }

  
  
    编译的 ARM64
    ```
a:
    ldxr w8, [x0]
    cmp w8, #5
    b.ne .L1
    mov w8, #6
    stxr w9, w8, [x0]
    ret
.L1:
    clrex
    ret

注意,compare_and_exchange 操作通常用于一个循环,如果该比较失败,则循环重复。然而,对于该示例,我们仅调用一次并且忽略它的返回值,这让我们在无干扰的情况下查看汇编。

ldxr 指令加载了值,然后立即通过 cmp(比较)指令将其与预期的值 5 进行比较。如果值不符合预期,b.ne(如果与预期值不等则跳转分支)指令会导致跳转到 .L1 标签,在此刻,clrex 指令用于中止 LL/SC 模式。如果值是 5,流程将通过 mov 和 stxr 指令继续,将新的值 6 存储到内存中,但这只会在与此同时没有任何东西覆盖 5 的情况下发生。

请记住,stxr 允许有伪阴性;即使 5 没有被覆盖,这里也可能失败。这没问题,因为我们正在使用 compare_exchange_weak,它也允许有伪阴性。事实上,这就是为什么存在 compare_exchange 的 weak 版本。

如果我们将 compare_exchange_weak 替换为 compare_exchange,我们得到的汇编代码几乎完全相同,除了在操作失败时会有额外的分支来重新启动操作:

Rust 源码
```

pub fn a(x: &AtomicI32) { x.compare_exchange(5, 6, Relaxed, Relaxed); }

  
  
    编译的 ARM64
    ```
a:
    mov w8, #6
.L1:
    ldxr w9, [x0]
    cmp w9, #5
    b.ne .L2
    stxr w9, w8, [x0]
    cbnz w9, .L1
    ret
.L2:
    clrex
    ret

正如预期的那样,现在有一个额外的 cbnz(非 0 的比较和分支)指令,在失败时去重新开始 LL/LC 循环。此外,mov 指令已经移出循环,以保证循环尽可能地短。

「比较并交换」循环的优化

正如我们在x86-比较并交换指令中看到的,在 x86-64 上,fetch_or 操作和等效的 compare_exchange 循环编译成了完全相同的指令。人们可能期望在 ARM 上也会发生同样的情况,至少在 compare_exchange_weak 上,因为加载和「弱比较并交换」操作可以直接映射到 LL/SC 指令。

不幸的是,当前(截至 Rust 1.66.0)的情况并非如此。

虽然随着编译器的不断改进,这种情况可能会在未来发生变化,但编译器要安全地将手动编写的「比较并交换」循环转化为相应的 LL/SC 循环还是相当困难的。其中一个原因是,可以放在 stxr 和 ldxr 指令之间的指令的数量和类型是有限的,这不是编译器在应用其他优化时需要考虑的内容。在像「比较并交换」这样的模式还可以识别时,表达式将编译成的确切指令还不清楚,这使得对于一般情况来说,这是一个非常棘手的优化问题。

因此,直到在我们有更聪明的编译器之前,如果可能的话,建议使用专用的「获取并修改」方法,而不是「比较并交换」循环。

缓存2

英文版本

读取和写入内存是缓慢的,并且很容易花费执行数十甚至数百条指令的时间。这就是为什么所有高性能的处理器都实现了缓存,以尽可能避免与相对较慢的内存进行交互。现代处理器中内存缓存的具体实现细节复杂、有的是独有的,而且最重要的是,当我们编写软件时,这些细节大部分对我们来说都不相关。毕竟,缓存(cache)这个词源自法语单词 caché,意思是隐藏。尽管如此,在优化软件性能时,理解大多数处理器幕后如何实现缓存的基本原理时可能非常有用。(当然,我们并不需要借口去学习更多有关的主题。)

除了非常小的微控制器,几乎所有现代处理器都使用缓存。这样的处理器从不直接与主内存交互,而是通过它的缓存路由每个读取和写入请求。如果一个指令需要从内存中读取某些内容,处理器将向其缓存请求这些数据。如果数据已经在缓存中,缓存将快速响应并提供缓存的数据,从而避免与主内存交互。否则,它将不得不走一条慢路,即缓存可能需要向主内存请求相关数据的副本。一旦主内存响应,缓存不仅最终会响应原始的读取请求,同时也会记住这些数据,以便在下次请求这些数据时能更快地响应。如果缓存满了,它会通过丢弃一些它认为最不可能有用的旧数据来腾出空间。

当一个指令想要将某些内容写入内存时,缓存可能会决定保留修改后的数据,而不将其写入主内存。任何后续对相同内存地址的读取请求将得到修改后数据的副本,从而忽略主内存中过时的数据。仅有在需要从缓存中丢弃修改后的数据以腾出空间时,才会实际将数据写回主内存。

在大多数处理器架构中,缓存以 64 字节的分块读取和写入内存,即使只请求了一个字节。这些块通常被称为缓存行(cache line)。通过缓存该请求字节周围的整个 64 字节分块,任何后续需要访问该分块中的其他字节的指令都不必等待主内存。

缓存一致性

英文版本

在现代处理器中,通常有不止一层缓存。第一层缓存,或者称为一级(L1)缓存,是最小且最快的。它不和主内存通信,而是和二级(L2)缓存通信,后者虽然更大,但速度慢一些。L2 缓存可能是与主内存通信的那个,或者可能还有另一个更大更慢的 L3 缓存——甚至可能有 L4 缓存。

添加额外的层并不会改变它们的工作方式;每一层都可以独立运行。然而,当存在多个处理器核心,每个核心都有自己的缓存时,情况就变得有趣了。在多核系统中,每个处理器核心通常有自己的 L1 缓存,而 L2 或 L3 缓存往往与部分或所有其他核心共享。

在这种条件下,原本的缓存实现会崩溃,因为缓存不能再假设它控制着所有与下一层的交互。如果一个缓存接受了写操作并将某个缓存行标记为已修改,而没有通知其他的缓存,那么缓存的状态可能会变得不一致。修改后的数据直到缓存将数据写入下一层之前,不会对其他核心可用,而且最终可能会与其他缓存中缓存的不同修改发生冲突。

为了解决这个问题,我们使用了一种叫做缓存一致性协议。这样的协议定义了如何准确地操作缓存并与其他缓存通信,以保持所有的状态一致。具体使用的协议根据架构、处理器模型,甚至每个缓存层都有所不同。

我们将讨论两种基本的缓存一致性协议。现代处理器使用这些协议的许多变体。

write-through 协议

英文版本

在缓存中,实施 write-through 缓存一致性协议,写操作不会被缓存,而是立即发送到下一层。其它缓存通过同一共享通信通道连接到下一层,这意味着它们可以观察到其它缓存与下一层的通信状况。当缓存观察到某个地址的写操作,而该地址当前已在缓存中,它会立即丢弃或更新自己的缓存行,以保持一致性。

使用这种协议,缓存永远不会包含任何处于已修改状态的缓存行。尽管这极大地简化了事情,但对于写操作,它丧失了缓存的优势。当仅针对读取进行优化时,这可能是一个很好的选择。

MESI 协议3

英文版本

MESI 缓存一致性协议是由之后的四种可能状态命名的,它为缓存行定义了:已修改(Modified,M)、独占(Exclusive,E)、共享(Shared,S)和无效(Invalid,I)。已修改(M)用于包含已经修改数据的缓存行,但该数据尚未写入到内存(或下一级缓存)数据。独占(E)用于包含未修改数据的缓存行,且该数据没有缓存在任意其他缓存中(在同一级别)。共享(S)用于包含未修改数据的缓存行,这些缓存行可能也出现在一个或多个其他(同级别)的缓存中。无效(I)用于未使用(空的或被丢弃)的缓存行,它们不包含任何有用的数据。

使用此协议的缓存会与同级别的所有其他缓存进行通信。它们互相发送更新和请求,使它们能够保持一致性。

当一个缓存接收到一个它尚未缓存的地址的请求(也称为缓存未命中)时,它不会立即从下一层请求。相反,它首先询问其他(同级别的)缓存是否有可用的这个缓存行。如果没有,缓存将继续从(更慢的)下一层请求地址,并将结果标记为独占(E)。当此缓存行被写操作修改时,缓存可以将状态改为已修改(M),而不通知其他缓存,因为它知道其他缓存没有缓存相同的缓存行。

当请求一个已经在任何其他缓存中可用的缓存行时,结果是一个共享(S)的缓存行,可以直接从其他缓存获得。如果缓存行处于已修改(M)状态,它将首先被写入(或刷新)到下一层,然后再改变为共享(S)并共享。如果它处于独占(E)状态,它将立即被改变为共享(S)。

如果缓存想要独占访问权,而不是共享访问权(例如,因为它将在之后立即修改数据),其他缓存不会保持缓存行在共享(S)状态,而是通过将其更改为无效(I)来完全丢弃它。在这种情况下,结果是一个独占(E)的缓存行。

如果一个缓存需要对已经在共享(S)状态下可用的缓存行进行独占访问,它只需告诉其他缓存丢弃这个缓存行,然后再将其升级为独占(E)。

此协议有几种变体。例如,MOESI 协议添加了一个额外的状态,以允许在不立即将其写入下一层的情况下共享修改过的数据,而 MESIF 协议使用一个额外的状态,该状态决定哪个缓存可以响应多个缓存中可用的共享缓存行的请求。现代处理器通常使用更复杂的和专有的缓存一致性协议。

对性能的影响

英文版本

尽管缓存大多数时候对我们是隐藏的,但缓存行为对我们的原子操作性能可能有重要影响。让我们尝试测量其中一些影响。

测量单个原子操作的速度非常棘手,因为它们速度极快。为了能得到一些有用的数据,我们必须重复一个操作,比如说,十亿次,然后测量总体花费的时间。例如,我们可以尝试测量十亿次加载 load 需要多少时间,就像这样:

static A: AtomicU64 = AtomicU64::new(0);

fn main() {
    let start = Instant::now();
    for _ in 0..1_000_000_000 {
        A.load(Relaxed);
    }
    println!("{:?}", start.elapsed());
}

不幸的是,这并没有按照我们的预期工作。

当通过优化之后运行这段代码时(例如,使用 cargo run --release 或者 rustc -O),我们将看见不合理的低测量时间。编译器足够智能知道发生了什么状况,它能够理解我们并没有使用加载的值,所以它决定完全优化掉不需要的循环。

为了避免这种情况,我们可以使用特殊的 std::hint::black_box 函数。这个函数接受任何类型的参数,它只是在不做任何优化的情况下返回这个参数。这个函数的特殊之处在于,编译器会尽可能不假设这个函数做的任何事情;它把这个函数当作一个可能做任何事情的“黑箱”来对待。

我们可以使用这个函数来避免某些可能使基准测试无效的优化。在这种情况下,我们可以将 load 操作的结果传递给 black_box(),以停止任何优化,这里假设我们实际上不需要加载值的优化。然而,这还不够,因为编译器可能仍然假设 A 总是 0,这使得 load 操作是不必要的。为了避免这种情况,我们可以在开始时将一个指向 A 的引用传递给 black_box(),这样编译器就不能再假设只有一个线程访问 A 了。毕竟,它必须假设 black_box(&A) 可能已经产生了一个与 A 交互的额外线程。

让我们试试看:

use std::hint::black_box;

static A: AtomicU64 = AtomicU64::new(0);

fn main() {
    black_box(&A); // 新增!
    let start = Instant::now();
    for _ in 0..1_000_000_000 {
        black_box(A.load(Relaxed)); // 新增!
    }
    println!("{:?}", start.elapsed());
}

这段代码在运行多次时,输出可能有点波动,但是在一台不是很新的 x86-64 电脑上,它似乎是大约 300 毫秒的结果。

为了观察任何缓存影响,我们将产生一个后台线程与原子变量交互。这样,我们可以看到它是否影响主线程的的 load 操作。

首先,让我们尝试一下,只需在后台线程上加载操作,如下所示:

static A: AtomicU64 = AtomicU64::new(0);

fn main() {
    black_box(&A);

    thread::spawn(|| { // 新增!
        loop {
            black_box(A.load(Relaxed));
        }
    });

    let start = Instant::now();
    for _ in 0..1_000_000_000 {
        black_box(A.load(Relaxed));
    }
    println!("{:?}", start.elapsed());
}

注意,我们没有测量后台线程上的操作性能。我们仍然仅是测量主线程上执行一百万的 load 操作的性能。

运行这个程序,导致与之前类似的测量结果:当在同一台 x86-64 计算机上进行测试时,它会在 300 毫秒左右波动。后台线程并不会对主线程有什么影响。它们大概都在一个单独的处理器内核上运行,但两个核心的缓存都包含 A 的副本,这允许非常快速的访问。

现在让我们更改后台线程来执行 store 操作:

static A: AtomicU64 = AtomicU64::new(0);

fn main() {
    black_box(&A);
    thread::spawn(|| {
        loop {
            A.store(0, Relaxed); // 新增!
        }
    });
    let start = Instant::now();
    for _ in 0..1_000_000_000 {
        black_box(A.load(Relaxed));
    }
    println!("{:?}", start.elapsed());
}

这次,我们确实看到了显著的差异。现在在 x86-64 架构上运行这个程序,导致的输出波动大概有 3 秒,是之前的十倍。最新的计算机将展示更小的差异,但仍然是可衡量的不同。例如,在最新的苹果 M1 处理器上,它从 350 毫秒上升到 500 毫秒,在最新的 x86-64 AMD 处理器上,它从 250 毫秒上升到 650 毫秒。

这种行为匹配我们对缓存一致性的理解:store 操作需要独占访问缓存行,这会减慢在其他核上不再共享缓存行的后续 load 操作。

「比较并交换」操作失败

有趣的是,在大多数处理器架构中,当后台线程只进行「比较并交换」操作时,我们也能观察到和 store 操作相同的效果,即使所有的「比较并交换」操作都失败。

为了验证这一点,我们可以将后台线程的 store 操作替换为一个永远不会成功的 compare_exchange 调用:

  …
      loop {
          // 从不成功,因为 A 从不会是 10
          black_box(A.compare_exchange(10, 20, Relaxed, Relaxed).is_ok());
      }
  …

因为 A 总是 0,compare_exchange 操作将从不成功。它将加载当前的 A 值,但是从不更新它到一个新值。

人们可能合理的将这个行为与 load 操作等同,因为它从没有修改原子变量。然而,在大多数处理器架构中,无论比较是否成功,compare_exchange 的指令都将声明相关缓存行的独占访问权限。

这意味着,对于我们在第四章的 SpinLock 中不使用 compare_exchange(或 swap)来自旋循环可能更高效,而是首先使用 load 操作去检查锁是否已经释放锁。那样,我们可以避免不必要地声明相关缓存行的独占访问权限。

由于缓存是按照缓存行进行的,而不是按照单个字节或变量进行的,所以我们应该能够看到使用相邻的变量而不是相同的变量也会产生相同的效果。为了验证这个,让我们使用三个原子变量而不是一个,让主线程仅使用中间的变量,并让后台线程只使用其他两个,如下所示:

static A: [AtomicU64; 3] = [
    AtomicU64::new(0),
    AtomicU64::new(0),
    AtomicU64::new(0),
];

fn main() {
    black_box(&A);
    thread::spawn(|| {
        loop {
            A[0].store(0, Relaxed);
            A[2].store(0, Relaxed);
        }
    });
    let start = Instant::now();
    for _ in 0..1_000_000_000 {
        black_box(A[1].load(Relaxed));
    }
    println!("{:?}", start.elapsed());
}

运行这个片段后,我们得到的结果和之前类似:在同样的 x86-64 计算机上,它还是需要花费数秒的时间。尽管 A[0]A[1]A[2] 仅被一个线程使用,我们仍然看到相同的效果,与两个线程使用同一个变量一样。原因在于,A[1] 和其他一或两个变量共享同一缓存行。运行后台线程的处理器核心反复地对包含 A[0]A[2] 的缓存行(也包含 A[1])声明独占访问权限,从而拖慢了对 A[1] 的“无关”操作。这种问题被称为伪共享4

我们可以通过将原子变量间隔更远来避免这个问题,这样每个变量都可以拥有自己的缓存行。如前所述,64 字节是一个合理的猜测值,用于表示缓存行的大小,所以让我们试着将我们的原子变量包装在一个 64 字节对齐的结构体中,如下所示:

#[repr(align(64))] // 这个结构体必须是 64 字节对齐
struct Aligned(AtomicU64);

static A: [Aligned; 3] = [
    Aligned(AtomicU64::new(0)),
    Aligned(AtomicU64::new(0)),
    Aligned(AtomicU64::new(0)),
];

fn main() {
    black_box(&A);
    thread::spawn(|| {
        loop {
            A[0].0.store(1, Relaxed);
            A[2].0.store(1, Relaxed);
        }
    });
    let start = Instant::now();
    for _ in 0..1_000_000_000 {
        black_box(A[1].0.load(Relaxed));
    }
    println!("{:?}", start.elapsed());
}

#[repr(align)] 属性允许我们告诉编译器我们的类型的(最小)对齐值,以字节为单位。由于 AtomicU64 仅有 8 字节,这将给我们的 Aligned 结构体添加 56 字节的填充。

运行这个程序不再给出缓慢的结果。相反,我们得到的结果和完全没有后台线程一样:当在与之前同一台 x86-64 计算机上运行时,约需要 300 毫秒。

根据你正在尝试的处理器的类型,你可能需要使用 128 字节的对齐才能看到相同的效果。

上面的实验表明,建议不要把不相关的原子变量放得太近。例如,密集的小型 mutex 数组可能并不总是能够表现得和一个让 mutex 间距隔离更远的替代结构一样好。

另一方面,当多个(原子)变量相关并且经常快速连续访问时,将它们放在一起可能是有益的。例如,我们在第4章中的 SpinLock 将 T 紧挨着 AtomicBool 存储,这意味着包含 AtomicBool 的缓存行也可能包含 T,因此对一个(独占)访问的声明也包括了另一个。这是否有益完全取决于情况。

重排

英文版本

一致性缓存,例如我们在本章前面探讨的 MESI 协议,通常不会影响程序的正确性,即使涉及多个线程。由一致性缓存引起的唯一可观察的差异归结为时间上的差异。然而,现代处理器实现了更多的优化,尤其是这些优化在涉及多个线程时可能对正确性产生重大影响。

第 3 章开始时,我们简要地讨论了指令重排,即编译器和处理器如何改变指令的顺序。仅关注处理器,这里有一些指令,或者它们的效果,可能以不同的顺序发生的示例:

  • 存储缓冲区(store buffer)5

    因为写入可能较慢,即使有缓存,处理器核心通常包含一个存储缓冲区。内存的写操作可以存储在这个存储缓冲区中,这非常快,这允许处理器立即继续执行随后的指令。然后,在后台,通过写入(L1)缓存完成写操作,这可能要慢得多。这样,处理器就不需要等待缓存一致性协议跳入动作,以得到相关缓存行的独占访问权限。

    只要采取对后续来自同一内存地址的读操作特别注意的处理,这对于作为同一处理器核心上的同一线程的一部分运行的指令来说,是完全不可见的。然而,对于一个短暂的时刻,写操作还没有对其他核心可见,这导致了从在不同核心上运行的不同线程看内存的视图不一致。

  • 失效队列(Invalidation queue)6

    无论精确的一致性协议如何,并行方式运行的缓存都需要处理失效请求:这是一种指令,指示丢弃特定的缓存行,因为这个缓存行即将被修改并变得无效。作为性能优化,通常这样的请求并不会立即处理,而是排队等待(稍后)处理。当使用这样的失效队列时,缓存不再始终是一致的,因为缓存行可能在被丢弃前短暂地过时。然而,除了使单线程程序加快,这对单线程程序没有影响。唯一的影响是来自其他核心的写操作的可见性,这可能现在看起来像是(非常轻微的)延迟。

  • 流水线(pipeline)7

    另一个极其常见的可以显著提高性能的处理器特性是流水线:如果可能的话,尽可能并行执行连续的指令。在一个指令完成执行之前,处理器可能已经开始执行下一个指令。现代处理器通常可以在第一个指令仍在处理的同时,开始执行一系列的指令。

    如果每个指令都在根据前一个指令的结果运行,这并没有什么帮助;它们仍然需要等待前一个的结果。但是,当一个指令可以独立于前一个指令执行时,它甚至可能先完成。例如,一个对寄存器仅仅进行递增的指令可能很快就完成,而前面开始的一个指令可能仍在等待从内存中读取数据,或者其他一些慢的操作。

    虽然这对单线程程序(除了速度)没有影响,但当一个操作内存的指令在其前面的指令完成执行之前就完成了,可能会导致与其他核的交互发生在预期顺序之外。

在很多方面,现代处理器可能以完全不同于预期的顺序执行指令。其中涉及许多专有技术,有些只有在发现可以被恶意软件利用的微妙错误时才公开。然而,当它们按预期工作时,它们都有一个共同点:除了时间,它们不会影响单线程程序,但可能导致与其他核心的交互看起来是不一致的顺序。

允许内存操作被重排的处理器架构也提供了通过特殊指令防止这种情况发生的方式。例如,这些指令可能强制刷新处理器的存储缓冲区,或者在继续之前完成任何流水线的指令。有时,这些指令只防止某种类型的重排。例如,可能有一种指令可以防止存储操作相对于彼此被重排,同时仍然允许 load 操作被重排。可能发生哪种类型的重排,以及如何防止它们,取决于处理器架构。

内存排序

英文版本

当执行像 Rust 或 C 这样的语言中的任意原子操作时,我们会指定一个内存排序去告知编译器我们的排序需求。编译器将为处理器生成正确的指令,以防止它以某种方式重排指令,这将可能打破规则,使程序不正确。

允许哪种类型的指令重新排序屈居于内存的操作。对于非原子和 relaxed 原子操作,任意类型的重排是可接受的。在另一个极端情况下,顺序一致原子操作完全不允许任意类型的原子排序。

acquire 操作不能与随后的任意内存操作重排,而 release 操作不能与之前的任意内存操作重排。否则,可能在 acquire mutex 之前或者 release mutex 之后,访问一些受 mutex 保护的数据可能会导致数据竞争。

Other-Multi-Copy Atomicity

在一些处理器架构(例如,可能在显卡中找到的那些)中,内存操作顺序的影响方式并不总是可以通过指令重排序来解释。在一个核上的两个连续的 store 操作的效果可能会按照相同的顺序在第二个核上变得可见,但在第三个核上的顺序可能恰恰相反。例如,由于缓存不一致或共享存储缓冲区,可能会发生这种情况。由于这并不能解释第二核和第三核的观察结果之间的不一致性情况,所以无法通过第一个核上的指令被重排序来解释这种情况。

我们在第 3 章中讨论的理论内存模型为此类处理器架构留出了空间,因为它不要求除顺序一致的原子操作之外的任何操作具有全局一致的顺序。

我们在本章中聚焦的架构(x86-64 和 ARM64)是“other-multi-copy atomic”,这意味着一旦写操作对任何核可见,它们就同时对所有核可见。对于其他“other-multi-copy atomic”架构,内存排序只是指令重排序的问题。

一些架构(例如 ARM64)被称为弱排序,因为它们允许处理器自由地重排任意的内存操作。另一方面,强排序架构(例如 x86-64)对哪些内存操作可以排序是非常严格的。

x86-64:强排序

英文版本

在 x86-64 处理器上,load 操作将从不会在后续的内存操作之后发生。类似的,该架构也不允许 store 操作在之前的内存操作之前发生。你可能在 x86-64 上看到的唯一一种重新排序是 store 操作被延迟到稍后的 load 操作之后。

由于 x86-64 架构的重排序限制,它通常被描述为强排序架构,尽管有些人更愿意保留这个这个术语描述所有保留内存操作排序的架构。

这些限制满足了 acquire-load(因为 load 从不和后续操作重排序)和 release-store(因为 store 从不和之前的操作重排序)的所有需要。这意味着在 x86-64 上,我们可以“免费的”获取 release 和 acquire 语义:release 和 acquire 与 relaxed 操作等同。

我们可以通过查来自加载和存储以及 x86 lock 前缀片段来验证这些,然而我们要将 Relaxed 改变到 Release、Acquire 或 AcqRel:

Rust 源码
```

pub fn a(x: &AtomicI32) { x.store(0, Release); }

    ```
pub fn a(x: &AtomicI32) -> i32 {
    x.load(Acquire)
}
```

pub fn a(x: &AtomicI32) { x.fetch_add(10, AcqRel); }

  
  
    编译的 x86-64
    ```
a:
    mov dword ptr [rdi], 0
    ret
```

a: mov eax, dword ptr [rdi] ret

    ```
a:
    lock add dword ptr [rdi], 10
    ret

不出所料,尽管我们指定了更强的内存顺序,但汇编是相同的。

我们可以得出结论,在 x86-64 上,忽略潜在的编译器优化,acquire 和 release 操作仅和 relaxed 操作一样便宜。或者,更准确地说,relaxed 操作和 acquire 和 release 操作一样昂贵。

让我们看看 SeqCst 发生了什么:

Rust 源码
```

pub fn a(x: &AtomicI32) { x.store(0, SeqCst); }

    ```
pub fn a(x: &AtomicI32) -> i32 {
    x.load(SeqCst)
}
```

pub fn a(x: &AtomicI32) { x.fetch_add(10, SeqCst); }

  
  
    编译的 x86-64
    ```
a:
    xor eax, eax
    xchg dword ptr [rdi], eax
    ret
```

a: mov eax, dword ptr [rdi] ret

    ```
a:
    lock add dword ptr [rdi], 10
    ret

这段代码的 load 和 fetch_add 操作仍然导致和之前相同的汇编,单 store 操作的汇编代码完全改变了。xor 指令看起来有点突兀,但这仅是通过自己异或将 eax 寄存器设置为 0 的常见方式,异或结果总是 0。mov eax, 0 指令将也达到同样的效果,但是需要更多的空间。

有趣的部分是 xchg 指令,它通常用于 swap 操作:一个同时检索旧值的 store 操作。

对于 SeqCst store,像之前常规的 mov 指令不能满足要求,因为它将允许稍后的 load 操作重新排序,打破全局一致性排序。通过将其改为也执行 load 的操作,即使我们不关心它加载的值,我们也可以获得额外的保证,即我们的指令不会与后续的内存操作重排序,从而解决了问题。

SeqCst load 操作可以仍然是一个常规的 mov 指令,这正是因为 SeqCst store 被升级到 xchg。SeqCst 操作仅保证和其他 SeqCst 操作有全局一致性排序。SeqCst load 的 mov 可能仍然与前面的非 SeqCst store 操作的 mov 进行重排序,但这完全没有问题。

在 x86-64 上,store 操作是唯一一个在 SeqCst 和较弱的内存排序之间存在差异的原子操作。换句话说,除了 store 之外的 x86-64 SeqCst 操作与 Release、Acquire、AcqRel,甚至 Relaxed 操作的一样便宜。或者,如果你愿意,x86-64 使得除 store 之外的 Relaxed 操作和 SeqCst 操作一样昂贵。

ARM64:弱排序

英文版本

在如 ARM64 这样的弱排序架构上,所有的内存操作都有可能彼此之间被重新排序。这意味着,不像 x86-64,acquire 和 release 操作不会和 relaxed 操作一样。

让我们看看在 ARM64 上对于 Release、Acquire 和 AcqRel 会发生什么:

Rust 源码
```

pub fn a(x: &AtomicI32) { x.store(0, Release); }

    ```
pub fn a(x: &AtomicI32) -> i32 {
    x.load(Acquire)
}
```

pub fn a(x: &AtomicI32) { x.fetch_add(10, AcqRel); }

  
  
    编译的 x86-64
    ```
a:
    stlr wzr, [x0] #(1)
    ret
```

a: ldar w0, [x0] #(2) ret

    ```
a:
.L1:
    ldaxr w8, [x0] #(3)
    add w9, w8, #10
    stlxr w10, w9, [x0] #(4)
    cbnz w10, .L1
    ret

与我们之前的 Relaxed 版本相比,这些改变是微妙的:

  1. str(store 寄存器)现在是 stlr(store-release 寄存器)
  2. ldr(load 寄存器)现在是 ldar(load-acquire 寄存器)
  3. ldxr(load exclusive 寄存器)现在是 ldaxr(load-acquire exclusive 寄存器)
  4. stxr(store exclusive 寄存器)现在是 stlxr(store-release exclusive 寄存器)

如上所示,ARM64 对于 acquire 和 release 排序有一个特殊的版本的 load 和 store 指令。不同于 ldr 或者 ldxr 指令,ldar 或者 ldxar 指令将从不与任意后续的内存操作重排。类似地,与 str 或者 stxr 指令不同,stlr 或 stxlr 指令将从不会和任何之前的内存操作重排。

使用仅有 Release 或 Acquire 排序的「获取并修改」操作,而非 AcqRel,将仅使用 stlxr 或 ldxar 指令分别配对一个常规的 ldxr 或 stxr 指令。

除了对 release 和 acquire 语义所需的限制外,任何特殊的 acquire 和 release 指令都永远不会与其他任何这些特殊指令重新排序,这也使它们适合用于 SeqCst。

如下面所示,升级到 SeqCst 会产生和之前完全一样的汇编代码:

Rust 源码
```

pub fn a(x: &AtomicI32) { x.store(0, SeqCst); }

    ```
pub fn a(x: &AtomicI32) -> i32 {
    x.load(SeqCst)
}
```

pub fn a(x: &AtomicI32) { x.fetch_add(10, SeqCst); }

  
  
    编译的 x86-64
    ```
a:
    stlr wzr, [x0]
    ret
```

a: ldar w0, [x0] ret

    ```
a:
.L1:
    ldaxr w8, [x0]
    add w9, w8, #10
    stlxr w10, w9, [x0]
    cbnz w10, .L1
    ret

这意味着在 ARM64 上,顺序一致性操作的和 acquire 操作和 release 操作一样便宜。或者说,ARM64 的 Acquire、Release 和 AcqRel 操作和 SeqCst 一样昂贵。然而,与 x86-64 不同,Relaxed 操作相对较便宜,因为它们不会导致比必要的更强的排序保证。

ARMv8.1 原子 Release 和 Acquire 指令

正如我们在 ARMv8.1 原子指令讨论的,ARM64 的 ARMv8.1 版本包括 CISC 风格的原子操作指令,如 ldadd(load 和 add)作为 ldxr/stxr 循环的替代。

就像 load 和 store 操作带有 acquire 和 release 语义的特殊版本一样,这些指令也有对于更强内存排序的变体。因为这些指令既涉及到加载又涉及到存储,它们每一个都有三个额外的变体:一个用于 release(-l),一个用于 acquire(-a),和一个用于组合的 release 和 acquire(-al)语义。

例如,对于 ldadd,还有 ldaddl、ldadda 和 ldaddal。类似地,cas 指令带有 casl、casa 和 casal 变体。

就像 load 和 store 指令一样,组合的 release 和 acquire(-al)变体也足以用于 SeqCst 操作。

一个实验

英文版本

由强排序架构的普遍性带来的不幸后果是,某些类型的内存排序 bug 可能很容易被忽视。在需要 Acquire 或 Release 的地方使用 Relaxed 是不正确的,但在 x86-64 上,假设编译器没有重新排序你的原子操作,这可能最终在实践中偶然工作得很好。

请记住,不仅处理器可以导致事情无序发生。只要考虑到内存排序的约束,编译器也被允许重新排序它产生的指令。

实际上,编译器在涉及原子操作的优化上往往非常保守,但这在未来可能会发生改变。

这意味着人们可以轻易地编写不正确的并发代码,在 x86-64 上(意外地)运行得很好,但当在 ARM64 处理器编译和运行时可能会崩溃。

让我们试着做到这一点。

我们将创建一个自旋锁保护的计数器,但将所有的内存排序改为 Relaxed。让我们不费心创建自定义类型或者不安全的代码。相反,让我们仅使用 AtomicBool 作为锁和 AtomicUsize 作为计数器。

为确保编译器不会重新排序我们操作,我们将使用 std::sync::atomic::compiler_fence() 函数来通知编译器哪些操作应该是 Acquire 或 Release 的,但不告诉处理器。

我们将让四个线程反复锁定、增加 counter 和解锁——每个线程一百万次。把这些都放在一起,我们得到了以下代码:

fn main() {
    let locked = AtomicBool::new(false);
    let counter = AtomicUsize::new(0);

    thread::scope(|s| {
        // 产生 4 个线程,每个都迭代 100 万次
        for _ in 0..4 {
            s.spawn(|| for _ in 0..1_000_000 {
                // 使用错误的内存排序获取锁
                while locked.swap(true, Relaxed) {}
                compiler_fence(Acquire);

                // 持有锁的同时,非原子地增加 counter
                let old = counter.load(Relaxed);
                let new = old + 1;
                counter.store(new, Relaxed);

                // 使用错误的内存排序释放锁
                compiler_fence(Release);
                locked.store(false, Relaxed);
            });
        }
    });

    println!("{}", counter.into_inner());
}

如果锁工作正常,我们预期 counter 的最终值应该恰好是四百万。注意,增加 counter 的方式是非原子的,用的是单独的 load 和 store 操作,而不是单个 fetch_add 操作。这样做是确保自旋锁如果存在任何问题,可能会导致部分递增操作没有正确计入,从而使 counter 的总值降低。

在配备 x86-64 处理器的计算机上运行此程序几次:

4000000
4000000
4000000

不出所料,我们获得了“免费”的 Release 和 Acquire 语义,我们的错误不会造成任何问题。

在 2021 年的安卓手机和 Raspberry Pi 3 model B 上尝试这个,两者都使用 ARM64 处理器,结果是相同的输出:

4000000
4000000
4000000

这表明并非所有 ARM64 处理器都使用所有形式的指令重新排序,尽管我们不能根据这个实验假设太多。

在尝试使用 2021 款苹果的 iMac 时,它包含一个基于 ARM64 的 M1 处理器,我们得到了不同的结果:

3988255
3982153
3984205

我们之前隐藏的错误突然变成了一个实际问题——这个问题只在弱排序系统上可见。计数器仅仅偏离了大约 0.4%,这显示了这样的问题可能会有多么微妙。在现实生活的场景中,像这样的问题可能会长时间地保持未被发现。

当试图复现上述结果时,不要忘记启用优化(使用 cargo run --releaserustc -O)。如果没有优化,同样的代码通常会产生更多的指令,这可能会掩盖指令重排序的微妙影响。

内存屏障

英文版本

我们还有一种与内存排序相关的指令尚未看到:内存屏障。内存屏障(fence)或内存屏障(barrier)指令用于表示我们在第三章的“屏障”部分讨论过的 std::sync::atomic::fence

正如我们之前看到的,x86-64 和 ARM64 的内存排序都关乎指令重排的。屏障指令防止某些类型的指令被重排。

acquire 屏障必须防止之前的 load 操作与任何后续的内存操作进行重排序。同样,release 屏障必须防止后续的 store 操作与任何之前的内存操作进行重排序。顺序一致的屏障必须防止所有在其之前的内存操作与屏障之后的内存操作进行重排序。

在 x86-64 上,基本的内存排序语义已经满足了 acquire 和 release 屏障的需要。这是因为,该架构不允许发生这些屏障试图阻止的指令重排。

让我们深入了解一下四种不同屏障在 x86-64 和 ARM64 上编译为什么指令:

Rust 源码
```

pub fn a() { fence(Acquire); }

    ```
pub fn a() {
    fence(Release);
}
```

pub fn a() { fence(AcqRel); }

    ```
pub fn a() {
    fence(SeqCst);
}
编译的 x86-64
```

a: ret

    ```
a:
    ret
```

a: ret

    ```
a:
    mfence
    ret
编译的 ARM64
```

a: dmb ishld ret

    ```
a:
    dmb ish
    ret
```

a: dmb ish ret

    ```
a:
    dmb ish
    ret

不用惊讶,x86-64 上的 acquire 和 release 屏障不会生成任何指令。在这种架构上,我们可以“免费”获得 release 和 acquire 的语义。只有 SeqCst 屏障会导致生成 mfence(内存屏障)指令。这个指令确保在继续之前,所有的内存操作都已经完成。

在 ARM64 上,等效的指令是 dmb ish(data memory barrier, inner shared domain)。与 x86-64 不同,它也被用于 Release 和 AcqRel,因为这种架构不会隐式地提供 Acquire 和 Release 的语义。对于 Acquire,使用了一种影响稍微小一点的变体:dmb ishld。这种变体只等待 load 操作完成,但是允许先前的 store 操作自由地重新排序到它之后。

这与我们之前看到的原子操作类似,我们看到 x86-64 为我们“免费”提供了 Release 和 Acquire 的屏障,而在 ARM64 上,顺序一致的屏障的成本与 Release 屏障相同。

总结

英文版本

  • 在 x86-64 和 ARM64 中,relaxed load 和 store 操作与它们的非原子版本等同。
  • 常见的原子「获取并修改」和「比较并交换」操作在 x86-64(以及从 ARM8.1 开始的 ARM64)上有它们自己的指令。
  • 在 x86-64 中,对于等效的指令原子操作会编译到「比较并交换」循环。
  • 在 ARM64 中,任意ID原子操作都可以通过 ll/sc 指令循环表示:如果试图的内存操作被中断,循环会自动重新开始。
  • 缓存的操作时缓存行,一般是 64 字节。
  • 缓存使用缓存一致性协议保持一致,例如通过写入或者 MESI。
  • 填充可以通过防止伪共享4来提高性能,例如通过 #[repr(align(64)]
  • load 操作可能比失败的「比较并交换」操作要便宜得多,部分原因是后者通常需要对缓存行进行独占访问。
  • 指令重排序在单线程程序内部是不可见的。
  • 在大多数架构上,包括 x86-64 和 ARM64,内存排序是为了防止某些类型的指令重排。
  • 在 x86-64 上,每个内存操作都具有 acquire 和 release 语义,使其与 relaxed 操作一样便宜或昂贵。除存储和屏障外的所有其他操作也具有顺序一致的语义,无需额外成本。
  • 在 ARM64 上,acquire 和 release 语义不如 relaxed 操作便宜,但它们在没有额外成本的情况下也包括顺序一致(SeqCst)语义。

我们在本章节可以看见的汇编指令的总结可以在图 7-1 找到。

图 7-1。各种原子操作在 ARM64 和 x86-64 上编译为每个内存排序的指令概述。

下一篇,第八章:操作系统原语

4
1
2
3
5
6
7

第八章:操作系统原语

英文版本

目前,我们主要聚焦在非阻塞的操作中。如果我们想要实现一些类似互斥锁或者条件变量的内容,也就是能够等待另一个线程去解锁或者通知它的内容,我们需要一种有效地阻塞当前线程的方式。

正如我们在第四章所见到的,我们可以不依赖操作系统,通过自旋,重复地一遍又一遍地尝试某些操作,自己实现阻塞,但这浪费大量的处理器时间。如果我们想要高效地进行阻塞,我们需要操作系统内核的帮助。

内核,或者更具体地说是其中的调度部分,负责决定哪个进程或者线程在何时运行,运行多长时间,并且在哪个处理器核心运行。尽管线程在等待某个事件发生时,内核可以停止,并给它任意的处理器时间,优先考虑其他能更好地利用这个有限资源的线程。

我们将需要一种方式来通知内核我们正在等待某个事件,并要求它将我们的线程置于睡眠状态,直到发生相关的事情。

使用内核接口

英文版本

与内核进行通信的方式很大程度依赖于操作系统,甚至是它的版本。通常,如何工作的细节被一个库或者更多库所隐藏,这些库为我们处理这些细节。例如,使用 Rust 的标准库,我们可以仅调用 File::open() 去打开这个文件,而不必关心任何操作系统内核的细节。类似地,使用 C 标准库(libc)也可以调用标准的 fopen() 函数去打开一个文件。调用这样的函数最终会导致调用操作系统内核,也称为系统调用(syscall),通常通过专门的处理器指令来完成(在某些架构上,该指令甚至直接称为 syscall)。

通常期望程序(有时直接要求)不直接进行系统调用,而是利用操作系统携带的更高级别的库。在 Unix 系统中(例如那些基于 Linux 的),libc 扮演了与内核交换的标准接口的特殊角色。

POSIX1(可移植操作系统接口)标准,包括了在类 Unix 系统上的 libc,以及对其额外的要求。例如,在 C 标准的 fopen() 函数之外,POSIX 还要求存在更低级别的 open()openat() 函数来打开文件,这些函数通常直接对应一个系统调用。由于 libc 在类 Unix 系统上的特殊地位,使用其他语言编写的程序通常仍然使用 libc 来进行与内核的所有交互。

Rust 软件,包括标准库,通常通过相同名称的 libc crate 使用 libc 库。

尤其对于 Linux,系统调用接口被保证稳定,允许我们直接进行系统调用,而不使用 libc。尽管这不是最常见或最推荐的方式,但它正在逐渐变得更受欢迎。

然而,虽然 MacOS 也是一个 Unix 操作系统,跟随 POSIX 标准,但是它的内核系统调用接口并不稳固,并且我们并不建议直接使用它。程序被允许使用的唯一稳定接口是通过系统附带的库(如 libc、libc++)和其他库(用于 C、C++、Objective-C 和 Swift)提供的接口,这些是苹果公司的首选编程语言。

Windows 不遵循 POSIX 标准。它并没有携带一个拓展的 libc 作为主要的内核接口,而是携带了一系列独立的库,例如 kernel32.dll,它提供了 Windows 的特定功能,如用于打开文件的 CreateFileW。与在 macOS 上一样,我们不应使用未记录的较低级别函数或直接进行系统调用。

通过它们的库,操作系统为我们提供了需要与内核进行交互的同步原语,如互斥锁和条件变量。这些实现的哪一部分属于库/内核的一部分,在不同的操作系统中有很大的差异。例如,有时互斥锁的锁定和解锁操作直接对应一个内核系统调用,而在其他系统中,库会处理大部分操作,并且只在需要阻塞或唤醒线程时执行系统调用(后者往往更高效,因为进行系统调用可能较慢)。

POSIX

英文版本

作为 POSIX 线程扩展的一部分,更为人熟知的是 pthread,POSIX 规范了用于并发的数据类型和函数。尽管 libthread 在技术上是作为一个独立的系统库的一部分,但是如今它通常被直接包含在 libc 中。

除了线程的 spawn 和 join 功能(pthread_createpthread_join)外,pthread 还提供了最常见的同步原语:互斥锁(pthread_mutex_t)、读写锁(pthread_rwlock_t)和条件变量(pthread_cond_t)。

  • pthread_mutex_t

    Pthread 的互斥锁必须通过调用 pthread_mutex_init() 进行初始化,并使用 pthread_mutex_destroy() 进行销毁。初始化函数接收一个 pthread_mutexattr_t 类型的参数,该参数可用于配置互斥锁的某些属性。

    其中一个属性是互斥锁在递归锁定时的行为,其是指同一线程再次尝试锁定已经持有的锁时发生的情况。在默认设置(PTHREAD_MUTEX_DEFAULT)下使用递归锁定会导致未定义的行为,但也可以配置为产生错误(PTHREAD_MUTEX_ERRORCHECK)、死锁(PTHREAD_MUTEX_NORMAL)或成功的第二次锁定(PTHREAD_MUTEX_RECURSIVE)。

    通过 pthread_mutex_lock()pthread_mutex_trylock() 来锁定这些互斥锁,通过 pthread_mutex_unlock() 来解锁。此外,与 Rust 的标准互斥锁不同的是,它们还支持通过 pthread_mutex_timedlock() 在有限的时间内进行锁定。

    可以通过分配值 PTHREAD_MUTEX_INITIALIZER 来静态初始化 pthread_mutex_t,而无需调用 pthread_mutex_init()。但是,这仅适用于具有默认设置的互斥锁。

  • pthread_rwlock_t

    Pthread 的读写锁通过 pthread_rwlock_init()pthread_rwlock_destroy() 进行初始化和销毁。与互斥锁类似,默认的 pthread_rwlock_t 可以使用 PTHREAD_RWLOCK_INITIALIZER 静态地初始化。

    与 pthread 互斥锁相比,pthread 读写锁通过它的初始化函数可配置的属性要少得多。特别要注意的是,尝试递归写锁定将始终导致死锁。

    然而,尝试递归获取额外的读锁是保证会成功的,即使有 writer 正在等待。这实际上排除了任何优先考虑 writer 而不是 reader 的高效实现,这就是为什么大多数 pthread 实现优先考虑 reader 的原因。

    它的接口与 pthread_mutex_t 几乎相同,包括支持时间限制,除了每个锁定函数都有两个变体:一个用于 reader(pthread_rwlock_rdlock),一个用于 writer(pthread_rwlock_wrlock)。也许令人惊讶的是,仅有一个解锁函数(pthread_rwlock_unlock),其用于解锁任一类型的锁。

  • pthread_cond_t

    pthread 条件变量与 pthread 互斥锁一起使用。通过 pthread_cond_initpthread_cond_destroy 进行初始化和销毁,并且可以配置一些属性。其中最值得注意的是,我们可以配置时间限制使用单调时钟2(类似于 Rust 的 Instant)还是实时时钟3(类似于 Rust 的 SystemTime,有时称为“挂钟时间”)。具有默认设置的条件变量(例如由 PTHREAD_COND_INITIALIZER 静态初始化的条件变量)使用实时时钟。

    通过 pthread_cond_timedwait() 等待此类条件变量,可选择设置时间限制。通过调用 pthread_cond_signal() 唤醒等待的线程,或者为了一次唤醒所有等待的线程,调用 pthread_cond_broadcast()

    Pthread 提供的其余同步原语是屏障(pthread_barrier_t)、自旋锁(pthread_spinlock_t)和一次性初始化(pthread_once_t),我们不会讨论。

在 Rust 中包装类型

英文版本

通过方便地将其 C 类型(通过 libc crate)包装在 Rust 结构体中,我们可以轻松地将这些 pthread 同步原语暴露给 Rust,例如:

#![allow(unused)]
fn main() {
pub struct Mutex {
    m: libc::pthread_mutex_t,
}
}

然而,这种方法存在一些问题,因为该 pthread 类型是为 C 设计的,而不是为 Rust 设计的。

首先,Rust 关于可变性和借用有一些规则,通常不允许在共享时进行修改。由于类似 pthread_mutex_lock 这样的函数总是可能对互斥锁进行修改,我们将需要内部可变性来确保这是可接受的。因此,我们需要将其包装在 UnsafeCell 中:

#![allow(unused)]
fn main() {
pub struct Mutex {
    m: UnsafeCell,
}
}

一个巨大的问题是关于移动

在 Rust 中,我们所有时间都在移动对象。例如,通过从函数返回对象、将其作为参数传递或者简单地将其分配给新的位置。我们拥有的多所有东西(并且没有被其他东西借用),我们可以自由地移动它们到一个新位置。

然而,在 C 中,这并不是普遍正确的。在 C 中,类型通常依赖它的内存地址保持不变。例如,它可能包含一个指向自身的指针,或者在某个全局数据结构中存储一个指向自己的指针。在这种情况下,移动到一个新的位置可能导致未定义行为。

我们讨论的 pthread 类型不能保证它们是可移动的,在 Rust 中,这会带来很大的问题。即使是一个简单的惯用的 Mutex::new() 函数也是一个问题:它将返回一个 mutex 对象,这将移动到一个内存的新位置。

因为用户可能总是移动任何它们拥有的 mutex 对象到其他地方,我们要么需要承诺它别这么做,要么使接口不安全;或者我们需要取走所有权并且隐藏所有内容到一个包装类型后面(可以使用 std::pin::Pin 来完成)。这些都不是最好的解决方案,因为它们会影响我们的 mutex 类型的接口,使其用起来容易出错和/或不方便使用。

一个可以的解决方案是将 mutex 包装在一个 Box 中。通过将 pthread 的 mutex 放在它自己的内存分配中,即使所有者被移动,它仍然在内存中的相同位置。

#![allow(unused)]
fn main() {
pub struct Mutex {
    m: Box>,
}
}

这就是 std::sync::Mutex 在 Rust 1.62 之前在所有 Unix 平台上实现的方式。

这个方式的缺点就是开销大:每个 mutex 都有自己的内存分配,为创建、销毁以及使用 mutex 增加了显著的开销。另一个缺点是它阻止了 new 函数编译时执行(const),这妨碍了拥有静态 mutex 的方式。

即使 pthread_mutex_t 是可移动的,const fn new 也可能仅使用默认设置来初始化,这导致了当递归锁定时的未定义行为。没有办法设计一个安全的接口来防止递归锁定,因此这意味着我们要使用 unsafe 标记锁定函数,以使用户承诺他们不会这样做。

当丢弃 mutex 时,在我们的 Box 方法中仍然存在一个问题。看起来,如果设计正确,就不可能在被锁定时丢弃 mutex,因为通过 MutexGuard 借用它时,不可能丢弃它。MutexGuard 必须先被丢弃,解锁 Mutex。然而,在 Rust 中,安全地遗忘(或泄露)一个对象,而不将其丢弃是安全的。这意味着可以编写类似下面的代码:

fn main() {
    let m = Mutex::new(..);

    let guard = m.lock(); // 锁定它 ..
    std::mem::forget(guard); // .. 但是没有解锁它。
}

在以上示例中,m 将在作用域结束后被丢弃,尽管它仍然被锁定。根据 Rust 的编译器来看,这是好的,因为 guard 已经被泄漏并且不能再使用。

然而,pthread 规定在已锁定的 mutex 调用 pthread_mutex_destroy() 并不能保证工作并且可能导致未定义行为。一种解决方案是当丢弃我们的 Mutex 时,首先试图去锁定(和解锁)pthread mutex,并且当它已经锁定时触发 panic(或泄漏 Box),但这甚至要更大的开销。

这些问题不仅适用于 pthread_mutex_t,还适用于我们讨论的其他类型。总体而言,pthread 的同步原语设计对 C 是好的,但是并不完全适合 Rust。

Linux

英文版本

在 Linux 系统中,pthread 同步原语所有都是使用 futex 系统调用实现。它的名称来自“快速用户区互斥4”(fast user-space mutex),因为增加这个系统调用最初的动机就是允许库(如 pthread 实现)包含一个快速且高效 mutex 实现。它的灵活远不止于此,可以用来构建许多不同的同步工具。

在 2003 年,futex 系统调用被增加到 Linux 内核,此后进行了几次改善和扩展。一些其他的系统调用因此也增加了相似的功能,更值得注意的是,在 2012 年 Windows 8 也增加了 WaitOnAddress(我们将会稍后在“Windows”部分讨论这个)。在 2020 年,C++ 语言甚至把基础的类 futex 操作增加到了标准库,并添加了 atomic_waitatomic_notify 函数。

Futex

英文版本

在 Linux 上,SYS_futex 是一个系统调用,在 32 位的原子整数上它实现了各种操作。主要的两个操作是 FUTEX_WAITFUTEX_WAKE。等待操作会让线程进入睡眠状态,而在同一个原子变量上进行唤醒操作则会将线程唤醒。

这些操作并不会在原子整数中存储任何内容。相反,内核会记住哪些线程正在等待哪个内存地址,以便唤醒操作能够正确地唤醒线程。

第一章的“等待:阻塞和条件变量”中,我们看到其他阻塞和唤醒线程的机制,需要一种方式以确保唤醒操作不会在竞争中丢失。对于线程的阻塞操作,通过将 unpark() 操作应用于未来的 park() 操作,来解决这个问题。并且对于条件变量来说,这是通过与条件变量一起使用的互斥锁来解决的。

对于 futex 的等待和唤醒操作,使用了另一种机制。等待操作接受一个参数,该参数是我们期望原子变量具有的值,如果不匹配,就会拒绝阻塞。等待操作在与唤醒操作的原子性上保持一致,这意味着在检查期望值和实际进入睡眠状态之间,不会丢失任何唤醒信号。

如果我们确保在唤醒操作之前改变原子变量的值,我们就可以确保即将开始等待的线程不会进入睡眠状态,这样就不再关心可能丢失 futex 唤醒操作的问题了。

让我们通过一个简单的例子来实践一下。

首先,我们需要能够调用这些系统调用。我们可以使用 libc crate 中的 syscall 函数来实现,并将每个调用封装在一个方便的 Rust 函数中,如下所示:

#![allow(unused)]
fn main() {
#[cfg(not(target_os = "linux"))]
compile_error!("Linux only. Sorry!");

pub fn wait(a: &AtomicU32, expected: u32) {
    // 参考 futex (2) 手册中的系统调用签名
    unsafe {
        libc::syscall(
            libc::SYS_futex, // futex 系统调用
            a as *const AtomicU32, // 要操作的原子
            libc::FUTEX_WAIT, // futex 操作
            expected, // 预期的值
            std::ptr::null::(), // 没有超时
        );
    }
}

pub fn wake_one(a: &AtomicU32) {
    // 参考 futex (2) 手册中的系统调用签名
    unsafe {
        libc::syscall(
            libc::SYS_futex, // futex 系统调用
            a as *const AtomicU32, // 要操作的原子
            libc::FUTEX_WAKE, // futex 操作
            1, // 要唤醒的线程数量
        );
    }
}
}

现在,作为一个使用示例,让我们用这些让一个线程等待另一个线程。我们将使用一个原子变量,我们用 0 为它初始化,主线程将在该变量上进行 futex 等待。第二个线程会将变量更改为 1,然后在上面运行 futex 唤醒操作以唤醒主线程。

就像线程阻塞和等待一个条件变量,futex 等待操作可能甚至在没有任何发生的情况下虚假唤醒。因此,通常在循环中使用它,如果我们等待的条件尚未满足,就会重复它。

让我们来看一下下面的示例:

fn main() {
    let a = AtomicU32::new(0);

    thread::scope(|s| {
        s.spawn(|| {
            thread::sleep(Duration::from_secs(3));
            a.store(1, Relaxed); // 1
            wake_one(&a); // 2
        });

        println!("Waiting...");
        while a.load(Relaxed) == 0 { // 3
            wait(&a, 0); // 4
        }
        println!("Done!");
    });
}
  1. 在几秒钟后,创建的线程将设置原子变量的值为 1。
  2. 然后,它执行一个 futex 唤醒操作去唤醒主线程,以防止它正在睡眠,这样可以看到变量已经发生了变化。
  3. 主线程将会等待直到变量是 0,然后继续打印最终的消息。
  4. futex 的 wait 操作用于将线程置入睡眠状态。非常重要的是,在进入睡眠之前,此操作将检查变量是否仍然是 0,这是在步骤 3 和步骤 4 之间不能丢失来自产生线程的信号的原因。要么 1(并且因此 2)尚未发生,它将进入睡眠状态,要么 1(并且可能 2)已经发生,线程将立即继续执行。

在这里一个重要的观察是,如果 a 已经在 while 循环之前设置为 1,那么就可以完全避免等待调用。以类似地方式,如果主线程还在原子变量中存储了它是否开始等待的信号(通过将其设置为除了 0 或 1 之外的值),如果主线程尚未开始等待,发送信号的线程可以跳过 futex 的等待操作。这就是基于 futex 的同步原语如此快速的原因:由于我们自己管理状态,除非我们真正的需要阻塞,否则我们不需要依赖内核。

自 Rust 1.48 以来,在 Linux 上,标准库的线程阻塞(park)函数是这样实现的。它们每个线程使用一个原子变量,有三种可能的状态:0 表示空闲和初始状态、1 为“已释放但尚未阻塞”,-1 为“已阻塞但尚未释放”。

第九章,我们将使用这些操作实现互斥锁、条件变量以及读写锁。

Futex 操作

英文版本

接下来到等待和唤醒操作,futex 系统调用还支持其他几个操作。在该章节,我们将简要地讨论此系统调用的每个支持的操作。

futex 的第一个参数始终是指向要操作的 32 位原子变量的指针。第二个参数是一个表示操作的常量,例如 FUTEX_WAIT``,还可以添加最多两个标识:FUTEX_PRIVATE_FLAG 和/或 FUTEX_CLOCK_REALTIME,我们将在下面进行讨论。剩余的参数取决于具体的操作,我们将在每个操作的描述中进行说明。

  • FUTEX_WAIT

    该操作接收 2 个额外的参数:期待原子变量具有的值和指向表示最长时间等待的 timespec 的指针。

    如果原子变量的值匹配预期的值,等待操作将会阻塞,直到被其中一个唤醒操作唤醒,或者直到传递的 timespec 持续时间过去。如果 timespec 的指针为 null,则没有时间限制。此外,等待操作可能会在达到时间限制之前出现虚假唤醒,并返回没有相应的唤醒操作。

    与其他 futex 操作相比,检查和阻塞操作是单个原子操作,这意味着它们之间不会丢失唤醒信号。

    timespec 指定的持续时间默认代表单调时钟(如 Rust 的 Instant)上的持续时间。通过添加 FUTEX_CLOCK_REALTIME 标识,将使用实时时钟(如 Rust 的 SystemTime)。

    返回值指示是否匹配预期值以及是否达到了超时。

  • FUTEX_WAKE

    此操作需要 1 个额外的参数:要唤醒的线程数,使用 i32 类型。

    这会唤醒指定数量的,在相同原子变量上的等待操作中被阻塞的线程。(如果没有很多等待的线程,则唤醒较少的线程)更常见的是,这个参数要么只唤醒一个线程,要么是设置为 i32::MAX 唤醒所有线程。

    返回值是唤醒的线程数。

  • FUTEX_WAIT_BITSET

    这个操作接收 4 个额外的参数:期待原子变量具有的值、指向表示最长时间等待的 timespec 指针、一个忽略的指针以及一个 32 位“bitset”(u32)。

    该操作行为与 FUTEX_WAIT 相同,但是有两点区别。

    第一个区别是它接收一个 bitset 参数,可以仅用于等待特定的唤醒操作,而不是在相同的原子变量上的所有唤醒操作等待。FUTEX_WAKE 操作从不会被忽略,但是如果等待的“bitset”和唤醒的“bitset”没有一位是相等的,则忽略来自 FUTEX_WAKE_BITSET 操作的信号。

    例如,FUTEX_WAKE_BITSET 操作的“bitset”是 0b0101,它能唤醒位集为 0b1100FUTEX_WAIT_BITSET 操作,但是不能唤醒的位集为 0b0010

    这在实现类似读写锁的时候很有用,可以唤醒 writer,而不唤醒任何 reader。然而,请注意,对于处理两种不同类型的 writer,使用两个单独的原子变量可能比使用一个更高效,因为内核将针对每个原子变量维护一个等待者(waiter)列表。

    FUTEX_WAIT_BITSETFUTEX_WAIT 的另一个区别是,它使用 timespec 作为绝对时间戳,而不是持续时间。因此,通常会将 FUTEX_WAIT_BITSETu32::MAX(所有位都为 1)的“bitset”一起使用,从而将其转变为常规的 FUTEX_WAIT 操作,但设置了绝对时间戳作为等待的时间限制。

  • FUTEX_WAKE_BITSET

    此操作接收了 4 个额外的参数:要唤醒的线程数量、2 个忽略的指针以及 32 位“bitset”(u32)。

    此操作与 FUTEX_WAKE 操作相同,只是它不会唤醒那些“bitset”没有重复的 FUTEX_WAIT_BITSET 操作。(见上面的 FUTEX_WAIT_BITSET。)

    当 bitset 设置为 u32::MAX(所有位都为 1)时,这与 FUTEX_WAKE 操作相同。

  • FUTEX_REQUEUE

    此操作接收 3 个额外的参数:要唤醒的线程数(i32)、要重新排队的线程数(i32)和一个次要原子变量的地址。

    该操作唤醒一个给定数量的等待线程,并且将剩余的等待线程重新排队,一等待另一个原子变量。

    重新排队的等待线程继续等待,但不再受到主原子变量的唤醒操作的影响。相反,它们现在通过在次要原子变量上唤醒操作来唤醒。

    这对于实现类似条件变量的“notify_all”操作是有用的。与其唤醒所有线程,不如随后尝试锁定 mutex,否则很可能导致除了一个线程外的其他线程都在随后立即等待该 mutex,我们可以仅唤醒一个线程,并将其所有其他的线程重新排队,直接让它们等待 mutex 而不先唤醒它们。

    与 FUTEX_WAKE 操作类似,可以使用 i32::MAX 的值来重新排队所有等待的线程。(指定唤醒线程数为 i32::MAX 的值并不是非常有用,因为这将使该操作等效于 FUTEX_WAKE。)

    返回值是唤醒线程的数量。

  • FUTEX_CMP_REQUEUE

    此操作接收额外的 4 个参数:要唤醒的线程数(i32)、要重新排队的线程数(i32)、次要原子变量的地址以及主要原子变量预期的值。

    这个操作与 FUTEX_REQUEUE 几乎相同,但如果主要原子变量的值不匹配预期值,它会拒绝执行。对值的检查和重新排队操作在与其他 futex 操作相比是原子的。

    与 FUTEX_REQUEUE 不同,它返回被唤醒和重新排队的线程数量之和。

  • FUTEX_WAKE_OP

    此操作接收 4 个额外的参数:在主要原子变量上要唤醒的线程数(i32)、在次要原子变量上可能要唤醒的线程数(i32)、次要原子变量的地址以及一个 32 位的值,其用于编码要执行的操作和要进行比较的条件。

    这是一个非常专业的用于修改次要原子变量的操作,唤醒许多等待著原子变量的线程,检查原子变量的前一个值是否与给定值匹配,如果匹配,则还会唤醒次要原子变量上的一些线程。

    换句话说,它与下面的代码等同,除了整个操作行为与其他 futex 操作相比是原子的:

    #![allow(unused)]
    fn main() {
    let old = atomic2.fetch_update(Relaxed, Relaxed, some_operation);
    
    wake(atomic1, N);
    if some_condition(old) {
        wake(atomic2, M);
    }
    }

    通过系统调用的最后一个参数来指定要执行的修改操作以及要检查的条件,这是一个 32 位编码。操作可以以下之一:赋值、加运算、二进制或、二进制与非、二进制异或,其中包含一个 12 位参数或者是一个 32 位的 2 的幂次方参数。比较操作可以选择 ==!=、`` 和 >=,并带有一个 12 位参数。

    有关该参数的编码详细信息,请参阅 futex(2) 手册页,或使用 crates.io 上的 linux-futex crate,该 crate 提供了一种方便的构造参数的方法。

    返回值是唤醒线程的总数。

    乍看之下,这似乎是一个具有许多用途的灵活操作。然而,它最初设计用于 GNU libc 中的一个特定用例,其中需要从两个单独的原子变量中唤醒两个线程。这个特定的用例已经被不同的实现替代,不再利用 FUTEX_WAKE_OP。

可以添加 FUTEX_PRIVATE_FLAG 到其中的任何一个操作,以启用可能的优化。(通常情况下,如果对同一原子变量的所有相关 futex 操作来自同一进程,则可以利用此标识)。为了使用该标识,每个相关的 futex 操作都必须包括相同的标识。通过允许内核假设不会与其他进程发生交互,它可以跳过执行 futex 操作中的一些可能高开销的步骤,从而提高性能。

除了 Linux,NetBSD 也支持上述所有的 futex 操作。OpenBSD 也有一个 futex 系统调用,但仅支持 FUTEX_WAIT、FUTEX_WAKE 和 FUTEX_REQUEUE 操作。FreeBSD 没有原生的 futex 系统调用,但包含一个名为 _umtx_op 的系统调用,其中包含与 FUTEX_WAIT 和 FUTEX_WAKE 几乎相同的功能:UMTX_OP_WAIT(用于 64 位原子变量)、UMTX_OP_WAIT_UINT(用于 32 位原子变量)和 UMTX_OP_WAKE。Windows 也包含与 futex 等待和唤醒操作非常相似的函数,我们将在本章后面讨论。

新的 Futex 操作

发布在 2022 年的 Linux 5.16,引入了一个新的系统调用:futex_waitv。这个新的系统调用通过向它提供一个包含待等待的原子变量(及其期望值)的列表,允许一次等待多个 futex。在 futex_waitv 上被阻塞的线程可以通过在任意指定的变量上进行唤醒操作来被唤醒。

这个新的系统调用还为未来的扩展留出了空间。例如,可以指定待等待的原子变量的大小。虽然最初的实现只支持 32 位原子变量,就像原始的 futex 系统调用一样,但在未来可能会扩展为支持 8 位、16 位和 64 位原子变量。

优先继承 Futex 操作

英文版本

优先级反转5是指高优先级线程在低优先级线程持有的锁上被阻塞的问题。高优先级线程实际上“反转”了它的优先级,因为它现在必须等待低优先级线程释放锁才能继续执行。

解决这个问题的方法是优先级继承,即阻塞的线程继承等待它的最高优先级线程的优先级,在持有锁期间临时提高低优先级线程的优先级。

除了我们之前讨论过的七个 futex 操作外,还有六个专门用于实现优先级继承锁的优先级继承 futex 操作。

我们之前讨论过的通用 futex 操作对于原子变量的具体内容没有任何要求。我们可以自己选择 32 位的表示方式。然而,对于优先级继承 mutex,内核需要能够理解 mutex 是否被锁定,如果锁定了,则需要知道哪个线程锁定了它。

为了避免在每个状态变化上进行系统调用,优先级继承 futex 操作指定了 32 位原子变量的确切内容,以便内核可以理解它:最高位表示是否有任何线程正在等待锁定 mutex,最低的 30 位包含持有锁的线程 ID(Linux 的 tid,而不是 Rust 的 ThreadId),当解锁时为零。

作为额外的功能,如果持有锁的线程在未解锁的情况下终止,内核将设置次高位,但前提是没有任何等待着。这使得 mutex 具有鲁棒性6:这是一个术语,用于描述 mutex 在“拥有”线程意外终止的情况下能够正常处理的能力。

优先级继承 futex 操作与标准 mutex 操作一一对应:FUTEX_LOCK_PI 用于锁定,FUTEX_UNLOCK_PI 用于解锁,FUTEX_TRYLOCK_PI 用于非阻塞锁定。此外,FUTEX_CMP_REQUEUE_PI 和 FUTEX_WAIT_REQUEUE_PI 操作可用于实现与优先级继承 mutex 配对的条件变量。

我们将不详细讨论这些操作。有关详细信息,请参阅 futex(2) Linux 手册页或 crates.io 上的 linux-futex crate。

macOS

英文版本

macOS 部分的内核支持各种有用的低级并发相关的系统调用。然而,就像大多数操作系统一样,内核接口并不是稳定的,并且我们应该直接地使用它。

软件与 macOS 内核交互的唯一方式是通过系统携带的库。这些库包含它对 C(libc)、C++(libc++)、Objective-C 和 Swift 的标准库实现。

作为符合 POSIX 标准的 Unix 系统,macOS C 标准库包含一个完整的 pthread 实现。其他语言中的标准库锁通常在底层使用 pthread 原语。

在 macOS 上,与其他系统对比,Pthread 的锁相对低较慢。原因之一是 macOS 上的锁默认情况下是公平锁(Fair Lock),这意味着几个线程试图去锁定相同的 mutex 时,它们会按照到达的顺序一次获得锁定,就像一个完美的队列。尽管公平性可能是值得拥有的属性,但它会显著降低性能,特别是在高竞争的情况下。

os_unfair_lock

英文版本

除了 pthread 原语,macOS 10.12 引入了一种新的轻量级平台特定的互斥锁,它是不公平的:os_unfair_lock。它的大小仅有 32 位,可以使用 OS_UNFAIR_LOCK_INIT 常来那个静态地初始化,并且不需要销毁。它可以通过 os_unfair_lock_lock()(阻塞)或 os_unfair_lock_trylock()(非阻塞)来锁定它,并且通过 os_unfair_lock_unlock() 来解锁。

不幸的是,它没有条件变量,也没有 reader-writer 变体。

Windows

英文版本

Windows 操作系统携带了一系列库,它们一起形成了 Windows API,通常称之为“Win32 API”(甚至在 64 位系统也是)。它构成了一个在“Native 之上”的层:大部分是与内核没有交互的接口,我们不建议直接使用它。

通过微软官方提供的 windows 和 windows-sys crate,Windows API 可以为 Rust 程序所用,这在 crates.io 上是可获得的。

重量级内核对象

英文版本

在 Windows 上可用的许多旧的同步原语完全由内核管理,这使得它们非常重量,并赋予它们与其他内核管理对象(例如文件)类似的属性。它们可以被多个进程使用,可以通过名称进行命名和定位,并且支持细粒度的权限,类似于文件。例如,可以允许一个进程等待某个对象,而不允许它通过该对象发送信号来唤醒其他进程。

这些重量级的内核管理同步对象包括 Mutex(可以锁定和解锁)、Event(可以发送信号和等待)以及 WaitableTimer(可以在选择的时间后或定期自动发送信号)。创建这样的对象会得到一个句柄(HANDLE),就像打开一个文件一样,可以轻松地传递并与常规的 HANDLE 函数一起使用,特别是一系列的等待函数。这些函数允许我们等待各种类型的一个或多个对象,包括重量级同步原语、进程、线程和各种形式的 I/O。

轻量级对象

英文版本

在 Windows API 中,一个轻量级的同步原语包括是“临界区7”(critical section)。

临界区这个术语指的是程序的一部分,即代码的“区段”,可能不允许超过一个线程进入。这种保护临界区段机制通常称之为互斥锁。然而,微软为这种机制使用“临界区”的名称,可能因为之前讨论的重量级 mutex 对象已经采用了“互斥锁”这个名称。

Winodows 中的 CRITICAL_SECTION 实际上是一个递归互斥锁,只是它使用了“enter”(进入)和“leave”(离开)而不是“lock”(锁定)和“unlock”(解锁)。作为递归互斥锁,它仅被设计用于保护其他的线程。它允许相同的线程多次锁定(或者“进入”)它,也要求该线程也必须相同的次数解锁(“离开”)。

当使用 Rust 包装该类型时,有些东西值得注意。成功地锁定(进入)CRITICAL_SECTION 不应该导致对其数据保护的独占引用(&mut T)。否则,线程可以使用此来创建对同一数据的两个独占引用,这会立即导致未定义行为。

CRITICAL_SECTION 使用 InitializeCriticalSection() 函数来初始化,使用 DeleteCriticalSection() 函数来销毁,并且不能被移动。通过 EnterCriticalSection() 或者 TryEnterCriticalSection() 来锁定,并且使用 LeaveCriticalSection() 解锁。

在 Rust 1.51 之前,Windows XP 上的 std::sync::Mutex 基于(Box 的内存分配)CRITICAL_SECTION 对象。(Rust 1.51 放弃了对 Windows XP 的支持。)

精简的读写(SRW)锁8

英文版本

从 Windows Vista(和 Windows Server 2008)开始,Windows API 包含了一个非常轻量级的优秀锁原语:精简读写锁,简称 SRW 锁

SRWLOCK 类型仅是一个指针大小,可以用 SRWLOCK_INIT 静态初始化,并且不需要销毁。当不再被使用(借用),我们甚至允许移动它,使它成为 Rust 类型的理想选择。

它通过 AcquireSRWLockExclusive()TryAcquireSRWLockExclusive()ReleaseSRWLockExclusive() 提供了独占(writer)锁定和解锁,并通过 AcquireSRWLockShared()TryAcquireSRWLockShared()ReleaseSRWLockShared() 提供了共享(reader)锁定和解锁。通常可以将其用作普通的互斥锁,只需忽略共享(reader)锁定函数即可。

SRW 锁既不优先考虑 writer 也不优先考虑 reader。虽然不能保证,但是它试图去按顺序去服务所有锁请求,以减少性能下降。在已经持有一个共享(reader)锁定的线程上不要尝试获取第二个共享(reader)锁定。如果该操作在另一个线程的独占(writer)锁定操作之后排队,那么这样做可能会导致永久死锁,因为第一个线程已经持有的第一个共享(reader)锁定会阻塞第二个线程。

SRW 锁与条件变量一起引入了 Windows API。CONDITION_VARIABLE 仅占用一个指针的大小,可以使用 CONDITION_VARIABLE_INIT 进行静态初始化,不需要销毁。只要它没有被使用(被借用),我们也可以移动它。

条件变量不仅通过 SleepConditionVariableSRW 与 SRW 锁一起使用,还可以通过 SleepConditionVariableCS 与临界区一起使用。

唤醒等待线程要么通过 WakeConditionVariable 唤醒单个线程,要么通过 WakeAllConditionVariable 唤醒所有等待线程。

最初,标准库中使用的 Windows SRW 锁和条件变量被包装在 Box 中,以避免移动对象。直到我们在 2020 年要求之后,微软才记录了这些对象的可移动性保证。自 Rust 1.49 起,std::sync::Mutexstd::sync::RwLockstd::sync::Condvar 在 Windows Vista 及更高版本中直接封装了 SRWLOCK 或 CONDITION_VARIABLE,无需任何额外的内存分配。

基于地址的等待

英文版本

Windows 8(和 Windows Server 2012)引入了一种新的、更灵活的同步功能类型,非常类似于本章前面讨论的 Linux FUTEX_WAITFUTEX_WAKE 操作。

WaitOnAddress 函数可以操作 8 位、16 位、32 位 或 64 位的原子变量。它接收了 4 个参数:原子变量地址、保存期望值的变量地址、原子变量大小(以字节为单位)以及在放弃之前的最大等待最大毫秒数(或者无限超时的 u32::MAX)。

就像 FUTEX_WAIT 操作一样,它将原子变量的值与预期值进行比较,如果匹配则进入睡眠状态,等待相应的唤醒操作。检查和睡眠操作相对于唤醒操作是原子发生的,这意味着没有唤醒信号会在两者之间丢失。

唤醒正在等待 WaitOnAddress 的线程可以通过 WakeByAddressSingle 来唤醒单个线程,或者通过 WakeByAddressAll 来唤醒所有等待的线程。这两个函数只接受一个参数:原子变量的地址,该地址也被传递给 WaitOnAddress

Windows API 的一些(但不是全部)同步原语是使用这些函数实现的。更重要的是,它们是构建我们自己的原始物的绝佳基石,我们将在第九章中这样做。

总结

英文版本

  • 系统调用(syscall)是进入操作系统内核的调用,与普通函数调用相比,相对较慢。

  • 通常,程序不直接进行系统调用,而是通过操作系统的库(如 libc)与内核进行交互。在许多操作系统中,这是与内核进行交互的唯一支持方式。

  • libc crate 提供了 Rust 代码访问 libc 的能力。

  • 在 POSIX 系统上,libc 包含了不仅符合 C 标准所需的内容,还符合 POSIX 标准的内容。

  • POSIX 标准包括 pthread,这是一个具有并发原语(如 pthread_mutex_t)的库。

  • pthread 类型是为 C 设计的,而不是为 Rust 设计的。例如,它们不可移动,这可能是一个问题。

  • Linux 有一个 futex 系统调用,支持在 AtomicU32 上进行几种等待和唤醒操作。等待操作验证原子的期望值,以避免错过通知。

  • 除了 pthread,macOS 还提供了 os_unfair_lock 作为轻量级锁定原语。

  • Windows 的重量级并发原语始终需要与内核进行交互,但可以在进程之间传递,并与标准的 Windows 等待函数一起使用。

  • Windows 的轻量级并发原语包括“slim”读写锁(SRW 锁)和条件变量。这些可以很容易地在 Rust 中包装,因为它们是可移动的。

  • Windows 还通过 WaitOnAddress 和 WakeByAddress 提供了类似 futex 的基本功能。

    下一篇,第九章:构建我们自己的「锁」

1
2

绝对时间。表示系统(或程序)启动后流逝的时间,更改系统的时间对它没有影响。每次系统(或程序)启动时,该值都归 0

3

挂钟时间,即现实世界里我们感知到的时间,如 2008-08-08 20:08:00。但对计算机而言,这个时间不一定是单调递增的。因为人觉得当前机器的时间不准,可以随意拨慢或调快。

7
8
4
5
6

参考:

第九章:构建我们自己的「锁」

英文版本

在该章节,我们将建造属于我们自己的互斥锁(mutex1、条件变量(condition variable2以及读写锁(reader-writer lock3。对于它们中的任何一个,我们都会从一个非常基础的版本开始,然后逐步扩展它以使其更高效。

由于我们并不会使用来自标准库中的锁类型(因为这将是作弊行为),因此我们将不得不使用来自第八章的工具,才能够在不忙碌循环(busy-looping4)的情况下使线程等待。然而,正如我们在该章节了解的,不同操作系统提供的可用工具因平台而异,因此构建跨平台工作的东西颇具挑战性。

幸运地是,更多现代化操作系统都支持类似 futex 的功能,或者至少支持唤醒(wake)和等待(wait)操作。正如我们在第八章看到的,Linux 自从 2003 年就一直支持 futex 系统调用,Windows 自从 2012 年就支持 WaitOnAddress 系列函数,FreeBSD 自从 2016 年就将 _umtx_op 作为系统调用的一部分,等等。

最让人意外的是 macOS。尽管它的内核支持这些操作,但是它并没有暴露任意稳定、公共的 C 函数给我们使用。然而,macOS 附带了一个最新版本的 libc++(这是一个 C++ 标准库的实现)。该标准库包含对 C++20 的支持,该版本内置了非常基础对原子等待和唤醒操作(像 std::atomic::wait())。尽管由于各种原因,Rust 利用这些还非常的棘手,然而,这当然是可能的,这也可以让我们在 macOS 上访问基本的像 futex 的等待和唤醒功能。

我们将不再深入研究那些复杂的细节,而是选择利用 crates.ioatomic-wait crate,为我们的「锁」原语提供基础的构建模块。该 crate 提供了三个函数:wait()wake_one() 以及 wake_all()。它使用我们上面讨论的特定于平台规范的实现,为所有主要的平台实现了这些功能。这意味着我们只要坚持使用这三个函数,我们不再需要考虑任何平台的特定细节。

这些函数的行为就像我们在 Linux 第八章中的“Futex”中实现的同名函数一样,不过让我们快速回顾一下如何工作的。

  • wait(&AtomicU32, u32)

    该函数用于等待直到原子变量不再包含给定的值。如果原子变量中存储的值等于给定值,它将阻塞。当另一个线程修改了原子变量的值,该线程需要在同一原子变量上调用以下的任意一个唤醒函数,以将等待的线程从睡眠中唤醒。

    该函数可能没有对应的唤醒操作,从而虚假地返回。因此,请确保在原子变量返回后检查其值,并在必要时重复 wait()

  • wake_one(&AtomicU32)

    该函数将唤醒单个线程,其是当前在相同原子变量上通过 wait() 方法阻塞的线程。在修改原子变量后,立即使用它,以通知一个正在等待的线程该原子变量发生了变化。

  • wake_all(&AtomicU32)

    该函数将唤醒所有线程,其是当前在相同原子变量上通过 wait() 方法阻塞的线程。在修改原子变量后,立即使用它,以通知正在等待的线程该原子变量发生了变化。

仅支持 32 位原子,因为在所有主要平台这是唯一受支持的大小。

第八章中的“Futex”中,我们讨论了一个最小示例,以展示这些函数在实践中是如何使用的。如果你已经忘记,请务必在继续之前查看该示例。

为了使用 atomic-wait crate,在你的 Cargo.toml增加 atomic-wait="1"[dependencies];或者运行 cargo add atomic-wait@1,这样也同样达到相同的目的。这三个函数在 crate 的根中定义,你可以使用 atomic_wait::{wait, wake_one, wake_all}; 导入它们。

当你阅读到这篇文章时,该 crate 可能有后续的可用版本,但该章节将使用主版本为 1 的构建。而后续的版本可能有不兼容的接口。

现在,我们已经有基础的知识,让我们开始吧。

Mutex

英文版本

在构建 Mutex 时,我们将参考来自第四章SpinLock 类型。在不涉及阻塞的部分,例如守卫类型的设计,将保持不变。

让我们从类型定义开始。与自旋锁相比,我们必须做一个更改:而不是将 AtomicBool 设置为 false 或者 true,我们将使用 AtomicU32,将其设为 0 或者 1,所以我们可以将其与原子等待和唤醒函数一起使用。

#![allow(unused)]
fn main() {
pub struct Mutex {
    /// 0: 解锁
    /// 1: 锁定
    state: AtomicU32,
    value: UnsafeCell,
}
}

就像是自旋锁一样,我们也需要保证 Mutex 可以在线程之间共享,即使它包含一个可怕的 UnsafeCell

我们将增加一个 MutexGuard 类型,该类型实现了 Deref trait,以提供一个完全安全的锁接口,就像我们在第四章:使用锁守卫的安全接口

#![allow(unused)]
fn main() {
pub struct MutexGuard {
    mutex: &'a Mutex,
}

impl Deref for MutexGuard {
    type Target = T;
    fn deref(&self) -> &T {
        unsafe { &*self.mutex.value.get() }
    }
}

impl DerefMut for MutexGuard {
    fn deref_mut(&mut self) -> &mut T {
        unsafe { &mut *self.mutex.value.get() }
    }
}
}

对于锁守卫类型的设计和操作,参见第四章:使用锁守卫的安全接口

在我们进入有趣的部分之前,让我们也将 Mutex::new 函数拿出来。

#![allow(unused)]
fn main() {
impl Mutex {
    pub const fn new(value: T) -> Self {
        Self {
            state: AtomicU32::new(0), // 解锁状态
            value: UnsafeCell::new(value),
        }
    }

    //…
}
}

现在,我们已经完成了,然而还有剩下的两块未完成:锁定(Mutex::lock())和解锁(为 MutexGuardDrop)。

我们为自旋锁实现的 lock 函数,使用了一个原子交换(swap)操作以试图去获取锁,如果它成功的将状态从“解锁”更改到“锁定”,则返回。如果未成功,它将立刻再次尝试。

为了锁定我们的 mutex,我们将做几乎相同的操作,除了在再次尝试之前,我们会使用 wait() 等待:

#![allow(unused)]
fn main() {
    pub fn lock(&self) -> MutexGuard {
        // 设置 state 到 1:锁定
        while self.state.swap(1, Acquire) == 1 {
            // 如果它已经锁定..
            // .. 等待,直到 state 不再是 1。
            wait(&self.state, 1);
        }
        MutexGuard { mutex: self }
    }
}

对于内存排序,与我们的自旋锁相同。对于该细节,可以参考:第四章

注意,仅有在我们调用它,state 仍设置为 1(锁定)时,wait() 函数才会阻塞,这样我们就不必担心在 swap 和 wait 调用之间失去唤醒调用的可能性。

守卫类型的 Drop 实现是负责解锁 mutex。解锁我们的自旋锁是简单的:仅需要设置 state 到 false(解锁锁)。然而,对于我们的 mutex 来说,这还不够。如果有一个线程等待锁定 mutex,除非我们使用唤醒操作通知它,否则它不会知道 mutex 已经被解锁。如果我们不唤醒它,它将更可能永远保持睡眠。(也许它很幸运,在正确的时间被虚假地唤醒(spurious wake-up)5,但是我们不要指望这一点。)

因此,我们不仅将 state 设置回 0(解锁),而且还会在之后立即调用 wake_one()

#![allow(unused)]
fn main() {
impl Drop for MutexGuard {
    fn drop(&mut self) {
        // 设置 state 回到 0:解锁。
        self.mutex.state.store(0, Release);
        // 如果有,唤醒其中一个等待的线程。
        wake_one(&self.mutex.state);
    }
}
}

唤醒一个线程就足够了,因为即使有多个线程在等待,也仅有其中的一个线程能够认领锁。锁定它的下一个线程将在锁定完成后唤醒另一个线程,以此类推。同时唤醒多个线程,只会让这些线程感到不满,浪费宝贵的处理器时间,因为其中除了一个幸运的线程能够获取锁,其它线程都会在意识到自己失去机会后,从而再次进入休眠状态。

注意,我们不能保证唤醒的每一个线程都能抓住锁。其它线程可能仍然在它有机会之前立刻抓住锁。

这里做出的一个重要的观察是,如果没有等待和唤醒功能,这个 mutex 的实现在技术上仍然是正确的(即内存安全)。因为 wait() 操作可以虚假地唤醒,我们无法对他何时返回作出任何假设。我们仍然得去管理我们锁定原语的状态。如果我们移除等待和唤醒函数调用,我们的 mutex 将与我们的自旋锁状态基本相同。

一般来说,从内存安全方面,原子等待和唤醒函数从不会影响正确性。它们仅是一个(非常重要的)优化,以避免忙碌循环。这并不意味着由任何实际标准都无法使用的低效锁将是“正确的”,但是尝试去推理关于不安全的 Rust 代码时,这种见解可能是有帮助的。

Lock API

如果你正在计划将实现 Rust 锁当作一个新的爱好,那么你可能很快对涉及提供安全接口的样板代码感到厌烦。也就是说,UnsafeCell、Sync 实现、守卫类型、Deref 实现等等。

crate.io 上的 lock_api 可以自动地去处理这些事情。你仅需要制作一个锁定状态的类型,并通过(不安全)lock_api::RawMutex trait 提供(不安全)锁定和解锁功能。lock_api::Mutex 类型将根据你的锁实现,提供一个完全安全的和符合人体工学的 mutex 类型作为返回,包括 mutex 守卫。

避免系统调用

英文版本

到目前为止,我们 mutex 中最慢的部分是等待和唤醒,因为这(可能)导致一个系统调用,即对操作系统内核的调用。像这样与内核交互是一个相当复杂的过程,往往相当缓慢,尤其是与原子操作比较。因此,对于一个高性能 mutex 的实现,我们应该尽可能尝试去避免等待和唤醒调用。

幸运地是,我们已经走了一半。因为在 wait() 调用之前,我们的锁定函数 while 循环才会检查状态,因此在 mutex 未锁定的情况下,等待操作在这种情况下将完全地跳过,我们并不需要等待。然而,我们在解锁时,会无条件地调用 wake_one() 函数。

如果我们知道没有其它线程在等待,我们可以跳过 wake_one()。为了知道是否有等待线程,我们需要自己跟踪这些信息。

我们可以通过将“锁定”状态分割成两个单独的状态来完成这一点:“没有等待者的锁定”和“有等待者的锁定”。我们将使用值 1 和 2 做这些,并更新我们文档中结构体定义中的状态字段的注释。

#![allow(unused)]
fn main() {
pub struct Mutex {
    /// 0: 解锁
    /// 1: 锁定,没有其他线程等待
    /// 2: 锁定,有其他线程等待
    state: AtomicU32,
    value: UnsafeCell,
}
}

现在,对于一个解锁的 mutex,我们的 lock 函数仍然需要将 state 设置为 1 才能锁定它。然而,如果它已经锁定,我们的 lock 函数需要在睡眠之前将 state 设置为 2,以便解锁(unlock)函数可以判断这有一个等待线程。

为了做到这一点,我们首先使用「比较并交换」函数,试图改变 state 从 0 到 1。如果成功,我们就已经锁定了 mutex,并且我们知道,这没有其它的等待者,因为 mutex 之前没有锁定。如果它失败了,那一定是因为 mutex 当前被锁定(在 state 为 1 或 2)。在这种情况下,我们将使用一个原子 swap 操作将其设置为 2。如果 swap 操作返回 1 或 2 的旧值,那这意味着 mutex 事实上仍然被锁定,并且仅有这样,我们才会使用 wait() 去阻塞,直到它改变。如果 swap 操作返回 0,这意味着我们通过改变它的 state 从 0 到 2 已经成功锁定 mutex。

#![allow(unused)]
fn main() {
    pub fn lock(&self) -> MutexGuard {
        if self.state.compare_exchange(0, 1, Acquire, Relaxed).is_err() {
            while self.state.swap(2, Acquire) != 0 {
                wait(&self.state, 2);
            }
        }
        MutexGuard { mutex: self }
    }
}

现在,我们解锁功能可以在必要时通过跳过 wake_one() 调用来利用新信息。我们现在使用 swap 操作,而不是仅仅存储一个 0 去解锁 mutex,这样我们就可以检查它之前的值。仅当值为 2 时,我们将继续唤醒一个线程:

#![allow(unused)]
fn main() {
impl Drop for MutexGuard {
    fn drop(&mut self) {
        if self.mutex.state.swap(0, Release) == 2 {
            wake_one(&self.mutex.state);
        }
    }
}
}

注意,将 state 设置回 0 后,它不再指示是否有任何其它的等待线程。唤醒的线程负责将 state 设置为 2,以确保没有任何其它的线程忘记。这是为什么在我们的 lock 函数中「比较并交换」操作不是我们 while 循环的一部分。

这确实意味着,每当线程在锁定时不得不 wait(),当解锁时,它将也调用 wake_one()。然而,更重要的是,没有争议的情况是,线程不试图同时获取锁的理想状况,这完全地避免了 wait()wake_one() 调用。

图 9-1 展示了两个线程同时尝试锁定我们的 mutex 操作的情况下的 happens-before 关系。首先线程通过改变 state 从 0 到 1 锁定 mutex。此时,第二个线程将无法获取锁,并且在改变 state 从 1 到 2 后进入睡眠。当第一个线程解锁 mutex 时,它会交换 state 回 0。因为是 2,表示一个等待线程,它调用 wake_one() 来唤醒第二个线程。注意,我们不能依赖于唤醒和等待操作之间的任何 happens-before 关系。虽然唤醒操作可能是负责唤醒等待线程的操作,但 happens-before 关系是通过 acquire swap 操作建立的,观察 release swap 操作存储的值。

 图 9-1。同时试图锁定我们的 mutex 的两个线程之间 happens-before 的关系。

进一步优化

英文版本

在这一点上,我们似乎没有什么可以进一步优化的了。在无竞争的情况下,我们执行零系统调用,并且剩下的只是两个非常简单的原子操作。

避免等待和唤醒操作的唯一方式是回到我们的自旋锁实现。尽管自旋通常是非常低效的,但它至少避免了系统调用的潜在开销。唯一能提高自旋效率的情况是仅等待很短的时间。

对于锁定一个 mutex,自旋等待仅在以下情况有效:当前持有 mutex 锁的线程和想要锁定 mutex 的线程在不同的 CPU 核心上并行运行,并且当前线程只持有锁很短的时间。然而,这是一个非常常见的场景。

我们可以尝试将两种方法的优点结合起来,在调用 wait() 之前进行非常短暂的自旋。这样,如果锁被很快释放,我们根本不需要调用 wait(),但我们仍然避免了消耗其它线程更好利用的不合理的处理器时间。

实现这个仅需要改变我们的 lock 函数。

为了在无竞争的情况下尽可能保持性能,我们将在 lock 函数开始时保留原始的「比较并交换」操作。我们将使自旋等待作为一个单独的函数。

#![allow(unused)]
fn main() {
impl Mutex {
    //…

    pub fn lock(&self) -> MutexGuard {
        if self.state.compare_exchange(0, 1, Acquire, Relaxed).is_err() {
            // 锁已经被锁定。:(
            lock_contended(&self.state);
        }
        MutexGuard { mutex: self }
    }
}

fn lock_contended(state: &AtomicU32) {
    //…
}
}

lock_contended 中,在继续等待循环之前,我们可能简单地重复相同的「比较并交换」操作几百次。然而,一个「比较并交换」操作通常尝试去获取相关缓存行(cache line)的独占访问(第七章 MESI 协议),当重复执行时,这可能比简单的 load 操作更昂贵。

考虑到这一点,我们来到了以下 lock_contented 的实现:

fn lock_contended(state: &AtomicU32) {
    let mut spin_count = 0;

    while state.load(Relaxed) == 1 && spin_count  一百个周期的自旋时间大多数是任意选择的。迭代花费的时间和系统调用的时间长短(我们试图避免)很大程度上取决于平台。大范围的基准测试可以帮助我们选择一个正确的数字,但是不幸地是,没有一个准确的答案。
>
>Rust 标准库的 `std::sync::Mutex` 的 Linux 实现(至少在 Rust 1.66.0 中)使用的是 100 次的自旋计数。

锁定状态改变后,我们再次尝试将其设定为 1 来锁定它,然后我们再放弃并且开始自旋等待。正如我们之前讨论的,我们调用 `wait()` 后,我们不能再通过设定它的 state 到 1 来锁定 mutex,因为这可能导致其它的等待者被忘记。


> **Cold 和 Inline 属性**
>
  你可以增加 `#[cold]` 属性到 `lock_contented` 函数定义,以帮助编译器理解在常见(无竞争)情况下不会调用这个函数,这有助`lock` 方法的优化。

  此外,你也可以增加 `#[inline]` 属性到 Mutex 和 MutexGuard 方法,以通知编译器将其内联可能是一个好主意:将生成的指令将其放置在调用方法的地方。一般来说,是否能提高性能很难说,但对于这些非常小的函数,通常可以。



## 基准测试

([英文版本](https://marabos.nl/atomics/building-locks.html#benchmarking))

测试 mutex 实现的性能是很难的。写基准测试和得到一些数字很容易,但是很难去得到一些有意义的数字。

优化 mutex 的实现以在特定基准测试表现良好是相对容易的,但这并没有很有用。毕竟,关键是去做一些在真实世界表现良好的东西,而不仅是在测试程序中。

我们将试图去写两个简单的基准测试,表明我们的优化至少对一些用例产生了一些积极影响,但请注意,任何结论在不同场景都不一定成立。

在我们的第一次测试中,我们将创建一个 Mutex 并在同一线程上锁定和解锁它几百万次,测量它所需的总时间。这是对简单无竞争场景的测试,其中永远没有任何需要唤醒的线程。希望这将向我们展示两状态和三状态版本的显著差异。

```rust
fn main() {
    let m = Mutex::new(0);
    std::hint::black_box(&m);
    let start = Instant::now();
    for _ in 0..5_000_000 {
        *m.lock() += 1;
    }
    let duration = start.elapsed();
    println!("locked {} times in {:?}", *m.lock(), duration);
}

我们使用 std::hint::black_box(像我们在第七章“对性能的影响”)去强制编译器假设有更多的代码去访问 mutex,阻止它优化循环或者锁定操作。

结果将因硬件和操作系统不同而不同。在一台配备最新 AMD 处理器的特定 Linux 计算机上尝试,对于我们为优化的两状态的 mutex 花费时间大约 400ms,对于我们优化过后的三状态的 mutex 大约 40ms。一个因素获得十倍的性能提升!在另一个有着老式 Intel 处理器 Linux 计算机中,差异甚至更大:大约 1800ms 比上 60ms。这证实了,第三个状态的加入确实是一个非常大的优化。

然而,在 macOS 上运行,这会产生一个完全不同的结果:这两个版本大约都是 50ms,这展示了非常高的平台依赖性。

事实证明,我们在 macOS 上使用的 libc++ 的 std::atomic::wake() 实现,已经进行了自己的内部管理,独立于内核,以避免不必要的系统调用。Windows 上的 WakeByAddressSingle() 也是如此。

避免调用这些函数仍然可能带来稍微更好的性能,因为它们的实现并非是微不足道的,尤其因为它们并不能在原子变量本身中存储任何信息。然而,如果我们的目标仅针对这些操作系统,我们将质疑在我们的 mutex 中增加第三个状态是否是值得的。

为了看看我们的自旋优化是否有任何积极的影响,我们需要一个不同的基准测试:一个有着大量竞争的测试,多个线程反复尝试去锁定一个已经上锁的 mutex。

让我们尝试一个场景,四个线程都尝试锁定和解锁 mutex 上百万次:

fn main() {
    let m = Mutex::new(0);
    std::hint::black_box(&m);
    let start = Instant::now();
    thread::scope(|s| {
        for _ in 0..4 {
            s.spawn(|| {
                for _ in 0..5_000_000 {
                    *m.lock() += 1;
                }
            });
        }
    });
    let duration = start.elapsed();
    println!("locked {} times in {:?}", *m.lock(), duration);
}

注意,这是一个极端和不切实际的场景。mutex 仅能保留一个极短的时间(仅增加一个整数),并且在解锁后,线程将立刻试图再次锁定 mutex。不同的场景将导致非常不同的结果。

让我们像以前一样,在两台相同的 Linux 计算机上运行基准测试。在那台较旧的 Intel 处理器的计算机上,不使用自旋版本大约需要 900ms,而使用自旋版本大约需要 750ms。这是一个改进。而在那台新的 AMD 处理器计算机上,我们得到一个相反的结果:不使用自旋大约 650ms,而使用自旋大约需要 800ms。

总之,不幸的是,关于自旋是否真正提高性能的答案是“这取决于平台等”,即使只看一个场景。

条件变量

英文版本

让我们做一些更有趣的事情:实现一个条件变量。

正如我们在第一章“条件变量”中见到的,条件变量与 mutex 一起使用,以等待受 mutex 保护的数据匹配某些条件。它有一个等待方法解锁 mutex,等待一个信号,并再次锁定相同的 mutex。通常由其它线程发送信号,在修改 mutex 保护的数据后立即发送给一个等待的线程(通常叫做“notify one”或“signal”)或者通知所有等待的线程(通常叫做“notify all”或“broadcast”)。

虽然条件变量试图让等待线程保持睡眠状态,直到它收到一个信号,但等待线程可能没有相应信号的情况下被虚假唤醒。然而,条件变量的等待操作在返回之前仍会重新锁定 mutex。

注意,此接口与我们的类 futex wait()wake_one() 以及 wake_all() 函数几乎相同。主要的不同是在于防止信号丢失的机制。条件变量在解锁 mutex 之前开始“监听”信号,以便不错过任何信号,而我们的 futex 风格的 wait() 函数依赖于原子变量状态的检查,以确保等待仍然是一个好的方式。

这导致了条件变量以下最小实现的想法:如果我们确保每个通知都更改原子变量(例如计数器),那么我们的 Condvar::wait() 方法需要做的就是在解锁 mutex 之前,检查该变量的值,并且在解锁它之后,传递它到 futex 风格的 wait() 函数。这样,如果自解锁 mutex 以来,收到任意通知信号,它将不再睡眠。

我们试试吧!

我们开始从 Condaver 结构体开始,该结构体仅包含单个 AtomicU32,我们用 0 初始化:

#![allow(unused)]
fn main() {
pub struct Condvar {
    counter: AtomicU32,
}

impl Condvar {
    pub const fn new() -> Self {
        Self { counter: AtomicU32::new(0) }
    }

    //…
}
}

通知方法是简单的。它们仅需要改变 counter 以及使用相应的唤醒操作去通知任意的等待线程:

#![allow(unused)]
fn main() {
    pub fn notify_one(&self) {
        self.counter.fetch_add(1, Relaxed);
        wake_one(&self.counter);
    }

    pub fn notify_all(&self) {
        self.counter.fetch_add(1, Relaxed);
        wake_all(&self.counter);
    }
}

(稍后我们会讨论内存排序)

等待方法将接收 MutexGuard 作为参数,因为它表示已锁定 mutex 的证明。它将也返回 MutexGuard,因为它要确保在返回之前,再次锁定 mutex。

正如我们之前概述的那样,该方法将首先检查 counter 当前的值,然后再解锁 mutex。解锁 mutex 之后,如果 counter 仍未改变,它将继续等待,以确保我们不会失去任意信号。以下是代码的内容:

#![allow(unused)]
fn main() {
   pub fn wait(&self, guard: MutexGuard) -> MutexGuard {
        let counter_value = self.counter.load(Relaxed);

        // 通过丢弃 guard 解锁 mutex,
        // 但要记住 mutex,以便稍后可以再次锁定它。
        let mutex = guard.mutex;
        drop(guard);

        // 等待,但仅当 counter 自解锁以来仍为改变。
        wait(&self.counter, counter_value);

        mutex.lock()
    }
}

这里用了 MutexGuard 的私有 mutex 字段。Rust 中的私有性是基于模块的,所以如果你正在与 MutexGuard 不同的模块定义这个,则需要将 MutexGuard 的 mutex 字段标记如下,例如,pub(crate) 使其在 crate 中的其它模块可见。

在我们成功的完成我们的条件变量之前,让我们开始考虑一下内存排序。

当 mutex 锁定时,没有其它线程可以改变受保护的 mutex 数据。因此,我们并不需要担心来自我们解锁 mutex 之前的通知,因为只要我们保持 mutex 锁定,数据就不会发生任何变化,这会让我们改变关于想要睡眠和等待的想法。

我们唯一感兴趣的情况是,我们释放 mutex 后,另一个线程出现并且锁定 mutex,改变受保护的数据,并且向我们发出信号(希望在解锁 mutex 之后)。

在这种情况下,在 Condvar::wait() 解锁 mutex 和在通知线程中锁定 mutex 之间有一个 happens-before 关系。该关系是确保我们的 Relaxed 加载(在解锁之前发生)会观察到通知的 Relaxed 自增操作(在锁定之后发生)之前的值。

我们并不知道 wait() 操作是否会看到在自增之前还是之后的值,因为此时没有任何东西可以保证排序。然而,这并不重要,因为 wait() 在相应的唤醒操作中具有原子性行为。要么它看见新值,在这种情况下,它根本不会进入睡眠,或者它看见旧值,在这种情况下,它会进入睡眠,并由来自通知中相应的 wake_one() 或者 wake_all() 唤醒。

图 9-2 展示了操作和 happens-before 关系,在这种情况下,一个线程使用 Condvar::wait() 等待一些受 mutex 保护的数据更改,并由第二个线程唤醒,该线程修改数据并且调用 Condvar::wake_one()。请注意,由于解锁和锁定操作,第一次 load 操作能够保证值递增之前观察到该值。

 图 9-2。一个线程使用 Condvar::wait() 被另一个使用 Condvar::notify_one() 的线程唤醒的操作和 happens-before 的关系。

我们应该也考虑如果 counter 溢出会发生什么。

只要每次通知之后计数器是不同的,它的真实值就无关紧要。不幸的是,在超过 40 亿个通知之后,计数器将溢出,并以 0 重新启动,回到之前使用过的值。从技术上讲,我们的 Condvar::wait() 实现可能在不应该的时候进入睡眠状态:如果它正好错过了 4,292,967,296 条通知(或者任意它的倍数),它会溢出计数器到它之前拥有过的值。

认为这种情况发生的可能性可以忽略不计是完全合理的。与我们在 mutex 锁定方法所做的事不同,我们不会在这里唤醒后,重新检查 state 和重复 wait() 调用,所以我们仅需要关心在 counter 的 relaxed load 操作和 wait() 调用之间的那一刻发生溢出往返(round-trip)。如果一个线程中断太久,以至于(确切地)允许发生许多通知,那么可能已经出现大问题,并且程序已经变得没有响应。此时,人们可能会合理地争辩到:线程保持睡眠的微小额外风险不再重要。

在支持有时间限制的 futex 式等待的平台上,可以对等待操作使用几秒钟的超时来降低溢出的风险。发送 40 亿条通知将花费更长的时间,此时,额外的几秒钟的风险将产生非常小的影响。这完全消除了由于等待线程错误地一直待在睡眠状态而导致程序锁定的的任何风险。

让我们看看它是否工作!

#![allow(unused)]
fn main() {
#[test]
fn test_condvar() {
    let mutex = Mutex::new(0);
    let condvar = Condvar::new();

    let mut wakeups = 0;

    thread::scope(|s| {
        s.spawn(|| {
            thread::sleep(Duration::from_secs(1));
            *mutex.lock() = 123;
            condvar.notify_one();
        });

        let mut m = mutex.lock();
        while *m  Self {
        Self {
            counter: AtomicU32::new(0),
            num_waiters: AtomicUsize::new(0), // 新增!
        }
    }

    //…
}
}

通过将 AtomicUsize 用于 num_waiters,我们不必要担心它溢出。usize 是足够大的,能够计算内存中的每个字节,所以如果我们假设每个激活的线程占用至少内存的一个字节,它是绝对的足够大,能够计算任意数量的并发的存在线程。

下一步,我们更新我们的通知功能,如果没有等待线程,则什么也不做:

#![allow(unused)]
fn main() {
    pub fn notify_one(&self) {
        if self.num_waiters.load(Relaxed) > 0 { // 新增!
            self.counter.fetch_add(1, Relaxed);
            wake_one(&self.counter);
        }
    }

    pub fn notify_all(&self) {
        if self.num_waiters.load(Relaxed) > 0 { // 新增!
            self.counter.fetch_add(1, Relaxed);
            wake_all(&self.counter);
        }
    }
}

(我们稍后会讨论内存排序)。

最后,最重要的是,我们在等待方法开始时递增它,并在它唤醒后立即递减:

#![allow(unused)]
fn main() {
    pub fn wait(&self, guard: MutexGuard) -> MutexGuard {
        self.num_waiters.fetch_add(1, Relaxed); // 新增!

        let counter_value = self.counter.load(Relaxed);

        let mutex = guard.mutex;
        drop(guard);

        wait(&self.counter, counter_value);

        self.num_waiters.fetch_sub(1, Relaxed); // 新增!

        mutex.lock()
    }
}

我们应该再次仔细询问我们自己,对于所有这些原子操作,Relaxed 内存排序是否应该足够。

我们引入的一个新的潜在风险是,通知方法在 num_waiters 中观察到 0,跳过了它的唤醒操作,实际上却有一个线程需要唤醒。这种情况可能当通知方法在递增操作之前或者递减操作之后观察到值时发生。

就像从 counter 中的 relaxed load 操作一样,事实上,在递增 num_waiters 时,等待者将仍然持有 mutex,这确保了在解锁 mutex 之后发生的任何 num_waiters load 操作都不会看到它被递增之前的值。

我们也不需要担心通知线程观察到递减值“太快”,因为一旦执行递减操作,或许在虚假唤醒之后,等待线程不再需要被唤醒。

换句话说,mutex 建立的 happens-before 关系仍然提供了我们需要的所有保证。

避免虚假唤醒

英文版本

另一个方式是通过避免虚假唤醒来优化我们的条件变量。每次线程被唤醒时,它将尝试锁定 mutex,这可能与其它线程产生竞争,这可能在性能上有一个巨大的影响。

底层 wait() 操作偶尔会虚假唤醒是很罕见的,但是我们的条件变量实现很容易使得 notify_one() 导致不止一个线程去停止等待。如果一个线程正在进入睡眠的过程,刚刚加载了 counter 的值,但是仍然没有进入睡眠,那么调用 notify_one() 将由于更新的 counter 从而阻止线程进入睡眠状态,但也会因为后续的 wake_one() 操作导致第二个线程唤醒。这两个线程将先后竞争 mutex,浪费宝贵的处理器时间。

这听起来像是一个罕见的现象,但因为 mutex 最终同步线程的方式,这实际上很容易发生。在条件变量上调用 notify_one() 的线程最有可能在此之前立即锁定和解锁 mutex,以改变等待线程正在等待的数据的某些内容。这意味着,一旦 Condvar::wait() 方法解锁了 mutex,那就有可能立刻解除了正在等待 mutex 的通知线程的阻塞。此刻,这两个线程正在竞争:等待线程正在进入睡眠,通知线程正在锁定和解锁 mutex 并且通知条件变量。如果通知线程赢得竞争,等待线程将由于 counter 递增而不会进入睡眠,但是通知线程仍然调用 wake_one()。这正是上面描述的问题情况,它可能会不必要地唤醒一个额外线程。

一个相对简单的解决方案是跟踪允许唤醒的线程数量(即从 Condvar::wait() 返回)。notify_one() 方法会将其递增 1,并且如果它不是 0,等待方法会试图将其递减 1。如果 counter 是 0,它可以进入(返回)睡眠状态,而不是试图重新锁定 mutex 并且返回。(通知添加另一个专门用于 notify_all 的计数器来通知所有线程来完成,该 counter 永远不会减少。)

这种方式是有效的,但是带来一个新的、更微妙的问题:通知可能唤醒一个甚至还没有调用 Condvar::wait() 的线程,包括它自身。调用 Condvar::notify_one()增加应该唤醒的线程数量,并且使用 wake_one() 去唤醒一个等待线程。然而,如果在已经等待的线程有机会醒来之前,另一个(甚至相同的)线程随后调用 Condvar::wait(),新等待的线程可以看到一个通知待处理,并通过将 counter 递减到 0 来认领它,并立即返回。正在等待的第一个线程将返回睡眠状态,因为另一个线程已经获取了一个通知。

根据用例,这可能完全没有问题,也可能是一个大问题,导致一些线程永远无法取得进展。

GNU libc 的 pthread_cond_t 的实现曾经受到这个问题影响。后来,经过大量关于 POSIX 规范是否允许的讨论,这个问题最后随着 2017 的 GNU libc 2.25 的发布而最终解决,这包含一个全新的条件变量实现。

在很多使用条件变量的情况下,等待线程会抢走一个更早的通知。然而,当为一般类型而不是特定用例的类型实现条件变量时,该行为可能是不可接受的。

同样,我们必须得出结论,我们是否应该使用一个优化方式的答案是,不出意外的是,“这取决于平台等”。

有一些方法去避免这个问题,同时仍然避免虚假唤醒,但这些方法要比其它方法复杂得多。

GNU libc 的新条件变量解决方案包括将等待线程分为两组,仅允许第一组去消费通知,并在第一组没有等待线程时交换组。

这种算法的一个缺点是,不仅是算法的复杂性,同时也显著增加条件变量类型的大小,因为它现在需要跟踪更多的信息。

惊群问题(Thundering Herd Problem)

当使用 notify_one() 唤醒正在等待很多相同事情的线程时,当使用条件变量时可能遇到的高性能问题。

问题是,在唤醒后,所有这些线程都将立即尝试锁定相同的 mutex。更可能地是,仅有一个线程将成功,并且所有其它线程都将回到睡眠状态。很多线程都急于宣称相同资源的资源浪费问题被称为惊群问题

认为 Condvar::notify_all() 是从根本上不值得优化的反模式不是没有原因的。条件变量的目的是去解锁 mutex 并且当接受通知时重新锁定它,因此也许一次通知多个线程从来不是任何好主意。

即便如此,如果我们想针对这种情况进行优化,我们可以在支持类似 futex 重新排队 这种操作的操作系统上,例如在 Linux 上的 FUTEX_REQUEUE(参见第八章“Futex 操作”

与其唤醒许多线程,除一个线程外的其它线程一旦意识到锁已被占用都将立刻回到睡眠状态,我们可以将除一个线程外的其它所有线程重新排队,以便它们的 futex 等待操作不再等待条件变量的 counter,而是开始等待 mutex 的状态。

重新排队一个等待线程不会唤醒它。事实上,线程甚至不知道自己已经在重新排队。不幸地是,这可能导致一些非常细微的陷阱。

例如,还记得三状态 mutex 总是在唤醒后必须锁定到正确的状态(“有着等待线程的锁定”),以确保其它等待线程不会被遗忘?这意味着我们应该不在我们的 Condvar::wait() 实现中使用常规的 mutex 方法,这可能将 mutex 设置到一个错误的状态。

一个重新排队的条件变量实现需要存储等待线程使用的 mutex 的指针。否则,通知线程将不知道等待线程重新排队到哪个原子变量(互斥状态)。这就是为什么条件变量通常不允许两个线程去等待不同的 mutex。尽管许多条件变量的实现并未利用重新排队,但为未来版本保留利用此功能的可能性是有用的。

读写锁

英文版本

是时候实现读写锁了。

回想一下,与互斥锁不同,读写锁支持两种类型的锁:读锁和写锁,有时称为共享锁定和排他性锁定。写锁的行为与锁定 mutex 相同,一次仅允许一个锁,而读锁则一次可以允许多个 reader 锁定。换句话说,它与 Rust 中的独占引用(&mut T)和共享引用(&T)密切相关,仅允许一个独占引用,或者任意数量的共享引用同时处于活动状态。

对于我们的 mutex,我们仅需要去跟踪它是否是锁定的。对于我们的读写锁,然而,我们也需要去知道当前持有多少个(读)锁,以确保所有写锁释放它们的锁后才会发生写锁。

让我们开始编写 RwLock 结构体,该结构体使用 AtomicU32 作为它的状态。我们将使用它去表示当前当前可获取的读锁数量,因此,值为 0 意味着它是未锁定的。为了表示写锁的 state,让我们使用一个特殊的值 u32::MAX

#![allow(unused)]
fn main() {
pub struct RwLock {
    /// reader 的数量,或者如果写锁定则是 u32::MAX。
    state: AtomicU32,
    value: UnsafeCell,
}
}

对于我们的 Mutex,我们必须限制它的同步实现,其类型 T 必须实现 Send,以确保它们不能将 Rc 发送到另一个线程。对于我们的新 RwLock,我们还需要要求 T 也实现 Sync,因为多个 reader 将能够同时访问数据。

#![allow(unused)]
fn main() {
unsafe impl Sync for Rwlock where T: Send + Sync {}
}

因为我们的 RwLock 可以两种不同的方式锁定,我们将有两个单独的锁定函数,每个都有属于自己的守卫:

#![allow(unused)]
fn main() {
impl RwLock {
    pub const fn new(value: T) -> Self {
        Self {
            state: AtomicU32::new(0), // 解锁!
            value: UnsafeCell::new(value),
        }
    }

    pub fn read(&self) -> ReadGuard {
        // …
    }

    pub fn write(&self) -> WriteGuard {
        // …
    }
}

pub struct ReadGuard {
    rwlock: &'a RwLock,
}

pub struct WriteGuard {
    rwlock: &'a RwLock,
}
}

写守卫应该表现得像一个排他性引用(&mut T),我们通过为它实现 DerefDerefMut 来实现这一点:

#![allow(unused)]
fn main() {
impl Deref for WriteGuard {
    type Target = T;
    fn deref(&self) -> &T {
        unsafe {&*self.rwlock.value.get()}
    }
}

impl DerefMut for WriteGuard {
    type Target = T;
    fn deref_mut(&mut self) -> &T {
        unsafe {&mut *self.rwlock.value.get()}
    }
}
}

然而,读守卫将仅实现 Deref,不用 DerefMut,因为它并不用独占数据的访问,这使它的行为像一个共享引用(&T):

#![allow(unused)]
fn main() {
impl Deref for ReadGuard {
    type Target = T;
    fn deref(&self) -> &T {
        unsafe { &*self.rwlock.value.get() }
    }
}
}

既然,我们已经摆出了样板代码,那让我们谈谈有趣的部分:锁定和解锁。

为了读取锁定我们的 RwLock,我们必须将 state 递增,但是前提它必须还没有写锁定。我们将使用一个「比较并交换」循环(第二章“「比较并交换」操作”)去做这些。如果 state 是 u32::MAX,这意味着 RwLock 是写锁,我们将使用一个 wait() 操作去睡眠并且稍后重试。

#![allow(unused)]
fn main() {
    pub fn read(&self) -> ReadGuard {
        let mut s = self.state.load(Relaxed);
        loop {
            if s  return ReadGuard { rwlock: self },
                    Err(e) => s = e,
                }
            }
            if s == u32::MAX {
                wait(&self.state, u32::MAX);
                s = self.state.load(Relaxed);
            }
        }
    }
}

写锁定是简单的;我们仅需要改变 state 从 0 到 u32::MAX,或者如果它已经锁定则是 wait()

#![allow(unused)]
fn main() {
    pub fn write(&self) -> WriteGuard {
        while let Err(s) = self.state.compare_exchange(
            0, u32::MAX, Acquire, Relaxed
        ) {
            // 当它已锁定,则等待
            wait(&self.state, s);
        }
        WriteGuard { rwlock: self }
    }
}

注意,锁定的 RwLock 确切 state 值是如何变化的,但是 wait() 操作希望我们给它一个确切的值去与 state 比较。这是为什么我们将来自「比较并交换」操作的返回值用于 wait() 操作。

解锁 reader 涉及到递减 state。最终解锁 RwLock 的 reader,会将 state 从 1 改变到 0,负责唤醒等待的 writer(如果有的话)。

仅唤醒一个线程就足够了,因为我们知道目前没有任何正在等待的 reader。reader 根本没有理由正在等待一个读锁定的 RwLock。

#![allow(unused)]
fn main() {
impl Drop for ReadGuard {
    fn drop(&mut self) {
        if self.rwlock.state.fetch_sub(1, Release) == 1 {
            // 如果有,唤醒一个等待的 writer。
            wake_one(&self.rwlock.state);
        }
    }
}
}

writer 必须重设 state 到 0 以解锁,之后它应该唤醒一个等待的 writer 或者所有正在等待的 writer。

我们并不知道 reader 或者 writer 正在等待,我们也没有办法去唤醒一个 writer 或者只唤醒 reader。所以,我们只需要唤醒所有的线程:

#![allow(unused)]
fn main() {
impl Drop for WriteGuard {
    fn drop(&mut self) {
        self.rwlock.state.store(0, Release);
        // 唤醒所有等待的 reader 和 writer。
        wake_all(&self.rwlock.state);
    }
}
}

仅此而已!我们建立了一个非常简单但完全可用的读写锁。

是时候解决一些问题了。

避免 writer 忙碌循环

英文版本

我们实现的一个问题是写锁可能导致意外地忙碌循环。

如果我们有一个很多 reader 重复锁定和解锁的 RwLock,那么锁定状态可能会持续变化,上下快速波动。对于我们的 write 方法,这导致了在「比较并交换」操作和随后的 wait() 操作之间发生锁定状态的变化可能很大,尤其 wait() 操作作为(相对缓慢的)系统调用直接实现。这意味着 wait() 操作将立即返回,即使锁从未解锁;它只是 reader 数量与预期的不同。

解决方案是使用一个不同的 AtomicU32 让等待者去等待,并且仅有在我们真正想唤醒 writer 时,才改变原子的值。

让我们尝试这个,通过增加一个新的 writer_wake_counter 字段到我们的 RwLock:

#![allow(unused)]
fn main() {
pub struct RwLock {
    /// reader 的数量,或者如果写锁定,则是 u32::MAX。
    state: AtomicU32,
    /// 唤醒 writer 的数量。
    writer_wake_counter: AtomicU32, // 新增!
    value: UnsafeCell,
}

impl RwLock {
    pub const fn new(value: T) -> Self {
        Self {
            state: AtomicU32::new(0),
            writer_wake_counter: AtomicU32::new(0), // 新增!
            value: UnsafeCell::new(value),
        }
    }

    // …
}
}

read 方法仍然保留未改变,但是 write 方法现在需要等待新的原子变量。为了确保我们在看到 RwLock 被读锁定和实际进入睡眠之间不失去任何通知,我们将使用类似于实现条件变量的模式:在检查我们是否仍然想要睡眠之前,检查 writer_wake_counter

#![allow(unused)]
fn main() {
    pub fn write(&self) -> WriteGuard {
        while self.state.compare_exchange(
            0, u32::MAX, Acquire, Relaxed
        ).is_err() {
            let w = self.writer_wake_counter.load(Acquire);
            if self.state.load(Relaxed) != 0 {
                // 如果 RwLock 仍然锁定,但前提是
                // 自从我们检查以来仍然没有唤醒信号,则等待。
                wait(&self.writer_wake_counter, w);
            }
        }
        WriteGuard { rwlock: self }
    }
}

writer_wake_counter 的 Acquire load 操作将与 Release 递增操作形成一个 happens-before 关系,该操作在解锁状态后立即执行,然后唤醒等待的 writer:

#![allow(unused)]
fn main() {
impl Drop for ReadGuard {
    fn drop(&mut self) {
        if self.rwlock.state.fetch_sub(1, Release) == 1 {
            self.rwlock.writer_wake_counter.fetch_add(1, Release); // 新增!
            wake_one(&self.rwlock.writer_wake_counter); // 改变!
        }
    }
}
}

happens-before 关系确保 write 方法不能观察到递增的 writer_wake_counter 值,而之后仍然看到尚未减少的状态值。否则,写锁定的线程可能认为 RwLock 仍然被锁定,而错过唤醒通知。

正如之前的一样,写解锁应该唤醒一个 writer 或者所有等待的 reader。由于我们仍然不知道是否有 writer 或者 reader 正在等待,我们不得不唤醒一个等待的 writer(通过 wake_one)和所有等待的 reader(使用 wake_all):

#![allow(unused)]
fn main() {
impl Drop for WriteGuard {
    fn drop(&mut self) {
        self.rwlock.state.store(0, Release);
        self.rwlock.writer_wake_counter.fetch_add(1, Release); // 新增!
        wake_one(&self.rwlock.writer_wake_counter); // 新增!
        wake_all(&self.rwlock.state);
    }
}
}

在一些操作系统中,唤醒操作背后的操作会返回它唤醒的线程数量。它可能表示低于唤醒线程实际数量的个数(由于虚假唤醒),但是它的返回值仍然可以用于优化。

在以上的 drop 实现中,例如,如果 wake_one() 操作表明它实际唤醒了一个线程,我们可以跳过 wake_all() 调用。

避免 writer 陷入饥饿

英文版本

RwLock 的一个通常用例是频繁使用 reader 的情况,但是非常少(通常仅有一个)不经常的 writer 的情况。例如,一个线程可能负责读取一些传感器输入或者定期下载许多其它线程需要使用的新数据。

在这种情况下,我们很快就会遇到一个叫做 writer 饥饿的问题:一种情况是,writer 从未得到一个机会去锁定 RwLock,因为周围总是有 reader 保持 RwLock 读锁定。

一个解决方式是去防止任何新的 reader 在有 writer 时取得锁,即使 RwLock 仍然是读锁定。这样,所有新的 reader 都将等待直到轮到 writer,这确保了 reader 将获取到 writer 想要共享的最新的数据。

让我们实现这个。

为了完成这个,我们需要跟踪是否有任意的等待 writer。为了在 state 变量中为这些信息腾出空间,我们可以将 reader 的数量乘以 2,并且有 writer 等待的情况下加 1。这意味着 6 或者 7 的 state 都表示有 3 个激活的 read 锁定的情况:6 没有一个等待的 writer,7 有一个等待的 writer。

如果我们将 u32::MAX(这是一个奇数)保持为写锁定的状态,那么如果 state 是奇数,那么 reader 将必须等待。但是如果 state 是偶数,reader 就可以通过递增 2 它来获取一个读锁。

#![allow(unused)]
fn main() {
pub struct RwLock {
    /// 读锁的数量乘以 2,如果有一个 writer 正在等待,则加 1。
    /// 如果已写锁定,则是 u32::MAX。
    ///
    /// 这意味着当 state 是偶数时,reader 可能获取锁,
    /// 但当 state 是奇数时,则是需要阻塞
    state: AtomicU32,
    /// 唤醒 writer 的数量。
    writer_wake_counter: AtomicU32,
    value: UnsafeCell,
}
}

我们必须更改我们 read 方法中的两个 if 语句,不再将 state 与 u32::MAX 进行比较,而是检查 state 是否是偶数还是奇数。我们还需要以确保我们增加 2 而不是 1 来锁定。

#![allow(unused)]
fn main() {
    pub fn read(&self) -> ReadGuard {
        let mut s = self.state.load(Relaxed);
        loop {
            if s % 2 == 0 { // 偶数
                assert!(s != u32::MAX - 2, "too many readers");
                match self.state.compare_exchange_weak(
                    s, s + 2, Acquire, Relaxed
                ) {
                    Ok(_) => return ReadGuard { rwlock: self },
                    Err(e) => s = e,
                }
            }
            if s % 2 == 1 { // 奇数
                wait(&self.state, s);
                s = self.state.load(Relaxed);
            }
        }
    }
}

我们的 write 方法必须经历更大的改变。我们将使用一个「比较并交换」循环,就像我们上面的 read 方法那样。如果 state 是 0 或者 1,这意味着 RwLock 是解锁的,我们将试图去改变 state 到 u32::MAX 以写锁定它。否则,我们将不得不等待。然而,在这样做之前,我们需要确保 state 是奇数,以停止新的 reader 获取锁。在确保 state 是奇数后,我们等待 writer_wake_counter 变量,同时需要确保锁在此期间一直没有解锁。

在代码中,这看起来像:

#![allow(unused)]
fn main() {
    pub fn write(&self) -> WriteGuard {
        let mut s = self.state.load(Relaxed);
        loop {
            // 如果解锁,尝试去锁定。
            if s  return WriteGuard { rwlock: self },
                    Err(e) => { s = e; continue; }
                }
            }
            // 通过确保 state 是奇数,阻塞新的 reader。
            if s % 2 == 0 {
                match self.state.compare_exchange(
                    s, s + 1, Relaxed, Relaxed
                ) {
                    Ok(_) => {}
                    Err(e) => { s = e; continue; }
                }
            }
            // 如果它仍然锁定,则等待。
            let w = self.writer_wake_counter.load(Acquire);
            s = self.state.load(Relaxed);
            if s >= 2 {
                wait(&self.writer_wake_counter, w);
                s = self.state.load(Relaxed);
            }
        }
    }
}

因为我们现在跟踪是否有任意等待的 writer,读解锁现在可以在不需要的时候跳过 wake_one() 调用:

#![allow(unused)]
fn main() {
impl Drop for ReadGuard {
    fn drop(&mut self) {
        // 将 state 递减 2,以移除一个读锁。
        if self.rwlock.state.fetch_sub(2, Release) == 3 {
            // 如果我们从 3 减少到 1,那意味着 RwLock
            // 现在是解锁状态,*并且*有一个等待的 writer。
            // 我们会唤醒它。
            self.rwlock.writer_wake_counter.fetch_add(1, Release);
            wake_one(&self.rwlock.writer_wake_counter);
        }
    }
}
}

当写锁定(state 是 u32::MAX)时,我们并不跟踪任何关于是否有线程正在等待的的信息。所以,我们没有用于用于写解锁的的新信息,这些将保持不变:

#![allow(unused)]
fn main() {
impl Drop for WriteGuard {
    fn drop(&mut self) {
        self.rwlock.state.store(0, Release);
        self.rwlock.writer_wake_counter.fetch_add(1, Release);
        wake_one(&self.rwlock.writer_wake_counter);
        wake_all(&self.rwlock.state);
    }
}
}

对于针对“频繁读和频繁写”用例进行优化的读写锁,这是完全可以接受的,因为写锁定(并且因此写解锁)很少发生。

然而,对于更普遍目的的读写锁定,这绝对是值得进一步优化的,这使写锁定和解锁的性能接近于高效的三状态的互斥锁性能。这对读者来说是一个有趣的练习。

总结

英文版本

  • atomic-wait crate 提供了一个基础的类 futex 功能,适用于所有主要的操作系统(最新版本)。

  • 一个最小的实现仅需要两个状态,像我们来自第四章SpinLock

  • 一个更有效的 mutex 追踪是否有任何的等待线程,所以它可以避免一个不需要的唤醒操作。

  • 在进行睡眠之前自旋可能对一些用例是有益的,但这很大程度取决于情况、操作系统和硬件。

  • 一个最小的条件变量的实现仅需要一个通知 counter,Condvar::wait() 将不得不在解锁 mutex 之前和之后检查。

  • 条件变量可能跟踪等待线程的数量,以避免不需要的唤醒操作。

  • 避免从 Condvar::wait 虚假唤醒可能很棘手,需要额外的内部管理。

  • 一个最小的读写锁仅需要一个原子计数作为状态。

  • 一个额外的原子变量可以用于独立于 reader 唤醒 writer。

  • 为了避免 writer 饥饿,需要额外的状态优先考虑一个等待的 writer 而不是新的 reader。

    下一篇,第十章:理念和灵感

4
5

虚假唤醒是一个线程在没有收到明确的信号的情况下,从等待状态中被唤醒

1
2
3

参考:

第十章:理念和灵感

英文版本

有无数与并发相关的话题、算法、数据结构、轶事以及其它可能的章节都可能成为本书的一部分。然而,我们已经到了最后一章,我们即将结束我们的旅程,希望给你全新的可能性并对这些可能性感到兴奋,并准备在实践中应用新的知识和技能。

最终章节的目的是为了向你展示一些可以学习、探索和构建的想法,为你自己的创造和未来工作提供灵感。

信号量1

英文版本

信号量实际上仅是有两个操作的计数器:信号(signal,也叫做 up 或 V)和等待(wait,也叫做 down 或 P)。signal 操作增加计数器到一个确定的最大值,而等待操作递减计数器的值。如果计数器是 0,wait 操作将阻塞并等待匹配的 signal 操作,以防止计数器将变成负数。这是一个灵活的工具,可以用于实现其它同步原语。

信号量可以实现为用于计数器的 Mutex 以及用于等待操作的 Condvar 的组合。然而,有几种方式能更有效地实现它。更值得关注的是,在支持类 futex 操作(第八章“futex”)的平台上,可以使用单个 AtomicU32(或者甚至 AtomicU8)更高效地实现。

最大值为 1 的信号量又是被称为二进制信号量,它可以用作构建其他原语的基石。例如,它可以通过初始化计数器初始化为 1、使用锁定的 wait 操作以锁定以及使用 signal 操作以解锁,来用作 mutex。通过将它初始化到 0,它也可以被用作信号,类似于条件变量。例如,在标准库 std::threadpark()unpark() 函数可以实现与线程关联的二进制信号量上的 wait 和 signal 操作。

注意,mutex 可以使用信号量来实现,而信号量可以使用 mutex(或者)来实现。建议避免使用基于 mutex 的信号量来实现基于信号量的 mutex,反之亦然。

进一步阅读:

RCU

英文版本

如果你想要多个线程去(更多地)读和(少量地)更改一些数据,你可以使用 RwLock。当这些数据仅是单个整数时,你可以使用单个原子变量(例如 AtomicU32)去避免锁定,这样更有效。然而,对于巨大数据的分块,像有着很多字段的结构体,没有可用的原子类型允许对整个对象进行无锁原子操作。

就像计算机科学中的其他问题一样,该问题也可以通过增加间接的层的方式来解决。你可以使用原子变量去存储一个指向它的指针,而不是结构体本身。这仍然不允许你以原子地方式修改整个结构体,但它允许你以原子地方式替换整个结构体,这差不多。

这种模式通常称为 RCU,代表“读取、复制、更新”,这是替换数据所需要的步骤。读取指针后,可以将结构体复制进新的内存分配中,无需担心其他线程即可进行修改。准备就绪后,可以使用「比较并交换」操作(第二章节的“比较并交换”操作)来更新原子指针,如果没有其他线程在此期间替换数据,这将成功。

关于 RCU 模式最有趣的部分是最后一步,它没有首字母缩略的单词:重新分配旧数据(deallocating the old data)。成功更新后,如果其他线程在更新前读取指针,它们仍然可能读取旧副本。你必须等待所有这些线程的完成,才能重新分配旧副本。

对于这个问题有很多可能的解决方案,包括引用计数(例如 Arc)、泄漏内存(忽视问题)、垃圾收集、冒险指针2(线程告诉其他线程它们当前正在使用什么指针的方式)以及静态状态跟踪(等待每个线程达到不再使用任何指针的点)。最后一个在某些情况下非常高效。

在 Linux 内核中的很多数据结构是基于 RCU 的,并且有很多关于它们实现细节有意思的讨论和文章,这可以提供一个很棒的灵感。

进一步阅读:

无锁链表

英文版本

在基本的 RCU 模式上进行扩展,可以增加一个原子指针到结构体以指向下一个结构体,从而将其转换为链表。这允许线程以原子地方式增加或移除链表中的元素,而无需每次更新时复制整张表。

为了在表开始插入一个新元素,你仅需要分配该元素并将它的指针指向列表中的第一个元素,然后原子更新初始化指针以指向你最新分配到元素。

同样,移除元素可以通过更新元素之前(元素)的指针指向后一个元素来完成。然而,当涉及多个 writer 时,必须处理相邻元素的并发插入或者删除操作。否则,你可能还会意外地并发地移除新插入的元素,或者撤销了并发移除的元素的移除。

为了保持简单,你可以使用常规的 mutex 来避免并发的修改。这样,读仍然是一个无锁操作,但是你不需要担心处理并发修改。

从链表列表中分离元素后,你将遇到与之前相同的问题:它会等待,直到你释放它(或者以其他方式宣称所有权)。在这种情况下,我们之前讨论的基本的 RCU 模式的相同解决方案在这里也有效。

总的来说,你可以基于原子指针上的「比较并交换」操作,构建各种精心设计的无锁数据结构,但是你将总是需要一个好的策略来释放或者以其他方式收回分配的所有权。

进一步阅读:

基于队列的锁

英文版本

对于大多数标准锁定的原语,操作系统内核都会跟踪被阻塞的线程,并负责在被询问时,挑选一个线程来唤醒。一个有趣的替代方案是通过手动地跟踪等待线程的队列来实现 mutex(或者其他锁定原语)。

例如一个 mutex 可能作为单个 AtomicPtr 实现,其可以指向一个等待线程(列表)。

在这个列表中的每个元素都需要包含一些字段,这些字段用于唤醒相应的线程,例如 std::thread::Thread 对象。原子指针一些未使用的位可以用于存储 mutex 自身的状态,以及管理队列状态的任何所需的东西。

有很多可能的变体。队列可能由它自己的锁位保护,或者也可以实现为(部分地)无锁结构。元素不必在堆上分配,而可以是等待的线程的局部变量。队列可以是一个双向链表,不仅包含指向下一个元素的指针,同时也包含指向前一个元素。第一个元素也包含一个指向最后元素的指针,以便有效地在末尾追加一个元素。

这种模式仅允许使用可以用于阻塞和唤醒单个线程的方式(例如 parking)来实现高效的锁原语。

Windows SRW 锁(第8章中的“精简的读写(SRW)锁”)使用此模式实现。

进一步阅读:

基于阻塞的锁

英文版本

为了创建一个尽可能小而高效的 mutex,你可以通过将队列移动到全局的数据结构,在 mutex 自身只留下 1 或者 2 个位,来构建基于队列锁的想法。这样,mutex 仅需要是一个字节。你甚至可以把它放置在一些未使用的指针位中,这允许非常细粒度的锁定,几乎没有其他额外的开销。

全局的数据结构可以是一个 HashMap,将内存地址映射到等待该地址的 mutex 的线程队列。全局的数据结构通常叫做 parking lot,因为它是一组被阻塞(park)的线程合集。

这种模式可以是广泛的,其不仅是跟踪 mutex 的队列,同时也还跟踪和其他原语。通过跟踪任何原子变量的队列,这有效地提供了一种不在原生支持该功能的平台上实现类似 futex 功能的方式。

这种模式最出名的是 2015 年在 WebKit 中的实现,在那里它被用来锁定 JavaScript 对象。它的实现启发了其他实现,例如流行的 parking_lot Rust crate。

进一步阅读:

顺序锁(Sequence Lock)

英文版本

顺序锁是不使用传统(阻塞)锁的原子更新(巨大)的数据的另一种解决方案。当数据正在更新时,甚至数据正在准备读取时,它使用一个奇数的原子计数器。

在更改数据之前,写入线程必须将计数器从偶数递增到奇数,之后它必须再次递增计数器以使其保持(不同的)偶数值。

任何读取线程都可以在任何时候,在不阻塞的情况下,通过在前后读取计数器来读取数据。如果来自计数器的两个值是相等的或是偶数,就没有并发更改,这意味着你读取了有效的数据副本。否则,你可能读取的数据被并发地修改了,在这种情况下,你应该再次尝试。

这是一个向其他线程提供数据的绝佳模式,而不会使读线程阻塞写线程。它通常用在操作系统内核和许多嵌入式系统。因为 reader 仅需要对内存的读取访问,并没有涉及指针,因此这可以是一个很好的数据结构,可以在共享内存中安全地使用,在处理器之间,而无需信任 reader。例如,Linux 内核使用这个模式通过为进程提供对(共享)内存的只读访问,非常有效地为进程提供时间戳。

一个有趣的问题是,这如何融入内存模型。对相同数据的并发非原子读和写会导致未定义的行为,即使读取数据被忽略。这意味着,从技术上讲,读和写操作都应该仅使用原子操作,尽管整个读或者写并不必须是单一的原子操作。

教学材料

英文版本

花费许多时间(或者许多年)去发明新的并发数据结构和设计人性化的 Rust 实现是非常有趣的。如果你正在寻找与 Rust、原子操作、锁、并发数据结构以及并发性相关的其他知识,那么创建新的教材与其他人分享你的知识也非常有成就感。

对于这些主题的初学者,缺乏可接触的资源。Rust 在使系统编程对所有人更易接触方面扮演一个重要的角色,但很多程序员仍然避免底层并发。原子操作通常被认为是一个略微神秘的主题,最后留给一小部分专家,这是可惜的。

我希望这本书能够产生显著的影响,但是对于更多的书籍、博客、文章、视频课程、会议演讲和其他关于 Rust 的并发材料,还有很大空间。

1
2

索引

A

  • AArch64(参见 ARM64)
  • ABA 问题,#
  • 终止进程,#
  • AcqRel,#
    • (参见 release 和 acquire 内存排序)
  • acquire 内存排序(参见 release 和 acquire 内存排序)
  • add 指令(ARM),#
  • add 指令(x86),#
  • 基于地址的等待(Windows),#
    • (参见 futex)
  • 凭空出现的值,#
  • alignment,#
  • 分配(参见 ID 分配)
  • AMD 处理器,#
  • and 指令(x86),#
  • Arc,#
    • 构建我们自己的 Arc,#
    • 循环结构,#
    • get_mut,#
    • 内存排序,####
    • 命名克隆,#
    • 用于 Channel 的情况,#
    • weak 指针,#
      • 性能开销,#
  • arguments,consuming,#
  • ARM64(处理器架构),#
    • aarch64-unknown-linux-musl target,#
    • other-multi-copy atomic,#
    • weakly ordered,#
  • ARM64 指令
    • add,#
    • ARMv8.1 atomic instructions,#,#
    • b.ne (branch if not equal),#
    • cbnz (compare and branch on nonzero),#
    • clrex (clear exclusive),#
    • cmp (compare),#
    • dmb (data memory barrier),#
    • ldar (load-acquire register),#
    • ldaxr (load-acquire exclusive register),#
    • ldr (load register),#
    • ldxr (load exclusive register),#
    • load-linked and store-conditional instructions,#
    • mov (move),#
    • overview,#
    • ret (return),#
    • stlr (store-release register),#
    • stlxr (store-release exclusive register),#
    • str (store register),#
    • stxr (store exclusive register),#
  • ARMv8 (see ARM64)
  • ARMv8.1 atomic instructions,#,#
    • overview,#
  • array::from_fn,#
  • assembler,#
  • assembly,#
    • inspecting compiler output,#
  • atomic,#,#
    • compare-and-exchange operations,#
      • weak,#,#,#,#,#,#,#,#,#,#,#,#
    • fetch-and-modify operations,#
      • wrapping behavior (add and sub),#,#,#,#
    • load and store operations,#
      • example,stop flag,#,#,#,#,#
    • memory ordering (see memory ordering)
    • reference counting (see Arc)
  • atomic barriers (see fences)
  • atomic fences (see fences)
  • atomic types,#,#
    • compare_exchange,#
    • compare_exchange_weak,#
    • fetch_add,#
      • wrapping behavior,#
      • (see also overflows)
    • fetch_and,#
    • fetch_max,#
    • fetch_min,#
    • fetch_nand,#
    • fetch_or,#
    • fetch_store (see swap)
    • fetch_sub,#
      • wrapping behavior,#
      • (see also overflows)
    • fetch_update,#
    • fetch_xor,#
    • get_mut,#
    • load,#
    • store,#
    • swap,#
  • atomic-wait crate,#
  • AtomicBool,#
    • (see also atomic types)
    • locking using,#,#
  • AtomicI8 (see atomic types)
  • AtomicI16 (see atomic types)
  • AtomicI32 (see atomic types)
  • AtomicI64 (see atomic types)
  • AtomicIsize (see atomic types)
  • AtomicPtr,#
    • (see also atomic types)
    • compare-and-exchange,#
    • lazy initialization,#
  • AtomicU8 (see atomic types)
  • AtomicU16 (see atomic types)
  • AtomicU32 (see atomic types)
  • AtomicU64 (see atomic types)
  • AtomicUsize (see atomic types)
  • auto traits,#

B

  • b.ne (branch if not equal) instruction (ARM),#
  • barriers (see fences)
  • basics,#
  • benchmarking,#,#
    • black_box,avoiding optimizations with,#,#
  • binary semaphore,#
  • black_box,#,#
  • blocking,#
    • channel,#
    • condition variables,#
    • futex wait operation,#
    • (see also futex)
    • mutexes,#
    • Once and OnceLock,#,#
    • semaphores,#
    • spin loop,#
    • thread parking (see thread parking)
  • boolean (atomic) (see AtomicBool)
  • borrowing,#
    • bending the rules,#
    • error,#
    • exclusive,#
    • from multiple threads (Sync),#
    • immutable,#
    • (see also shared)
    • local variables in a thread,#
    • mutable,#
    • (see also exclusive)
    • shared,#
    • splitting,#
    • undefined behavior,#
  • Box
    • from_raw,#,#
    • into_raw,#
    • leak,#,#
    • unmovable type,wrapping in,#
  • btc (bit test and complement) instruction (x86),#
  • btr (bit test and reset) instruction (x86),#
  • bts (bit test and set) instruction (x86),#
  • building our own
    • Arc,#
    • channels,#
    • condition variables,#
    • mutexes,#
    • reader-writer locks,#
    • spin locks,#
  • busy-looping,#
    • (see also spinning)

C

  • C standard library,#
    • (see also libc)
  • cache coherence,#
    • protocol,#
      • write-through,#,#,#,#
  • cache lines,#
    • performance experiment,#
  • cache miss,#
  • caching (processors),#
    • (see also cache coherence)
    • compare-and-exchange operations,effect of,#
    • per core,#
    • performance experiment,#
  • cargo-show-asm,#
  • cas (compare and swap) instruction (ARM),#
  • casa (compare and swap,acquire) instruction (ARM),#
  • casal (compare and swap,acquire and release) instruction (ARM),#
  • casl (compare and swap,release) instruction (ARM),#
  • cbnz (compare and branch on nonzero) instruction (ARM),#
  • Cell,#
    • unsafe (see UnsafeCell)
  • channels
    • blocking,#
    • borrowing,#
    • building our own,#
    • dropping,#
    • one-shot,#
    • safe interface,#
    • Sender and Receiver types,#,#
    • storing in Arc,#
      • avoiding,#
    • unsafe interface,#
  • Clone trait,#,#,#,#,#
  • closures
    • captured values
      • moving,#,#
    • spawning scoped threads using,#
    • spawning threads using,#
  • clrex (clear exclusive) instruction (ARM),#
  • cmp (compare) instruction (ARM),#
  • cmpxchg (compare and exchange) instruction (x86),#
  • #[cold],#
  • compare-and-exchange operations (atomic),#
    • on ARM64,#
    • caching,effect on,#
    • compiler optimization,#
    • example,ID allocation,#
    • example,lazy initialization,#,#
    • memory ordering,#
    • using for channel state,#
    • using for mutex state,#
    • using for reader-writer lock state,#
    • using on AtomicPtr,#
    • using to lock reference counter,#
    • weak,#
      • on ARM64,#
    • on x86-64,#
  • Compiler Explorer,#
  • compiler fence,#,#
  • compiler optimization
    • black_box,avoiding with,#,#
    • #[cold],#
    • of compare-and-exchange loops,#
    • enabling,#,#
    • #[inline] #
    • reordering,#
  • complex instruction set computer (CISC),#
  • concurrency,basics,#
  • condition variables,#
    • building our own,#
    • example,#
    • memory ordering,#
    • pthread_cond_t,#
    • thundering herd problem,#
    • timeout,#
    • using to build a channel,#
    • Windows,#
  • Condvar,#
    • (see also condition variables)
  • consume memory ordering,#
  • consuming arguments by value,#
  • contention (mutexes),#,#
    • benchmarking,#
  • Copy trait,#,#
    • atomic types,not implementing,#
    • moving,#
  • critical section (Windows),#
  • current thread,#,#,#
  • cyclic structures (Arc),#

D

  • data races,#
    • avoiding using atomics,#,#
  • Deref trait,#,#,#
  • DerefMut trait,#,#,#,#
  • disassembler,#,#
  • dmb (data memory barrier) instruction (ARM),#
  • drop function,#,#,#
  • Drop trait,#,#,#,#,#,#,#,#,#,#,#,#
  • dword,#

E

  • --emit=asm (rustc),#
  • exclusive references,#

F

  • fair locks,#
  • false sharing,#
  • fences,#,#,#
    • on ARM64,#
    • compiler fence,#,#
    • instructions,#
    • process-wide memory barriers,#
    • on x86-64,#
  • fetch-and-modify operations (atomic),#
    • on ARM64,#
    • example,ID allocation,#
    • example,progress reporting,#
    • example,statistics,#
    • wrapping behavior (add and sub),#
      • (see also overflows)
    • on x86-64,#
  • fetch_store operation (atomic) (see swap operation)
  • fetch_update (atomic),#
  • FlushProcessWriteBuffers (Windows),#
  • forgetting (see leaking)
  • FreeBSD,umtx_op syscall,#
    • (see also futex)
  • from_fn (array),#
  • futex,#
    • cross-platform futex-like functionality,#
    • example,#
    • memory safety,#
    • on other platforms,#
    • operations (Linux),#
      • FUTEX_WAIT,#,#,#,#,#,#,#,#,#,#,#
    • requeuing,#,#
    • spurious wake-ups,#
    • timeout,#,#
    • wait operation,#
    • wake operation,#

G

  • globally consistent order,#
    • (see also sequentially consistent memory ordering)
  • Godbolt,#
  • good luck,#
  • guards
    • dropping,#
    • join guard,#
    • mutex guard,#,#
    • read guard,#,#
    • spin lock guard,#
    • write guard,#,#

H

  • hand,things getting out of,#
  • happens-before relationships,#
    • in Arc,#,#
    • between threads,#
    • locking and unlocking,#,#
    • spawning and joining threads,#
    • through a release-acquire pair,#,#
      • chaining,#
    • within the same thread,#
  • hint::black_box,#,#
  • hint::spin_loop,#

I

  • ID allocation
    • using compare_exchange_weak,#
    • using fetch_add,#
    • using fetch_update,#
  • ideas and inspiration,#
  • if let statement
    • lifetime of temporaries,#
  • ignorance,blissful,#
  • immutable references,#
    • (see also shared references)
  • indivisible,#
  • #[inline],#
  • inspiration,#
  • Instant,#
  • instruction reordering (see reordering)
  • instructions,#
    • (see also ARM64 instructions; x86-64 instructions)
    • compare-and-exchange operations,#,#
    • fences,#
    • load and store operations,#
    • load-linked/store-conditional (LL/SC) instructions,#
    • memory ordering,#
    • overview,#
    • read-modify-write operations,#
  • Intel processors,#
  • interior mutability,#,#,#
  • invalidation queues,#

J

  • jne (jump if not equal) instruction (x86),#
  • join method,#
  • JoinGuard,#
  • JoinHandle,#
  • joining threads,#
    • happens-before relationship,#

K

  • kernel,#,#
    • interfacing with,#
    • kernel-managed objects (Windows),#

L

  • L1/L2/L3/L4 cache,#
  • label (assembly),#
  • lazy initialization
    • using compare_exchange,#
    • using compare_exchange and allocation,#
    • using load and store,#
  • ldadd (load and add) instruction (ARM),#
  • ldadda (load and add,acquire) instruction (ARM),#
  • ldaddal (load and add,acquire and release) instruction (ARM),#
  • ldaddl (load and add,release) instruction (ARM),#
  • ldar (load-acquire register) instruction (ARM),#
  • ldaxr (load-acquire exclusive register) instruction (ARM),#
  • ldr (load register) instruction (ARM),#
  • ldxr (load exclusive register) instruction (ARM),#
  • leaking,#,#
    • by mistake,#
    • a MutexGuard,#
  • “Leakpocalypse”,#
  • libc,#
    • pthreads functionality in,#
  • libpthread,#
    • (see also pthreads)
  • lifetime
    • elision,#,#
    • in a struct,#
    • of mutex guard,#
    • specifying using plain English,#
    • static,#
  • linked list,#
    • Linux
      • futex syscall,#
      • (see also futex)
    • arguments,#
    • futex_waitv syscall,#
    • interfacing with the kernel,#
    • libc,role of,#
    • membarrier syscall,#
    • process-wide memory barrier,#
    • RCU,#
  • load and store operations (atomic),#
    • on ARM64 and x86-64,#
    • compared to non-atomic operations,#,#
    • example,lazy initialization,#
    • example,progress reporting,#
    • example,stop flag,#
  • load-linked/store-conditional (LL/SC) loop,#
    • on ARM64,#
    • compiler optimization,#
  • lock poisoning,#
  • lock prefix (x86),#
  • lock_api crate,#
  • luck,good,#

M

  • machine code,#
  • machine instructions (see instructions)
  • macOS
    • futex-like functionality on,#
    • interfacing with the kernel,#,#
    • os_unfair_lock,#
  • main thread,#
  • ManuallyDrop,#
  • MaybeUninit,#,#,#
  • mem::forget,#
  • membarrier syscall,#
  • memory barriers (see fences)
  • memory fences (see fences)
  • memory model,#
  • memory ordering,#,#
    • on ARM64,#
    • compiler fence,#,#
    • consume,#
    • experiment,using relaxed instead of release and acquire,#
    • fences,#,#,#
    • happens-before relationship,#,#
    • Miri,detecting problems with,#
    • misconceptons about,#
    • out-of-thin-air values,#
    • at processor level,#
    • reference counting,#,#,#,#,#
    • relaxed,#,#
    • release and acquire,#
      • (see also release and acquire memory ordering)
      • locking and unlocking,#
    • sequentially consistent,#
      • (see also sequentially consistent memory ordering)
    • specifying using plain English,#
    • total modification order,#,#,#,#
    • on x86-64,#
  • MESI cache coherence protocol,#
  • MESIF cache coherence protocol,#
  • mfence (memory fence) instruction (x86),#
  • microinstructions,#
  • Miri,#
  • MOESI cache coherence protocol,#
  • mov (move) instruction (ARM),#
  • mov (move) instruction (x86),#
  • movable,not
    • critical section (Windows),#
    • Pin,#
    • pthread types,#
    • wrapping in Box,#
  • move closure,#
  • multi-copy atomicity,#
  • mutability,interior (see interior mutability)
  • mutable references,#
    • (see also exclusive references)
  • Mutex,#,#
    • (see also mutexes)
  • mutexes,#
    • building our own,#
    • as container,#
    • contention,#,#
    • example,#
    • fair,#
    • happens-before relationship,#
    • into_inner,#
    • lifetime of mutex guard,#
    • memory ordering,#
    • Mutex type in Rust,#
    • os_unfair_lock (macOS),#
    • in other languages,#
    • poisoning,#
    • pthread
      • wrapping in Rust,#
    • pthread_mutex_t,#
    • recursive,#
    • robust,#
    • Send requirement,#
    • spin locks,#
      • (see also spin locks)
    • spinning,#,#
    • using to build a channel,#
  • MutexGuard,#
    • dropping,#
    • lifetime of,#
  • mutual exclusion (see mutexes)

N

  • name of a thread,#
  • NetBSD,futex support,#
    • (see also futex)
  • NonNull,#

O

  • -O flag (rustc),#,#
  • Once and OnceLock,#,#
  • one-shot channels,#
  • OpenBSD,limited futex support,#
    • (see also futex)
  • operating systems,#
    • (see also Linux; macOS; Windows)
    • libraries shipped with,#
    • synchronization primitives,#
  • optimization (see compiler optimization)
  • or instruction (x86),#,#
  • Ordering,#,#
    • AcqRel,#
      • (see also release and acquire memory ordering)
    • Acquire,#
      • (see also release and acquire memory ordering)
    • Consume,#
    • Relaxed,#,#
    • Release,#
      • (see also release and acquire memory ordering)
    • SeqCst,#
      • (see also sequentially consistent memory ordering)
  • os_unfair_lock (macOS),#
  • other-multi-copy atomicity,#
  • out of order execution (see reordering)
  • out-of-thin-air values,#
  • output locking,#
  • overflows (atomic),#
    • (see also wrapping behavior)
    • aborting on,#
    • notification counter,#
    • panicking on,#
    • preventing (compare-and-exchange),#
    • reference counter,#
    • usize,big enough,#
  • overview of atomic instructions,#
  • ownership
    • moving,#,#
    • sharing,#
    • transferring to another thread (Send),#

P

  • panicking
    • poisoned mutexes,#
    • RefCell,borrowing,#
    • thread name in panic messages,#
    • using a Condvar with multiple mutexes,#
    • when joining a thread,#,#
    • when spawning a thread,#
  • parking (see thread parking)
  • parking lot-based locks,#
  • parking_lot crate,#
  • PhantomData,#,#
  • Pin,#
  • pipelining,#
  • pointers
    • atomic (see AtomicPtr)
    • neither Send nor Sync,#
    • NonNull,#
  • poisoning,lock,#
  • POSIX,#
    • pthreads,#
  • println,use of output locking,#
  • priority inheritance,#
  • priority inversion,#
  • privacy (modules),#
  • process-wide memory barriers,#
  • processes,#
  • processor architecture,#
    • (see also ARM64; x86-64)
    • strongly ordered,#
    • weakly ordered,#
  • processor caching (see caching)
  • processor instructions (see instructions)
  • processor registers,#
    • return value,#
  • pthreads,#
    • pthread_cond_t,#,#
    • pthread_mutex_t,#
    • dropping while locked,#
    • pthread_rwlock_t,#
    • wrapping in Rust,#

Q

  • queue-based locks,#

R

  • racing,#
  • Rc,#
  • RCU (read,copy,update),#,#
  • reader-writer locks,#
    • avoiding accidental spinning,#
    • building our own,#
    • pthread_rwlock_t,#
    • Send requirement,#
    • SRW locks (Windows),#
    • Sync requirement,#
    • writer starvation,#,#
  • recursive locking,#,#
  • reduced instruction set computer (RISC),#
  • RefCell,#
    • RwLock compared to,#
  • reference counting,#
    • (see also Arc)
  • references
    • exclusive,#
    • immutable,#
      • (see also shared)
    • mutable,#
      • (see also exclusive)
    • shared,#
  • registers,#
    • return value,#
  • relaxed memory ordering,#,#,#
    • counter-intuitive results,#
    • misconceptions about,#,#
    • out-of-thin-air values,#
    • reference counting,#
    • total modification order,#,#,#,#
  • release and acquire memory ordering,#
    • acquire fence,#,#,#,#,#
    • on ARM64,#
    • example,lazy initialization,#
    • experiment,using relaxed instead,#
    • happens-before relationship,#,#
      • chaining,#
    • locking and unlocking,#,#
    • reference counting,#,#,#,#
    • release fence,#
    • on x86-64,#
  • --release flag (cargo),#,#
  • reordering (instructions),#,#
    • memory ordering,#
  • #[repr(align)],#
  • requeuing waiting threads,#,#
  • ret (return) instruction (ARM),#
  • ret (return) instruction (x86),#
  • robust mutexes,#
  • rustup,#
  • RwLock,#,#
    • (see also reader-writer locks)
  • RwLockReadGuard,#
  • RwLockWriteGuard,#

S

  • safe interface,#,#,#
  • safety requirements of unsafe functions,#
  • scheduler,#
  • scoped threads,#
  • semaphores,#
  • Send trait,#,#,#
    • error,#
    • implementing for Arc,#
    • requirement by Mutex and RwLock,#
  • SeqCst (see sequentially consistent memory ordering)
  • sequence locks,#
  • sequentially consistent memory ordering,#
    • on ARM64,#
    • fence,#
    • misconceptions about,#,#
    • on x86-64,#
  • shadowing,#
  • shared ownership,#
    • leaking,#
    • reference counting,#
    • statics,#
  • shared references,#
    • mutating atomics through,#
  • slim reader-writer locks (Windows),#,#
  • spawning threads,#
    • failing to,#
    • happens-before relationship,#
    • scoped,#
  • spin locks
    • building our own,#
    • cache lines,effect of,#
    • compare-and-exchange,(not) using,#
    • experiment,using wrong memory ordering,#
    • guard,#
    • memory ordering,#
  • spin loop hint,#,#
  • spinning,#,#,#
    • avoiding accidental (reader-writer lock),#
  • splitting (borrowing),#
  • spurious wake-ups,#,#,#
  • SRW locks (Windows),#,#
  • stack size,#
  • starvation,#,#
  • static lifetime,#
  • statics,#
  • stlr (store-release register) instruction (ARM),#
  • stlxr (store-release exclusive register) instruction (ARM),#
  • stop flag,#
  • store buffers,#
  • store operations (atomic) (see load and store operations)
  • store-conditional (see load-linked/store-conditional)
  • str (store register) instruction (ARM),#
  • stress,reducing,#
  • strongly ordered architecture,#
  • stxr (store exclusive register) instruction (ARM),#
  • sub (subtract) instruction (x86),#
  • swap operation (atomic),#
    • locking using,#
  • Sync trait,#,#
    • implementing for Arc,#
    • implementing for channel,#
    • implementing for mutex,#
    • implementing for reader-writer lock,#
    • implementing for spin lock,#
    • requirement by RwLock,#
  • SYS_futex (Linux),#
    • (see also futex)
    • arguments,#
  • syscalls,#
    • avoiding,#,#

T

  • --target (rustc),#
  • teaching,#
  • thin air,out of,#
  • thread builder,#
  • thread name,#
  • Thread object,#
    • id,#
    • unpark,#,#
  • thread parking,#,#,#,#
    • spurious wake-ups,#
  • timeout,#
    • example,#
  • thread safety,#,#
    • keeping objects on one thread,#
  • ThreadId,#
  • threads,#
    • joining,#
    • panicking,#,#
    • returning a value,#
    • scoped,#
    • spawning,#
  • thundering herd problem,#
  • time travel,#
  • timeout
    • condition variables,#
    • futex,#,#
  • thread parking,#
    • example,#
  • total modification order,#,#,#,#

U

  • uncontended (mutexes),#,#
    • benchmarking,#
  • undefined behavior,#
    • borrowing,#
    • data races,#
    • Miri,detecting with,#
    • time travel,#
  • uninitialized memory,#
  • Unix systems
    • interfacing with the kernel,#
    • libc,role of,#
  • unmovable
    • critical section (Windows),#
    • Pin,#
    • pthread types,#
    • wrapping in Box,#
  • unpark (Thread),#
  • unparking (see thread parking)
  • unsafe code,#
  • unsafe functions,#
  • unsafe trait implementation,#
  • UnsafeCell,#,#,#
    • get_mut,#
  • unsound,#

V

  • VecDeque,#

W

  • waiting (see blocking)
  • WaitOnAddress (Windows),#
  • WakeByAddressAll (Windows),#
  • WakeByAddressSingle (Windows),#
  • Weak (see Arc; weak pointers)
  • weakly ordered architecture,#
    • experiment,using relaxed instead of release and acquire,#
  • Windows,#
    • condition variables,#
    • critical section,#
    • FlushProcessWriteBuffers,#
    • interfacing with the kernel,#
    • kernel-managed objects,#
    • Native API,#
    • process-wide memory barrier,#
    • SRW locks,#,#
  • WaitOnAddress,#
  • WakeByAddressAll,#
  • WakeByAddressSingle,#
  • Win32 API,#
  • windows crate,#
  • windows-sys crate,#
  • wrapping behavior (fetch_add and fetch_sub),#
    • (see also overflows (atomic))
  • wrapping unmovable object in Box,#
  • write-through cache coherence protocol,#
  • writer starvation,#,#

X

  • x86-64 (processor architecture),#
    • other-multi-copy atomic,#
    • strongly ordered,#
    • x86_64-unknown-linux-musl target,#
  • x86-64 instructions
    • add,#
    • and,#
    • btc (bit test and complement),#
    • btr (bit test and reset),#
    • bts (bit test and set),#
    • cmpxchg (compare and exchange),#
    • jne (jump if not equal),#
    • lock prefix,#
    • mfence (memory fence),#
    • mov (move),#
    • or,#,#
    • overview,#
    • ret (return),#
    • sub (subtract),#
    • xadd (exchange and add),#
    • xchg (exchange),#,#
    • xor,#
  • xadd (exchange and add) instruction (x86),#
  • xchg (exchange) instruction (x86),#,#
  • xor instruction (x86),#

译注

英文中译可能出现章节
allocation内存分配1、3、5、6、8、10
atomic原子all
benchmark基准测试4、9
borrow借用1、4、5
building block基石1、2、8、10
cache coherence缓存一致性7
compare and exchange比较并交换3、4、6、9
condition variable条件变量1、2、5、8、9
drop丢弃1、3、4、5、6、9
fetch and modify获取并修改1、3
fence屏障3、6、7、8
formalize形式化的3
guard守卫4、9
happens-beforehappens-before3、4、5、6、7、9
Invalidation queue失效队列7
leak(内存)泄漏1、3、5、8、10
load operationload 操作2、3、5、6、7、8、9
lock(v)锁定all
memory ordering内存排序2、3、4、5、6、7、9
mutex互斥锁1、2、3、4、8、9
mutation可变性1、2、4、6、8
notify通知1、4、5、8、9
notify_allnotify all9
notify_onenotify one9
park/block阻塞all
pipeline流水线7
reader-writer lock读写锁1、3、4、8、9
readerreader1、4、8、9、10
receiver接收者5
reference引用all
spinLock自旋锁3、4、8、9
sender发送者5
spurious虚假的1、2、5、8、9
static静态值1
stop the world停止其他活动7
store buffer存储缓冲区7
store operationstore 操作2、3、5、6、7、8、9
swap operationswap 操作2、3、5、6、7、8、9
syscall系统调用8、9
unlock(v)解锁all
unpark释放all
use cases用例all
wait(er)等待(者)all
writerwriter1、4、8、9、10