Rust 共享状态(RwLock、原子类型)
在并发那一页,你用 Arc<Mutex<T>> 在线程之间共享了一个计数器:Arc 让多个线程都能拥有这个值,Mutex 保证同一时间只有一个线程能碰它。在信道那一页,你干脆不共享,改为发送消息。这两样都是好工具,用它们几乎什么都能做出来。
不过,Mutex 就像办公室厕所只有一把钥匙。够用是够用,可如果你只是想看看走廊里的公告栏,这就有点小题大做了。Rust 标准库还有另外几样工具,每一样都更适合某一类工作。这一页会介绍其中四种:作用域线程(scoped thread)、RwLock、原子类型(atomics),以及 OnceLock/LazyLock。你还会学到怎样避开编译器唯一帮不了你的那种 bug:死锁(deadlock)。
作用域线程:借用而不是移动
Section titled “作用域线程:借用而不是移动”用 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。它更简单,如果以后测量发现这把锁是瓶颈,再换也不迟。
原子类型:不用锁的计数器
Section titled “原子类型:不用锁的计数器”如果只是一个数字或者一个真/假标志,连锁都显得多余了。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 是什么?
Section titled “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 // 线程 Blet 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。卡死了!三个习惯能让你远离死锁:
- 总是按同样的顺序获取锁。 如果每个线程都先锁
alice再锁bob(比如按账户 ID 排序),就不可能形成循环等待。 - 让持有锁的范围尽量短。 只在读取或修改数据所需的时间里持有锁。千万不要在做慢操作时持有锁,比如网络请求、写文件或者很长的计算。在这整段时间里,其他线程都只能干等。
- 尽早丢弃守卫。 守卫被丢弃时就会解锁。你可以用一个内部代码块
{ ... }让它结束(就像上面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 语句把任务取出来。
该用哪个工具?
Section titled “该用哪个工具?”| 你想要…… | 就用 |
|---|---|
| 把工作或结果从一个线程发送到另一个线程 | 信道(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. 分成四块求和
Section titled “1. 分成四块求和”创建一个装着 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。
2. 不重复的订单号
Section titled “2. 不重复的订单号”一家网店给每个订单编号,从 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
- 共享状态并发 — Rust 程序设计语言:官方讲解
Mutex和Arc的章节,是这一页的基础。 std::thread::scope— 标准库文档(英文):作用域线程的工作原理,还有更多示例。std::sync::RwLock— 标准库文档(英文):读守卫和写守卫、中毒(poisoning),以及公平性如何取决于操作系统的说明。std::sync::atomic— 标准库文档(英文):所有原子类型,以及对内存顺序的简短概述。- Rust Atomics and Locks(Mara Bos 著)(英文):一本免费的在线书,一步一步讲解原子操作、内存顺序以及锁是怎样实现的。