[!|center] 普若哥们儿
并发
安全且高效地处理并发编程是 Rust 的另一个主要目标。并发编程(Concurrent programming),代表程序的不同部分相互独立地执行;而 并行编程(parallel programming)代表程序不同部分同时执行。
注意:出于简洁的考虑,我们将很多问题归类为 并发,而不是更准确的区分 并发和(或)并行。
起初,Rust 团队认为确保内存安全和防止并发问题是两个分别需要不同方法应对的挑战。随着时间的推移,团队发现所有权和类型系统是一系列解决内存安全和并发问题的强有力的工具!通过利用所有权和类型检查,在 Rust 中很多并发错误都是 编译时 错误,而非运行时错误,Rust 能够在编译时发现并发错误。
本章将要涉及到的内容:
- 如何创建线程来同时运行多段代码。
- 消息传递(Message passing)并发,其中信道(channel)被用来在线程间传递消息。
- 共享状态(Shared state)并发,其中多个线程可以访问同一片数据。
Sync和Sendtrait,将 Rust 的并发保证扩展到用户定义的以及标准库提供的类型中。
使用线程同时运行代码
在大部分现代操作系统中,已执行程序的代码在一个 进程(process)中运行,操作系统负责管理多个进程。在程序内部,也可以拥有多个同时运行的独立部分。这些独立部分称为 线程(threads)。因为线程是同时运行的,所以无法预先保证不同线程中的代码的执行顺序。这会导致诸如此类的问题:
- 竞争,多个线程同时访问数据或资源
- 死锁,两个线程相互等待对方,这会阻止两者继续运行
- 只会发生在特定情况且难以稳定重现和修复的 bug
操作系统提供了创建新线程的 API,不同编程语言实现线程的方法不尽相同,Rust 标准库采用 1:1 线程实现方案,每一个语言级线程对应一个系统线程。
使用 spawn 创建新线程
调用 thread::spawn 函数并传递一个闭包,Rust 会创建了一个新线程,并在新线程中运行这个闭包:
use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
}
注意当 Rust 程序的主线程结束时,新线程也会结束,而不管其是否执行完毕。thread::sleep 调用强制线程停止执行一小段时间,从而允许其他不同的线程运行。这些线程可能会轮流运行,不过并不保证如此,这依赖操作系统如何调度线程。
使用 join 等待所有线程结束
由于主线程结束,上例的代码大部分时候会提早结束新建线程。可以通过将 thread::spawn 的返回值储存在变量中来修复这个问题。thread::spawn 的返回值是 JoinHandle 类型的变量。当对该变量调用 join 方法时,会阻塞当前线程直到该变量所代表的线程结束。
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
handle.join().unwrap(); // 阻塞当前线程(主线程),直到 handle 所代表的线程结束
}
这里我们将 join 调用放在了主线程的 for 循环之后。这两个线程仍然会交替执行,不过主线程会由于 handle.join() 调用会等待直到新建线程执行完毕。看看将 handle.join() 移动到 main 中 for 循环之前会发生什么:
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
handle.join().unwrap();
for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
}
主线程会等待直到新建线程执行完毕之后才开始执行 for 循环,所以输出将不会交替出现。
将 move 闭包与线程一同使用
之前我们讲到可以使用 move 关键字强制闭包获取从环境中捕获的值的所有权,这在创建新线程将值的所有权从一个线程移动到另一个线程时非常有用。
下例展示了一个尝试在主线程中创建一个 vector 并用于新建线程的例子,不过这么写不能工作:
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Here's a vector: {:?}", v);
});
handle.join().unwrap();
}
闭包使用了 v,所以闭包会捕获 v 并使其成为闭包环境的一部分。因为 thread::spawn 在一个新线程中运行这个闭包,所以可以在新线程中访问 v, 然而当编译这个例子时会出错。Rust 会 推断 如何捕获 v,因为 println! 只需要 v 的引用,闭包尝试借用 v。然而这有一个问题:Rust 不知道这个新建线程会执行多久,所以无法知晓对 v 的引用是否一直有效。
下例展示了一个 v 的引用很有可能不再有效的场景:
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();
}
如果 Rust 允许这段代码运行,则新建线程则可能会立刻被转移到后台并完全没有机会运行。新建线程内部有一个 v 的引用,不过主线程立刻就使用 drop 丢弃了 v。接着当新建线程开始执行,v 已不再有效,所以其引用也是无效的。
通过在闭包之前增加 move 关键字,我们强制闭包获取其使用的值的所有权,而不是任由 Rust 推断它应该借用值:
let handle = thread::spawn(move || {
println!("Here's a vector: {:?}", v);
});
}
消息传递并发
Rust 解决多线程之间共享数据的方式之一是消息传递,这个思想来源于 Go 编程语言文档中 的口号:“不要通过共享内存来通讯;而是通过通讯来共享内存。”(“Do not communicate by sharing memory; instead, share memory by communicating.”)。
Rust 标准库提供了 信道(channel)用于线程之间传递消息。信道由两类部件组成:发送者(transmitter)和接收者(receiver),发送者发送数据,接收者接收数据。一个信道是多对一的编程模型,可以有多个发送者,但是只能有一个接收者。也就是说,可以有多个线程通过信道发送数据,每个线程线程绑定一个发送者,唯一的线程绑定一个接收者从信道接收数据。当所有的发送者或唯一的接收者被销毁时信道就被被关闭(closed)了。
单个发送者
在下例中,创建一个信道,创建一个新线程使用信道的发送者发送数据,在主线程中从信道的接收者获取值。
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);
}
这里使用 mpsc::channel 函数创建一个新的信道。mpsc 是 多个生产者,单个消费者(multiple producer, single consumer)的缩写。
mpsc::channel 函数返回一个元组:第一个元素是发送者,而第二个元素是接收者。这里使用了一个 let 语句和模式来解构了此元组,tx 为发送者,rx 为接收者。
使用 thread::spawn 来创建一个新线程并使用 move 将 tx 移动到闭包中,这样新建线程就拥有 tx 了。
信道的发送端有一个 send 方法用来获取需要放入信道的值。send 方法返回一个 Result<T, E> 类型,所以如果接收端已经被丢弃了,将没有发送值的目标,所以发送操作会返回错误。在这例中,出错的时候调用 unwrap 产生 panic。
注意: tx 发送的数据未被接收时会被缓存。
信道的接收者有两个有用的方法:recv 和 try_recv。这里,我们使用了 recv,这个方法会阻塞主线程执行直到从信道中接收一个值。一旦发送了一个值,recv 会在一个 Result<T, E> 中返回它。当信道发送端关闭,recv 会返回一个错误表明不会再有新的值到来了。
try_recv 不会阻塞,相反它立刻返回一个 Result<T, E>:Ok 值包含可用的信息,而 Err 值代表此时没有任何消息。如果线程在等待消息过程中还有其他工作时使用 try_recv 很有用:可以编写一个循环来频繁调用 try_recv,在有可用消息时进行处理,其余时候则处理一会其他工作直到再次检查。
出于简单的考虑,这个例子使用了 recv;主线程中除了等待消息之外没有任何其他工作,所以阻塞主线程是合适的。
信道与所有权转移
所有权规则在消息传递中扮演了重要角色,其有助于我们编写安全的并发代码。现在尝试在新建线程中的信道中发送完 val 值 之后 再使用它。
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);
}
这里尝试在通过 tx.send 发送 val 到信道中之后将其打印出来。这样做是有问题的:一旦将值发送到另一个线程后,那个线程可能会在我们再次使用它之前就将其修改或者丢弃。其他线程对值可能的修改会由于不一致或不存在的数据而导致错误或意外的结果。因此尝试编译示例代码时 Rust 会给出编译错误:
error[E0382]: borrow of moved value: `val`
--> src/main.rs:10:31
|
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)
send 函数获取其参数的所有权并移动这个值归接收者所有,这可以防止在发送后再次意外地使用这个值;所有权系统检查一切是否合乎规则。
[!note] 发送的不是复制品,而是原件,发送方一旦将数据发送出去,发送方就失去了这个数据。
发送多个值并观察接收者的等待
下例证明代码是并发执行的:新建线程现在会发送多个消息并在每个消息之间暂停一秒钟。
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);
}
}
这一次,在新建线程中有一个字符串 vector 希望发送到主线程。我们遍历它们,单独的发送每一个字符串并通过一个 Duration 值调用 thread::sleep 函数来暂停一秒。
在主线程中,不再显式调用 recv 函数:而是将 rx 当作一个迭代器。对于每一个接收到的值,我们将其打印出来。当信道被关闭时,迭代器也将结束。
因为主线程中的 for 循环里并没有任何暂停或等待的代码,所以可以说主线程是在等待从新建线程中接收值。
通过克隆发送者来创建多个生产者
mpsc 是 multiple producer, single consumer 的缩写。可以运用 mpsc 创建向同一接收者发送值的多个线程,这可以通过克隆发送者来做到,如下例所示:
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
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);
}
}
在创建新线程之前,对发送者调用了 clone 方法创建了新的发送者。例中有两个线程,每个线程通过不同的发送者向信道的接收端发送不同的消息。
共享状态并发
前面讲到的信道类似于单所有权,因为一旦将一个值传送到信道中,将无法再使用这个值。Rust 线程间共享数据的另一种方式是共享内存,共享内存类似于多所有权:多个线程可以共享相同的内存位置。Rust 的类型系统和所有权规则有助于协调多线程对共享内存的访问冲突,保证数据共享的正确性。
作为例子,让我们看看互斥器,一个常见的共享内存并发原语。
互斥器一次只允许一个线程访问数据
互斥器(mutex)是 mutual exclusion 的缩写,也就是说,任意时刻,其只允许一个线程访问共享数据。为了访问互斥器中的数据,线程首先需要通过获取互斥器的 锁(lock)来表明其希望访问数据。锁是一个作为互斥器一部分的数据结构,它记录谁有数据的独占访问权。
互斥器的使用规则是:
- 在使用数据之前尝试获取锁。
- 处理完被互斥器所保护的数据之后,必须解锁数据,这样其他线程才能够获取锁。
正确的使用互斥器比较复杂,然而,Rust 借助于类型系统和所有权,保证我们不会在锁和解锁上出错。
Mutex<T> 的 API
作为展示如何使用互斥器的例子,让我们从在单线程上下文使用互斥器开始,如下例所示:
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut num = m.lock().unwrap();
*num = 6;
}
println!("m = {:?}", m);
}
像很多类型一样,我们使用关联函数 new 来创建一个 Mutex<T>。使用 lock 方法获取锁,以访问互斥器中的数据。这个调用会阻塞当前线程,直到我们拥有锁为止。
如果另一个线程拥有锁,并且那个线程 panic 了,则 lock 调用会失败。在这种情况下,没人能够再获取锁,所以这里选择 unwrap 并在遇到这种情况时使线程 panic。
一旦获取了锁,就可以将返回值(在这里是 num)视为一个其内部数据的可变引用了。类型系统确保了我们在使用 m 中的值之前获取锁。m 的类型是 Mutex<i32> 而不是 i32,所以 必须 获取锁才能使用这个 i32 值,也就是说,m.lock() 成功了才能获得 num 值,才能进一步访问 num 引用的 i32 类型的值。
正如你所猜测的,Mutex<T> 是一个智能指针。更准确的说,lock 调用 返回 一个 MutexGuard 类型的智能指针。这个智能指针实现了 Deref 来指向其内部数据;也提供了一个 Drop 使得 MutexGuard 离开作用域时自动释放锁,这正发生于示例内部作用域的结尾。因此,我们不会忘记释放锁并阻塞互斥器为其它线程所用的风险,因为锁的释放是自动发生的。
在线程间共享 Mutex<T>
现在让我们尝试使用 Mutex<T> 在多个线程间共享值。我们将启动十个线程,并在各个线程中对同一个计数器值加一,这样计数器将从 0 变为 10。下例会出现编译错误,而我们将通过这些错误来学习如何使用 Mutex<T>,以及 Rust 又是如何帮助我们正确使用的。
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());
}
这里创建了一个 counter 变量来存放内含 i32 的 Mutex<T>。接下来遍历 range 创建了 10 个线程。使用了 thread::spawn 并对所有线程使用了相同的闭包:它们每一个都将调用 lock 方法来获取 Mutex<T> 上的锁,接着将互斥器中的值加一。当一个线程结束执行,num 会离开闭包作用域并释放锁,这样另一个线程就可以获取它了。
在主线程中,join 方法来确保所有线程都会结束。这时,主线程会获取锁并打印出程序的结果。
之前提示过这个例子不能编译,让我们看看为什么!
error[E0382]: use of moved value: `counter`
--> src/main.rs:9:36
|
5 | let counter = Mutex::new(0);
| ------- move occurs because `counter` has type `Mutex<i32>`, which does not implement the `Copy` trait
...
9 | let handle = thread::spawn(move || {
| ^^^^^^^ value moved into closure here, in previous iteration of loop
10 | let mut num = counter.lock().unwrap();
| ------- use occurs due to use in closure
错误信息表明 counter 值在上一次循环中被移动了。所以 Rust 告诉我们不能将 counter 锁的所有权移动到多个线程中。让我们通过多所有权手段来修复这个编译错误。
多线程和多所有权
之前讲过使用线程安全的智能指针 Arc<T> 来创建引用计数的值,以便拥有多所有者。将下例中的 Mutex<T> 封装进 Arc<T> 中并在将所有权移入线程之前克隆了 Arc<T> 。
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());
}
这会打印出:
Result: 10
成功了!我们从 0 数到了 10。
注意,如果是简单的数值运算,[标准库中 std::sync::atomic 模块][atomic] 提供的比 Mutex<T> 更简单的类型,这些类型提供了基本类型之上安全、并发、原子的操作。这个例子中选择在基本类型上使用 Mutex<T> 以便我们可以专注于 Mutex<T> 如何工作。
可以看到 counter 是不可变的,不过可以获取其内部值的可变引用,这意味着 Mutex<T> 提供了内部可变性,就像 Cell 系列类型那样。正如使用 RefCell<T> 可以改变 Rc<T> 中的内容那样,同样的可以使用 Mutex<T> 来改变 Arc<T> 中的内容。
另一个值得注意的细节是 Rust 不能避免使用 Mutex<T> 的全部逻辑错误。回忆一下之前使用 Rc<T> 就有造成引用循环的风险,这时两个 Rc<T> 值相互引用,造成内存泄漏。同理,Mutex<T> 也有造成 死锁(deadlock)的风险。这发生于当一个操作需要锁住两个资源而两个线程各持一个锁,这会造成它们永远相互等待。
使用 Sync 和 Send trait 的可扩展并发
Rust 的并发并不是 Rust 语言本身的功能,我们之前讨论的几乎所有内容,都属于标准库,而不是语言本身。由于不需要语言提供并发支持,并发方案不受标准库或语言所限,我们可以编写自己的或使用别人编写的并发功能。然而有两个并发概念是内嵌于语言中的:std::marker 中的 Sync 和 Send trait。
通过 Send 允许在线程间转移所有权
Send 是一个标记 trait,它表明实现 Send 的类型的值的所有权可以在线程间传送,几乎所有的 Rust 类型都是 Send 的。
不过有一些例外,比如 Rc<T> 不是 Send 的,因为如果克隆了 Rc<T> 的值并尝试将克隆的值的所有权转移到另一个线程,这两个线程都可能同时更新引用计数,因此,Rc<T> 只能用于单线程场景。
Rust 类型系统和 trait bound 确保永远也不会意外地将 Rc<T> 在线程间发送,当尝试这么做的时候,会得到错误 the trait Send is not implemented for Rc<Mutex<i32>>,而使用标记为 Send 的 Arc<T> 就没有问题。
此外,裸指针(raw pointer)也不是 Send 的。
任何完全由 Send 的类型组成的类型也会自动被标记为 Send。
Sync 允许多线程访问
Sync 标记 trait 表明一个实现了 Sync 的类型可以安全的在多个线程中拥有其值的引用,也就是说,对于任意类型 T,如果 &T(T 的不可变引用)是 Send 的话 T 就是 Sync 的,这意味着其引用就可以安全的发送到另一个线程。类似于 Send 的情况,基本类型是 Sync 的,完全由 Sync 的类型组成的类型也是 Sync 的。
智能指针 Rc<T> 也不是 Sync 的,原因与其不是 Send 相同;RefCell<T> 和 Cell<T> 类型不是 Sync 的。裸指针不是 Send 相同。
Mutex<T> 是 Sync 的,它可以被用来在多线程中共享访问。
手动实现 Send 和 Sync 是不安全的
通常并不需要手动实现 Send 和 Sync trait,因为由 Send 和 Sync 的类型组成的类型,自动就是 Send 和 Sync 的。因为它们是标记 trait,甚至都不需要实现任何方法。它们只是用来加强并发相关的不可变性的。
手动实现这些标记 trait 涉及到编写不安全的 Rust 代码,当前重要的是,在创建新的由不是 Send 和 Sync 的部分构成的并发类型时需要多加小心,以确保维持其安全保证。