跳转到内容

Rust 共享状态(RwLock、原子类型)

在并发那一页,你用 Arc<Mutex<T>> 在线程之间共享了一个计数器:Arc 让多个线程都能拥有这个值,Mutex 保证同一时间只有一个线程能碰它。在信道那一页,你干脆不共享,改为发送消息。这两样都是好工具,用它们几乎什么都能做出来。

不过,Mutex 就像办公室厕所只有一把钥匙。够用是够用,可如果你只是想看看走廊里的公告栏,这就有点小题大做了。Rust 标准库还有另外几样工具,每一样都更适合某一类工作。这一页会介绍其中四种:作用域线程(scoped thread)、RwLock、原子类型(atomics),以及 OnceLock/LazyLock。你还会学到怎样避开编译器唯一帮不了你的那种 bug:死锁(deadlock)。

用 thread::spawn 时,通常需要 move,还经常需要 Arc。这是因为新创建的线程可能在 main 已经往下走之后还在运行,所以 Rust 不允许它借用你的局部变量。

std::thread::scope 用一个承诺解决了这个问题:在作用域里启动的每个线程,都会在作用域结束之前结束。 既然线程不可能比你的数据活得更久,它们就可以直接借用数据。不用 Arc,不用 move,也不用克隆。

use std::thread;
fn main() {
let shop = "街角小店";
let sales = vec![12, 7, 30, 5, 18, 22, 9, 14];
let (morning, afternoon) = sales.split_at(4);
let (am, pm) = thread::scope(|s| {
let am = s.spawn(|| morning.iter().sum::<i32>());
let pm = s.spawn(|| afternoon.iter().sum::<i32>());
(am.join().unwrap(), pm.join().unwrap())
});
println!("{shop}:上午 {am},下午 {pm},全天 {}", am + pm);
println!("还归我用:{:?}", sales);
}

输出:

街角小店:上午 54,下午 63,全天 117
还归我用:[12, 7, 30, 5, 18, 22, 9, 14]
▶ 动手试一试

具体发生了什么:

  • thread::scope(|s| { ... }) 打开一个作用域。在里面用 s.spawn(...) 启动线程,而不是 thread::spawn(...)。
  • 两个闭包借用了 morning 和 afternoon,它们只是 sales 的两个 slice。什么都没有被复制。
  • 作用域的闭包返回什么(这里是装着两个和的元组),thread::scope 的值就是什么。
  • 作用域结束后,sales 仍然归 main 所有,所以你可以继续使用它。

就算你忘了调用 join(),作用域也会在结尾的 } 处等它所有的线程结束。正是这个承诺让借用变得安全。

RwLock:多个读者,或者一个写者

Section titled “RwLock:多个读者,或者一个写者”

想象一家咖啡馆的菜单板。很多顾客可以同时看,这没问题。但店主改价格的时候,大家都得稍等一下,等新菜单挂好再看。

这就是 RwLock(读写锁,read-write lock)。它有两个入口:

  • read() 给你一个共享的读守卫(read guard)。很多线程可以同时持有它。
  • write() 给你一个独占的写守卫(write guard)。写者持有它的时候,其他人既不能读也不能写。

Mutex 不做这种区分:就算是两个读者,也得轮流来。下面是一个设置缓存,几个请求处理器读取它,管理员更新它一次:

use std::sync::{Arc, RwLock};
use std::thread;
struct Settings {
theme: String,
max_upload_mb: u32,
}
fn main() {
let settings = Arc::new(RwLock::new(Settings {
theme: String::from("light"),
max_upload_mb: 10,
}));
// 四个请求处理器同时读取设置。
let mut handles = Vec::new();
for id in 1..=4 {
let settings = Arc::clone(&settings);
handles.push(thread::spawn(move || {
let s = settings.read().unwrap(); // 共享:别人也能同时读
format!("请求 {id}:主题={},上传上限={}MB", s.theme, s.max_upload_mb)
}));
}
for handle in handles {
println!("{}", handle.join().unwrap());
}
// 管理员修改一次设置。
{
let mut s = settings.write().unwrap(); // 独占:其他人都得等
s.theme = String::from("dark");
s.max_upload_mb = 25;
} // 写守卫在这里被丢弃,锁又空出来了
let s = settings.read().unwrap();
println!("更新之后:主题={},上传上限={}MB", s.theme, s.max_upload_mb);
}

