写点什么

Rust 从 0 到 1- 并发 - 线程

用户头像
关注
发布于: 2 小时前
Rust从0到1-并发-线程

Rust 的另一个主要目标是可以安全和高效的编写并发程序(Concurrent programming)。并发程序,意思是程序的不同部分相互独立的执行;而 并行程序(parallel programming),意思是程序不同部分同时执行。随着计算机在多处理器方面的增强,它们变得越来越重要。同时,在过往,编写并发或并行程序一直是相对困难且容易出错的:Rust 希望能改变这一点。

最初,Rust 团队认为确保内存安全和防止并发问题是两类需要使用不同方法独立应对的挑战。随着时间的推移,Rust 团队发现所有权和类型系统是用于解决内存安全和并发问题的强大工具集!利用所有权和类型的检查,很多并发错误在 Rust 中都是编译时错误,而不是运行时错误。因此,不正确的代码将被拒绝编译并通过错误信息提示出来,而不是花费大量时间尝试重现运行时产生的并发问题。这样,我们就可以尽早的在开发时发现和修复问题,而不是等到部署到生产环境以后。我们给 Rust 的这方面特性起了一个绰号“无畏并发(fearless concurrency)”。无畏并发让我们的代码避免产生难以发现的 bug 并可以放心的进行重构而无需担心引入新的 bug。


注意:由于这并不是一本专门讲解并发和并行的书。简单起见,在这里我们将很多问题都统称为并发问题,而不更准确的进一步区分为并发和并行。



很多语言所提供的处理并发问题的解决方法是相对不全面的。譬如,Erlang 在并发之间发送消息(message-passing)的功能非常优雅,但用于线程间共享状态的方法却难以让人理解。对于高级语言来说,仅提供可能的解决方案的一个子集是一种合理的选择,因为高级语言所提供的一些特性是通过牺牲一些控制来换取抽象而获得的。而底层语言则期望在任何场景下尽可能提供最好的性能,并且减少对硬件的抽象。因此,Rust 针对问题模型提供了多种工具,让我们可以根据实际情况和需求进行选择。在本章中我们将讨论以下内容:

  • 如何创建用于同时运行多段代码的线程。

  • 并发间的消息传递(message-passing concurrency),通过通道(channel,类似 Go)在线程间传递消息。

  • 并发间的状态共享(shared-state concurrency),多个线程共享同一片数据。

  • Sync 和 Send trait,让我们自己编写的代码也可以像标准库的代码一样可以获得 Rust 对并发的保障。

下面我们首先介绍线程。在大部分现代操作系统中,程序在进程(process)中运行,操作系统负责同时管理多个进程。在程序中我们可以通过线程(threads)同时运行多个独立的程序片段。将程序拆分为多个线程运行可以(但不是必然)改善性能,因为我们可以同时执行多个任务,但这也会带来复杂性。因为线程是同时运行的,并不保证不同线程中代码的执行顺序,这就会导致一些问题,譬如:

  • 资源竞争(race conditions),线程无序的访问数据或资源

  • 死锁(Deadlocks),两个线程互相等待彼此释放其所占用的资源,导致双方都无法继续执行下去

  • 只会在特定情况下才会发生的 bug,难以重现和确切的修复

Rust 尝试减轻使用线程带来的负面影响,但是编写多线程程序仍然需要非常小心,并且其代码结构也与编写单线程程序不同。

编程语言通过几种不同方式实现了线程。很多操作系统提供了创建线程的 API,这种直接调用操作系统 API 创建线程的方式有时被称为 1:1,即一个操作系统线程对应编程语言中的一个线程。但也有很多编程语言提供了自己的线程实现,这类线程被称为绿色线程(green threads),绿色线程会运行在与之不等数量的操作系统线程中。为此,使用绿色线程的方式被称为 M:N :M 个绿色线程对应 N 个操作系统线程,这里 M 和 N 不一定需要相同。每种方式都有其优略,对于 Rust 来说所考虑的做重要的取舍是运行时的支撑(runtime support)。运行时是一个容易混淆的术语,它在不同上下文中可能具有不同的含义。

在此文中,运行时的意思是包含在二进制文件中的语言代码。除了汇编语言以外,不同语言总会有一些运行时代码,大小也不相同。因此,通常我们说一个语言 “没有运行时”,一般代表 “运行时代码比较小”。越小的运行时拥有的功能也越少,但其优点是对应的二进制也更小,使其更易于在更多场景下与其它语言相结合。虽然对于很多语言来说选择增加运行时来换取更多功能是没太大问题的,但是 Rust 为了保证高性能,需要做到几乎没有运行时并且能够调用 C 语言。

绿色线程需要比较大的运行时来管理线程。因此,Rust 标准库只提供了 1:1 线程模型实现。由于 Rust 是较为底层的语言,有些 crates 实现了 M:N 线程模型,如果你愿意牺牲一些开销来换取其他方面的功能,譬如,对线程运行更多的控制、更低的上下文切换成本等,可以选择使用它们。

我们解释了 Rust 中是如何定义线程的,下面让我们开始探索标准库中提供的线程相关的 API 。

创建线程

我们可以通过调用 thread::spawn 函数并传递一个包含希望在新线程中运行的代码的闭包(前面章节介绍过闭包)做为参数。参考下面的例子:

