使用线程同时运行代码

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

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

  • 竞争条件,其中线程以不一致的顺序访问数据或资源。
  • 死锁,其中两个线程在等待对方,阻止了两个线程的继续执行。
  • 仅在某些情况下发生的错误,很难重现和可靠地修复

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

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

使用spawn创建新线程

要创建一个新线程,我们调用 thread::spawn 函数并传递一个包含我们希望在新线程中运行的代码的闭包(我们在第 13 章讨论过闭包)。列表 16-1 中的示例从主线程打印一些文本,从新线程打印其他文本:

Filename: 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));
    }
}
Listing 16-1: Creating a new thread to print one thing while the main thread prints something else

请注意,当 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 退出之前完成:

Filename: 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();
}
Listing 16-2: Saving a JoinHandle from thread::spawn to guarantee the thread is run to completion

在句柄上调用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 循环之前时会发生什么,如下所示:

Filename: 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。现在,我们将更多地关注movethread::spawn之间的交互。

请注意列表 16-1 中传递给 thread::spawn 的闭包不接受任何参数:我们在派生线程的代码中没有使用主线程中的任何数据。要使用主线程中的数据在派生线程中,派生线程的闭包必须捕获它需要的值。列表 16-3 展示了尝试在主线程中创建一个向量并在派生线程中使用它的例子。然而,这还不能工作,你马上就会看到。

Filename: 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();
}
Listing 16-3: Attempting to use a vector created by the main thread in another thread

闭包使用了 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的引用无效的情况:

Filename: 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();
}
Listing 16-4: A thread with a closure that attempts to capture a reference to v from a main thread that drops 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 的修改将按我们的预期编译和运行:

Filename: 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();
}
Listing 16-5: Using the move keyword to force a closure to take ownership of the values it uses

我们可能会尝试使用 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

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有了基本了解之后,让我们看看我们可以用线程什么。