输出:

请求 1:主题=light,上传上限=10MB
请求 2:主题=light,上传上限=10MB
请求 3:主题=light,上传上限=10MB
请求 4:主题=light,上传上限=10MB
更新之后:主题=dark,上传上限=25MB
▶ 动手试一试

线程结束的顺序不固定,但输出每次都一样。因为每个线程只是返回自己那一行,而不是直接打印,main 再按 join 的顺序把它们打印出来。

什么时候 RwLock 比 Mutex 好? 读很频繁、写很少,而且每次读都要花点时间(比如在一个很大的缓存里查找)的时候。这时读者才是真正并排同时运行的。

什么时候不好? 如果每次读都很快,或者写一直在发生,RwLock 就只是一个更复杂的 Mutex。它要做额外的记录工作,而读者和写者大部分时间还是在互相等待。拿不准的时候,先用 Mutex。它更简单,如果以后测量发现这把锁是瓶颈,再换也不迟。

如果只是一个数字或者一个真/假标志,连锁都显得多余了。AtomicUsize、AtomicI64、AtomicBool 这些原子类型可以被很多线程同时安全地修改,完全不需要锁。CPU 会让每个操作在一个不可分割的步骤里完成,所以不会丢失任何更新。

下面有 8 个线程,每个线程记录 1000 次页面浏览。它们还会统计投票:先投一张赞成票,再投三张反对票,如此反复。总票数会变成负数,所以这里用的是 AtomicI64,而不是无符号类型:

use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
use std::thread;
fn main() {
let page_views = AtomicUsize::new(0);
let net_votes = AtomicI64::new(0);
thread::scope(|s| {
for _ in 0..8 {
s.spawn(|| {
for n in 0..1000 {
page_views.fetch_add(1, Ordering::Relaxed);
if n % 4 == 0 {
net_votes.fetch_add(1, Ordering::Relaxed); // 赞成票
} else {
net_votes.fetch_sub(1, Ordering::Relaxed); // 反对票
}
}
});
}
}); // 到这里,8 个线程都已经结束
println!("页面浏览量:{}", page_views.load(Ordering::Relaxed));
println!("净票数:{}", net_votes.load(Ordering::Relaxed));
}

输出:

页面浏览量:8000
净票数:-4000
▶ 动手试一试

注意 page_views 并不是 mut 的。原子类型通过共享引用来修改,这正是很多线程能同时使用它的原因。最常用的几个方法:

  • fetch_add(n, ordering) / fetch_sub(n, ordering) 做加法或减法,并返回旧值。
  • load(ordering) 读取当前值。
  • store(value, ordering) 覆盖当前值。

每个原子操作都要求一个 Ordering(内存顺序)。它告诉 CPU 和编译器:这个操作和它周围的内存操作之间,顺序要保持得多严格。完整的原理很深,但作为新手,你只需要记住两条:

  • 拿不准的时候,用 Ordering::SeqCst。 它是最严格、最安全的选择。
  • 简单的计数器用 Ordering::Relaxed 就行,比如上面那个只在最后才读取的计数器。总数依然完全正确。

如果有一天你想深入了解,免费的书 Rust Atomics and Locks(链接在页面最下方)把内存顺序讲得很清楚。

AtomicBool 很适合用来当“请停下”的信号。这里有一个后台工作线程一直在自动保存,直到 main 把标志翻过来:

