无畏并发:Send、Sync 与所有权的复用
这一卷本该是最难的,但对你来说可能是最轻松的 —— 因为 Rust 处理并发没有发明任何新规则。数据竞争这个多线程头号杀手,它的定义恰好逐字就是你早就吃透的借用铁律。于是「数据竞争」在 Rust 里,和「移动后使用」是同一类编译错误。这就是「无畏并发」四个字的全部底气。
数据竞争,其实你早就认识它
并发编程最可怕的 bug 叫数据竞争:它极难复现(只在特定的线程交错时机出现)、极难调试(加个打印它就不出现了)、后果不可预测。但它的定义,出奇地简单,同时满足三条就是:
- 两个或以上的线程同时访问同一块内存;
- 其中至少一个在写;
- 没有任何同步机制。
现在把它和第 6 章那条借用铁律并排放 —— 「要么很多人一起读,要么一个人独自写,不能同时」。你发现了吗?「同时访问」= 存在别名(多个引用指向同一处),「至少一个在写」= 存在 &mut。「同时访问 + 至少一个在写」,逐字就是「共享借用与可变借用并存」—— 那个借用检查器从第一天起就在禁止的东西。
数据竞争 = 别名 + 可变,同时存在。
而借用铁律 = 别名 XOR 可变,不许同时。
所以 —— 禁掉了数据竞争,Rust 什么新东西都没做,它只是把单线程里就在执行的「共享 XOR 可变」,延伸到了跨线程。你为学所有权付的那笔苦,在这里连本带利地还给你:并发安全,是白送的赠品。
先看最基础的:开一个线程
开线程很简单,但注意那个 move:
use std::thread;
fn main() {
let data = String::from("给新线程的一本书");
let handle = thread::spawn(move || { // move:把 data 的所有权搬进新线程
println!("新线程拿到了:{}", data);
});
// println!("{}", data); // ✗ 所有权已经搬走,主线程用不了了
handle.join().unwrap(); // 等新线程结束
}
看那个 move || —— 它把 data 的所有权移进了新线程的闭包。移进去之后,主线程就再也碰不到 data 了(老朋友 E0382 会拦你)。这正是安全的来源:所有权只有一个,它在哪个线程,就只有那个线程能碰 —— 没有共享,何来竞争?移动语义,天生就是并发安全的。
Send 与 Sync:编译器怎么知道什么能跨线程
那编译器凭什么知道 String 能安全搬进线程、而某些类型不能?靠两个标记 trait,它们是并发安全的类型级凭证:
Send:这个类型的值,能安全地把所有权移到另一个线程。Sync:这个类型,能安全地把&共享引用同时给多个线程(等价地说,T: Sync当且仅当&T: Send)。
关键是:这两个标记,绝大多数类型由编译器自动推导 —— 一个类型如果它的每个字段都是 Send,它自己就自动是 Send,你什么都不用写。然后当你 thread::spawn 时,编译器检查你搬进去的东西是不是 Send。不是,就编译不过。
下面逐个看:String、i32 能搬(Send);Rc 不能(它的计数不是原子的,两个线程一起加减会算错,所以 Rc 没实现 Send);&mut 不能(那就成了跨线程的可变别名,正是数据竞争);Arc<Mutex<T>> 能,还能让多个线程安全地改同一个值。每个判定背后都是一条清晰的理由。
Rc 那个例子特别能说明问题:Rc 在单线程里好好的,一往线程里搬就编译报错 —— 因为它的引用计数用的是普通加减,两个线程同时 clone 会让计数错乱(一种数据竞争)。编译器在编译期就拦下了这个,并直接建议你换 Arc(下一章)—— 那是 Rc 的「计数用原子操作」的线程安全版。你甚至不用懂原子操作,类型系统会把你推到正确的工具上。
回流
Java / Kotlin:JVM 上的数据竞争是运行时的幽灵。你用 synchronized、volatile、AtomicInteger、并发集合去防它,但编译器完全不检查你有没有防对 —— 忘了加锁、锁错了对象、以为某处不会并发结果会,编译照样通过,然后在生产环境某个高并发的午后,给你一个无法复现的诡异 bug。整个《Java 并发编程实战》那本书的厚度,就是这个「编译器不管、全靠你」造成的。Rust 把这一大类问题提到了编译期:忘了同步?Send/Sync 检查不过,根本编译不出来。
Go:Go 有出色的并发原语(goroutine、channel),哲学是「不要通过共享内存来通信,要通过通信来共享内存」。但 Go 不阻止你共享内存 —— 你照样能让两个 goroutine 竞争一个 map(会直接崩),数据竞争要靠 -race 检测器在运行时抽查。Rust 的 channel(mpsc)提供了同样的「通信共享」范式,但额外用类型系统在编译期堵死了共享内存的竞争。同样的好思想,Rust 多给了一层编译期保证。
这一章的一句话
数据竞争的定义(同时访问 + 至少一个写 + 无同步)逐字就是借用铁律禁掉的「别名 + 可变并存」—— 所以 Rust 的并发安全不是新规则,是所有权规则搬到多线程。Send / Sync 两个标记编译器自动推,让数据竞争变成编译错误。
但线程之间总有真需要共享并修改同一个值的时候(比如多个 worker 更新同一个计数器)。这时该请出并发版的「共享可变」工具了。你会发现它和上一卷的 Rc<RefCell> 长得几乎一模一样 —— 下一章,Arc<Mutex<T>>。