阶段三 · 多线程并发

共享状态:Arc 与 Mutex

一句话总结

多线程共享可变数据的标准组合是 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>>

这张表是本篇的钥匙

如果吃透了阶段二的 RcRefCell, 那么多线程并发的共享几乎是"同一套思想换个线程安全的实现".

Arc = 原子计数版的 Rc; Mutex = 阻塞版的 RefCell(冲突时不 panic 而是等待). 理解这个对应, 本篇就赢了一半.

Arc: 原子引用计数

Arc<T>(Atomically Reference Counted)与 Rc 接口几乎一样, 唯一区别是它的引用计数用原子操作, 因此能安全地跨线程共享:

use std::sync::Arc;
use std::thread;

let data = Arc::new(vec![1, 2, 3]);
let mut handles = vec![];

for i in 0..3 {
let d = Arc::clone(&data); // 每个线程一份 Arc(计数+1)
handles.push(thread::spawn(move || {
println!("线程 {i} 看到: {:?}", d); // 多线程共享读取
}));
}
for h in handles { h.join().unwrap(); }

但注意: 上面只是共享读取. ArcRc 一样, 本身只给不可变访问--要修改, 还得套一层提供内部可变性的工具.

Mutex: 把锁和数据绑在一起

Mutex<T>(互斥锁)提供线程安全的可变访问. 这里有一个 Rust 与 Go 的关键设计差异, 务必体会:

Go 的 sync.Mutex独立的--声明一把锁, 再声明被它保护的数据, 二者只靠"程序员记得在访问数据前 Lock()"这个约定关联. 忘了加锁, 编译器毫不知情.
Rust 的 Mutex 则把数据装进锁里: 想访问数据 T, 必须先 lock()--拿不到锁就拿不到数据. 锁和数据是一体的, "忘记加锁"在语法上不可能发生.

use std::sync::Mutex;

let m = Mutex::new(5); // 数据 5 被装进 Mutex

{
let mut guard = m.lock().unwrap(); // 加锁, 拿到数据的可变引用(守卫)
*guard += 1; // 通过守卫修改数据
} // guard 离开作用域, 锁自动释放(靠 Drop!)

println!("{:?}", m.lock().unwrap()); // 6

lock() 返回一个 MutexGuard(锁守卫)--它既是"已加锁"的凭证, 又能解引用访问内部数据. 守卫离开作用域时自动解锁(通过 Drop). 这意味着不会忘记解锁, 这又是所有权/Drop 机制带来的安全红利.

Arc<Mutex>: 多线程共享可变的标准式

把两者组合: Arc 让多个线程共享所有权, Mutex 让它们安全地轮流修改. 经典的"多线程计数器":

use std::sync::{Arc, Mutex};
use std::thread;

let counter = Arc::new(Mutex::new(0)); // Arc 包 Mutex 包数据
let mut handles = vec![];

for _ in 0..10 {
let c = Arc::clone(&counter); // 每个线程共享同一个 Arc
handles.push(thread::spawn(move || {
let mut n = c.lock().unwrap(); // 加锁
*n += 1; // 修改
})); // 守卫在此 drop, 自动解锁
}
for h in handles { h.join().unwrap(); }

println!("结果: {}", *counter.lock().unwrap()); // 10, 无数据竞争

读法依然是从外到内: Arc("多线程都能持有")→ Mutex("持有者要改必须先抢锁")→ 数据. 这和单线程的 Rc<RefCell<T>> 结构一一对应, 只是每一层都换成了线程安全的版本.

RwLock: 读多写少时的优化

Mutex 不区分读写--任何访问都独占. 如果场景是"读远多于写", 用 RwLock(读写锁)更高效: 它允许多个读者同时持有, 或单个写者独占:

use std::sync::RwLock;

let lock = RwLock::new(5);
{
let r1 = lock.read().unwrap(); // 多个读锁可同时存在
let r2 = lock.read().unwrap();
println!("{} {}", *r1, *r2);
}
{
let mut w = lock.write().unwrap(); // 写锁独占
*w += 1;
}

注意到这条规律了吗

RwLock 的"多读或单写"规则, 正是借用规则"共享不可变, 可变不共享"的运行时版本!

Rust 从单线程借用, 到 RefCell, 到 RwLock, 反复贯彻同一条核心原则, 只是检查时机(编译期/运行期)和作用范围(单线程/多线程)不同. 看穿这条主线, 这些工具就不再是零散的 API.

死锁: 锁没让你免于一切

Rust 的 Mutex 保证不会有数据竞争, 但不保证不会死锁. 死锁是逻辑问题, 编译器管不了:

let m = Mutex::new(0);
let g1 = m.lock().unwrap();
let g2 = m.lock().unwrap(); // 死锁! 当前线程已持锁, 又来抢同一把锁, 永久阻塞

常见死锁与防范

  • 重复加锁: 同一线程对已持有的锁再 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 保证无数据竞争, 但不防死锁; 靠统一加锁顺序, 缩短临界区等设计规避.

动手练习

  1. 为什么多线程共享可变数据不能用 Rc<RefCell<T>>? 该换成什么?
参考答案

Rc 的引用计数非原子(非 Send/Sync), RefCell 的借用记账也非线程安全(非 Sync), 编译器会拒绝把它们跨线程使用.

多线程版应换成 Arc<Mutex<T>>: Arc 用原子计数支持跨线程共享所有权, Mutex 用锁保证安全的互斥可变访问. 结构与 Rc<RefCell<T>> 一一对应.

  1. 解释 Rust 的 Mutex<T> 与 Go 的 sync.Mutex 在"锁与数据关系"上的根本差异.
参考答案

Go 的 sync.Mutex独立于数据的--锁和它保护的数据分开声明, 靠程序员"记得在访问前加锁"的约定关联, 忘了加锁编译器不会报错.

Rust 的 Mutex 把数据 T 装进锁内部: 要拿到数据必须先 lock() 得到守卫, 拿不到锁就拿不到数据. 锁与数据一体, "忘记加锁"在语法上无法发生; 且守卫离开作用域自动解锁, 也不会"忘记解锁".

  1. 补全这个多线程累加程序, 使 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::{Arc, Mutex};
use std::thread;

let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut n = c.lock().unwrap();
*n += 1;
}));
}
for h in handles { h.join().unwrap(); }
println!("{}", *counter.lock().unwrap()); // 10
  1. 下面代码会死锁. 指出原因并修复.

    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(), 当前线程等待一把自己正持有的锁, 永久阻塞.

修复: 让第一个守卫先释放, 或只加一次锁:

let m = Mutex::new(0);
{
let a = m.lock().unwrap();
println!("{}", *a);
} // a 在此释放
let b = m.lock().unwrap(); // 现在能拿到锁
println!("{}", *b);
  1. 什么场景下该把 Mutex 换成 RwLock? 它和借用规则有什么呼应?
参考答案

当访问模式是"读远多于写"时, 用 RwLock: 它允许多个读锁同时存在(读不互斥), 只在写时独占, 比 Mutex(任何访问都独占)并发度更高.

它的规则"多个读者 XOR 单个写者"正是借用规则"共享不可变, 可变不共享"的运行时多线程版--多个 &T 或单个 &mut T. Rust 在不同层面反复贯彻同一条核心原则.