使用线程并发运行代码
在大多数当前的操作系统中,已执行程序的代码在一个叫做*进程*的东西中运行,操作系统将同时管理多个进程。在一个程序内部,你也可以拥有同时运行的独立部分。运行这些独立部分的功能叫做*线程*。例如,一个 web 服务器可以拥有多个线程,以便它可以同时响应多个请求。
将程序中的计算拆分成多个线程以同时运行多个任务可以提高性能,但也会增加复杂性。因为线程可以同时运行,所以代码的不同部分在不同线程上的运行顺序没有内在的保证。这可能会导致一些问题,例如:
- 竞态条件,即线程以不一致的顺序访问数据或资源
- 死锁,即两个线程相互等待,导致两个线程都无法继续
- 只在特定情况下发生的错误,并且难以可靠地重现和修复
Rust 试图减轻使用线程带来的负面影响,但在多线程环境中编程仍然需要仔细思考,并且需要与单线程程序不同的代码结构。
编程语言以几种不同的方式实现线程,许多操作系统都提供了一个 API,语言可以调用它来创建新的线程。Rust 标准库使用线程实现的*1:1*模型,即程序为每个语言线程使用一个操作系统线程。也有一些单元包实现了其他的线程模型,它们与 1:1 模型相比做出了不同的权衡。
使用 spawn
创建新线程
要创建一个新的线程,我们调用 thread::spawn
函数,并传递一个闭包(我们在第 13 章讨论过闭包),其中包含我们想在新线程中运行的代码。清单 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)); } }
请注意,当 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。
如果你运行这段代码,并且只看到来自主线程的输出,或者没有看到任何重叠,请尝试增加范围内的数字,以便为操作系统创造更多在线程之间切换的机会。
使用 join
句柄等待所有线程完成
清单 16-1 中的代码不仅会在大多数情况下由于主线程结束而过早地停止派生的线程,而且由于线程运行的顺序没有保证,我们也不能保证派生的线程会得到运行!
我们可以通过将 thread::spawn
的返回值保存在一个变量中来解决派生线程没有运行或过早结束的问题。thread::spawn
的返回类型是 JoinHandle
。JoinHandle
是一个拥有的值,当我们调用它的 join
方法时,它会等待它的线程完成。清单 16-2 展示了如何使用我们在清单 16-1 中创建的线程的 JoinHandle
,并调用 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(); }
在句柄上调用 join
会阻塞当前运行的线程,直到句柄所代表的线程终止。*阻塞*一个线程意味着阻止该线程执行工作或退出。因为我们在主线程的 for
循环之后放置了对 join
的调用,所以运行清单 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
闭包
我们经常在传递给 thread::spawn
的闭包中使用 move
关键字,因为闭包会获取其从环境中使用的值的所有权,从而将这些值的所有权从一个线程转移到另一个线程。在第 13 章“捕获引用或移动所有权”部分中,我们讨论了在闭包上下文中使用 move
。现在,我们将更多地关注 move
和 thread::spawn
之间的交互。
请注意,在代码清单 16-1 中,我们传递给 thread::spawn
的闭包不带任何参数:我们没有在生成线程的代码中使用来自主线程的任何数据。要在生成线程中使用来自主线程的数据,生成线程的闭包必须捕获它需要的值。代码清单 16-3 展示了尝试在主线程中创建一个向量并在生成线程中使用它的示例。但是,这还不能工作,您稍后会看到。
文件名: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();
}
闭包使用了 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();
}
如果 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 的修改将按预期编译和运行
文件名: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-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
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 有了基本了解后,让我们看看我们可以使用线程做什么。