第16章 无畏并发
安全且高效地处理并发编程,是 Rust 的另一个主要目标。并发编程(concurrent programming)指程序的不同部分彼此独立地执行,而并行编程(parallel programming)则指程序的不同部分同时执行。随着越来越多的计算机开始利用多处理器的优势,这两个概念正变得越来越重要。历史上,在这些场景中编程一直都很困难且容易出错。Rust 希望改变这一点。
起初,Rust 团队认为确保内存安全和防止并发问题是两个分别需要不同方法应对的挑战。随着时间的推移,团队发现所有权和类型系统是一系列解决内存安全和并发问题的强有力的工具!通过利用所有权和类型检查,在 Rust 中很多并发错误都是编译时错误,而非运行时错误。因此,相比花费大量时间尝试重现运行时并发 bug 出现的特定情况,不正确的代码会直接编译失败并提供解释问题的错误信息。因此,你可以在开发时修复代码,而不是在部署到生产环境后修复代码。我们给 Rust 的这一部分起了一个绰号无畏并发(fearless concurrency)。无畏并发令你的代码免于出现诡异的 bug 并可以轻松重构且无需担心会引入新的 bug。
注意:出于简洁的考虑,我们将很多问题归类为并发,而不是更准确的区分并发和/或并行。对于本章,当我们谈到并发时,请自行脑内替换为 并发和/或并行。在下一章中当区分二者更为重要时,我们会使用更准确的表述。
很多语言对它们所提供的并发问题解决方案都比较“教条”。例如,Erlang 拥有优雅的消息传递并发功能,但在线程间共享状态方面只有一些不那么直观的方式。对于高级语言来说,只支持可能解决方案中的一个子集,是合理的策略,因为高级语言通过放弃一部分控制权来换取抽象,并承诺由此带来的收益。然而,底层语言通常被期望在任何给定场景下都能提供性能最佳的解决方案,并且对硬件施加更少抽象。因此,Rust 提供了多种工具,让你能够按照最适合自身场景和需求的方式来建模问题。
如下是本章将要涉及到的内容:
- 如何创建线程来同时运行多段代码。
- 消息传递(message-passing)并发,其中信道(channel)在线程之间发送消息。
- 共享状态(shared-state)并发,其中多个线程可以访问同一份数据。
Sync和Sendtrait,它们将 Rust 的并发保证扩展到用户定义类型以及标准库提供的类型。
使用线程同时地运行代码
使用线程同时运行代码
在大多数当前的操作系统中,一个运行中的程序代码会在一个进程(process)中执行,而操作系统会同时管理多个进程。在一个程序内部,也可以存在彼此独立、同时运行的多个部分。运行这些独立部分的功能被称为线程(threads)。例如,一个 web 服务器可以拥有多个线程,以便同时响应多个请求。
将程序中的计算拆分进多个线程可以改善性能,因为程序可以同时进行多个任务,不过这也会增加复杂性。因为线程是同时运行的,所以无法预先保证不同线程中的代码的执行顺序。这会导致诸如此类的问题:
- 竞态条件(Race conditions),多个线程以不一致的顺序访问数据或资源
- 死锁(Deadlocks),两个线程相互等待对方,这会阻止两者继续运行
- 只会发生在特定情况且难以稳定重现和修复的 bug
Rust 尝试减轻使用线程的负面影响。不过在多线程上下文中编程仍需格外小心,同时其所要求的代码结构也不同于运行于单线程的程序。
编程语言实现线程的方式各不相同,许多操作系统都提供了可供编程语言调用、用来创建新线程的 API。Rust 标准库使用的是线程实现的 1:1 模型,也就是程序中的每一个语言级线程都对应一个操作系统线程。也有一些 crate 实现了其他线程模型,它们相对于 1:1 模型有着不同的取舍。(我们将在下一章看到的 Rust async 系统,也提供了另一种并发方式。)
使用 spawn 创建新线程
为了创建一个新线程,需要调用 thread::spawn 函数并传递一个闭包(第十三章学习了闭包),并在其中包含希望在新线程运行的代码。示例 16-1 中的例子在主线程打印了一些文本而另一些文本则由新线程打印:
文件名:src/main.rs
use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}
}
示例 16-1: 创建一个打印某些内容的新线程同时主线程打印其它内容
注意当 Rust 程序的主线程结束时,所有新线程也会结束,而不管其是否执行完毕。这个程序的输出可能每次都略有不同,不过它大体上看起来像这样:
hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
thread::sleep 调用强制线程停止执行一小段时间,这会允许其他不同的线程运行。这些线程可能会轮流运行,不过并不保证如此:这依赖操作系统如何调度线程。在这里,主线程首先打印,即便新创建线程的打印语句位于程序的开头,甚至即便我们告诉新建的线程打印直到 i 等于 9,它在主线程结束之前也只打印到了 5。
如果运行代码只看到了主线程的输出,或没有出现重叠打印的现象,尝试增大区间 (变量 i 的范围) 来增加操作系统切换线程的机会。
等待所有线程结束
由于主线程结束,示例 16-1 中的代码大部分时候不光会提早结束新建线程,因为无法保证线程运行的顺序,我们甚至不能实际保证新建线程会被执行!
可以通过将 thread::spawn 的返回值储存在变量中来修复新建线程部分没有执行或者完全没有执行的问题。thread::spawn 的返回值类型是 JoinHandle<T>。JoinHandle<T> 是一个拥有所有权的值,当对其调用 join 方法时,它会等待其线程结束。示例 16-2 展示了如何使用示例 16-1 中创建的线程的 JoinHandle<T> 并调用 join 来确保新建线程在 main 退出前结束运行。
文件名:src/main.rs
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}
handle.join().unwrap();
}
示例 16-2: 从 thread::spawn 保存一个 JoinHandle<T> 以确保该线程能够运行至结束
对句柄调用 join 会阻塞当前正在运行的线程,直到该句柄所代表的线程结束。阻塞(blocking)一个线程,意味着这个线程会被阻止继续工作或退出。因为我们把 join 调用放在主线程的 for 循环之后,运行示例 16-2 时应该会得到类似如下的输出:
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
这两个线程仍然会交替执行,不过主线程会由于 handle.join() 调用而不会结束直到新建线程执行完毕。
不过让我们看看将 handle.join() 移动到 main 中 for 循环之前会发生什么,如下:
文件名:src/main.rs
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});
handle.join().unwrap();
for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}
}
主线程会等待直到新建线程执行完毕之后才开始执行 for 循环,所以输出将不会交替出现,如下所示:
hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!
诸如将 join 放置于何处这样的小细节,会影响线程是否同时运行。
将 move 闭包与线程一同使用
move 关键字经常用于传递给 thread::spawn 的闭包,因为闭包会获取从环境中取得的值的所有权,因此会将这些值的所有权从一个线程传送到另一个线程。在第十三章“捕获环境”部分讨论了闭包上下文中的 move。现在我们会更专注于 move 和 thread::spawn 之间的交互。
注意示例 16-1 中传递给 thread::spawn 的闭包并没有任何参数:并没有在新建线程代码中使用任何主线程的数据。为了在新建线程中使用来自于主线程的数据,需要新建线程的闭包获取它需要的值。示例 16-3 展示了一个尝试在主线程中创建一个 vector 并用于新建线程的例子,不过这么写还不能工作,如下所示:
文件名:src/main.rs
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Here's a vector: {v:?}");
});
handle.join().unwrap();
}
示例 16-3: 尝试在另一个线程使用主线程创建的 vector
闭包使用了 v,所以闭包会捕获 v 并使其成为闭包环境的一部分。因为 thread::spawn 在一个新线程中运行这个闭包,所以可以在新线程中访问 v。然而当编译这个例子时,会得到如下错误:
$ cargo run
Compiling threads v0.1.0 (file:///projects/threads)
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
--> src/main.rs:6:32
|
6 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `v`
7 | println!("Here's a vector: {v:?}");
| - `v` is borrowed here
|
note: function requires argument type to outlive `'static`
--> src/main.rs:6:18
|
6 | let handle = thread::spawn(|| {
| __________________^
7 | | println!("Here's a vector: {v:?}");
8 | | });
| |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
|
6 | let handle = thread::spawn(move || {
| ++++
For more information about this error, try `rustc --explain E0373`.
error: could not compile `threads` (bin "threads") due to 1 previous error
Rust 会推断如何捕获 v,因为 println! 只需要 v 的引用,闭包尝试借用 v。然而这有一个问题:Rust 不知道这个新建线程会执行多久,所以无法知晓对 v 的引用是否一直有效。
示例 16-4 展示了一个 v 的引用很有可能不再有效的场景:
文件名:src/main.rs
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Here's a vector: {v:?}");
});
drop(v); // oh no!
handle.join().unwrap();
}
示例 16-4: 一个具有闭包的线程,尝试使用一个在主线程中被回收的引用 v
如果 Rust 允许这段代码运行,则新建线程可能会立刻被转移到后台并完全没有机会运行。新建线程内部有一个 v 的引用,不过主线程立刻就使用第十五章讨论的 drop 丢弃了 v。接着当新建线程开始执行,v 已不再有效,所以其引用也是无效的。噢,这太糟了!
为了修复示例 16-3 的编译错误,我们可以听取错误信息的建议:
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
|
6 | let handle = thread::spawn(move || {
| ++++
通过在闭包之前增加 move 关键字,我们强制闭包获取其使用的值的所有权,而不是任由 Rust 推断它应该借用值。示例 16-5 中展示的对示例 16-3 代码的修改,就能按预期编译并运行:
文件名:src/main.rs
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("Here's a vector: {v:?}");
});
handle.join().unwrap();
}
示例 16-5: 使用 move 关键字强制获取它使用的值的所有权
我们可能希望尝试同样的方法来修复示例 16-4 中的代码,其主线程使用 move 闭包调用了 drop。然而这个修复行不通,因为示例 16-4 所尝试的操作由于一个不同的原因而不被允许。如果为闭包增加 move,将会把 v 移动进闭包的环境中,如此将不能在主线程中对其调用 drop 了。我们会得到如下不同的编译错误:
$ cargo run
Compiling threads v0.1.0 (file:///projects/threads)
error[E0382]: use of moved value: `v`
--> src/main.rs:10:10
|
4 | let v = vec![1, 2, 3];
| - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
5 |
6 | let handle = thread::spawn(move || {
| ------- value moved into closure here
7 | println!("Here's a vector: {v:?}");
| - variable moved due to use in closure
...
10 | drop(v); // oh no!
| ^ value used here after move
|
help: consider cloning the value before moving it into the closure
|
6 ~ let value = v.clone();
7 ~ let handle = thread::spawn(move || {
8 ~ println!("Here's a vector: {value:?}");
|
For more information about this error, try `rustc --explain E0382`.
error: could not compile `threads` (bin "threads") due to 1 previous error
Rust 的所有权规则又一次帮助了我们!示例 16-3 中的错误是因为 Rust 是保守的并只会为线程借用 v,这意味着主线程理论上可能使新建线程的引用无效。通过告诉 Rust 将 v 的所有权移动到新建线程,我们向 Rust 保证主线程不会再使用 v。如果对示例 16-4 也做出如此修改,那么当在主线程中使用 v 时就会违反所有权规则。move 关键字覆盖了 Rust 默认保守的借用,但它不允许我们违反所有权规则。
现在我们已经了解了线程的概念以及线程 API 提供的方法,下面让我们看看在什么情况下可以使用线程。
使用消息传递在线程间通信
使用消息传递在线程间传送数据
一个日益流行的确保安全并发的方式是消息传递(message passing),这里线程或 actor 通过发送包含数据的消息来相互沟通。这个思想来源于 Go 编程语言文档 中的口号:“不要通过共享内存来通讯;而要通过通讯来共享内存。”(“Do not communicate by sharing memory; instead, share memory by communicating.”)
为了实现消息传递并发,Rust 标准库提供了一个信道(channel)实现。信道是一个通用编程概念,表示数据从一个线程发送到另一个线程。
你可以将编程中的信道想象为一个水流的渠道,比如河流或小溪。如果你将诸如橡皮鸭之类的东西放入其中,它们会顺流而下到达下游。
信道有两个组成部分:一个发送端(transmitter)和一个接收端(receiver)。发送端位于上游位置,在这里可以将橡皮鸭放入河中,接收端则位于下游,橡皮鸭最终会漂流至此。代码中的一部分调用发送端的方法以及希望发送的数据,另一部分则检查接收端收到的消息。当发送端或接收端任一被丢弃时可以认为信道被关闭(closed)了。
这里,我们将开发一个程序,它会在一个线程生成值向信道发送,而在另一个线程会接收值并打印出来。这里会通过信道在线程间发送简单值来演示这个功能。一旦你熟悉了这项技术,你就可以将信道用于任何相互通信的任何线程,例如一个聊天系统,或利用很多线程进行分布式计算并将部分计算结果发送给一个线程进行聚合。
首先,在示例 16-6 中,创建了一个信道但没有做任何事。注意这还不能编译,因为 Rust 不知道我们想要在信道中发送什么类型:
文件名:src/main.rs
use std::sync::mpsc;
fn main() {
let (tx, rx) = mpsc::channel();
}
示例 16-6: 创建一个信道,并将其两端赋值给 tx 和 rx
这里使用 mpsc::channel 函数创建一个新的信道;mpsc 是 多生产者,单消费者(multiple producer, single consumer)的缩写。简而言之,Rust 标准库实现信道的方式意味着一个信道可以有多个产生值的 发送端(sending),但只能有一个消费这些值的接收端(receiving)。想象一下多条小河小溪最终汇聚成大河:所有通过这些小河发出的东西最后都会来到下游的大河。目前我们以单个生产者开始,但是当示例可以工作后会增加多个生产者。
mpsc::channel 函数返回一个元组,其中第一个元素是发送端,第二个元素是接收端。tx 和 rx 这两个缩写在许多领域里传统上分别表示 发送端(transmitter)和 接收端(receiver),因此我们就用它们来给这两端命名。这里我们使用了带模式的 let 语句来解构这个元组;第十九章会讨论在 let 语句中使用模式以及解构。现在只要知道,这样使用 let 是一种从 mpsc::channel 返回的元组中方便地取出各个部分的方法即可。
让我们将发送端移动到一个新建线程中并发送一个字符串,这样新建线程就可以和主线程通讯了,如示例 16-7 所示。这类似于在河的上游扔下一只橡皮鸭或从一个线程向另一个线程发送聊天信息:
文件名:src/main.rs
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let val = String::from("hi");
tx.send(val).unwrap();
});
}
示例 16-7: 将 tx 移动到一个新建的线程中并发送 "hi"
这里再次使用 thread::spawn 来创建一个新线程并使用 move 将 tx 移动到闭包中这样新建线程就拥有 tx 了。新建线程需要拥有信道的发送端以便能向信道发送消息。信道的发送端有一个 send 方法用来获取需要放入信道的值。send 方法返回一个 Result<T, E> 类型,所以如果接收端已经被丢弃了,将没有发送值的目标,所以发送操作会返回错误。在这个例子中,出错的时候调用 unwrap 产生 panic。不过在一个真实应用中,需要合理地处理它:回到第九章复习正确处理错误的策略。
在示例 16-8 中,我们在主线程中从信道的接收端获取值。这类似于在河的下游捞起橡皮鸭或接收聊天信息:
文件名:src/main.rs
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let val = String::from("hi");
tx.send(val).unwrap();
});
let received = rx.recv().unwrap();
println!("Got: {received}");
}
示例 16-8: 在主线程中接收并打印内容 "hi"
信道的接收端有两个有用的方法:recv 和 try_recv。这里,我们使用了 recv,它是 receive 的缩写,这会阻塞主线程执行直到从信道中接收一个值。一旦发送了一个值,recv 会在一个 Result<T, E> 中返回它。当信道发送端关闭,recv 会返回一个错误表明不会再有新的值到来了。
try_recv 不会阻塞,相反它立刻返回一个 Result<T, E>:Ok 值包含可用的信息,而 Err 值代表此时没有任何消息。如果线程在等待消息过程中还有其他工作时使用 try_recv 很有用:可以编写一个循环来频繁调用 try_recv,在有可用消息时进行处理,其余时候则处理一会其他工作直到再次检查。
出于简单的考虑,这个例子使用了 recv;主线程中除了等待消息之外没有任何其他工作,所以阻塞主线程是合适的。
运行示例 16-8 中的代码时,我们将会看到主线程打印出这个值:
Got: hi
完美!
通过信道转移所有权
所有权规则在消息传递中扮演了重要角色,其有助于我们编写安全的并发代码。防止并发编程中的错误是在 Rust 程序中考虑所有权的一大优势。现在让我们做一个实验来看看信道与所有权如何一同协作以避免产生问题:我们将尝试在新建线程中的信道中发送完 val 值之后再使用它。尝试编译示例 16-9 中的代码并看看为何这是不允许的:
文件名:src/main.rs
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let val = String::from("hi");
tx.send(val).unwrap();
println!("val is {val}");
});
let received = rx.recv().unwrap();
println!("Got: {received}");
}
示例 16-9: 在我们已经发送到信道中后,尝试使用 val 引用
这里尝试在通过 tx.send 发送 val 到信道中之后将其打印出来。允许这么做是一个坏主意:一旦将值发送到另一个线程后,那个线程可能会在我们再次使用它之前就将其修改或者丢弃。其他线程对值可能的修改会由于不一致或不存在的数据而导致错误或意外的结果。然而,尝试编译示例 16-9 的代码时,Rust 会给出一个错误:
$ cargo run
Compiling message-passing v0.1.0 (file:///projects/message-passing)
error[E0382]: borrow of moved value: `val`
--> src/main.rs:10:27
|
8 | let val = String::from("hi");
| --- move occurs because `val` has type `String`, which does not implement the `Copy` trait
9 | tx.send(val).unwrap();
| --- value moved here
10 | println!("val is {val}");
| ^^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
For more information about this error, try `rustc --explain E0382`.
error: could not compile `message-passing` (bin "message-passing") due to 1 previous error
我们的并发错误会造成一个编译时错误。send 函数获取其参数的所有权并移动这个值归接收端所有。这可以防止在发送后意外地再次使用这个值;所有权系统检查一切是否合乎规则。
发送多个值
示例 16-8 中的代码可以编译和运行,不过它并没有明确的告诉我们两个独立的线程通过信道相互通讯。示例 16-10 则有一些改进会证明示例 16-8 中的代码是并发执行的:新建线程现在会发送多个消息并在每个消息之间暂停一秒钟。
文件名:src/main.rs
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let vals = vec![
String::from("hi"),
String::from("from"),
String::from("the"),
String::from("thread"),
];
for val in vals {
tx.send(val).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
for received in rx {
println!("Got: {received}");
}
}
示例 16-10: 发送多个消息,并在每次发送后暂停一段时间
这一次,在新建线程中有一个字符串 vector 希望发送到主线程。我们遍历它们,单独发送每一个字符串并通过一个 Duration 值调用 thread::sleep 函数来暂停一秒。
在主线程中,不再显式调用 recv 函数:而是将 rx 当作一个迭代器。对于每一个接收到的值,我们将其打印出来。当信道被关闭时,迭代器也将结束。
当运行示例 16-10 中的代码时,将看到如下输出,每一行都会暂停一秒:
Got: hi
Got: from
Got: the
Got: thread
因为主线程中的 for 循环里并没有任何暂停或等待的代码,所以可以说主线程是在等待从新建线程中接收值。
创建多个生产者
之前我们提到过,mpsc 是 multiple producer, single consumer 的缩写。现在就来实际用用这个特性,把示例 16-10 中的代码扩展为:创建多个线程,并都把值发送给同一个接收端。我们可以通过克隆发送端来做到这一点,如示例 16-11 所示:
文件名:src/main.rs
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
// --snip--
let (tx, rx) = mpsc::channel();
let tx1 = tx.clone();
thread::spawn(move || {
let vals = vec![
String::from("hi"),
String::from("from"),
String::from("the"),
String::from("thread"),
];
for val in vals {
tx1.send(val).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
thread::spawn(move || {
let vals = vec![
String::from("more"),
String::from("messages"),
String::from("for"),
String::from("you"),
];
for val in vals {
tx.send(val).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
for received in rx {
println!("Got: {received}");
}
// --snip--
}
示例 16-11: 从多个生产者发送多个消息
这一次,在创建新线程之前,我们对发送端调用了 clone 方法。这会给我们一个可以传递给第一个新建线程的发送端句柄。我们会将原始的信道发送端传递给第二个新建线程。这样就会有两个线程,每个线程将向信道的接收端发送不同的消息。
运行代码时,输出应该看起来类似如下:
Got: hi
Got: more
Got: from
Got: messages
Got: for
Got: the
Got: thread
Got: you
虽然你可能会看到这些值以不同的顺序出现;这依赖于你的系统。这也就是并发既有趣又困难的原因。如果通过 thread::sleep 做实验,在不同的线程中提供不同的值,就会发现它们的运行更加不确定,且每次都会产生不同的输出。
现在我们见识过了信道如何工作,再看看另一种不同的并发方式吧。
共享状态并发
共享状态并发
消息传递是一个很好的处理并发的方式,但并不是唯一一个。另一种方式是让多个线程访问相同的共享数据。再考虑一下 Go 语言文档中的这句口号:“不要通过共享内存来通讯”。(“do not communicate by sharing memory.”)
通过共享内存进行通信,会是什么样的代码?此外,为什么喜欢消息传递的人会警告说谨慎使用内存共享?
在某种程度上,任何编程语言中的信道都类似于单所有权,因为一旦将一个值传送到信道中,将无法再使用这个值。共享内存类似于多所有权:多个线程可以同时访问相同的内存位置。在 15 章中,我们介绍了智能指针可以实现多所有权,然而这会增加额外的复杂性,因为需要管理多个所有者。Rust 的类型系统和所有权规则在正确管理这些问题上提供了极大的帮助:举个例子,让我们来看看互斥器(mutexes),较为常见的共享内存并发原语之一。
使用互斥器控制访问
互斥器(mutex)是互相排斥(mutual exclusion)的缩写,因为在同一时刻,它只允许一个线程访问数据。为了访问互斥器中的数据,线程首先需要通过获取互斥器的锁(lock)来表明其希望访问数据。锁是一个数据结构,作为互斥器的一部分,它记录谁有数据的专属访问权。因此我们讲,互斥器通过锁系统保护(guarding)其数据。
互斥器以难以使用著称(译注:原文指互斥器在其他编程语言中难以使用),因为你必须记住两个规则:
- 在使用数据之前,必须获取锁。
- 使用完被互斥器所保护的数据之后,必须解锁数据,这样其他线程才能够获取锁。
作为一个现实中互斥器的例子,想象一下在某个会议的一次小组座谈会中,只有一个麦克风。如果一位成员要发言,他必须请求或表示希望使用麦克风。得到了麦克风后,他可以畅所欲言,讲完后再将麦克风交给下一位希望讲话的成员。如果一位成员结束发言后忘记将麦克风交还,其他人将无法发言。如果对共享麦克风的管理出现了问题,座谈会将无法正常进行!
正确的管理互斥器异常复杂,这也是许多人之所以热衷于信道的原因。然而,在 Rust 中,得益于类型系统和所有权,你不可能在锁和解锁上出错。
Mutex<T> 的 API
我们先从在单线程环境中使用互斥器开始,作为展示其用法的一个例子,如示例 16-12 所示:
文件名:src/main.rs
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut num = m.lock().unwrap();
*num = 6;
}
println!("m = {m:?}");
}
示例 16-12: 出于简单的考虑,在一个单线程上下文中探索 Mutex<T> 的 API
像很多类型一样,我们使用关联函数 new 来创建一个 Mutex<T>。使用 lock 方法来获取锁,从而可以访问互斥器中的数据。这个调用会阻塞当前线程,直到我们拥有锁为止。
如果另一个线程拥有锁,并且那个线程 panic 了,则 lock 调用会失败。在这种情况下,没人能够再获取锁,所以我们调用 unwrap,使当前线程 panic。
一旦获取了锁,就可以将返回值(命名为 num)视为一个其内部数据的可变引用了。类型系统确保了我们在使用 m 中的值之前获取锁。m 的类型是 Mutex<i32> 而不是 i32,所以必须调用 lock 才能使用这个 i32 值。我们不能忘记这么做,因为如果没有获取锁,类型系统就不允许访问内部的 i32 值。
正如你所猜想的,Mutex<T> 是一个智能指针。更准确的说,lock 调用会返回一个叫做 MutexGuard 的智能指针。MutexGuard 智能指针实现了 Deref 来指向其内部数据;它也实现了 Drop,当 MutexGuard 离开作用域时,自动释放锁(发生在示例 16-12 内部作用域的结尾)。这样一来,就不会有忘记释放锁从而导致互斥器阻塞无法被其他线程使用的潜在风险,因为锁的释放是自动发生的。
释放锁之后,我们可以打印出互斥器内部的 i32 值,并发现我们刚刚已经将其值改为 6。
给 Mutex<T> 共享访问
现在让我们尝试使用 Mutex<T> 在多个线程间共享同一个值。我们将启动 10 个线程,并在各个线程中对同一个计数器值加 1,这样计数器将从 0 累加到 10。示例 16-13 中的例子会出现编译错误,而我们将通过这些错误来学习如何使用 Mutex<T>,以及 Rust 又是如何帮助我们正确使用它的。
文件名:src/main.rs
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Mutex::new(0);
let mut handles = vec![];
for _ in 0..10 {
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
示例 16-13: 程序启动了 10 个线程,每个线程都通过 Mutex<T> 来增加计数器的值
这里创建了一个 counter 变量来存放内含 i32 的 Mutex<T>,类似示例 16-12 那样。接下来我们遍历一个整数 range 来创建了 10 个线程。我们使用了 thread::spawn,并为所有线程传入了相同的闭包:它们每一个都将计数器移动进线程,调用 lock 方法来获取 Mutex<T> 上的锁,接着将互斥器中的值加一。当一个线程结束执行,num 会离开闭包作用域并释放锁,这样另一个线程就可以获取它了。
在主线程中,我们像示例 16-2 那样收集了所有的 JoinHandle,并调用它们的 join 方法来等待所有线程结束。然后,主线程会获取锁,并打印出程序的结果。
之前提示过,这个例子不能编译。现在让我们看看为什么!
$ cargo run
Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0382]: borrow of moved value: `counter`
--> src/main.rs:21:29
|
5 | let counter = Mutex::new(0);
| ------- move occurs because `counter` has type `std::sync::Mutex<i32>`, which does not implement the `Copy` trait
...
8 | for _ in 0..10 {
| -------------- inside of this loop
9 | let handle = thread::spawn(move || {
| ------- value moved into closure here, in previous iteration of loop
...
21 | println!("Result: {}", *counter.lock().unwrap());
| ^^^^^^^ value borrowed here after move
|
help: consider moving the expression out of the loop so it is only moved once
|
8 ~ let mut value = counter.lock();
9 ~ for _ in 0..10 {
10 | let handle = thread::spawn(move || {
11 ~ let mut num = value.unwrap();
|
For more information about this error, try `rustc --explain E0382`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error
错误信息表明 counter 值在上一次循环中被移动了。所以 Rust 告诉我们,不能将 counter 锁的所有权移动到多个线程中。让我们通过一个第 15 章讨论过的多所有权手段,来修复这个编译错误。
多线程和多所有权
在第 15 章中,我们用智能指针 Rc<T> 来创建引用计数,使得一个值有了多个所有者。让我们做同样的事,看看会发生什么。将示例 16-14 中的 Mutex<T> 封装进 Rc<T> 中,并在将所有权移入线程之前克隆(clone)Rc<T>。
文件名:src/main.rs
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Rc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Rc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
示例 16-14: 尝试使用 Rc<T> 来允许多个线程拥有 Mutex<T>
再一次编译并…出现了不同的错误!编译器真是教会了我们很多!
$ cargo run
Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0277]: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
--> src/main.rs:11:36
|
11 | let handle = thread::spawn(move || {
| ------------- ^------
| | |
| ______________________|_____________within this `{closure@src/main.rs:11:36: 11:43}`
| | |
| | required by a bound introduced by this call
12 | | let mut num = counter.lock().unwrap();
13 | |
14 | | *num += 1;
15 | | });
| |_________^ `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
|
= help: within `{closure@src/main.rs:11:36: 11:43}`, the trait `Send` is not implemented for `Rc<std::sync::Mutex<i32>>`
note: required because it's used within this closure
--> src/main.rs:11:36
|
11 | let handle = thread::spawn(move || {
| ^^^^^^^
note: required by a bound in `spawn`
--> /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/std/src/thread/mod.rs:723:1
For more information about this error, try `rustc --explain E0277`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error
哇哦,错误信息太长不看!下面是重点要关注的内容:`Rc<Mutex<i32>>` cannot be sent between threads safely。编译器也指出了原因:the trait `Send` is not implemented for `Rc<Mutex<i32>>`。下一节我们会讲到 Send:这是一个确保所使用的类型可以用于并发环境的 trait。
不幸的是,Rc<T> 并不能安全的在线程间共享。当 Rc<T> 管理引用计数时,它必须在每一个 clone 调用时增加计数,并在每一个克隆体被丢弃时减少计数。Rc<T> 并没有使用任何并发原语,无法确保改变计数的操作不会被其他线程打断。这可能使计数出错,并导致诡异的 bug,比如可能会造成内存泄漏,或在使用结束之前就丢弃一个值。我们所需要的是一个与 Rc<T> 完全一致,又以线程安全的方式改变引用计数的类型。
原子引用计数 Arc<T>
所幸 Arc<T> 正是这么一个类似 Rc<T> 并可以安全地用于并发环境的类型。字母 a 代表 原子性(atomic),所以这是一个原子引用计数(atomically reference counted)类型。原子类型 (Atomics) 是另一类这里还未涉及到的并发原语:请查看标准库中 std::sync::atomic 的文档来获取更多细节。目前我们只需要知道:原子类型就像基本类型一样,可以安全地在线程间共享。
你可能会好奇,为什么不是所有的基本类型都是原子性的?为什么标准库中的类型没有全部默认使用 Arc<T> 实现?原因在于,线程安全会造成性能损失,我们希望只在必要时才为此买单。如果只是在单线程中对值进行操作,不必强制原子性所提供的保证可以使代码运行得更快。
回到之前的例子:Arc<T> 和 Rc<T> 有着相同的 API,所以我们只需修改程序中的 use 行、new 调用和 clone 调用。示例 16-15 中的代码最终可以编译并运行。
文件名:src/main.rs
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
示例 16-15: 使用 Arc<T> 包装一个 Mutex<T> 能够实现在多线程之间共享所有权
这段代码会打印出如下:
Result: 10
成功了!我们从 0 数到了 10,这好像没啥大不了的,不过一路上我们确实学习了很多关于 Mutex<T> 和线程安全的知识!这个例子中构建的结构可以用于比增加计数更为复杂的操作。使用这个策略,我们可将计算任务分成独立的部分,并分散到多个线程中,接着使用 Mutex<T> 使用各自的运算结果来更新最终的结果。
注意,对于简单的数值运算,标准库中 std::sync::atomic 模块 提供了比 Mutex<T> 更简单的类型。针对基本类型,这些类型提供了安全、并发、原子的操作。在上面的例子中,为了专注于讲明白 Mutex<T> 的用法,我们才选择在基本类型上使用 Mutex<T>。(译注:对于上面例子中出现的 i32 加法操作,更好的做法是使用 AtomicI32 类型来完成。具体参考文档。)
比较 RefCell<T>/Rc<T> 和 Mutex<T>/Arc<T>
你可能注意到了,尽管 counter 是不可变的,我们仍然可以获取其内部值的可变引用;这意味着 Mutex<T> 提供了内部可变性,就像 Cell 系列类型那样。使用 RefCell<T> 可以改变 Rc<T> 中内容(在 15 章中讲到过),同样地,使用 Mutex<T> 我们也可以改变 Arc<T> 中的内容。
另一个值得注意的细节是,Rust 不能完全避免使用 Mutex<T> 所带来的逻辑错误。回忆一下,第 15 章中讲过,使用 Rc<T> 就有造成引用循环的风险:两个 Rc<T> 值相互引用,造成内存泄漏。同理,Mutex<T> 也有造成死锁(deadlock)的风险:当某个操作需要锁住两个资源,而两个线程分别持有两个资源的其中一个锁时,它们会永远相互等待。如果你对这个话题感兴趣,尝试编写一个带有死锁的 Rust 程序,接着研究其它语言中使用互斥器的死锁规避策略,并尝试在 Rust 中实现它们。标准库中 Mutex<T> 和 MutexGuard 的 API 文档会提供有用的信息。
作为本章的最后,我们将讨论 Send 和 Sync trait,以及如何将它们用于自定义类型。
使用 Sync 与 Send Traits 的可扩展并发
使用 Send 和 Sync trait 的可扩展并发
Rust 的并发模型中一个有趣的方面是:我们之前讨论的几乎所有内容,都属于标准库,而不是语言本身的内容。处理并发的方案并不受标准库或语言所限:我们可以编写自己的或使用他人编写的并发特性。
然而,有一些关键的并发概念是内嵌于语言本身而非标准库的,其中就包括 std::marker 的 Send 和 Sync trait。
在线程间转移所有权
Send 标记 trait 表明实现了 Send 的类型值的所有权可以在线程间传送。几乎所有的 Rust 类型都是Send 的,不过有一些例外,包括 Rc<T>:这是不能实现 Send 的,因为如果克隆了 Rc<T> 的值并尝试将克隆的所有权转移到另一个线程,这两个线程都可能同时更新引用计数。为此,Rc<T> 被实现为用于单线程场景,这时不需要为拥有线程安全的引用计数而付出性能代价。
因此,Rust 类型系统和 trait 约束确保永远也不会意外的将不安全的 Rc<T> 在线程间发送。当尝试在示例 16-14 中这么做的时候,会得到错误 the trait Send is not implemented for Rc<Mutex<i32>>。而使用实现了 Send 的 Arc<T> 时,代码就能成功编译。
任何完全由 Send 的类型组成的类型也会自动被标记为 Send。几乎所有基本类型都是 Send 的,除了第二十章将会讨论的裸指针(raw pointer)。
多线程访问
Sync 标记 trait 表明一个实现了 Sync 的类型可以安全的在多个线程中拥有其值的引用。换一种方式来说,对于任意类型 T,如果 &T(T 的不可变引用)实现了 Send 的话 T 就实现了 Sync,这意味着其引用就可以安全的发送到另一个线程。类似于 Send 的情况,基本类型都实现了 Sync,完全由实现了 Sync 的类型组成的类型也实现了 Sync。
智能指针 Rc<T> 也没有实现 Sync,原因和它没有实现 Send 时一样。RefCell<T>(第十五章讨论过)和相关的 Cell<T> 系列类型也没有实现 Sync。RefCell<T> 在运行时进行的借用检查不是线程安全的。Mutex<T> 实现了 Sync,正如“给 Mutex<T> 共享访问”中讲到的,它可以用来在多线程间共享访问。
手动实现 Send 和 Sync 是不安全的
通常并不需要手动实现 Send 和 Sync trait,因为完全由实现了 Send 和 Sync 的类型组成的类型,也会自动实现 Send 和 Sync。因为它们是标记 trait,甚至都不需要实现任何方法。它们只是用来强制执行与并发有关的不变性。
手动实现这些 trait 需要编写 unsafe Rust 代码。第二十章会讲到如何使用 unsafe Rust;目前更重要的是,要知道如果构建新的并发类型,而它又不是完全由实现了 Send 和 Sync 的部分组成,就需要认真思考,以维持其安全保证。“The Rustonomicon” 中有更多关于这些保证以及如何维持它们的信息。
总结
这不会是本书最后一个出现并发的章节:下一章我们会专注于异步编程,并且第二十一章的项目会在更现实的场景中使用这些概念,而不像本章中讨论的这些小例子。
正如之前提到的,因为 Rust 本身很少有处理并发的部分内容,有很多的并发方案都由 crate 实现。它们比标准库要发展的更快;请在网上搜索当前最新的用于多线程场景的 crate。
Rust 提供了用于消息传递的信道,和像 Mutex<T> 和 Arc<T> 这样可以安全的用于并发上下文的智能指针。类型系统和借用检查器会确保这些场景中的代码不会出现数据竞争和无效的引用。一旦代码可以编译了,我们就可以坚信这些代码可以正确地运行于多线程环境,而不会出现其他语言中经常出现的那些难以追踪的 bug。并发编程不再是什么可怕的概念:无所畏惧地并发起来吧!