进程与线程
学过操作系统,或背过“八股文”的同学应该对进程与线程的概念不陌生:
- 一个操作系统下运行多个进程(process),一个进程下又运行多个线程(thread)。
将程序的计算拆成多个部分可以使用多核CPU的优势、改善性能。但是安全性很值得担忧:
- 竞争状态(Race conditions),多个线程以不同的顺序访问资源
- 死锁(Deadlocks)
- 只会发生在特定情况且难以稳定重现和修复的 bug
线程的创建
不同的编程语言有不同的方法实现线程。很多操作系统提供了创建新线程的API。
如果编程语言调用操作系统的API来创建线程,这种叫 1:1模型,即一个OS线程对应一个语言线程。
还有很多语言提供了自己的特殊实现。编程语言自己提供的线程称为 绿色线程,这种语言会在不同数量的 OS 线程上下文中执行它们。因此,这种模型被称为 M:N 模型:M 个绿色线程对应 N 个 OS 线程。
什么是运行时?
此处的运行时(Runtime)是指,编程语言会为二进制文件中的代码提供一个运行环境。比如,你想打篮球,但是没有场地,简单的话你可以拿一个脸盆当做球框;如果复杂一点,你可以叫一个施工队给你盖一个篮球场。这里的脸盆和篮球场就是所谓的 runtime。
那么很明显:
- 运行时越复杂、庞大,提供的功能也就越多。
- 运行时越简单,成本越低。
虽然很多语言觉得增加运行时来换取更多功能没有什么问题,但是 Rust 需要做到几乎没有运行时,同时为了保持高性能必须能够调用 C 语言,这点也是不能妥协的。
因此,Rust 标准库只提供了 1:1 线程模型实现。由于 Rust 是较为底层的语言,如果你愿意牺牲性能来换取抽象,以获得对线程运行更精细的控制及更低的上下文切换成本,你可以使用实现了 M:N 线程模型的 crate。
使用spawn创建新线程
调用 thread::spawn
函数并传递一个闭包,闭包中是线程代码。
use std::thread;
use std::time::Duration;
fn main () {
thread::spawn(|| {
for i in 1..10 {
println!("sub thread: {}", i);
thread::sleep(Duration::from_millis(10));
}
});
for i in 1..5 {
println!("main thread: {}", i);
thread::sleep(Duration::from_millis(10));
}
}
要注意的是,当主线程结束时,其他线程也会结束。
使用join等待所有线程结束。
主线程结束,其他线程会立刻结束。
可以通过将 thread::spawn
的返回值储存在变量中。返回值的类型是 JoinHandle
,这是一个拥有所有权的值,当对其调用 join
方法时,它会等待其线程结束。
use std::thread;
use std::time::Duration;
fn main () {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("sub thread: {}", i);
thread::sleep(Duration::from_millis(10));
}
});
for i in 1..5 {
println!("main thread: {}", i);
thread::sleep(Duration::from_millis(10));
}
handle.join();
}
线程与move闭包
move
闭包,我们曾在第十三章简要的提到过,其经常与 thread::spawn
一起使用,因为它允许我们在一个线程中使用另一个线程的数据。
可以在参数列表前使用 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();
}
如果不在闭包前使用 move 关键字,编译器会报错。Rust会推断如何捕获v,由于闭包中只使用了v的引用,闭包尝试借用v。但是线程不知道要执行多久,那么v的声明周期可能在线程结束之前就结束了,因此会报错。
这也说明了所有权规则在帮助我们尽可能实现并发安全。
现在我们对线程和线程 API 有了基本的了解,让我们讨论一下使用线程实际可以 做 什么吧。