use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
fn main() {
let stop = AtomicBool::new(false);
let report = thread::scope(|s| {
let worker = s.spawn(|| {
while !stop.load(Ordering::SeqCst) {
// 假装在这里自动保存文档
thread::sleep(Duration::from_millis(5));
}
"自动保存线程已正常停止"
});
thread::sleep(Duration::from_millis(30)); // main 在做别的事
stop.store(true, Ordering::SeqCst); // 请工作线程停下
worker.join().unwrap()
});
println!("{report}");
}

输出:

自动保存线程已正常停止
▶ 动手试一试

工作线程每转一圈循环就检查一次标志。它会做完手头这一步,然后自己退出。比起在保存文件到一半时把线程强行杀掉,这要温和得多。

OnceLock 和 LazyLock:只初始化一次

Section titled “OnceLock 和 LazyLock:只初始化一次”

有时你需要一个所有线程都能读取的全局值:一张查找表、应用的名字、启动时加载的配置。难点在于构建它:如果两个线程恰好在同一时刻都想去构建,怎么办?

std::sync::LazyLock 和 std::sync::OnceLock 就是来解决这个问题的。这个值会在第一次被需要时创建,而且只创建一次。如果在构建过程中有其他线程来要,它们会先等着,然后大家共享同一个值。

use std::collections::HashMap;
use std::sync::{LazyLock, OnceLock};
use std::thread;
// LazyLock:构建方法就写在这里,第一次使用时运行。
static CURRENCIES: LazyLock<HashMap<&str, &str>> = LazyLock::new(|| {
println!("正在构建货币表……");
HashMap::from([("US", "USD"), ("JP", "JPY"), ("DE", "EUR"), ("BR", "BRL")])
});
// OnceLock:一开始是空的,在使用的地方再决定怎样填充。
static STORE_NAME: OnceLock<String> = OnceLock::new();
fn store_name() -> &'static str {
STORE_NAME.get_or_init(|| {
std::env::var("STORE_NAME").unwrap_or_else(|_| String::from("Rusty 小店"))
})
}
fn main() {
let mut handles = Vec::new();
for country in ["US", "JP", "DE", "BR"] {
handles.push(thread::spawn(move || {
format!("{}在 {country} 用 {} 收款", store_name(), CURRENCIES[country])
}));
}
for handle in handles {
println!("{}", handle.join().unwrap());
}
}

输出:

正在构建货币表……
Rusty 小店在 US 用 USD 收款
Rusty 小店在 JP 用 JPY 收款
Rusty 小店在 DE 用 EUR 收款
Rusty 小店在 BR 用 BRL 收款
▶ 动手试一试

四个线程差不多同时都要用这张表,可“正在构建货币表……”只打印了一次。而且这些都是在整个程序运行期间一直存在的 static 值,所以线程根本不需要 Arc。

该选哪一个?

  • LazyLock:在声明的地方就知道怎样构建这个值。用起来就像一个普通的全局变量。
  • OnceLock:这个值要以后才能确定,比如由命令行参数决定。用 get_or_init(...) 或 set(...) 来填充它(如果已经填过了,set 会很有礼貌地返回失败)。

两者都属于标准库。LazyLock 是 Rust 1.80 才加入的,所以旧代码里经常用 once_cell 或 lazy_static 这两个 crate 来做同样的事。

编译器能阻止数据竞争,但有一种并发 bug 它抓不到:死锁。也就是线程之间互相等待,永远等下去,程序就这么僵住了。没有报错,没有崩溃,什么都没有。

最经典的成因是两个线程以不同的顺序获取两把锁。想象两个人都需要同时拿到笔和笔记本。一个人抓住了笔,另一个人抓住了笔记本,然后两人都客客气气地等对方先放手。永远等下去。

// 线程 A // 线程 B
let from = alice.lock().unwrap(); let from = bob.lock().unwrap();
let to = bob.lock().unwrap(); let to = alice.lock().unwrap();
// A 拿着 alice,等着 bob。 // B 拿着 bob,等着 alice。卡死了!

