写一段Rust多线程代码,编译器报了个错:
bash
error[E0277]: `Rc<i32>` cannot be sent between threads safely
你去查文档告诉你 Rc<T> 没有实现 Send。那什么是 Send?文档说"实现了 Send 的类型可以安全地在线程间转移所有权"。
读完之后你还是不知道它到底在"防"什么。
这两个 trait 的定义之所以让人困惑,是因为它们本身没有任何方法------它们是 marker trait,纯粹用来做编译期标记。编译器不是通过它们来"执行"什么逻辑,而是通过它们来"拒绝"不安全的代码通过编译。
理解 Send 和 Sync,与其去背定义,不如直接看编译器到底在"拦"什么。
Send:你能不能把这个值丢给另一个线程
bash
use std::thread;
use std::rc::Rc;
fn main() {
let data = Rc::new(42);
thread::spawn(move || {
println!("{}", data);
});
}
编译不过。Rc<i32> 没实现 Send,不能 move 进另一个线程的闭包。
为什么?Rc 的内部是一个引用计数器加一个指向堆数据的指针。Rc::clone() 的时候,计数器加1;Rc 被 drop 的时候,计数器减1;减到0就释放内存。
你可以把 Rc 想成一本图书馆的书,上面挂了一张借阅卡,每借出去一次就在卡上用铅笔加个1,还回来就擦掉一个1,卡上数字归零就把书销毁。这套系统在只有一个图书管理员的时候运转得很好。
问题在于这个计数器不是原子操作。它就是一个普通的 usize,用普通的加减指令修改。如果两个线程同时 clone 或 drop 同一个 Rc------相当于两个管理员同时拿起同一张借阅卡、同时读到上面写着1、各自加1写回2------但正确值应该是3。
更危险的是 drop:两个管理员同时读到卡上是1,各自减到0,各自去销毁同一本书。double free,程序直接炸。
这就是 Rc 不能 Send 的原因:它的内部状态在并发修改下会损坏。不是"用起来不方便"的问题,是"内存会炸"的问题。
换成 Arc 就行了。Arc 相当于把借阅卡上的铅笔换成了机械计数器------拨一下就是加1,不存在两个人同时写导致数错的问题。底层用的是原子操作(AtomicUsize),fetch_add 和 fetch_sub 在CPU层面保证了多线程下的正确性。所以 Arc<T> 在 T: Send + Sync 的时候实现了 Send。
一句话总结 Send 的含义:把这个值的所有权交给另一个线程之后,不会导致内存安全问题。
Sync:你能不能让多个线程同时持有这个值的共享引用
Send 管的是"转移",Sync 管的是"共享"。打个比方,Send 是问"这个东西能不能快递到另一个城市",Sync 是问"这个东西能不能放在公共区域让大家一起用"。有的东西可以寄走但不能共用(比如一把没有锁的日记本),有的东西可以共用但不能寄走(下面会讲到)。
准确地说,T: Sync 等价于 &T: Send------如果一个类型的共享引用可以安全地发送到另一个线程,那这个类型就是 Sync 的。
Cell<i32> 是 Send 的(你可以把它 move 到另一个线程),但它不是 Sync 的。
bash
use std::cell::Cell;
let cell = Cell::new(0);
let r = &cell; // 共享引用
// 如果 Cell 是 Sync,那 &Cell 就是 Send
// 两个线程就可以同时持有 &cell
// 然后同时调用 r.set(1) 和 r.set(2)
Cell::set() 通过共享引用 &self 就能修改内部的值,这是 Rust 的"内部可变性"。它靠的是 UnsafeCell------直接对内存做写操作,没有任何同步机制。
就像一块白板,谁拿到笔都能往上面写字。一个人用的时候没问题,但要是两个人同时往同一个位置写,写出来的就是乱码。Cell 就是这块没上锁的白板------单线程下没问题,但如果两个线程同时调 set(),就是一个裸的数据竞争。
所以编译器禁止 &Cell<T> 被发送到另一个线程。Cell 不是 Sync,&Cell 不是 Send,编译器直接拦住。
RefCell 同理。它的借用检查是运行时做的(一个普通的计数器记录当前有几个借用),这个计数器也不是原子的,多线程下会坏掉。
那 Mutex<T> 呢?Mutex<T> 是 Sync 的(只要 T: Send)。Mutex 相当于给白板装了一把锁和一支笔------想写的人先拿锁,拿到锁才能拿笔,写完把锁和笔一起还回去。你可以安全地把 &Mutex<T> 共享给多个线程------它们都能调 .lock(),但同一时刻只有一个能拿到锁。
Send 但不 Sync,Sync 但不 Send
实现了 Send 但没实现 Sync 的类型?
有。Cell<T> 和 RefCell<T> 就是。
它们可以安全地转移给另一个线程(Send),但不能被多个线程同时通过共享引用访问(非 Sync)。原因上面说了------内部可变性的实现没有同步机制。
反过来,实现了 Sync 但没实现 Send 的类型?
标准库里的典型例子是 MutexGuard<T>。
bash
// MutexGuard 的 trait 实现(标准库源码)
impl<T: ?Sized> !Send for MutexGuard<'_, T> {}
unsafe impl<T: ?Sized + Sync> Sync for MutexGuard<'_, T> {}
MutexGuard 被显式标记为 !Send------不能发送到另一个线程。你可以把它理解成一把钥匙,门规定"谁锁的谁开",你不能把钥匙寄给别人让别人去开锁。底层原因是某些操作系统(比如 POSIX pthread)要求 mutex 必须在加锁的同一个线程上解锁。如果你把 MutexGuard 发到另一个线程,它在那个线程被 drop 时会调用 unlock,但这个线程不是加锁的那个线程,行为就是未定义的。
但钥匙可以给别人看一眼------MutexGuard<T> 在 T: Sync 的时候是 Sync 的。共享引用 &MutexGuard<T> 只能读取内部数据,不能修改(因为没有 &mut),只要 T 本身支持多线程共享读取(T: Sync),那多个线程持有 &MutexGuard<T> 就是安全的。
所以 Send 和 Sync 是两个独立的维度。一个管"能不能转移",一个管"能不能共享"。它们经常同时出现,但互相不蕴含。
你不需要自己实现它们(大部分时候)
绝大多数类型不需要手动实现 Send 和 Sync。编译器会自动推导:如果一个结构体的所有字段都是 Send 的,那这个结构体自动就是 Send 的。Sync 同理。
bash
struct MyData {
name: String, // Send + Sync
count: i32, // Send + Sync
}
// MyData 自动就是 Send + Sync
struct NotSendable {
name: String,
rc: Rc<i32>, // 不是 Send,也不是 Sync
}
// NotSendable 自动就不是 Send,也不是 Sync
一个字段不满足,整个类型都不满足。就像一箱子东西要过安检,里面有一件违禁品,整箱都过不了。编译器在这件事上很保守------宁可误拦,不可放过。
如果你确实知道自己在做什么,可以用 unsafe impl 手动实现:
bash
struct MyWrapper(*mut u8);
unsafe impl Send for MyWrapper {}
unsafe impl Sync for MyWrapper {}
这行 unsafe 的意思是你在跟编译器签军令状------"我保证这个类型在多线程下是安全的"。签错了,后果就是数据竞争和未定义行为,编译器不会再帮你兜底。
标准库里 Arc 的 Send 和 Sync 实现就是 unsafe impl。Arc 内部用了裸指针,裸指针默认既不是 Send 也不是 Sync,但 Arc 的作者通过原子操作保证了多线程安全性,所以用 unsafe impl 手动声明。实际写业务代码的时候,你大概率永远不需要写这行 unsafe impl------用标准库和社区里的类型组合就够了。
其他语言为什么没有这套东西
Java 里你往 new Thread() 的 Runnable 里传一个 HashMap,编译器一句话都不会说。两个线程同时往里面 put,运行时偶发一个 ConcurrentModificationException,而且是"偶发"------测试环境跑一百遍没事,上了生产流量一大就炸。你查三天日志,最后发现是某个 HashMap 没加锁。
C++ 更狠。你自己写个非原子计数器的智能指针,往多线程里传,编译器不拦你,运行时也不报异常------直接内存损坏,程序行为变得不可预测。可能跑了三个月都没事,某天凌晨三点突然 segfault。
这两种语言的哲学是"我信任程序员"。Rust 的哲学是"我不信任程序员,但我给你绕过检查的能力(unsafe)"。
说白了,Send 和 Sync 做的事情很简单:把"运行时才能发现的多线程 bug"变成"编译时直接报错"。
代价是你偶尔得跟编译器较劲。好处是凡是 cargo build 能通过的代码,不会出现数据竞争。死锁还是会有,逻辑 bug 也还是会有,但"两个线程同时读写同一块内存导致的未定义行为"这一类问题,从你写下第一行 Rust 代码开始就不存在了。
Go 选择了另一条路------不在类型系统里做约束,而是提供 race detector(go run -race)在运行时检测数据竞争。能抓到大部分问题,但只能检测到实际执行路径上触发了的竞争,没跑到的代码路径里藏着的 race 它看不到。Rust 的方式更彻底,但学习曲线也更陡。