使用线程同时运行代码

RustRustBeginner
立即练习

This tutorial is from open-source community. Access the source code

💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版

简介

欢迎来到「使用线程同时运行代码」实验。本实验是 《Rust 程序设计语言》 的一部分。你可以在 LabEx 中练习 Rust 技能。

在本实验中,我们将探讨编程中线程的概念,以及如何使用线程同时运行代码,这既能提高性能,也会增加复杂性和潜在问题,比如竞态条件、死锁以及难以重现的错误。


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL rust(("Rust")) -.-> rust/DataStructuresandEnumsGroup(["Data Structures and Enums"]) rust(("Rust")) -.-> rust/BasicConceptsGroup(["Basic Concepts"]) rust(("Rust")) -.-> rust/ControlStructuresGroup(["Control Structures"]) rust(("Rust")) -.-> rust/FunctionsandClosuresGroup(["Functions and Closures"]) rust(("Rust")) -.-> rust/MemorySafetyandManagementGroup(["Memory Safety and Management"]) rust/BasicConceptsGroup -.-> rust/variable_declarations("Variable Declarations") rust/ControlStructuresGroup -.-> rust/for_loop("for Loop") rust/FunctionsandClosuresGroup -.-> rust/function_syntax("Function Syntax") rust/FunctionsandClosuresGroup -.-> rust/expressions_statements("Expressions and Statements") rust/MemorySafetyandManagementGroup -.-> rust/lifetime_specifiers("Lifetime Specifiers") rust/DataStructuresandEnumsGroup -.-> rust/method_syntax("Method Syntax") subgraph Lab Skills rust/variable_declarations -.-> lab-100437{{"使用线程同时运行代码"}} rust/for_loop -.-> lab-100437{{"使用线程同时运行代码"}} rust/function_syntax -.-> lab-100437{{"使用线程同时运行代码"}} rust/expressions_statements -.-> lab-100437{{"使用线程同时运行代码"}} rust/lifetime_specifiers -.-> lab-100437{{"使用线程同时运行代码"}} rust/method_syntax -.-> lab-100437{{"使用线程同时运行代码"}} end

使用线程同时运行代码

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

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

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

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

编程语言以几种不同的方式实现线程,并且许多操作系统都提供了一种语言可以调用的 API 来创建新线程。Rust 标准库使用1:1的线程实现模型,即一个程序为每个语言线程使用一个操作系统线程。也有一些 crate 实现了其他线程模型,它们与 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));
    }
}

清单 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 关键字,因为这样闭包就会获取其从环境中使用的值的所有权,从而将这些值的所有权从一个线程转移到另一个线程。在“使用闭包捕获环境”中,我们在闭包的上下文中讨论了 move。现在我们将更多地关注 movethread::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();
}

清单 16-3:尝试在另一个线程中使用主线程创建的向量

闭包使用了 v,所以它会捕获 v 并使其成为闭包环境的一部分。因为 thread::spawn 在一个新线程中运行这个闭包,我们应该能够在那个新线程中访问 v。但是当我们编译这个示例时,会得到以下错误:

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 || {
  |                                ++++

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); // 糟了!

    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。我们会得到这个编译器错误:

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

Rust 的所有权规则又一次帮了我们!我们从清单 16-3 的代码中得到一个错误,是因为 Rust 比较保守,只在线程中借用 v,这意味着主线程理论上可以使派生线程的引用无效。通过告诉 Rust 将 v 的所有权移动到派生线程,我们向 Rust 保证主线程不会再使用 v 了。如果我们以同样的方式修改清单 16-4,那么当我们试图在主线程中使用 v 时,就会违反所有权规则。move 关键字覆盖了 Rust 保守的默认借用行为;它不会让我们违反所有权规则。

既然我们已经介绍了线程是什么以及线程 API 提供的方法,让我们来看看一些可以使用线程的场景。

总结

恭喜你!你已经完成了“使用线程同时运行代码”实验。你可以在 LabEx 中练习更多实验来提升你的技能。