键盘快捷键

按 ← 或 → 在章节之间导航

按 S 或 / 在书中搜索

按 ? 显示此帮助

按 Esc 隐藏此帮助

使用线程同时运行代码

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

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

  • 竞态条件(race conditions),即线程以不一致的顺序访问数据或资源
  • 死锁(deadlocks),即两个线程互相等待对方,导致两个 线程都无法继续执行
  • 只在特定情况下发生、难以稳定复现和修复的 bug

Rust 试图减轻使用线程带来的负面影响, 但在多线程环境中编程仍然需要谨慎思考, 并要求采用与单线程程序不同的 代码结构。

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

使用 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));
    }
}
示例 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。

如果你运行这段代码,只看到主线程的输出,或者没有看到任何 交错输出,请尝试增大范围中的数值,以便为操作系统 在线程之间切换创造更多机会。

等待所有线程完成

示例 16-1 中的代码不仅在大多数情况下由于主线程结束而 过早地停止了派生线程,而且由于不能保证 线程运行的顺序,我们也无法保证派生线程 根本有机会运行!

我们可以通过将 thread::spawn 的返回值保存在变量中 来修复派生线程不运行或过早结束的问题。 thread::spawn 的返回类型是 JoinHandle<T>。JoinHandle<T> 是一个 拥有所有权的值,当我们对其调用 join 方法时,它会等待 对应线程完成。示例 16-2 展示了如何使用在示例 16-1 中创建的 线程的 JoinHandle<T>,以及如何调用 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();
}
示例 16-2:保存 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 循环之前,会发生什么?如下所示:

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 闭包

我们经常将 move 关键字与传递给 thread::spawn 的闭包一起使用, 因为闭包随后会获取它从环境中使用的值的所有权, 从而将这些值的所有权从一个线程转移到 另一个线程。在第 13 章的“捕获引用或移动所有权”一节中,我们在闭包的上下文中讨论了 move。现在我们将 更集中地讨论 move 与 thread::spawn 之间的交互。

请注意,在示例 16-1 中,我们传递给 thread::spawn 的闭包不接收任何 参数:我们没有在派生线程的代码中使用主线程的任何数据。 要在派生线程中使用主线程的数据,派生线程的 闭包必须捕获它所需要的值。示例 16-3 展示了 在主线程中创建一个 vector 并在派生线程中使用它的尝试。 不过,这暂时还无法工作,稍后你就会看到原因。

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();
}
示例 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 的引用 失效的场景。

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();
}
示例 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 的修改 将按我们的预期编译并运行。

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();
}
示例 16-5:使用 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 提供的方法,让我们来看看一些可以使用线程的 场景。