一句话总结
多线程共享可变数据的标准组合是 Arc<Mutex<T>>: Arc 提供线程安全的共享所有权, Mutex 提供线程安全的互斥可变访问.
它正是单线程 Rc<RefCell<T>> 的多线程版本.
Rust 的 Mutex 把锁和数据绑在一起--锁包住数据, 而非像 Go 那样靠约定保护一段代码.
两个线程要改同一份数据
第 08 篇的 move 只能把数据交给一个线程. 现在要让多个线程共享并修改同一个计数器. 第一直觉可能是用上一阶段的 Rc<RefCell<T>>--但第 09 篇告诉我们, 这俩都不是线程安全的(非 Send/Sync), 编译器会拒绝. 需要它们的线程安全对应物.
| 能力 | 单线程 | 多线程 |
|---|---|---|
| 共享所有权 | Rc<T> |
Arc<T> |
| 内部可变 | RefCell<T> |
Mutex<T> / RwLock<T> |
| 共享 + 可变 | Rc<RefCell<T>> |
Arc<Mutex<T>> |
这张表是本篇的钥匙
如果吃透了阶段二的 Rc 和 RefCell, 那么多线程并发的共享几乎是"同一套思想换个线程安全的实现".
Arc = 原子计数版的 Rc; Mutex = 阻塞版的 RefCell(冲突时不 panic 而是等待). 理解这个对应, 本篇就赢了一半.
Arc: 原子引用计数
Arc<T>(Atomically Reference Counted)与 Rc 接口几乎一样, 唯一区别是它的引用计数用原子操作, 因此能安全地跨线程共享:
|
但注意: 上面只是共享读取. Arc 和 Rc 一样, 本身只给不可变访问--要修改, 还得套一层提供内部可变性的工具.
Mutex: 把锁和数据绑在一起
Mutex<T>(互斥锁)提供线程安全的可变访问. 这里有一个 Rust 与 Go 的关键设计差异, 务必体会:
Go 的 sync.Mutex 是独立的--声明一把锁, 再声明被它保护的数据, 二者只靠"程序员记得在访问数据前 Lock()"这个约定关联. 忘了加锁, 编译器毫不知情.
Rust 的 Mutex 则把数据装进锁里: 想访问数据 T, 必须先 lock()--拿不到锁就拿不到数据. 锁和数据是一体的, "忘记加锁"在语法上不可能发生.
|
lock() 返回一个 MutexGuard(锁守卫)--它既是"已加锁"的凭证, 又能解引用访问内部数据. 守卫离开作用域时自动解锁(通过 Drop). 这意味着不会忘记解锁, 这又是所有权/Drop 机制带来的安全红利.
Arc<Mutex>: 多线程共享可变的标准式
把两者组合: Arc 让多个线程共享所有权, Mutex 让它们安全地轮流修改. 经典的"多线程计数器":
|
读法依然是从外到内: Arc("多线程都能持有")→ Mutex("持有者要改必须先抢锁")→ 数据. 这和单线程的 Rc<RefCell<T>> 结构一一对应, 只是每一层都换成了线程安全的版本.
RwLock: 读多写少时的优化
Mutex 不区分读写--任何访问都独占. 如果场景是"读远多于写", 用 RwLock(读写锁)更高效: 它允许多个读者同时持有, 或单个写者独占:
|
注意到这条规律了吗
RwLock 的"多读或单写"规则, 正是借用规则"共享不可变, 可变不共享"的运行时版本!
Rust 从单线程借用, 到 RefCell, 到 RwLock, 反复贯彻同一条核心原则, 只是检查时机(编译期/运行期)和作用范围(单线程/多线程)不同. 看穿这条主线, 这些工具就不再是零散的 API.
死锁: 锁没让你免于一切
Rust 的 Mutex 保证不会有数据竞争, 但不保证不会死锁. 死锁是逻辑问题, 编译器管不了:
|
常见死锁与防范
- 重复加锁: 同一线程对已持有的锁再
lock()(如上), 永久阻塞. - 锁顺序不一致: 线程 A 先锁 X 再锁 Y, 线程 B 先锁 Y 再锁 X, 互相等待. 防范: 全局统一加锁顺序.
- 持锁过久: 让
MutexGuard尽早离开作用域(用{ }框住临界区), 减少持锁时间.
这与 Go 的 sync.Mutex 完全一致--死锁是并发逻辑的固有风险, 任何语言都需靠设计规避, 而非靠类型系统消除.
std::sync::Mutex 是不可重入锁
上面"重复加锁"暴露了一个重要特性: Rust 的 Mutex 是不可重入锁(non-reentrant mutex)--同一线程不能对同一把锁连续 lock() 两次, 否则死锁. POSIX 术语叫 PTHREAD_MUTEX_NORMAL.
不可重入不是 Bug, 是设计选择. 可重入锁(如 Java 的 synchronized)需要额外记录"谁持锁, 持几次"--每次加锁查表, 判断是否为当前线程, 递增计数, 解锁时递减. 不可重入锁省掉这层账本, 更快也更简单. 更重要的是, 可重入锁常常掩盖了设计问题: 如果在一个已持锁的方法里调了另一个也要加锁的方法, 往往说明锁的粒度太大, 或调用链没有理清.
不可重入锁像一个单坑厕所: 进去后锁上门, 不管是谁(包括自己)再拧把手都打不开.
可重入锁像一个带计数器的闸机: 刷员工卡进门时记录"来过了", 出去时计数减一, 自己刷两次不会把自己锁在里面.
前者简单耐用, 后者在嵌套调用时方便--但如果要"出门时再进来", 大概率是设计上有问题.
快速回顾
- 对应关系: 多线程共享 = 单线程工具换线程安全版.
Rc→Arc,RefCell→Mutex/RwLock,Rc<RefCell>→Arc<Mutex>. - Arc: 原子引用计数, 线程安全的共享所有权; 本身仍只读, 可变需套 Mutex.
- Mutex: 锁与数据绑定--必须
lock()才能访问数据, 守卫离开作用域自动解锁, 杜绝"忘记加锁/解锁". - RwLock: 多读或单写, 是借用规则"共享不可变, 可变不共享"的运行时多线程版.
- 死锁: Mutex 保证无数据竞争, 但不防死锁; 靠统一加锁顺序, 缩短临界区等设计规避.
动手练习
- 为什么多线程共享可变数据不能用
Rc<RefCell<T>>? 该换成什么?
参考答案
Rc 的引用计数非原子(非 Send/Sync), RefCell 的借用记账也非线程安全(非 Sync), 编译器会拒绝把它们跨线程使用.
多线程版应换成 Arc<Mutex<T>>: Arc 用原子计数支持跨线程共享所有权, Mutex 用锁保证安全的互斥可变访问. 结构与 Rc<RefCell<T>> 一一对应.
- 解释 Rust 的
Mutex<T>与 Go 的sync.Mutex在"锁与数据关系"上的根本差异.
参考答案
Go 的 sync.Mutex 是独立于数据的--锁和它保护的数据分开声明, 靠程序员"记得在访问前加锁"的约定关联, 忘了加锁编译器不会报错.
Rust 的 Mutex 把数据 T 装进锁内部: 要拿到数据必须先 lock() 得到守卫, 拿不到锁就拿不到数据. 锁与数据一体, "忘记加锁"在语法上无法发生; 且守卫离开作用域自动解锁, 也不会"忘记解锁".
-
补全这个多线程累加程序, 使 10 个线程各加 1, 最终结果为 10.
use std::sync::{Arc, Mutex};
use std::thread;
let counter = ???;
let mut handles = vec![];
for _ in 0..10 {
let c = ???;
handles.push(thread::spawn(move || {
???
}));
}
for h in handles { h.join().unwrap(); }
println!("{}", ???);
参考答案
|
-
下面代码会死锁. 指出原因并修复.
use std::sync::Mutex;
let m = Mutex::new(0);
let a = m.lock().unwrap();
let b = m.lock().unwrap();
println!("{} {}", *a, *b);
参考答案
死锁原因: 第一次 lock() 得到的守卫 a 还活着(锁未释放), 第二行又对同一把锁再次 lock(), 当前线程等待一把自己正持有的锁, 永久阻塞.
修复: 让第一个守卫先释放, 或只加一次锁:
|
- 什么场景下该把
Mutex换成RwLock? 它和借用规则有什么呼应?
参考答案
当访问模式是"读远多于写"时, 用 RwLock: 它允许多个读锁同时存在(读不互斥), 只在写时独占, 比 Mutex(任何访问都独占)并发度更高.
它的规则"多个读者 XOR 单个写者"正是借用规则"共享不可变, 可变不共享"的运行时多线程版--多个 &T 或单个 &mut T. Rust 在不同层面反复贯彻同一条核心原则.