16.1 使用多线程同时运行代码

16.1.1. 什么是并发

  • Concurrent 指的是程序的不同部分之间独立运行
  • Parallel(并行)指的是程序的不同部分同时运行

The Rust Programming Language 中用这么一段话形容了 Rust 对并发的支持:

Fearless concurrency(无畏并发)
安全高效地处理并发编程是 Rust 的另一个主要目标。随着越来越多的计算机利用其多个处理器,并发编程(程序的不同部分独立执行)和并行编程(程序的不同部分同时执行)变得越来越重要。从历史上看,在这些环境中编程一直是困难且容易出错的——Rust 希望改变这一点。

最初,Rust 团队认为确保内存安全和防止并发问题是两个独立的挑战,需要用不同的方法来解决。随着时间的推移,团队发现所有权和类型系统是一组强大的工具,可以帮助管理内存安全和并发问题!

通过利用所有权和类型检查,许多并发错误是 Rust 中的编译时错误,而不是运行时错误。因此,不正确的代码会被拒绝编译并显示解释问题的错误,而不是让你花费大量时间尝试重现发生运行时并发错误的确切情况。因此,你可以在处理代码时修复代码,而不是在将代码交付生产后修复。

我们将 Rust 的这一方面称为“无畏并发”。无畏并发允许你编写没有细微错误的代码,并且易于重构而不会引入新的错误。

其中最重要的一句话就是:无畏并发允许你编写没有细微错误的代码,并且易于重构而不会引入新的错误

注:这章所指的“并发”泛指 Concurrent 和 Parallel。

16.1.2. 进程和线程

在大部分现代的操作系统里,代码运行在进程(process)中,系统同时管理多个进程。在你的程序里,各独立部分可以同时运行,运行这些独立部分的就是线程(thread)。

由于多个线程是可以同时运行的,所以我们通常会把程序的计算拆分成为多个线程来同时运行。这样做有利有弊:

  • 提升性能表现
  • 增加复杂性:无法保证各线程的执行顺序

16.1.3. 多线程可导致的问题

  • 竞争状态(race condition):线程以不一致的顺序访问数据或资源
  • 死锁(deadlock):两个线程彼此等待对方使用完所持有的资源,线程无法继续
  • 引起只在某些情况下发生的 Bug,很难可靠地复现 Bug 并修复。

16.1.4. 实现线程的方式

  • 通过调用操作系统的 API 来创建线程,叫做1:1 模型,也就是一个操作系统的线程对应一个语言的线程。它的优点是需要较小的运行时。

  • 语言自己可以实现线程(也叫绿色线程),是M:N 模型。也就是 M 个绿色线程对应 N 个系统线程。它需要比较大的运行时。

每一种模型都有其自身的优势和缺点,Rust 需要权衡运行时的支持。

除了汇编语言,其他的编程语言都有一定的运行时。

即使是 C/C++,运行时的功能极少,都有较小的运行时,所以它们能生成较小的二进制文件,并且使该语言在多种场景下都可以与其他语言组合使用。

而有一些语言增加运行时来提供更多的功能,比如 Java、C# 和 Go。

对于 Rust 来说,它尽可能保持几乎没有运行时的状态,这样就能方便地与 C 语言进行交互,并且获得较高的性能。所以Rust 标准库仅提供 1:1 模型的线程

但是由于 Rust 具有良好的底层抽象能力,在社区里也有很多支持M:N 模型的第三方包。

16.1.5. 通过 spawn 创建线程

通过 thread::spawn 函数可以创建新线程。它有一个参数,接收闭包作为在新线程里运行的代码。

看个例子:

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));
    }
}
  • 这个闭包没有参数。里面的逻辑很简单:从 1 循环到 10(不包括 10),把数打印出来,每次循环都有一个 sleep 函数,其参数是 Duration::from_millis(1),表示暂停一毫秒。

  • 在主线程里也有一个循环,从 1 循环到 5(不包括 5),把数打印出来,一样的每次循环都暂停 1 毫秒。

由于新创建的线程从 1 到 10,而主线程是从 1 到 5,所以主线程会先执行完,而 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!

在这次样例里,主线程输出完 4 后就要结束了;分线程还能再跑一会儿,在程序关闭前又输出了两行(hi number 4hi number 5)。

这样写不能保证另外一个线程能够完成它的执行,这时候就需要 JoinHandle

16.1.6. 通过 JoinHandle 来等待所有线程完成

thread::spawn 函数的返回类型是 JoinHandle,这个类型持有已创建线程的句柄的所有权,通过调用其 join 方法,可以等待它对应的线程完成。

调用 handle.join() 会阻塞当前运行线程的执行,直到 handle 所表示的线程终结。

看个例子:

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();
}
  • thread::spawn 的返回值赋给变量 handle
  • 最后使用 handle 上的 join 方法,再调用 unwrap。它会阻塞当前线程——在这个例子中就是主线程——直到 handle 所对应的线程(就是新创建的线程)执行完毕。
    使用 unwrap 的原因是 handle.join() 的返回值是一个 Result 类型,如果成功执行就返回 Ok(T)T 是线程的返回值;如果线程在执行时发生了恐慌就返回 Err(e)e 是错误信息。
    如果你确信线程不会 panic,可以直接调用 unwrap 来简化代码,从而忽略 Err 分支。

输出(线程交错顺序不确定;以下为一次代表性的本地运行结果):

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!
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 4 from the main thread!" 之后会在 join 上等待,因此分线程不会被主循环结束打断,可以继续输出到 9。

让我们看看当将 handle.join() 移到 main 中的 for 循环之前会发生什么,如下所示:

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));
    }
}

输出:

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!

这次就是先执行完分线程,才执行主线程的循环。

使用 move 闭包

move 闭包通常和 thread::spawn 函数一起使用,它允许你使用其他线程的数据。也就是说,在创建线程时,把值的所有权从一个线程转到另一个线程里。

看个例子:

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}
  • 在主函数里创建了 Vector,命名为 v
  • 新线程调用了 v,打印出 v
  • 最后 handle.join().unwrap(); 让主线程等待分线程结束。

输出:

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

报错信息提到闭包里借用了 v(编译器推断出闭包使用 v 的代码只需要借用就可以了),但是闭包的生命周期可能比 v 还要长。

比如说:

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();
}

在闭包作为分线程在执行时,主线程可能就已经执行到 drop(v);v 丢弃了,那么分线程里的 v 就没法使用了。

最简单的方法就是把 v 的所有权移交给闭包。在管道符 || 前写上 move 关键字即可:

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();
}

这样写的缺点就是主线程就使用不了 v 了。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