使用线程同时运行代码
在大多数现代操作系统中,已执行程序的代码运行在一个 进程(process)中,操作系统会同时管理多个进程。 在程序内部,你也可以让多个独立部分同时运行。 运行这些独立部分的特性称为线程(threads)。例如, Web 服务器可以拥有多个线程,以便能同时响应 多个请求。
将程序中的计算拆分为多个线程以同时运行多个 任务可以提升性能,但同时也增加了复杂性。 由于线程可以同时运行,因此无法内在地保证 不同线程上代码各部分执行的顺序。这可能导致 一些问题,例如:
- 竞态条件(race conditions),即线程以不一致的顺序访问数据或资源
- 死锁(deadlocks),即两个线程互相等待对方,导致两个 线程都无法继续执行
- 只在特定情况下发生、难以稳定复现和修复的 bug
Rust 试图减轻使用线程带来的负面影响, 但在多线程环境中编程仍然需要谨慎思考, 并要求采用与单线程程序不同的 代码结构。
编程语言以几种不同的方式实现线程,许多 操作系统提供了可供编程语言调用以创建 新线程的 API。Rust 标准库使用 1:1 的线程 实现模型,即程序每使用一个语言线程就对应一个 操作系统线程。也有一些 crate 实现了其他线程模型, 这些模型与 1:1 模型做出了不同的权衡。(Rust 的 async 系统—— 我们将在下一章看到——也提供了另一种并发方式。)
使用 spawn 创建新线程
要创建新线程,我们调用 thread::spawn 函数并传入一个
闭包(我们在第 13 章讨论过闭包),其中包含我们希望在新线程中
运行的代码。示例 16-1 中的代码从主线程打印一些文本,
并从新线程打印另一些文本。
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));
}
}
请注意,当 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。
如果你运行这段代码,只看到主线程的输出,或者没有看到任何 交错输出,请尝试增大范围中的数值,以便为操作系统 在线程之间切换创造更多机会。
等待所有线程完成
示例 16-1 中的代码不仅在大多数情况下由于主线程结束而 过早地停止了派生线程,而且由于不能保证 线程运行的顺序,我们也无法保证派生线程 根本有机会运行!
我们可以通过将 thread::spawn 的返回值保存在变量中
来修复派生线程不运行或过早结束的问题。
thread::spawn 的返回类型是 JoinHandle<T>。JoinHandle<T> 是一个
拥有所有权的值,当我们对其调用 join 方法时,它会等待
对应线程完成。示例 16-2 展示了如何使用在示例 16-1 中创建的
线程的 JoinHandle<T>,以及如何调用 join 来确保
派生线程在 main 退出之前完成。
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();
}
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 循环之前,会发生什么?如下所示:
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 的闭包一起使用,
因为闭包随后会获取它从环境中使用的值的所有权,
从而将这些值的所有权从一个线程转移到
另一个线程。在第 13 章的“捕获引用或移动所有权”一节中,我们在闭包的上下文中讨论了 move。现在我们将
更集中地讨论 move 与 thread::spawn 之间的交互。
请注意,在示例 16-1 中,我们传递给 thread::spawn 的闭包不接收任何
参数:我们没有在派生线程的代码中使用主线程的任何数据。
要在派生线程中使用主线程的数据,派生线程的
闭包必须捕获它所需要的值。示例 16-3 展示了
在主线程中创建一个 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。但当我们编译这个
示例时,会得到如下错误:
$ 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 的引用
失效的场景。
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();
}
v 的引用,而主线程丢弃了 v如果 Rust 允许我们运行这段代码,派生线程
有可能立即被放到后台而完全不运行。
派生线程内部持有 v 的引用,但主线程立即
使用我们在第 15 章讨论过的 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 的修改
将按我们的预期编译并运行。
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();
}
move 关键字强制闭包获取其使用的值的所有权我们可能会想尝试用同样的方法——使用 move 闭包——
来修复示例 16-4 中主线程调用 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 提供的方法,让我们来看看一些可以使用线程的 场景。