共享状态并发
本教程共 78 篇 · 第 67 篇 · 更新于 2026-08-08 · 约 8 分钟阅读
本节目标:理解互斥锁 Mutex 如何保护多线程共享的可变数据,学会
lock获取锁、用{}缩窄锁范围、看懂 Mutex 的”内部可变性”,并明白为什么 Mutex 要搭配 Arc 才能跨线程共享。
上一章讲了”用通信来共享内存”。这一章走另一条路:让多个线程真的去共享同一块数据,但用一把锁把访问管起来。这条路的名字就叫共享状态并发,而最常用的那把锁叫 Mutex。
1-1 为什么需要锁
设想两个线程同时给一个计数器加一。听起来简单,但底层其实是”读旧值 → 加一 → 写回去”三步。如果两个线程同时读到了同一个旧值,各自加一写回,结果只加了一次,而不是两次。这种多个线程以不一致的顺序抢同一份数据,就叫竞态条件(race condition)。
锁的作用,就是保证同一时刻只有一个线程能碰这块数据。谁拿到锁,谁才能进去改;没拿到的在门外等。这样就不会有两个线程同时修改的混乱局面。
1-2 Mutex 是什么
Mutex 是 mutual exclusion(互斥)的缩写。它的工作方式是:想访问里面的数据,必须先 lock(加锁)。lock 会返回一把”守卫”(guard),你通过这把守卫才能读写里面的数据。当守卫离开作用域被丢弃(drop)时,锁会自动释放。这个”自动释放”非常重要——它意味着哪怕中间发生了 panic,锁也不会永远卡住。
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
// 加锁,拿到守卫 guard
let mut num = m.lock().unwrap();
*num = 6;
} // 这里 guard 离开作用域,锁自动释放
println!("结果: {:?}", m);
}
注意 m.lock() 返回的是 Result<MutexGuard, ...>,用 unwrap 简单处理。拿到 num 后,我们用 *num = 6 去修改里面的数据——这里 num 是个智能指针,解引用后才是真正的 i32。
Tip锁会自动释放,靠的是
MutexGuard实现了Drop。所以写多线程代码时,尽量用{}把加锁的范围缩到最小,别把不相干的代码也圈在锁里面,否则别的线程会被你白白堵住。
1-3 Mutex 的内部可变性
你可能会疑惑:上面 m 是个不可变变量(let m,没写 mut),为啥里面的值还能改?这就是 Rust 里一个常见模式——内部可变性。
Mutex 提供的是”内部可变性”:外层看起来是不可变的,但通过锁的守卫,你可以在内部修改数据。类似的还有 RefCell(单线程)和 Cell。对初学者而言,记住一句话就行:加了锁,就能安全地改里面的值,哪怕外面的变量本身不可变。
这和所有权规则也吻合:要么多个不可变借用,要么一个可变借用。Mutex 用锁把”同一时刻只有一个可变借用”这件事在运行时强制管了起来,于是编译期就允许你共享它了。
1-4 多线程下必须用 Arc 配合
现在的关键问题来了:怎么让多个线程共享同一个 Mutex?如果你直接 clone 一个 Mutex 交给线程,会编译不过——因为 Mutex 本身不能简单地被复制,而且它没实现 Send 那样能在线程间随意搬动的保证。
正确做法是用 Arc(原子引用计数指针)把 Mutex 包起来。每个线程 clone 一个 Arc 的引用,大家共享同一个被锁保护的数据。完整写法要等下一章讲 Arc 才展开,这里先给你一个预览,让你知道最终长什么样:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// Arc 包住 Mutex,多个线程共享同一把锁和里面的数据
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!("最终计数: {}", *counter.lock().unwrap());
}
十个线程各自给计数器加一,最后打印出 10,没有竞态。这里 Arc 负责”多个线程共享同一份数据”,Mutex 负责”同一时刻只允许一个线程改”。这俩配合,是 Rust 多线程共享状态的标准组合拳。具体 Arc 怎么工作,第 68 章细讲。
Warning死锁是共享状态并发最经典的坑:线程 A 拿着锁 1 等锁 2,线程 B 拿着锁 2 等锁 1,两边互相等,谁都过不去。写代码时尽量按固定顺序加锁,或者缩小锁范围,能大幅降低死锁概率。
1-5 条件变量 Condvar
有时候一个线程要”等某个条件成立”才能继续,比如等数据准备好了再处理。光用锁不够优雅,标准库提供了 Condvar(条件变量)来配合 Mutex 用。
use std::thread;
use std::sync::{Arc, Mutex, Condvar};
fn main() {
let pair = Arc::new((Mutex::new(false), Condvar::new()));
let pair2 = Arc::clone(&pair);
thread::spawn(move || {
let (lock, cvar) = &*pair2;
let mut started = lock.lock().unwrap();
*started = true;
cvar.notify_one(); // 通知等待的线程:条件变了
});
let (lock, cvar) = &*pair;
let mut started = lock.lock().unwrap();
while !*started {
started = cvar.wait(started).unwrap(); // 等通知,期间释放锁
}
println!("条件已满足");
}
主线程拿到锁后,发现条件没满足,就调用 wait 挂起等待,并临时把锁让出去;子线程改完条件后调用 notify_one 通知它。这样主线程不用空转消耗 CPU,等通知了再回来。条件变量在生产者-消费者、线程池等场景非常常见。
1-6 锁的粒度与死锁防范
写共享状态时,初学者最容易犯两个错。一是锁范围太大:把跟共享数据无关的耗时计算也圈在 lock 里面,导致别的线程被白白堵住。正确做法是拿到守卫后只做必要的最小操作,立刻让守卫离开作用域释放锁。二是死锁:线程 A 拿着锁 1 等锁 2,线程 B 拿着锁 2 等锁 1,两边互相等,谁都过不去,程序直接假死。
防范死锁有几条经验:尽量只用一个锁;如果必须用多把锁,所有线程按相同顺序加锁;避免在持锁期间调用外部未知函数(它可能又去拿别的锁)。读多写少的场景,还可以用 RwLock 替代 Mutex——它允许多个读者同时读,只在写时独占,能提升并发读的性能。
Warning死锁不会报错,只会让程序”卡住不动”。多线程共享状态调试时,如果程序无预兆地停住,先怀疑是不是两把锁互相锁住了。缩小锁范围、固定加锁顺序,是最有效的预防手段。
1-7 小结
共享状态并发的核心就一句话:多个线程要改同一份数据,就用 Mutex 把访问串行化。加锁拿守卫、离开作用域自动解锁;Mutex 通过内部可变性让你能在不可变外壳下改数据;真正跨线程共享时,Mutex 必须被 Arc 包起来。再配合 Condvar,就能写出”等条件、再行动”的协调逻辑。下一章我们专门把 Arc 讲明白。