use std::thread;use std::time::Duration;
fn main() { thread::spawn(|| { for i in 1..10 { println!("hi number {} from the spawned thread!", i); thread::sleep(Duration::from_millis(1)); } });
for i in 1..5 { println!("hi number {} from the main thread!", i); thread::sleep(Duration::from_millis(1)); }}
复制代码

在上面的例子中我们同时在主线程和新线程中打印了一些信息。注意,当主线程结束时,新线程也会结束,不管其是否执行完毕。其输出类似下面这样:

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 会强制线程停止运行一小段时间,这会给其他线程运行的机会。这些线程可能会轮流运行,但并不能保证一定如此,这依赖于操作系统是如何调度线程的。在上面运行的结果中我们可以看到,虽然新创建线程进行打印的语句位于程序的开头,但是主线程首先执行了打印;甚至即便我们在新创建的线程里的代码逻辑是打印到 9 才结束 ,但是它在主线程结束之前只打印到了 5。如果我们在运行的结果中我们只看到了主线程的输出,或者主线程和新线程没有交叉打印,可以尝试增加区间的范围来创造更多操作系统切换线程的机会。

等待所有线程结束

前面的例子中由于主线程结束,我们新创建的线程也会被结束,而且大部分情况下是提前结束,甚至完全不能保证我们创建的线程会被执行。其原因在于线程运行的顺序无法保证!

我们可以通过将 thread::spawn 的返回值储存在变量中来修复创建的线程未执行完或者没有执行的问题。thread::spawn 的返回值类型是 JoinHandle。JoinHandle 类型的值拥有所有权,当调用其 join 方法时,它会等待线程结束。参考下面的例子:

use std::thread;use std::time::Duration;
fn main() { let handle = thread::spawn(|| { for i in 1..10 { println!("hi number {} from the spawned thread!", i); thread::sleep(Duration::from_millis(1)); } });
for i in 1..5 { println!("hi number {} from the main thread!", i); thread::sleep(Duration::from_millis(1)); }
handle.join().unwrap();}
复制代码

在上面的例子中,调用 handle 的 join 方法会阻塞当前线程直到 handle 对应的线程结束。阻塞(Blocking) 线程意味着阻止线程执行或退出。因为我们是在主线程的 for 循环之后调用的 join 方法,我们会看到类似下面的运行结果:

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() 移动到 for 循环之前会发生什么?参考下面的例子:

use std::thread;use std::time::Duration;
fn main() { let handle = thread::spawn(|| { for i in 1..10 { println!("hi number {} from the spawned thread!", i); thread::sleep(Duration::from_millis(1)); } });
handle.join().unwrap();
for i in 1..5 { println!("hi number {} from the main thread!", i); 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 一起使用,因为它允许我们在线程中使用另一个线程的数据。前面我们介绍过,可以通过使用 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 ,并在创建的新线程中尝试打印它,但如果我们尝试编译这个例子,会得到类似下面的错误:

$ 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 || {  |                                ^^^^^^^
error: aborting due to previous error
For more information about this error, try `rustc --explain E0373`.error: could not compile `threads`
To learn more, run the command again with --verbose.
复制代码

Rust 会推断(infers )如何获取变量  v,由于 println! 只需用到 v 的引用,因此闭包尝试借用  v。然而在这里会存在一个问题:Rust 无确定这个线程会执行多久,所以也无法确定在线程运行的时候 v 的引用是否一直有效。参考下面的例子:

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();}
复制代码

假设上面的代码可以正常运行,则我们创建的线程可能会被转移到后台并且在调用 handle.join() 之前没有机会运行;而在 handle.join() 之前我们调用了 drop(v) 丢弃了变量 v,导致 v 已经不再有效,那么在我们调用 handle.join()  之后线程开始执行时,v 已不再有效,所以其引用也无效的。这太糟了!我们可以参考编译器的错误提示来修复错误:

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 推断的应该借用。参照提示修改后的代码如下所示:

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();}
复制代码

那么如果使用了 move 关键字,同时又在主线程调用了 drop 会发生什么呢?move 在这种情况下能解决错误吗?答案是,不行。因为 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` trait5  | 6  |     let handle = thread::spawn(move || {   |                                ------- value moved into closure here7  |         println!("Here's a vector: {:?}", v);   |                                           - variable moved due to use in closure...10 |     drop(v); // oh no!   |          ^ value used here after move
error: aborting due to previous error
For more information about this error, try `rustc --explain E0382`.error: could not compile `threads`
To learn more, run the command again with --verbose.
复制代码

Rust 的所有权规则再一次发挥了作用!在未使用 move 关键字时,因为 Rust 是保守的,创建的线程中只会借用 v,而主线程理论上可能使其引用无效,这违反了所有权规则。而使用了 move 关键字后,我们告诉 Rust 将 v 的所有权移动到闭包中,代表我们告诉 Rust 在主线程中不会再使用 v,否则就会违反所有权规则。虽然 move 关键字改变了 Rust 默认保守的行为(借用),但也同时必须遵守所有权规则。

发布于: 2 小时前阅读数: 2
用户头像

关注

公众号"山 顽石"欢迎大家关注;) 2021.03.23 加入

IT老兵,关注Rust、Java、前端、架构、大数据、AI、算法等;平时喜欢美食、旅游、宠物、健身、篮球、乒乓球等。希望能和大家交流分享。

评论

发布
暂无评论
Rust从0到1-并发-线程