← Rust:从入门到精通
全栈开发Rust:从入门到精通

无畏并发:数据竞争如何变成编译错误

Rust 从入门到精通系列第 9 篇:Send 与 Sync 两个标记 trait 如何把「线程安全」变成类型层面的事实、scoped threads 如何合法借用栈上数据、Arc<Mutex<T>> 与第 6 篇 Rc<RefCell<T>> 的对应关系,以及 channel 背后「用通信共享内存」的所有权哲学。

RustConcurrencySend SyncArc MutexChannelsFearless Concurrency

第 2 篇埋过一个承诺:读者与写者二选一的那条借用规则,“同一条规则”会让数据竞争成为编译错误。现在到了兑现的时候。先回忆一下数据竞争的定义:两个线程同时访问同一块内存、至少一个在写、没有任何同步。再看一眼第 2 篇的规则:任意时刻,任意多个读者 XOR 恰好一个写者。这两段话描述的是同一个禁区——借用检查器不知道什么是线程,它也不需要知道。其他语言里并发 bug 是运气问题:测试过了,上线炸;Rust 里它是构建问题:编译不过,根本不存在“上线”这一步。这就是“fearless concurrency”这个词的全部含义。

编译期的数据竞争禁令

先看两条“理所当然会失败”的代码——它们在其他语言里不但编译通过,还常常“大部分时候能跑”:

let mut v = vec![1, 2, 3];
let _h1 = thread::spawn(move || v.push(4));
let _h2 = thread::spawn(move || v.push(5));
// error[E0382]: use of moved value: `v`

move 闭包拿走 v 的所有权——而所有权只有一份,第二个闭包拿到的是空气。两个写者并行改同一个 Vec 的剧本,在所有权层面就被撕掉了。再看共享的场景:

let shared = Rc::new(vec![1, 2, 3]);
thread::spawn(move || println!("{shared:?}"));
// error[E0277]: `Rc<Vec<i32>>` cannot be sent between threads safely

第 6 篇说过 Rc 的计数不是原子的——两个线程同时 clone 会让计数错乱,然后双重释放。这个灾难的引信,被一个叫 Send 的 trait 在编译期剪掉了。

SendSync 是理解整个故事的两个标记 trait(marker trait)——没有方法,只是类型的“属性标签”:

  • Send:这个类型的所有权可以安全地转移给另一个线程。
  • Sync:这个类型的共享引用可以安全地同时被多个线程使用。

两个关键设计:其一,它们是 auto trait——编译器自动为几乎所有类型推导(StringVec、你的 struct,只要字段全都满足),不需要你写任何代码;其二,它们把第 6 篇的价目表翻译成了类型事实——Rc 两者都不是(所以跨线程即 E0277),RefCellSend 但不是 Sync(单线程可变,共享不可行),而它们的跨线程亲戚 Arc<Mutex<T>> 两者都是。手动为类型实现这两个 trait 是 unsafe 操作——因为你在替编译器做安全证明,那是 unsafe 篇的事。

Scoped threads:借用栈上数据的合法姿势

thread::spawn 要求闭包是 'static 的——第 3 篇的两副面孔在这里重逢:不是“永生数据”,是“不借用任何东西”。但有一类极其自然的并发模式被这条要求误伤:把一个大数组切开,几个线程各处理一段,主线程等它们干完继续。数据明明活得比线程久,签名却表达不出这个事实。

thread::scope(1.63 稳定)把这个事实变成了可证明的:作用域保证在退出前 join 所有 spawn 的线程,所以借用栈上数据完全合法——线程被证明死在数据之前。

let mut data = vec![1, 2, 3, 4];
let (left, right) = data.split_at_mut(2);
thread::scope(|s| {
    s.spawn(|| left.iter_mut().for_each(|x| *x *= 10));
    s.spawn(|| right.iter_mut().for_each(|x| *x += 100));
});  // 两个线程在此 join;借用随之结束
data.push(5);
println!("{data:?}");  // [10, 20, 103, 104, 5]

注意这段代码里藏着整个系列的回响:两个线程各拿一个 &mut,却不构成数据竞争——因为 split_at_mut 向借用检查器证明了两个切片不重叠。第 2 篇的规则、第 3 篇的生命周期、第 7 篇的“谁选择类型”,在并发语境里全部原样工作。不需要 Arc,不需要 'static,不需要堆分配——这是 Rust 并发最锋利的形态:编译器知道线程何时死,所以知道借用可以活多久。

