跳转到内容

Rust 并发

并发(concurrency),也就是同时运行多项工作,是很多语言里真正危险的地方:两个线程同时操作同一份数据,可能会把它弄坏,而且这种 bug 极难追查。借用规则要防止的正是这类问题,而这层保护也直接延伸到了线程上。

use std::thread;
fn main() {
let handle = thread::spawn(|| {
for i in 1..=3 {
println!("线程:{i}");
}
});
for i in 1..=3 {
println!("主线程:{i}");
}
handle.join().unwrap(); // 等待线程结束
}
▶ 动手试一试

thread::spawn 接收一个闭包,在一个新线程上运行它,和其他代码并行执行。.join() 会一直阻塞,直到那个线程结束。如果不调用它,main 可能在新线程还没来得及运行时就已经退出了。

传给 thread::spawn 的闭包必须完全拥有它用到的数据,因为它可能在原来的作用域结束之后才运行。加上 move 关键字就能强制做到这一点:

use std::thread;
fn main() {
let message = String::from("来自线程的问候");
let handle = thread::spawn(move || {
println!("{message}");
});
handle.join().unwrap();
}

输出:

来自线程的问候
▶ 动手试一试

如果不加 move,Rust 不会编译这段代码:它没法保证新线程真正运行时 message 仍然有效。move 把 message 的所有权转移到闭包里,这就是你在所有权一页见过的那种“移动”,只不过这次是交给一个线程,而不是另一个变量。

普通引用没法跨线程使用(它们不能比创建它们的作用域活得更久),所以要在线程之间共享数据,就要把你已经见过的两样东西组合起来:用 Arc(来自智能指针一页)实现共享所有权,再用 Mutex(互斥器)保证同一时间只有一个线程能碰这份数据:

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..5 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("结果:{}", *counter.lock().unwrap());
}

输出:

结果:5
▶ 动手试一试

counter.lock() 会一直等到能安全地独占这个值,完成更新后再自动释放它。五个线程给同一个计数器加 1,有 Mutex 在,它们永远不会互相踩脚:你每次都能稳稳地得到 5,而不是因为某次更新丢失而得到一个更小的数。

给共享数据加锁是线程协作的一种方式。另一种常见方式是干脆不共享,而是让线程之间互相发送消息。这就是信道一页要讲的内容。

在大多数语言里,没有什么能阻止你忘记给 Mutex 加锁,或者不小心把不该共享的引用跨线程共享出去。Rust 的编译器会真正强制执行这些规则:会在线程之间造成数据竞争的代码,干脆就无法编译。这和你在本教程前面学到的所有权与借用系统是同一套东西,只是从单个作用域用到了线程上。

用两个线程计算 1 到 100 的和:一个线程算 1 到 50 的和,另一个算 51 到 100 的和,然后由 main 把两个结果加起来。提示:线程的闭包可以返回一个值,handle.join().unwrap() 会把这个值交还给你。

查看答案
use std::thread;
fn main() {
let first = thread::spawn(|| {
let total: u32 = (1..=50).sum();
total
});
let second = thread::spawn(|| {
let total: u32 = (51..=100).sum();
total
});
let a = first.join().unwrap();
let b = second.join().unwrap();
println!("{a} + {b} = {}", a + b);
}

输出:

1275 + 3775 = 5050
▶ 动手试一试

这两个闭包没有用到任何外部变量,所以不需要 move。两个线程同时运行,join() 会等待每个线程结束,并交回它的结果。

启动三个线程。每个线程往同一个共享的 Vec<String> 里添加一行,比如 "线程 2 来过这里"。等所有线程都结束后,按排序后的顺序打印这些行。提示:像计数器的例子那样使用 Arc<Mutex<Vec<String>>>,最后对一份副本排序,因为线程结束的顺序是不确定的。

查看答案
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let book = Arc::new(Mutex::new(Vec::new()));
let mut handles = vec![];
for id in 1..=3 {
let book = Arc::clone(&book);
handles.push(thread::spawn(move || {
book.lock().unwrap().push(format!("线程 {id} 来过这里"));
}));
}
for handle in handles {
handle.join().unwrap();
}
let mut lines = book.lock().unwrap().clone();
lines.sort();
for line in lines {
println!("{line}");
}
}

输出:

线程 1 来过这里
线程 2 来过这里
线程 3 来过这里
▶ 动手试一试

把 lines.sort() 这一行删掉,多运行几次:顺序可能会变。排序能保证你每次都得到同样的输出。

  • thread::spawn(closure) 在一个新线程上运行闭包;.join() 等待它结束。
  • 传给线程的闭包通常需要 move,以便完全拥有它用到的数据。
  • 用 Arc(共享所有权)加上 Mutex(独占访问)在线程之间共享数据:counter.lock() 让你安全地、一次一个地访问这个值。
  • 会造成数据竞争的代码,编译器在编译时就会拒绝,而不是等到运行时。

小测验

1. 为什么传给 thread::spawn 的闭包通常需要 move 关键字?

2. Arc<Mutex<T>> 是用来做什么的?

3. 如果忘了对线程句柄调用 .join(),可能会出什么问题?

4. 为什么同一个程序每次运行时,“主线程:”和“线程:”这两种行的顺序可能不同?

得分: 0 / 4