第9章 并发
Rust 作为一种语言,对如何进行并发或并行并没有什么意见。标准库暴露了操作系统线程和阻塞系统调用,因为每个人都有这些东西,而且它们足够统一,你可以以一种相对没有争议的方式提供对它们的抽象。消息传递、绿色线程和异步 API 都是多种多样的,任何对它们的抽象都会涉及到我们不愿意在 1.0 中承诺的 trade-off。
然而,Rust 建立的并发模型,使得将你自己的并发范式设计成一个库变得相对容易,并且让其他人的代码可以与你的代码一起工作。只要要求正确的生命周期、Sync和Send,你就可以不用担心数据竞争了。
数据竞争和竞态条件
安全的 Rust 保证没有数据竞争,数据竞争的定义是:
- 两个或多个线程同时访问一个内存位置
- 其中一个或多个线程是写的
- 其中一个或多个是非同步的
数据竞争具有未定义行为,因此在 Safe Rust 中不可能执行。数据竞争主要是通过 Rust 的所有权系统来防止的:不可能别名一个可变引用,所以不可能进行数据竞争。但内部可变性使其更加复杂,这也是我们有 Send 和 Sync Trait 的主要原因(见下个章节更详细的说明)。
然而,Rust 并没有(也无法)阻止更广泛的竞态条件。
在你无法控制调度器的情况下,这在数学上是不可能的,而对于普通的操作系统环境来说你是无法控制调度器的。如果你确实控制了抢占,那么 有可能 防止一般的竞态——这种技术被像 RTIC 这样的框架所使用。然而,实际上拥有对调度的控制是一个非常罕见的情况。
因此,对于一个安全的 Rust 程序来说,在不正确的同步下出现死锁或做一些无意义的事情是完全“正常”的。很明显,这样的程序有问题,但 Rust 只能帮你到这里。不过,Rust 程序中的竞态条件本身并不能违反内存安全;只有与其他不安全的代码结合在一起,竞态条件才能真正违反内存安全。比如说:
#![allow(unused)] fn main() { use std::thread; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; let data = vec![1, 2, 3, 4]; // 使用 Arc,这样即使程序执行完毕,存储 AtomicUsize 的内存依然存在, // 否则由于 thread::spawn 的生命周期限制,Rust 不会为我们编译这段代码 let idx = Arc::new(AtomicUsize::new(0)); let other_idx = idx.clone(); // `move` 捕获了 other_idx 的值,将它移入这个线程 thread::spawn(move || { // 因为这是一个原子变量,不存在数据竞争问题,所以可以修改 other_idx 的值 other_idx.fetch_add(10, Ordering::SeqCst); }); // 因为我们只读取了一次原子的内存,因此用原子中的值做索引是安全的, // 然后将读出的值的拷贝传递给 Vec 做为索引, // 索引过程可以做正确的边界检查,并且在执行索引期间这个值也不会发生改变。 // 但是,如果上面的线程在执行这句代码之前增加了这个值,这段代码会 panic。 // 因为程序的正确执行(panic 几乎不可能是正确的),所以这就是一个 *竞态*, // 其执行结果依赖于线程的执行顺序 println!("{}", data[idx.load(Ordering::SeqCst)]); }
如果我们提前进行边界检查,然后使用未经检查的值不安全地访问数据,我们就可能引起数据竞争:
#![allow(unused)] fn main() { use std::thread; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; let data = vec![1, 2, 3, 4]; let idx = Arc::new(AtomicUsize::new(0)); let other_idx = idx.clone(); // `move` 捕获了 other_idx 值,将它移入这个线程 thread::spawn(move || { // 因为这是一个原子变量,不存在数据竞争问题,所以可以修改 other_idx 的值 other_idx.fetch_add(10, Ordering::SeqCst); }); if idx.load(Ordering::SeqCst) < data.len() { unsafe { // 所以在边界检查之后读取 idx 的值可能是不正确的 // 因为我们这里会 `get_unchecked`, 而这个操作是 `unsafe` 的, // 所以这里就存在着竞态,并且 *非常危险*! println!("{}", data.get_unchecked(idx.load(Ordering::SeqCst))); } } }
Send 和 Sync
并不是所有的东西都服从于继承的可变性。有些类型允许你在内存中对一个位置有多个别名,并且同时修改它。除非这些类型使用同步手段来管理这种访问,否则它们绝对不是线程安全的。Rust 通过 Send和Sync Trait 来解决这个问题:
- 如果将一个类型发送到另一个线程是安全的,那么它就是
Send - 如果一个类型可以安全地在线程间共享,那么它就是
Sync的(当且仅当&T是Send时,T是Sync的)
Send 和 Sync 是 Rust 的并发故事的基础。因此,存在大量的特殊工具来使它们正常工作。首先,它们是不安全的 Trait,这意味着它们的实现是不安全的,而其他不安全的代码可以认为它们是正确实现的。由于它们是标记特性(它们没有像方法那样的相关项目),正确实现仅仅意味着它们具有实现者应该具有的内在属性。不正确地实现 Send 或 Sync 会导致未定义行为。
Send 和 Sync 也是自动派生的 Trait。这意味着,与其它 Trait 不同,如果一个类型完全由 Send 或 Sync 类型组成,那么它就是 Send 或 Sync。几乎所有的基本数据类型都是Send和Sync,因此,几乎所有你将与之交互的类型都是Send和Sync。
主要的例外情况包括:
- 原始指针既不是 Send 也不是 Sync(因为它们没有安全防护)
UnsafeCell不是 Sync 的(因此Cell和RefCell也不是)Rc不是 Send 或 Sync 的(因为 Refcount 是共享的、不同步的)
Rc和UnsafeCell从根本上说不是线程安全的:它们共享了非同步的可变状态。然而,严格来说,原始指针被标记为线程不安全,更像是一个提示。用原始指针做任何有用的事情都需要对其进行解引用,这已经是不安全的了;当然,从这个角度上说,人们也可以认为将它们标记为线程安全的做法也没啥问题。
然而,更重要的是,它们不是线程安全的,是为了防止包含它们的类型被自动标记为线程安全的。这些类型的所有权并不明确,它们的作者也不太可能认真考虑线程安全问题。在Rc的例子中,我们有一个很好的例子,它包含一个绝对不是线程安全的*mut类型。
如果需要的话,那些没有自动派生的类型可以很简单地实现它们:
#![allow(unused)] fn main() { struct MyBox(*mut u8); unsafe impl Send for MyBox {} unsafe impl Sync for MyBox {} }
在难以置信的罕见情况下,一个类型被不恰当地自动派生为 Send 或 Sync,那么我们也可以不实现 Send 和 Sync:
#![allow(unused)] #![feature(negative_impls)] fn main() { // 假设我这里存在一些魔法,对于同步原语有着非常神奇的语义 struct SpecialThreadToken(u8); impl !Send for SpecialThreadToken {} impl !Sync for SpecialThreadToken {} }
请注意,正常情况下是不可能错误地派生出 Send 和 Sync 的。只有那些被其他不安全代码赋予特殊意义的类型才有可能因为不正确的 Send 或 Sync 而造成麻烦。
大多数对原始指针的使用应该被封装在一个足够的抽象后面,以便 Send 和 Sync 可以被派生。例如,所有 Rust 的标准集合都是 Send 和 Sync(当它们包含 Send 和 Sync 类型时),尽管它们普遍使用原始指针来管理内存分配和复杂的所有权。同样的,大多数这些集合的迭代器都是 Send 和 Sync 的,因为它们在很大程度上表现得像集合的&或&mut。
示例
Box由于各种原因,编译器将其作为自己的特殊内建类型来实现,但是我们可以自己实现一些具有类似行为的东西,来看看什么时候实现 Send 和 Sync 是合理的。让我们把它叫做Carton。
我们先写代码,把分配在栈上的一个值,转移到堆上:
#![allow(unused)] fn main() { pub mod libc { pub use ::std::os::raw::{c_int, c_void}; #[allow(non_camel_case_types)] pub type size_t = usize; unsafe extern "C" { pub fn posix_memalign(memptr: *mut *mut c_void, align: size_t, size: size_t) -> c_int; } } use std::{ mem::{align_of, size_of}, ptr, cmp::max, }; struct Carton<T>(ptr::NonNull<T>); impl<T> Carton<T> { pub fn new(value: T) -> Self { // 在堆上分配足够的可以存储一个类型 T 大小的空间 assert_ne!(size_of::<T>(), 0, "Zero-sized types are out of the scope of this example"); let mut memptr: *mut T = ptr::null_mut(); unsafe { let ret = libc::posix_memalign( (&mut memptr as *mut *mut T).cast(), max(align_of::<T>(), size_of::<usize>()), size_of::<T>() ); assert_eq!(ret, 0, "Failed to allocate or invalid alignment"); }; // NonNull 仅仅是对于指针的一层封装,强制要求指针是非空的 let ptr = { // 安全保证:因为我们从一个引用创建了 memptr,并且独占了所有权,所以可以解引用 ptr::NonNull::new(memptr.cast::<T>()) .expect("Guaranteed non-null if posix_memalign returns 0") }; // 将数据从栈上复制到堆上 unsafe { // 安全保证:如果 ptr 是非空的,posix_memalign 会返回一个已经内存对齐的有效的可写指针 ptr.as_ptr().write(value); } Self(ptr) } } }
这不是很有用,因为一旦我们的用户给了我们一个值,他们就没有办法访问它。Box实现了Deref和DerefMut,这样你就可以访问内部的值。让我们来做这件事:
#![allow(unused)] fn main() { use std::ops::{Deref, DerefMut}; struct Carton<T>(std::ptr::NonNull<T>); impl<T> Deref for Carton<T> { type Target = T; fn deref(&self) -> &Self::Target { unsafe { // 安全保证:self 指针已经内存对齐,并且初始化了, 在 `Self::new` 方法中已经解引用, // 我们要求 readers 引用 Carton,而这里返回值的生命周期和输入的 self 的生命周期对齐, // 因此 borrow checker 会强制保证这一点: // 直到这个引用被 drop,不能修改 Carton 中的内容 self.0.as_ref() } } } impl<T> DerefMut for Carton<T> { fn deref_mut(&mut self) -> &mut Self::Target { unsafe { // 安全保证:self 指针已经内存对齐,并且初始化了, 在 `Self::new` 方法中已经解引用, // 我们要求 writer 可写引用 Carton,而这里返回值的生命周期和输入的 self 的生命周期对齐, // 因此 borrow checker 会强制保证这一点: // 直到这个引用被 drop,不能访问 Carton 中的内容 self.0.as_mut() } } } }
最后,让我们考虑一下我们的Carton是否是 Send 和 Sync。一些东西可以安全地成为 Send,除非它与其他东西共享可变的状态,而不对其实施排他性访问。每个Carton都有一个唯一的指针,所以我们可以标记为 Send:
#![allow(unused)] fn main() { struct Carton<T>(std::ptr::NonNull<T>); // 安全保证:除了我们没有人拥有Carton中的裸指针,因此,只需要T可以Send,Carton就可以Send unsafe impl<T> Send for Carton<T> where T: Send {} }
那么 Sync 呢?为了使Carton能够 Sync,我们必须强制规定,你不能对存储在一个Carton中的东西进行写入,而这个东西可以从另一个Carton中读出或写入。因为你需要一个&mut Carton来写指针,并且借用检查器强制要求可变引用必须是排他的,所以把Carton标记为Sync也没啥问题:
#![allow(unused)] fn main() { struct Carton<T>(std::ptr::NonNull<T>); // 安全保证:存在将 `&Carton<T>` 转变为 `&T` 的公开 API, // 而这些 API 是 unsynchronized 的(比如 `Deref`), // 因此只有在T是 `Sync` 的情况下,`Carton<T>` 才可以是 `Sync` 的, // 反过来说,`Carton` 本身没有使用到任何 `内部可变性`, // 所有可变引用都只能通过独占的方式获取 (`&mut`), // 这也就意味着 `T` 的 `Sync` 特性可以传递给 `Carton<T>` unsafe impl<T> Sync for Carton<T> where T: Sync {} }
当我们断言我们的类型是 Send 和 Sync 时,我们通常需要强制要求每个包含的类型都是 Send 和 Sync。当编写行为像标准库类型的自定义类型时,我们可以断言我们有相同的要求。例如,下面的代码断言,如果同类的 Box 是 Send,那么 Carton 就是 Send —— 在这种情况下,这就等于说 T 是 Send:
#![allow(unused)] fn main() { struct Carton<T>(std::ptr::NonNull<T>); unsafe impl<T> Send for Carton<T> where Box<T>: Send {} }
现在Carton<T>有一个内存泄漏,因为它从未释放它分配的内存。一旦我们解决了这个问题,我们就必须确保满足 Send 的新要求:我们需要确认free释放由另一个线程的分配产生的指针。我们可以在libc::free的文档中来确认这么做是可行的。
#![allow(unused)] fn main() { struct Carton<T>(std::ptr::NonNull<T>); mod libc { pub use ::std::os::raw::c_void; unsafe extern "C" { pub fn free(p: *mut c_void); } } impl<T> Drop for Carton<T> { fn drop(&mut self) { unsafe { libc::free(self.0.as_ptr().cast()); } } } }
一个不会发生这种情况的好例子是 MutexGuard:注意它不是 Send。MutexGuard 的实现使用的库要求你确保不会释放你在不同线程中获得的锁。如果你能够将 MutexGuard 发送到另一个线程,那么析构器就会在新的线程中运行,这就违反了该要求。但 MutexGuard 仍然可以是 Sync,因为你能发送给另一个线程的只是一个&MutexGuard,丢弃一个引用并没有什么作用。
TODO: 更好地解释什么可以或不可以是 Send 或 Sync。仅仅针对数据竞争就足够了?
Atomics
Rust 非常明目张胆地从 C++20 继承了原子的内存模型。这并不是因为这个模型特别优秀或容易理解。事实上,这个模型相当复杂,而且已知有几个缺陷。但不论怎么说,这是一个务实的让步,因为每个人在原子建模方面都相当糟糕。至少,我们可以从现有的工具和围绕 C/C++ 内存模型的研究中获益(你会经常看到这个模型被称为“C/C++11”或只是“C11”。C 只是复制了 C++ 的内存模型;而 C++11 是该模型的第一个版本,但从那时起它已经得到了一些错误的修正)。
试图在这本书中完全解释这个模型是相当无望的。它被定义为疯狂的因果关系图,需要一整本书来正确理解。如果你想了解所有琐碎的细节,你应该看看 C++ 规范。不过,我们还是会试着介绍一下基础知识和 Rust 开发者面临的一些问题。
C++ 内存模型从根本上说是为了弥补我们想要的语义、编译器想要的优化和我们的硬件想要的之间不一致的混乱之间的差距。我们想只写程序,让它们完全按照我们说的做,但是,你知道,一定要快。那不是很好吗?
编译器重排序
编译器从根本上希望能够进行各种复杂的转换,以减少数据的依赖性,消除死代码。特别是,他们可能会从根本上改变事件的实际顺序,或者使事件永远不会发生!比如这样的代码:
x = 1;
y = 3;
x = 2;
编译器可能会得出结论,如果你的程序这样做,那会更好:
x = 2;
y = 3;
这颠倒了事件的顺序,并且完全删除了一个事件。从单线程的角度来看,这是完全无法观察到的:在所有语句执行完毕后,我们处于完全相同的状态。但是如果我们的程序是多线程的,我们可能一直依赖x在y被分配之前实际被分配为 1。我们希望编译器能够进行这类优化,因为它们可以大量地提高性能;而另一方面,我们也希望能够相信我们的程序做我们所说的事情。
硬件重排序
另一方面,即使编译器完全理解我们的意图并尊重我们的意愿,我们的硬件可能反而会给我们带来麻烦。麻烦来自于 CPU 的内存层次结构。在你的硬件中确实有一个全局共享的内存空间,但从每个 CPU 核心的角度来看,它是非常遥远的,而且非常慢。每个 CPU 宁可使用其本地的数据缓存,而只在其缓存中没有该内存的时候才去和共享内存对话,这是很痛苦的。
毕竟,这就是缓存的全部意义所在,对吗?如果每次从缓存中读出的数据都要跑回共享内存中去仔细检查是否有变化,那还有什么意义呢?最终的结果是,硬件并不能保证在一个线程上以某种顺序发生的事件,在另一个线程上以同样的顺序发生。为了保证这一点,我们必须向 CPU 发出特殊指令,让它变得不那么聪明。
例如,假设我们说服编译器发出这样的逻辑:
initial state: x = 0, y = 1
线程 1 线程 2
y = 3; if x == 1 {
x = 1; y *= 2;
}
理想情况下,这个程序有两种可能的最终状态:
y = 3:线程 2 在线程 1 完成之前做了检查y = 6:线程 2 在线程 1 完成后做了检查
然而,还有第三种潜在的状态是硬件可以实现的:
y = 2:线程 2 看到了x = 1,但没有看到y = 3,然后改写了y = 3
值得注意的是,不同种类的 CPU 提供不同的保证。通常将硬件分为两类:强有序和弱有序。最值得注意的是 x86/64 提供强有序保证,而 ARM 提供弱有序保证。这对并发编程有两个后果:
- 在强有序的硬件上要求更强的保证可能很便宜,甚至是无开销的,因为它们已经无条件地提供了强保证;较弱的保证可能只在弱有序的硬件上产生性能优势
- 在强有序硬件上要求太弱的保证,更有可能恰巧发生作用,即使你的程序严格来说是不正确的;如果可能的话,并发算法应该在弱有序的硬件上进行测试
数据访问
C++ 内存模型试图通过允许我们谈论我们程序的因果性来弥补这一差距。一般来说,这是通过在程序的各个部分和运行它们的线程之间建立一种happen-before的关系。这给了硬件和编译器一定的自由度,在没有建立严格的 happen-before 关系的地方更积极地优化程序,但也迫使他们在建立了关系的地方更加小心。我们沟通这些关系的方式是通过数据访问(data accesses)和原子访问(atomic accesses)。
数据访问是编程世界的主体,它们从根本上说是不同步的,编译器可以自由地对它们进行积极的优化。特别是,数据访问可以自由地被编译器重新排序,前提是程序是单线程的。硬件也可以自由地将数据访问中的变化传播给其他线程,只要它想,就可以懒散地、不一致地传播。最关键的是,数据访问是数据竞争发生的方式。数据访问对硬件和编译器非常友好,但正如我们所看到的,如果试图用它来编写同步代码,它提供的语义太弱了。
仅仅使用数据访问是不可能写出正确的同步代码的。
原子访问是我们告诉硬件和编译器我们的程序是多线程的方式。每个原子访问都可以用一个顺序来标记,指定它与其他访问的关系。在实践中,这可以归结为告诉编译器和硬件它们不能做的某些事情。对于编译器来说,这主要是围绕着指令的重新排序展开的。对于硬件来说,这主要是围绕着如何将写操作传播给其他线程。Rust 所提供的顺序集合是:
- 顺序一致(Squentially Consistent,SeqCst)
- Release
- Acquire
- Relaxed
(注意:我们明确地不暴露 C++ 的 consume 排序)
TODO:消极推理与积极推理?TODO:“不能忘记同步”
顺序一致性
顺序一致是所有顺序中最强大的,它意味着包含所有其他顺序的限制。直观地说,一个顺序一致的操作不能被重新排序:一个线程上所有发生在 SeqCst 访问之前和之后的访问都保持在它之前和之后。一个只使用顺序一致的原子和数据访问的无数据竞争程序有一个非常好的特性,即有一个所有线程都同意的程序指令的单一全局执行的顺序。这种执行方式也特别好推理:它只是每个线程的单独执行的交错。如果你开始使用较弱的原子顺序,这就不成立了(译者注:也就是说,同一时刻,针对同一个别名/内存位置,仅能有一条指令在执行,不能出现并发)。
顺序一致性对开发者的相对友好并不是免费的。即使在强排序的平台上,顺序一致性也会涉及到内存屏障。
在实践中,顺序一致性对于程序的正确性很少有必要。然而,如果你对其他的内存顺序没有信心的话,顺序一致性绝对是正确的选择。让你的程序运行得比它需要的慢一点,肯定比它运行得不正确要好!从机制上来说,降低原子操作的等级,以便在以后拥有较弱的一致性也是很容易的。只要把SeqCst改成Relaxed就可以了! 当然,证明这种转换是正确的是一个完全不同的问题。
Acquire-Release
Acquire 和 Release 在很大程度上是用来配对使用的。它们的名字暗示了它们的使用情况:它们非常适合于获取和释放锁,并确保关键部分不会重叠。
直观地说,一个 Acquire 的访问可以确保它之后的每一个访问都保持在它之后。然而,在 Acquire 之前发生的操作可以自由地被重新排序到它之后发生。同样地,一个 Release 访问确保它之前的每一个访问都保持在它之前。然而,在 Release 之后发生的操作可以自由地被重新排序到它之前发生。
当线程 A Release 了内存中的一个位置,然后线程 B 随后 Acquire 了内存中相同的位置,因果关系就建立了。在 A Release 之前发生的每一个写(包括非原子写和 Relaxed 的原子写)都会在 B Acquire 之后被观察到。然而,与任何其他线程的因果关系都没有建立。同样地,如果 A 和 B 访问内存中不同的位置,也不会建立因果关系。
因此,Release-Acquire 的基本用法很简单:你 Acquire 一个内存位置来开始关键部分,然后 Release 这个位置来结束它。例如,一个简单的自旋锁可能看起来像这样:
use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::thread; fn main() { let lock = Arc::new(AtomicBool::new(false)); // 我上锁了吗 // ... 用某种方式将锁分发到各个线程(thread::spawn) ... // 尝试将原子变量设置为 true,以此来获得锁 while lock.compare_and_swap(false, true, Ordering::Acquire) { } // 从循环中跳出,说明此时已经获取了锁 // ... 恐怖的数据访问 ... // 工作完成了,释放锁 lock.store(false, Ordering::Release); }
在强有序平台上,大多数访问都有 Release 或 Acquire 语义,使得 Release 和 Acquire 往往是完全免费的。而在弱有序平台上则不是这样。
Relaxed
Relaxed 的访问是绝对最弱的。它们可以被自由地重新排序,并且不提供任何 happen-before 的关系。不过,Relaxed 的操作仍然是原子性的。也就是说,它们不算是数据访问,对它们进行的任何读-改-写操作都是原子性的。Relaxed 操作适用于那些你肯定希望发生,但并不特别在意的事情。例如,如果你不使用计数器来同步任何其他访问,那么多个线程可以安全地使用 Relaxed 的fetch_add来增加一个计数器。
在强有序平台上,Relaxed 操作很少有好处,因为它们通常提供 Release-Acquire 的语义。然而,在弱有序平台上,Relaxed 的操作会更便宜。