Arc<Mutex<T>>:共享可变状态的多线程答案

当数据必须活得比作用域久——服务器状态、跨请求缓存——scoped 帮不了你,需要第 6 篇那个经典组合的多线程版本。对应关系精确得像查表:

单线程(第 6 篇) 多线程(本篇) 职责
Rc<T> Arc<T> 共享所有权(计数)
RefCell<T> Mutex<T> 可变性(运行期互斥)
Rc<RefCell<T>> Arc<Mutex<T>> 共享且可变
use std::sync::{Arc, Mutex};
use std::thread;

let counter = Arc::new(Mutex::new(0u32));
let mut handles = vec![];
for _ in 0..4 {
    let c = Arc::clone(&counter);
    handles.push(thread::spawn(move || {
        for _ in 0..1000 {
            *c.lock().unwrap() += 1;
        }
    }));
}
for h in handles { h.join().unwrap(); }
println!("count = {}", *counter.lock().unwrap());  // 4000

四个线程、四千次自增、结果精确等于 4000——每次加一都在锁的保护下。细节里有两个第 5 篇的老朋友:lock() 返回的守卫(MutexGuard)实现了 DerefMut,所以 *guard += 1 直接操作数据;它同时实现了 Drop,守卫离开作用域锁自动释放——Rust 里没有忘记解锁这回事,RAII 把最常见的并发事故变成了不可能。这是 MutexRefCell 最深的差异也是最深的相似:检查依然在运行期,但RefCell 违规是 panic,Mutex 的“违规”是排队等待——互斥本身就是规则。

两条使用纪律:锁的持有范围收到最小(拿着锁做 I/O 是把高速公路变成单行道的最快方式);多把锁时全项目统一获取顺序(死锁不是编译器能证明消灭的——这是本系列里第一条它帮不上忙的规则,诚实地说)。

Channel:用通信共享内存

共享状态之外的另一条路,也是 Rust 标准库直接支持的路:别共享内存,传消息。 Go 把这句格言喊得最响,但在 Rust 里它是字面事实——send 转移的是所有权

use std::sync::mpsc;
use std::thread;

let (tx, rx) = mpsc::channel();
let tx2 = tx.clone();                  // 第二个生产者
thread::spawn(move || {
    for i in 0..3 { tx.send(format!("job {i}")).unwrap(); }
});
thread::spawn(move || {
    tx2.send(String::from("job from second producer")).unwrap();
});
for msg in rx {                        // 最后一个 tx 被 drop 时,迭代自动结束
    println!("got: {msg}");
}

mpsc 是 multiple producer, single consumer:发送端可以 clone 出任意多个,接收端只有一个——这个不对称和第 2 篇的读者写者规则遥相呼应。发送之后值就从发送者的世界里消失了(所有权移交),所以“发送后继续修改同一个对象”这类 bug 无法写出;而接收端的 for msg in rx 在所有发送端 drop 后自然终结——优雅停机是默认值,不需要约定的毒丸消息。选型一句话:线程间传“工作”用 channel,传“状态”用 Arc<Mutex<T>>;拿不准时先试 channel——它把数据流画在代码里,而共享状态把它藏在时序里。

练习,以及第 10 篇

  1. 复现两个反面案例:双重 move 的 E0382、发送 Rc 的 E0277。完整读两条错误——它们就是本篇的两根承重墙,亲手撞一次比读十遍记得牢。
  2. 用 scoped threads 并行计算一个大向量每个分段的和,主线程汇总。要求:不用 Arc、不用堆上的共享状态——只用 split_at_mut(或 chunks_mut)和作用域。
  3. 把计数器 demo 改成“四个生产者 + 一个消费者”:生产者用 channel 发送增量,消费者累加并打印最终值。然后回答一个问题写在注释里:这个版本和 Arc<Mutex<T>> 版本各自让“数据在哪”这个事实变得更容易还是更难看清?

第 10 篇是并发的下半场:异步。async/.await 到底编译成了什么、Future 为什么“什么都不做直到被 poll”、async runtime(tokio 家族)解决了线程模型的哪个瓶颈,以及那条最重要的选型准则——CPU 密集选线程,I/O 密集选异步,而 Rust 让两者在同一类型系统里和平共处。

guest@swangnice:~$