三个习惯能让你远离死锁:

  1. 总是按同样的顺序获取锁。 如果每个线程都先锁 alice 再锁 bob(比如按账户 ID 排序),就不可能形成循环等待。
  2. 让持有锁的范围尽量短。 只在读取或修改数据所需的时间里持有锁。千万不要在做慢操作时持有锁,比如网络请求、写文件或者很长的计算。在这整段时间里,其他线程都只能干等。
  3. 尽早丢弃守卫。 守卫被丢弃时就会解锁。你可以用一个内部代码块 { ... } 让它结束(就像上面 RwLock 的写者那样),也可以自己调用 drop(guard)。

下面是第 3 个习惯的实际应用。每个工作线程从共享队列里取一个任务,先解锁队列,然后才去做慢的那部分:

use std::sync::Mutex;
use std::thread;
fn main() {
let queue = Mutex::new(vec!["调整照片大小", "发送邮件", "生成报告"]);
let finished = Mutex::new(Vec::new());
thread::scope(|s| {
for _ in 0..3 {
s.spawn(|| {
let mut jobs = queue.lock().unwrap();
let job = jobs.pop();
drop(jobs); // 在慢操作之前先解锁队列
if let Some(job) = job {
let result = format!("完成:{job}"); // 假设这一步要花好几秒
finished.lock().unwrap().push(result);
}
});
}
});
// 作用域已经结束,不再有线程使用 `finished`。
let mut done = finished.into_inner().unwrap();
done.sort(); // 线程结束的顺序不固定
for line in done {
println!("{line}");
}
}

输出:

完成:发送邮件
完成:生成报告
完成:调整照片大小
▶ 动手试一试

如果没有 drop(jobs),守卫会一直活到闭包结束。那样“慢”的工作就会在队列仍然被锁住的时候进行,三个工作线程只能一个接一个地运行,而不是同时并排运行。

还有一个隐蔽的情况需要知道:在 while let Some(job) = queue.lock().unwrap().pop() { ... } 里,守卫会活过整个循环体。也就是说,你处理任务的时候,队列一直是锁着的。应该像上面的例子那样,先用一条单独的 let 语句把任务取出来。

你想要…… 就用
把工作或结果从一个线程发送到另一个线程 信道(mpsc)
让很多线程读取和修改同一个值 Arc<Mutex<T>>
共享读得多、改得少的数据 Arc<RwLock<T>>
计数,或者共享一个简单的开/关标志 原子类型(AtomicUsize、AtomicBool 等)
构建一次全局值,之后只读取 LazyLock 或 OnceLock
让线程借用局部数据,不用 Arc thread::scope

这些工具可以随意组合。这一页的例子里就已经把 thread::scope 和原子类型、和 Mutex 搭配使用过了。挑最简单、又合适的那个就好,不用担心选“错”:以后要换,通常只是个小改动。

在 thread::spawn 里借用局部变量。 这段代码看起来没什么问题:

use std::thread;
fn main() {
let sales = vec![12, 7, 30, 5];
let handle = thread::spawn(|| {
let total: i32 = sales.iter().sum();
total
});
println!("total: {}", handle.join().unwrap());
}
error[E0373]: closure may outlive the current function, but it borrows `sales`, which is owned by the current function
--> src/main.rs:6:32
|
6 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `sales`
7 | let total: i32 = sales.iter().sum();
| ----- `sales` is borrowed here
|
help: to force the closure to take ownership of `sales` (and any other referenced variables), use the `move` keyword

你知道自己马上就会调用 join(),但 thread::spawn 并不知道。在它看来,这个线程可能会永远运行下去。修复方法有两种:加上 move(这样之后 main 就不能再用 sales 了),或者改用可以借用的 thread::scope:

let total: i32 = thread::scope(|s| s.spawn(|| sales.iter().sum()).join().unwrap());

忘了用 Arc。 没有 Arc 的话,第一个线程的 move 闭包就把 Mutex 拿走了,后面的线程什么也拿不到:

