使用线程同时运行代码

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

将程序中的计算分割成多个线程以同时运行多个任务可以提高性能,但这也会增加复杂性。由于线程可以同时运行,所以不同线程上的代码片段的运行顺序没有内在的保证。这可能导致一些问题,例如:

  • 竞态条件(Race conditions),即线程以不一致的顺序访问数据或资源
  • 死锁(Deadlocks),即两个线程相互等待对方,阻止两个线程继续运行
  • 只在某些特定情况下发生且难以可靠地重现和修复的 Bug

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 句柄等待所有线程完成

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

我们可以通过将 thread::spawn 的返回值保存在一个变量中来解决派生线程不运行或过早结束的问题。thread::spawn 的返回类型是 JoinHandle<T>JoinHandle<T> 是一个拥有的值,当我们在其上调用 join 方法时,它会等待其线程完成。列表 16-2 展示了如何使用我们在列表 16-1 中创建的线程的 JoinHandle<T>,以及如何调用 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<T>,以确保线程运行完成

对句柄调用 join 会阻塞当前运行的线程,直到该句柄所代表的线程终止。阻塞一个线程意味着该线程被阻止执行工作或退出。由于我们将 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 闭包与线程

我们经常在传递给 thread::spawn 的闭包中使用 move 关键字,因为这样闭包会获得它从环境中使用的值的所有权,从而将这些值的所有权从一个线程转移到另一个线程。在 第 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:一个带闭包的线程,该闭包试图捕获主线程中一个丢弃了 vv 的引用

如果 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-3 的修改如列表 16-5 所示,它将按照我们期望的方式编译和运行。

文件名: 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 提供的方法,接下来看看一些可以使用线程的场景。