第十三章 并发模型:Rust 怎么在编译期把”数据竞争”摁死

你有没有合租过房子?三户人家共用一个冰箱。张三往里塞了瓶醋,李四以为没人用顺手扔了,王五想拿的时候发现醋没了——还不知道被谁扔的。多线程共享内存,就是这个冰箱,只不过”醋没了”会变成”余额算错""数据损坏""半夜宕机”。

并发编程最深的坑,不是”慢”,是”偶尔错”。那种只在流量高峰、只在特定调度顺序下才冒出来的 bug,你复现不了、测试抓不到、上线才炸。传统语言把这种坑留给你自己用锁、用经验去填。Rust 不这么干——它把”共享可变状态是否安全”这件事,提前到编译期就审一遍。这一章我们拆开看:线程怎么起、为什么跨线程必须 move、怎么用 Arc<Mutex<T>> 安全共享、以及 Send/Sync 这两个标记 trait 凭什么敢说”消灭数据竞争”。

一、thread::spawn:起一个线程,就是派出去一个人

Rust 起线程很简单,标准库的 std::thread::spawn 接收一个闭包,闭包里就是新线程要干的活。

use std::thread;
 
let handle = thread::spawn(|| {
    println!("我是新线程,在并行干活");
});
handle.join().unwrap(); // 等它干完

看着平平无奇。但坑藏在”闭包要捕获变量”这件事里。你派出去的线程,是和主线程并行、甚至可能比主线程活得更久的独立执行流。如果它抓走了主线程的一个变量,而主线程那个变量已经过了作用域被回收了——新线程手里的就是悬垂引用,经典 use-after-free。所以 Rust 在这里立了一条铁规矩。

二、move 闭包:跨线程,所有权必须”带着走”

前面讲闭包时说过 move 关键字,意思是”把捕获的变量所有权搬进闭包”。在线程这个场景,move 从”可选优化”升级成”强制要求”——因为编译器无法保证新线程活得比你借的东西短,所以干脆要求:你要把一个值交给另一个线程用,就得把它的所有权 move 过去,别拿引用糊弄我。

use std::thread;
 
let data = vec![1, 2, 3];
// move:把 data 的所有权整个搬进新线程
let handle = thread::spawn(move || {
    println!("线程里拿到 data: {:?}", data);
});
handle.join().unwrap();
// 这里 data 已经不属于主线程了,再用会报错

你品这个设计的深意。它直接消灭了”一个线程拿着另一个线程的悬垂引用”这整类 bug——因为新线程拿到的不是引用,是这个值本身的所有权。值跟着线程走,谁也指不着一块已经释放的内存。

但新的麻烦来了:既然所有权只能 move 一次,那”我想让两个线程同时改同一份数据”怎么办?按所有权规则,move 过去一次就没了,第二个线程拿不到。这正是并发里”共享”需求的死结。Rust 的解法是:引入一个专门为了”安全共享”而生的工具——Arc<Mutex<T>>。

三、Arc<Mutex>:共享可变状态的”标准姿势”

两个词拆开看。

Arc(Atomic Reference Counted,原子引用计数):它让一份数据可以被多个所有者安全地共享。每多一个地方持有 Arc 副本,引用计数加一;最后一个持有者离开,数据才销毁。关键是它的”加一减一”是原子操作,多线程下不会数错。

Mutex(互斥锁):它保证”同一时刻只有一个线程能改里面的数据”。你想动数据?先抢锁;抢到了才能进,完事再放。别的线程想进?排队等。

把俩拼起来:Arc 负责”多个人都能指着同一份数据”,Mutex 负责”但同一时刻只能一个人动它”。于是——

use std::sync::{Arc, Mutex};
use std::thread;
 
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
 
for _ in 0..3 {
    let counter = Arc::clone(&counter); // 克隆 Arc,引用计数 +1
    let h = thread::spawn(move || {
        let mut num = counter.lock().unwrap(); // 抢锁
        *num += 1;                             // 改数据
    });                                        // 锁在这里自动释放
    handles.push(h);
}
 
for h in handles {
    h.join().unwrap();
}
println!("最终计数: {}", *counter.lock().unwrap()); // 输出 3

打个比方。Arc 就像给冰箱配了三把都能开的钥匙(三个人都是这冰箱的合法主人),Mutex 就像冰箱上挂了把锁——谁想开冰箱拿醋,先得把锁摘下来带在身上,开完再挂回去;另一个人想开,得等锁挂回去。于是”张三塞醋、李四扔醋”的混乱被彻底杜绝:同一时刻只有一个人能碰冰箱里的东西。