use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Mutex::new(0);
let mut handles = vec![];
for _ in 0..3 {
handles.push(thread::spawn(move || {
*counter.lock().unwrap() += 1;
}));
}
for handle in handles {
handle.join().unwrap();
}
}
error[E0382]: use of moved value: `counter`
--> src/main.rs:9:36
|
5 | let counter = Mutex::new(0);
| ------- move occurs because `counter` has type `std::sync::Mutex<i32>`, which does not implement the `Copy` trait
...
8 | for _ in 0..3 {
| ------------- inside of this loop
9 | handles.push(thread::spawn(move || {
| ^^^^^^^ value moved into closure here, in previous iteration of loop

用 Arc::new(...) 把它包起来,然后在循环开头克隆这个 Arc,就像并发那一页那样。或者改用 thread::scope,把 move 去掉。如果你用 Rc 代替 Arc,会得到 error[E0277]: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely。Rc 只能在单个线程里用(见智能指针)。

创建一个装着 1 到 1000 的 Vec。用 thread::scope 把它分成 4 块,每块 250 个数,每块在自己的线程里求和。打印每一块的和以及总和。提示:numbers.chunks(250) 会给你这些 slice。把句柄收集到一个 Vec 里,再按顺序 join。

查看答案
use std::thread;
fn main() {
let numbers: Vec<u64> = (1..=1000).collect();
let sums: Vec<u64> = thread::scope(|s| {
let mut handles = Vec::new();
for chunk in numbers.chunks(250) {
handles.push(s.spawn(move || chunk.iter().sum::<u64>()));
}
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
println!("每块的和:{:?}", sums);
println!("总和:{}", sums.iter().sum::<u64>());
}

输出:

每块的和:[31375, 93875, 156375, 218875]
总和:500500
▶ 动手试一试

这里的 move 只是把 chunk(一个借用来的小 slice)移动进每个线程。vector 本身只是被借用,并没有被移动,所以作用域结束后你还可以继续用 numbers。

一家网店给每个订单编号,从 1000 开始。启动 4 个线程,每个线程从同一个共享的 AtomicU64 里领 3 个订单号。把这 12 个号码收集起来,排序后打印。提示:fetch_add(1, Ordering::SeqCst) 返回的是加之前的值,所以每个号码只会发出去一次。

查看答案
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
fn main() {
let next_order = AtomicU64::new(1000);
let mut numbers: Vec<u64> = thread::scope(|s| {
let mut handles = Vec::new();
for _ in 0..4 {
handles.push(s.spawn(|| {
(0..3)
.map(|_| next_order.fetch_add(1, Ordering::SeqCst))
.collect::<Vec<u64>>()
}));
}
handles.into_iter().flat_map(|h| h.join().unwrap()).collect()
});
numbers.sort();
println!("{} 个订单:{:?}", numbers.len(), numbers);
}

输出:

12 个订单:[1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011]
▶ 动手试一试

哪个线程拿到哪个号码,每次运行都不一样,但没有任何号码会被发两次,也不会有号码被跳过。排序之后,打印出来的列表每次都一样。

  • thread::scope 保证它的线程会在它结束之前结束,所以这些线程不用 Arc 或 move 就能借用局部数据。
  • RwLock 允许多个读者或者一个写者。它适合读多写少的数据;其他情况下 Mutex 更简单。
  • 原子类型(AtomicUsize、AtomicI64、AtomicBool)不用锁就能处理计数器和标志。拿不准就用 SeqCst,简单的计数器用 Relaxed。
  • LazyLock 和 OnceLock 安全地创建一个全局值,而且只创建一次,即使很多线程同时来要也一样。
  • 避免死锁:按同样的顺序获取锁,让持有锁的范围尽量短,并尽早丢弃守卫。

小测验

1. 为什么在 thread::scope 里用 s.spawn 启动的线程可以借用局部变量?

2. 关于 RwLock,哪种说法是对的?

3. 你需要一个由很多线程共享的页面浏览计数器。最简单又合适的选择是什么?

4. 线程 A 先锁 alice 再锁 bob,线程 B 先锁 bob 再锁 alice。可能会发生什么?

得分: 0 / 4