4. 共享状态:Mutex与RwLock、Condvar条件变量、屏障(Barrier)、OnceCell与LazyLock
共享状态并发,说白了就是多个线程要读写同一块数据。这活儿看着简单,但坑特别多。我早年用C++写多线程服务时,就因为锁没用好,线上出了好几次死锁。到了Rust这里,编译器帮你管住了不少事,但底层逻辑你还得懂。
这一章,咱们把Rust里常用的共享状态工具捋一遍。从最基本的互斥锁,到读写锁,再到条件变量、屏障,最后聊聊延迟初始化的OnceCell和LazyLock。每个工具都有它的脾气,用对了地方,事半功倍。
4.1 Mutex:互斥锁
Mutex是最基础的同步原语。它保证同一时刻只有一个线程能访问被保护的数据。Rust的Mutex和别的语言不太一样——它直接包裹数据,而不是单独锁一个对象。
use std::sync::Mutex;
fn main() {
let counter = Mutex::new(0);
// 获取锁,返回MutexGuard
let mut num = counter.lock().unwrap();
*num += 1;
// 锁在MutexGuard离开作用域时自动释放
println!("counter: {}", *num);
}
嗯,这里要注意:lock()返回的是一个Result。为什么?因为持有锁的线程可能panic了,这时候锁会进入"中毒"状态。我个人习惯是直接用unwrap(),但如果你的程序对错误敏感,最好用match处理一下。
Rust不会自动帮你避免死锁。如果你在持有锁A的情况下去获取锁B,而另一个线程正持有锁B等着锁A——完蛋,死锁了。我曾经在一个消息队列项目里踩过这个坑,排查了一下午才发现是两个锁的获取顺序不一致。
多线程场景下,Mutex需要配合Arc使用,因为多个线程需要共享所有权:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
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!("Result: {}", *counter.lock().unwrap());
}
你想想看,如果没有Arc,所有权怎么分?每个线程都要一个Mutex的引用,但Rust的借用检查器不允许你这么干。Arc提供了线程安全的引用计数,完美解决这个问题。
4.2 RwLock:读写锁
Mutex有个问题:读操作和写操作互斥。但很多时候,读操作远多于写操作。这时候RwLock就派上用场了。它允许多个读者同时访问,但写者独占。
use std::sync::RwLock;
fn main() {
let data = RwLock::new(vec![1, 2, 3]);
// 多个读锁可以同时存在
let read1 = data.read().unwrap();
let read2 = data.read().unwrap();
println!("{:?}, {:?}", read1, read2);
// 写锁会阻塞所有读锁
let mut write = data.write().unwrap();
write.push(4);
}
读多写少的场景用RwLock,比如配置缓存、路由表。写多读少或者读写频率差不多的场景,直接用Mutex反而更简单高效。我在一个网关项目里用RwLock管理路由规则,读请求每秒几万次,写规则几分钟一次,性能比Mutex提升了将近3倍。
但RwLock有个潜在问题——写者饥饿。如果读线程一直来,写线程可能永远拿不到锁。标准库的RwLock不保证公平性,所以写操作频繁的场景要慎用。
4.3 Condvar:条件变量
条件变量用来让线程等待某个条件成立。它必须和Mutex配合使用。典型场景:生产者-消费者模型。
use std::sync::{Arc, Mutex, Condvar};
use std::thread;
fn main() {
let pair = Arc::new((Mutex::new(false), Condvar::new()));
let pair_clone = Arc::clone(&pair);
// 消费者线程
thread::spawn(move || {
let (lock, cvar) = &*pair_clone;
let mut ready = lock.lock().unwrap();
while !*ready {
// 等待条件变量通知
ready = cvar.wait(ready).unwrap();
}
println!("消费者:收到通知,开始干活");
});
// 主线程(生产者)
let (lock, cvar) = &*pair;
let mut ready = lock.lock().unwrap();
*ready = true;
cvar.notify_one();
println!("生产者:发送通知");
}
为什么wait()要传入一个MutexGuard?因为线程在等待时会释放锁,让其他线程能修改条件。等被唤醒后,它会重新获取锁。这个机制保证了条件检查的原子性。
我曾经在条件变量上吃过一个亏:用if而不是while检查条件。结果发生了"虚假唤醒"——线程被唤醒了,但条件其实还没满足。标准做法是用while循环,每次被唤醒都重新检查条件。
4.4 Barrier:屏障
Barrier让多个线程在某个点上同步等待。所有线程都到达屏障后,才一起继续执行。这个工具在并行计算里特别常见。
use std::sync::{Arc, Barrier};
use std::thread;
fn main() {
let mut handles = Vec::new();
let barrier = Arc::new(Barrier::new(5));
for id in 0..5 {
let barrier = Arc::clone(&barrier);
handles.push(thread::spawn(move || {
println!("线程{}:到达屏障", id);
barrier.wait();
println!("线程{}:通过屏障", id);
}));
}
for handle in handles {
handle.join().unwrap();
}
}
输出结果里,所有"到达屏障"的消息会先打印完,然后才打印"通过屏障"。Barrier内部用了一个计数器,每次wait()调用就减1,减到0时唤醒所有等待线程。
我在做图像处理项目时用过Barrier。把一张大图分成4块,4个线程各自处理一块,处理完后在屏障处等待,然后统一进行下一步合并操作。代码写起来特别清爽。
4.5 OnceCell与LazyLock
这两个工具解决的是"延迟初始化"问题。说白了就是:这个值我只初始化一次,而且是在第一次被用到的时候才初始化。
OnceCell是基础版本,LazyLock是它的语法糖版本(Rust 1.80+稳定)。
use std::sync::OnceLock;
static CONFIG: OnceLock<String> = OnceLock::new();
fn get_config() -> &'static str {
CONFIG.get_or_init(|| {
// 只执行一次
println!("初始化配置...");
std::fs::read_to_string("config.toml").unwrap()
})
}
fn main() {
println!("{}", get_config());
println!("{}", get_config()); // 不会再次初始化
}
用LazyLock更简洁:
use std::sync::LazyLock;
static CONFIG: LazyLock<String> = LazyLock::new(|| {
println!("初始化配置...");
std::fs::read_to_string("config.toml").unwrap()
});
fn main() {
println!("{}", *CONFIG);
}
- 全局配置:程序启动时加载,但不想在main函数里显式初始化
- 单例模式:全局唯一的资源,比如数据库连接池
- 计算密集型缓存:第一次使用时计算结果,后续直接复用
你可能会问:这和Mutex有什么区别?OnceCell保证初始化只执行一次,而且初始化完成后后续读取没有锁开销。而Mutex每次读写都要加锁。所以对于"写一次、读多次"的场景,OnceCell性能好得多。
知识体系总览
下面这张图把本章的核心概念串起来了。你可以看到每个工具的适用场景和核心特点:
这张图把五个工具按"互斥访问"和"同步协作"两个维度做了分类。Mutex和RwLock解决的是"怎么安全地访问数据",Condvar和Barrier解决的是"线程之间怎么协调节奏",OnceCell和LazyLock解决的是"什么时候初始化数据"。搞清楚了这些,你写并发代码时就知道该用哪个了。
公众号:蓝海资料掘金营,微信deep3321