注意 Arc::clone(&counter) 不是深拷贝数据,只是复制了”指向同一份数据的智能指针”并让计数加一,成本极低。而 lock() 返回的东西离开作用域会自动 unlock——又是 RAII 那套”离开作用域自动收”的思想,你不用手动 unlock,忘了也不会死锁(除非你故意抱着锁不撒手)。

四、Send / Sync:并发安全的真正地基

现在到了本章最硬核、也最容易被略过的部分。Arc<Mutex<T>> 为什么”敢”在多线程里用?背后是 Rust 标准库里两个看起来空空如也、却重如泰山的标记 trait:Send 和 Sync。

  • Send:一个类型如果实现了 Send,意思是”它的所有权可以在线程之间传递”(被 move 到另一个线程)。
  • Sync:一个类型如果实现了 Sync,意思是”它的不可变引用可以被多个线程同时持有”(即 &T 能跨线程共享)。

这俩是”自动 trait”——编译器根据你的类型里装了什么,自动决定它是否 Send/Sync,你通常不用手写。但它们的威力在于:所有跨线程的操作,编译器都会检查”你传过去的东西是不是 Send、你共享的东西是不是 Sync”。不是 Send 的,你 spawn(move) 它过去,编译器直接拒;不是 Sync 的,你拿它的引用跨线程共享,编译器也拒。

// 下面这种,如果 T 不是 Send,编译直接失败:
// thread::spawn(move || { use_something(T) });
// 编译器在说:T 不能安全地跨线程 move,我不放你过去

你品一下这套机制的狠处。传统语言里”数据竞争”是运行时才爆的雷;Rust 里,一个不安全的共享,根本编不过。编译器在编译期就遍历你的代码:“这个值被 move 到线程了吗?它 Send 吗?这个引用被多个线程抓了吗?它 Sync 吗?” 任何一环不满足,编译失败,bug 胎死腹中。

为什么 Mutex<T> 是 Sync 的?因为 Mutex 保证了”即使多线程拿到它的引用,真正改数据时也靠内部的锁串行化”,所以把 &Mutex<T> 跨线程共享是安全的——Sync 的判定逻辑正是建立在”锁能保证互斥”这个事实上。而裸的 RefCell<T>(第十章讲的内部可变性)不是 Sync,因为它靠运行时检查、没有跨线程的原子保护,你把它跨线程共享就会出乱子——于是编译器干脆不让你那么做。看到没?类型系统在这里直接编码了”什么能安全并发”这件事。

如果你写过靠手动加锁、或者靠运行时检测来防并发 bug 的语言,这里的心智要重装:在那些语言里,“会不会数据竞争”是你要时刻绷紧神经去防的运行时问题;在 Rust 里,它变成了一个编译期可被证明的属性——不是”你小心点别错”,是”你根本错不了,因为错的那版编不出来”。

五、恐惧借用检查器?到了多线程它成了你的保护伞

很多初学者怕借用检查器(borrow checker),觉得它”这也拦那也拦”。我跟你讲句掏心窝的:在单线程里,它拦你,你嫌烦;到了多线程,你会跪谢它。

为什么?因为多线程的 bug 比单线程凶残十倍。单线程的借用冲突顶多是”逻辑别扭”,多线程的数据竞争是”生产事故”。而借用检查器 + Send/Sync 这套组合,恰恰是把数据竞争挡在编译期的主力。你之前为它掉的每一根头发,在并发场景全变成了红利:它逼你把”谁拥有、谁共享、怎么加锁”在写代码时就想明白,而不是等上线后被告警叫醒。

墨叔常跟徒弟说一句话:在 Rust 里,你能写出”编译通过的多线程代码”,它大概率就已经是数据竞争安全的了。 这不是夸张,是 Send/Sync 这套类型级证明在背后撑腰。别的语言要靠代码评审、要靠压测、要靠撞大运去发现并发 bug;Rust 把这道关,焊死在了编译这一步。

六、务实提醒:锁不是银弹

讲完这套漂亮的机制,墨叔得泼盆冷水,免得你以为”上了 Arc<Mutex> 就万事大吉”。两个真实世界的坑:

第一,死锁。两个线程各拿一把锁,又各等对方那把,谁都不放,程序卡死。Mutex 防的是数据竞争,不防死锁——死锁是逻辑层面的,编译器管不了。所以锁的粒度、加锁顺序,还得你动脑子。

第二,锁的粒度太粗会失去并发意义。你把所有数据塞进一个 Mutex,结果所有线程排队等同一把锁,那跟单线程顺序执行没两样,白并发了。该细分的要细分。

但即便有这些坑,Rust 给你的底子依然比别的语言硬:至少”数据竞争”这一类最阴险的 bug,它从编译期就替你摁死了。剩下的死锁、粒度问题,是任何语言都躲不开的工程权衡,不是 Rust 的锅。

