卷 V · 并发CH 19深度 19/24

共享状态:Arc 与 Mutex

多个线程要更新同一个计数器、往同一个列表里塞结果 —— 这是「共享可变状态」,上一章的移动搬不动它。这时的工具是 Arc<Mutex<T>>。如果你觉得它眼熟,没错 —— 它就是第 15 章 Rc<RefCell<T>> 的线程安全孪生版,同一个形状。而它最漂亮的一招是:把锁和数据绑死,逼你先拿锁才能碰数据 —— 你想忘记加锁都做不到。

ArcMutex锁与数据绑定死锁

两个零件,各管一件事

「多个线程共享一个可变值」这件事,拆成两个独立的问题,各用一个零件解决:

  • 谁来持有这个值?多个线程都要持有它,所以需要「多所有者」。但上一章说了 Rc 不能跨线程(计数不是原子的)。于是用 Arc<T>(Atomically Reference Counted)—— 就是 Rc 的原子计数版,计数用 CPU 的原子指令加减,多线程一起 clone 也不会错。除此之外它和 Rc 用法一模一样。
  • 怎么保证同一时刻只有一个线程在改?Mutex<T>(互斥锁)—— 想访问里面的值,必须先 lock() 拿到锁;拿不到就阻塞等待。同一时刻只有一个线程能持锁,也就只有一个线程能碰数据。

合起来 Arc<Mutex<T>>Arc 让这个锁被多个线程共同持有,Mutex 让它们排队访问里面的值。经典的多线程计数器:

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);     // 每个线程分一个 Arc 句柄(计数 +1)
        handles.push(thread::spawn(move || {
            let mut n = counter.lock().unwrap(); // 拿锁,得到里面 0 的可变访问
            *n += 1;                             // 改它
        }));                                     // n 离开作用域 → 锁自动释放
    }
    for h in handles { h.join().unwrap(); }

    println!("{}", *counter.lock().unwrap());    // 10,不多不少
}

看出那个「同一个形状」了吗

把这两卷的工具并排放,规律清清楚楚:

单线程(卷 IV)多线程(本卷)
多所有者Rc<T>(普通计数)Arc<T>(原子计数)
共享 + 可变RefCell<T>(运行时借用检查,违反则 panic)Mutex<T>(运行时锁,冲突则阻塞等待)
组合拳Rc<RefCell<T>>Arc<Mutex<T>>

MutexRefCell 是同一个思想的两种实现 —— 都是「运行时保证同一时刻只有一个可变访问」。区别只在冲突时:RefCell(单线程,冲突意味着 bug)panicMutex(多线程,冲突是正常的排队)阻塞等待你在第 15 章学的心智模型,原样搬过来就是多线程版 —— 这又一次印证了:Rust 的并发没有新概念,全是老概念的换装。

它最妙的一招:锁和数据绑死了

这是 Mutex 值得单独夸一句的设计。在别的语言里,锁和它保护的数据是两个分开的东西:你声明一个 lock,再声明一个 data,然后靠约定、靠自觉「碰 data 之前记得 lock」。这个约定极其容易破 —— 某个新人、某个赶工的午后、某条你没注意到的代码路径,忘了加锁,直接读写了 data。编译器什么都不知道,因为在类型上,锁和数据毫无关系。

Rust 反过来:数据被装在锁里面。Mutex<i32> 里那个 i32,你没有任何办法不经过 lock() 就碰到它 —— 因为 lock() 是唯一能取出内部值的门,它返回的 MutexGuard 才让你访问数据。「忘记加锁」在 Rust 里不是「容易犯的错」,而是「根本写不出来」。

◆ 而且你也忘不了解锁 —— 又是 Drop

lock() 返回的 MutexGuard,在离开作用域时自动解锁(靠第 16 章的 Drop)。这意味着:你不会忘记解锁,也不会因为函数中途 return? 提前退出、或者 panic,而漏掉解锁 —— RAII 兜底,锁一定在 guard 离开作用域时释放。「加锁 / 解锁」这对最容易配错的操作,Rust 用「拿到 guard = 加锁,guard 析构 = 解锁」把它变成了不可能配错。

⚠ 死锁,Rust 不管

要诚实:Mutex 保证没有数据竞争,但不保证没有死锁。如果线程 A 拿着锁 1 等锁 2、线程 B 拿着锁 2 等锁 1,它俩就永远互相等下去 —— 这是 Rust 在编译期阻止的(死锁是逻辑问题,不是内存安全问题)。避免死锁仍然要靠老办法:约定加锁顺序、缩短持锁时间、少嵌套锁。另外,读多写少的场景用 RwLock<T>(多个读者或一个写者,正是借用铁律的运行时版)比 Mutex 更高效。

另一条路:不共享,用消息传递

共享内存 + 锁不是唯一的并发范式。Rust 也支持「通过通信来共享」——用通道(channel,std::sync::mpsc):一个线程把数据进通道(所有权随之移交),另一个线程从通道。因为所有权在发送时就移动走了,发送方之后碰不到它,从根上没有共享、也就没有竞争。很多时候,「把数据的所有权在线程间传递」比「共享一块加锁的内存」更清晰、更不容易错 —— 这也是 Go 和 Erlang 推崇的路子。两条路 Rust 都给你,按场景选。

回流

⟲ 回流 · 锁与数据的关系,是分是合

Java / Kotlin:synchronized(lockObj) { ... }ReentrantLock —— 锁和数据是分离的。你 synchronized 在哪个对象上、保护的是哪些字段,全靠你脑子里的约定和文档,编译器一无所知。于是「访问共享字段时忘了进同步块」「synchronized 错了对象」是 Java 并发 bug 的常客。Rust 的 Mutex<T> 把数据装进锁里,让「锁保护什么」从一个约定变成一个类型事实 —— 这是设计哲学上一个不小的进步。此外,Java 的 AtomicInteger 等价于 Rust 的 Arc<AtomicI32>(无锁原子),channel 则对应 BlockingQueue + 手动传递。

Go:channel 是 Go 的招牌,Rust 的 mpsc 是同一个思想。而 sync.Mutex 在 Go 里也是锁和数据分离的(一个 struct 里放个 mu sync.Mutex 和被保护的字段,靠约定关联)—— 同样有「忘了 mu.Lock()」的隐患。Rust 的「数据装进锁」在这点上比 Go 更稳。

这一章的一句话

Arc<Mutex<T>> 是 Rc<RefCell<T>> 的线程安全孪生版:Arc 原子计数管共享,Mutex 管排队访问。它把数据装进锁里,让「忘记加锁」从容易犯的错变成写不出来,还用 Drop 保证你忘不了解锁 —— 但死锁仍要你自己防。

共享状态讲完了。但现代高并发(尤其是网络服务),主力其实不是「开很多线程」,而是另一套东西 —— 一条线程照看成千上万个任务。它叫 async。很多人也在这里被劝退第二次。下一章,我们把它拆成一台你看得懂的状态机。