使用线程同时运行代码

在大多数当前的操作系统中,已执行程序的代码在一个进程中运行,并且操作系统将同时管理多个进程。在一个程序中,你也可以拥有同时运行的独立部分。运行这些独立部分的功能称为线程。例如,Web 服务器可以拥有多个线程,以便它可以同时响应多个请求。

将程序中的计算拆分为多个线程以同时运行多个任务可以提高性能,但也会增加复杂性。由于线程可以同时运行,因此无法保证不同线程上的代码部分的运行顺序。这可能会导致问题,例如

  • 竞态条件,即线程以不一致的顺序访问数据或资源
  • 死锁,即两个线程相互等待,阻止两个线程继续执行
  • 仅在某些情况下发生且难以重现和可靠修复的错误

Rust 尝试减轻使用线程的负面影响,但在多线程环境中编程仍然需要仔细思考,并且需要与在单线程中运行的程序不同的代码结构。

编程语言以几种不同的方式实现线程,许多操作系统提供语言可以调用的 API 以创建新线程。Rust 标准库使用1:1线程实现模型,由此程序为每个语言线程使用一个操作系统线程。有些 crate 实现了其他线程模型,这些模型对 1:1 模型进行了不同的权衡。(Rust 的异步系统,我们将在下一章中看到,也提供了另一种并发方法。)

使用 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)); } }
列表 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。

如果你运行此代码并且只看到来自主线程的输出,或者没有看到任何重叠,请尝试增加范围中的数字,以创建更多操作系统在线程之间切换的机会。

使用 join Handle 等待所有线程完成

列表 16-1 中的代码不仅由于主线程结束而过早地停止了派生线程,而且由于无法保证线程运行的顺序,我们也无法保证派生线程会运行!

我们可以通过将 thread::spawn 的返回值保存在变量中来解决派生线程不运行或过早结束的问题。thread::spawn 的返回类型是 JoinHandleJoinHandle 是一个拥有的值,当我们在其上调用 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(); }
列表 16-2:从 thread::spawn 保存 JoinHandle 以保证线程运行完成

在 handle 上调用 join 会阻塞当前正在运行的线程,直到 handle 表示的线程终止。阻塞线程意味着阻止该线程执行工作或退出。因为我们将对 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 的闭包一起使用,因为闭包随后将获取它从环境中使用的值的所有权,从而将这些值的所有权从一个线程转移到另一个线程。在 “捕获引用或移动所有权”第 13 章的章节中,我们讨论了闭包上下文中的 move。现在,我们将更多地关注 movethread::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 的主线程捕获对 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 的修改将按我们的预期编译和运行

文件名: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 中主线程调用 drop 的代码,方法是使用 move 闭包。但是,此修复程序不起作用,因为列表 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 的情况下,让我们看看我们可以用线程什么。