顺带点一句通道(channel)。Arc<Mutex<T>> 不是共享状态的唯一答案,有时”别共享,改传递”更干净。标准库的 std::sync::mpsc 或 tokio 的通道,让线程间通过发消息来协作,而非抢同一块内存。Go 社区那句”不要通过共享内存来通信,要通过通信来共享内存”,在 Rust 里同样成立,而且因为 Send 的约束,消息在线程间传递时安全性也是编译期保证的。墨叔的建议:能传递就别共享,共享才上锁;锁能细就别粗。这几条经验,能让你避开并发里大半的坑。

【思考题】

  1. 跨线程必须用 move 闭包,把所有权搬进新线程。请你推一下:如果 Rust 不要求 move、而是允许新线程只借一个引用去用主线程的变量,会发生什么具体的灾难?用”合租冰箱”的比喻讲清楚为什么引用在这儿玩不转。

  2. Arc 和 Mutex 各解决一半问题。请你想清楚:Arc 单独用、不加 Mutex,能安全地让两个线程同时改同一份数据吗?反过来,Mutex 单独用、不加 Arc(只在一个线程里 Mutex::new 然后 borrow),能实现多线程共享吗?把”各自管什么”讲通。

  3. Send 和 Sync 是”自动 trait”。现在假设有人写了一个类型,里面包装了一个不是 Sync 的东西(比如裸的 RefCell)。请你推:这个类型自己还能是 Sync 吗?编译器在”有人想把这个类型的引用跨线程共享”时,会怎么做?这背后体现了什么设计意图?

【参考答案】

  1. 如果不要求 move、允许新线程只借引用,灾难是这样的:主线程的变量 data 在主线程作用域结束时会被 drop 回收(RAII),但新线程可能还拿着 &data 在跑。于是新线程指向的是一块已经被释放的内存——这就是 use-after-free,而且是跨线程的、最阴险的那种。合租比喻:你(主线程)搬家退租了,把冰箱的”临时借用条”(引用)留给了室友(新线程),室友拿着条去开冰箱,发现房子早被收走了、冰箱也没了——条指向虚空。Rust 强制 move,等于让新线程直接”买下这冰箱”(拿走所有权),冰箱跟着线程走、不会被人提前收走,悬垂引用从根上消失。这也就是为什么 move 在线程场景从”可选”变”强制”。

  2. 两种单独用都不行,正好说明它俩是互补的。Arc 单独用不加 Mutex:Arc 只解决”多个人都能指向同一份数据”(共享),但它内部的数据还是普通的所有权的、不可变共享;两个线程各拿一个 Arc 副本去”改”里面的数据,会违反”同一时刻要么多读要么一写”的借用规则,编译期就拦你(或者你强行用内部可变性但 RefCell 不 Sync,照样编不过)。所以光有 Arc,你只能”一起看”,不能”一起改”。Mutex 单独用不加 Arc:Mutex 只解决”同一时刻一个人改”(互斥),但你得先把这把锁在多个线程间”指得到”。如果只在一个线程里 Mutex::new 然后借引用传给别的线程,那个引用得是 Sync 的且能被共享——可你单靠 Mutex 本身没法”复制出多个指向同一把锁的把手”给不同线程(裸引用借用规则不允许同时被多线程长期持有)。Arc 恰恰就是干”复制出多个安全把手”这件事的。所以正确姿势是 Arc(多把手共享同一份)+ Mutex(把手抢到才能改),缺一不可。

  3. 这个类型不能是 Sync。原因是:Sync 的判定是”自动 trait”——编译器看你的类型内部所有字段是否都满足 Sync,只要有一个字段不是(比如裸 RefCell,它靠运行时借用检查、没有跨线程原子保护,标准库明确不给它 Sync),整个外层类型就被推导为”非 Sync”。于是,当有人想把这个类型的 &T 跨线程共享时,编译器检查到它不 Sync,直接拒绝编译,报错说”它不能安全地被多个线程同时引用”。设计意图极其清楚:Rust 把”什么能安全地跨线程”这件事,编码进了类型系统本身——不是靠文档叮嘱你”别乱传”,而是靠 trait 自动推导 + 编译期检查,让不安全的共享从物理上编不出来。这就是”在编译期消灭数据竞争”的真正含义:危险的类型根本过不了 Send/Sync 这道编译闸。

小结

跨线程必须 move 所有权,从根上消灭悬垂引用;Arc 负责多把手安全共享同一份数据、Mutex 负责同一时刻只一人改,二者合起来是共享可变状态的标准姿势;而 Send/Sync 这对标记 trait 把”能否安全并发”编码进类型系统,让数据竞争在编译期就被摁死——单线程里让你头疼的借用检查器,到了多线程反而成了最可靠的保护伞。