写 Rust 写到一半,突然冒出一个编译错误:Rc<RefCell<T>> cannot be sent between threads safely。第一次见这报错的人多半会懵:我明明没开任何线程,为什么编译器在这儿拦我?其实这正是 Rust 线程安全机制在起作用,而背后站着的就是Send和Sync这两个 trait。今天我不打算罗列官方文档里的定义,而是想从“为什么需要这套机制”讲起,把Send/Sync到底是什么、怎么用、什么时候需要碰unsafe impl,以及日常开发里最常见的坑一次说清楚。这套东西搞明白了,你对 Rust 并发模型的理解基本就到位了。
Rust 的线程安全解决方案,和其他语言走的路子完全不一样。C++、Java 里你靠锁、靠原子变量、靠各种最佳实践来“小心地”避免数据竞争;Rust 则是把这套检查直接搬到编译期,变成类型系统的一部分。也就是说,当你的程序能编译通过的那一刻,跨线程的数据访问其实已经被系统性地审查过一遍了。这种体验一开始会有点不适应,但用久了你会发现,它省掉的调试时间远比适应期多得多。
1. 先从编译期并发安全的思路说起
1.1 Rust 为什么选择用类型系统管并发
很多语言处理并发安全靠的是运行时的“自觉”:你记得加锁就安全,忘了加锁就出 bug,而且这种 bug 通常还特别难复现。C++ 的多线程程序跑着跑着偶发崩溃,排查半天发现是某个共享变量没加锁,这种经历估计不少人都体会过。Java 虽然提供了synchronized、Lock、ConcurrentHashMap等一系列工具,但编译器并没法阻止你写出不加保护的共享访问。
Rust 的思路是把“能不能跨线程访问”这个判断提前到编译期。它不做运行时的额外负担,也不依赖程序员自觉,而是靠类型系统把“安全”变成一条不可绕过的规则。核心就是所有权系统和借用检查器:每一个值都有明确的拥有者,你没法在不知道生命周期的情况下把一个引用丢给另一个线程。
Send和Sync是这套体系里最关键的“接口”。它们不是普通的方法 trait,而是编译器的“标记”:编译器根据类型内部结构,自动判断一个类型能不能安全地跨线程转移(Send),能不能被多个线程同时共享引用(Sync)。这一判断在编译期完成,不需要运行时开销,也不需要你在代码里写任何锁。
1.2 Send 与 Sync 在整个安全体系中的位置
我习惯把 Rust 并发安全模型想象成三层。最底层是所有权系统和生命周期,它们保证“同一时刻只有一个变量拥有这块内存”;中间层是借用检查器,保证“要么只有一个可变引用,要么有多个不可变引用”;最上层才是Send和Sync,它们把单线程的借用规则扩展到多线程场景。
比如单线程里你可以用Rc<T>实现共享所有权,因为整个程序只有一个线程在操作引用计数,计数器增加减少不会有并发问题。但一旦跨线程,Rc的引用计数就不是原子的,两个线程同时增加计数就会产生数据竞争。编译器怎么知道这件事?就是通过Send/Sync标记。Rc<T>没有自动实现Send和Sync,所以当它试图跨线程移动时编译直接失败。
理解了这一层,你就明白Send/Sync不是在“锦上添花”,而是所有权和借用检查在并发域的延伸。它们不是独立的安全策略,而是同一套逻辑的自然结论。
2. 核心概念:Send 和 Sync 到底是什么
2.1 Send:能不能把“东西”搬到另一个线程
Send的定义很直白:一个类型如果实现了Send,就说明这个类型的值可以被安全地移动到另一个线程中。这里的“移动”和普通函数传参的移动本质上是同一回事,只不过跨越了线程边界。
几乎所有常用的类型都是Send的。i32、f64、String、Vec<T>(当T: Send时)、Box<T>(当T: Send)、Arc<T>(当T: Send + Sync)等等。为什么?因为这些类型内部没有共享的、非原子的可变状态。你把一个String从线程 A move 到线程 B,就是简单地搬了一块内存,没有谁在同时引用这块内存,自然没有数据竞争。
不Send的典型代表是Rc<T>。它内部维护一个非原子的引用计数,两个线程如果同时操作这个计数,Race Condition 就出现了。所以编译器直接禁止Rc<T>跨线程移动。
要判断一个自定义类型是否Send,不需要自己实现 trait,编译器会检查它的所有字段。只要有一个字段不Send,整个结构体就不Send:
struct MyStruct { data: Rc<u32>, // Rc<u32> 不是 Send,导致 MyStruct 也不是 Send }这个“自动推导”机制是理解整个系统的关键。你不用手动为每个类型标注“我是否线程安全”,编译器会自动根据字段分析。这样既省事,又不可能漏标。类型组合的时候,安全的组合自然就是安全的,不安全的组合在编译期就被拦下。
2.2 Sync:能不能让多个线程同时“借用”同一个东西
Sync的含义核心是:对于一个类型T,如果&T是Send的,那么T就是Sync的。换句话说,Sync类型允许你同时创建多个不可变引用,并把这些引用发送到不同线程中使用。
听起来有点绕,我举个例子就清楚了。i32是Sync的,因为多个线程可以同时读取同一个i32变量,不会出问题。Mutex<T>也是Sync的(当T: Send时),因为虽然多线程可以共享同一个Mutex,但它内部有锁机制保证同一时刻只有一个线程能拿到可变访问权。
与Send类似,大多数标准库类型都实现了Sync,但有明显例外。Cell<T>和RefCell<T>就不是Sync的,因为它们把“可变性”放在运行时检查上,而运行时检查本身只适用于单线程。你想想:两个线程同时拿到&RefCell<T>,同时尝试 borrow_mut,那内部的可变性标志位就会被并发修改,这显然是数据竞争。
Sync和Send常常是一起提到的,但它们描述的是两个不同的维度。一个类型完全可以Send但不是Sync,比如RefCell<T>:它可以把整个值从一个线程移到另一个线程(只要原线程不再用),但不能被多个线程同时借用引用。
2.3 组合类型的自动推导规则
自动推导有几条简单的规则。第一,如果一个结构体的所有字段都Send,那么结构体就是Send的;所有字段都Sync,结构体就是Sync的。第二,枚举类型同样按所有变体的字段来推导。第三,泛型类型的Send/Sync性质,由泛型参数和内部结构共同决定。
拿Arc<T>来说,它实现Send的条件是T: Send + Sync。为什么要求Sync?因为Arc允许多个线程持有同一个T的引用,如果T本身不能并发引用,那整个Arc就不安全。反过来看Mutex<T>,它实现Send的条件只需要T: Send,因为锁保证了即使多个线程共享引用,实际访问仍然是串行的。
这三个条件写出来就是标准库实现里的样子:
unsafe impl<T: ?Sized + Sync + Send> Send for Arc<T> {} unsafe impl<T: ?Sized + Sync + Send> Sync for Arc<T> {} unsafe impl<T: ?Sized + Send> Send for Mutex<T> {} unsafe impl<T: ?Sized + Send> Sync for Mutex<T> {}这段代码里的unsafe impl是“手动告诉编译器:我保证这个类型是安全的”。标准库已经为大部分基础类型做了正确标注,你在日常业务里很少需要自己写,但理解它的写法有助于你明白背后的逻辑。
3. 实操:如何用 Send/Sync 约束泛型并发接口
3.1 泛型约束:让编译器替你守住线程边界
Send/Sync最常用的场景是写泛型并发代码。例如你写一个线程池,希望工作函数能在多个线程间共享,就必须显式声明F: Send + Sync。如果你不写这个约束,编译器根本不敢让这个函数跨线程使用:
use std::thread; fn spawn_job<F>(f: F) where F: FnOnce() -> () + Send + 'static, { thread::spawn(f); }thread::spawn的签名就要求闭包实现Send + 'static。这里'static是因为新线程可能比当前函数活得久,闭包必须不持有任何借用;Send是因为闭包需要被移动到新线程中去执行。
写多线程代码时,如果遇到“不知道为什么报错”,十有八九是因为某个类型没有满足Send或Sync约束。这时候不要急着unsafe impl,先检查是不是选错了容器类型。绝大多数情况下,换一种数据结构就能解决。
3.2 从编译错误倒推安全性:三个常见报错解读
我挑三个最常碰到的编译错误,把它们的含义和解决方案讲明白。
第一个错误是Rc<RefCell<T>> cannot be sent between threads safely。很多人会疑惑:我明明已经用了RefCell做内部可变性,为什么还是不行?问题在于Rc不是Send,整个组合类型自然也不是。解决办法是把Rc换成Arc,把RefCell换成Mutex或RwLock:
// 错:Rc 不能跨线程 // let shared = Rc::new(RefCell::new(42)); // 对:Arc + Mutex 可以跨线程共享 let shared = Arc::new(Mutex::new(42));第二个错误是future cannot be sent between threads safely。这个通常在 async 代码里出现。Future内部可能持有某个不Send的类型,导致整个 future 无法跨线程。最常见的元凶是Rc和RefCell,异步任务里如果用了非Send的锁或者计数器,一旦任务被.await挂起再在另一个线程恢复,就会触发这个错误。解决办法是把内部不Send的类型换成Mutex、原子类型等Send版本。
第三个错误是cannot share data between threads safely。当你试图在多个线程中共享某个类型时,如果这个类型不是Sync,编译器就会报出类似信息。比如你想用thread::scope把多个线程里都传入同一个RefCell<Vec<u8>>的引用,就会失败。正确处理是用Mutex包住可变数据,或者改用原子类型。
这三个错误本质上都是同一套逻辑的不同面貌,理解了底层原则,就不会被报错信息吓到。
3.3 什么时候需要 unsafe impl Send/Sync,以及如何做对
说实话,日常业务代码里你几乎不需要手写unsafe impl Send或Sync。但确实会有一些特殊场景,比如对接 C 库、封装 FFI 类型、或者定义一些底层原语时,编译器没法自动推导出安全性,需要你手动标注。
最典型的例子是裸指针*const T和*mut T。裸指针本身既不是Send也不是Sync,因为编译器不知道它指向什么。但如果你能保证某个裸指针确实可以跨线程,就可以手动标注:
struct MyFfiHandle { ptr: *mut c_void, } // 我保证这个裸指针指向的数据是线程安全的 unsafe impl Send for MyFfiHandle {} unsafe impl Sync for MyFfiHandle {}写unsafe impl之前,一定要确认两件事。第一,这个类型的所有字段在跨线程场景下确实不可变或访问是安全的;第二,你愿意承担万一判断错误导致数据竞争的全部后果。Rust的unsafe不是让编译器闭嘴,而是把安全检查的责任交到你手里。一旦你标注错误,反而比普通竞态更隐蔽,因为编译器不会再给你任何提示。
有一个经验法则:如果你不确定这个类型到底应不应该实现Send/Sync,那就不实现。宁可编译器拦着你,也不要自己硬解。
4. 深入:Rc、RefCell、裸指针为什么不是线程安全的
4.1 Rc 为什么不 Send:引用计数不是原子的
Rc<T>的设计目标是单线程内共享所有权。它内部存了一个引用计数,每次clone时计数加一,每次 drop 时计数减一。当计数归零时才真正释放内存。
问题在于,这个计数加减操作不是原子的。在单线程里没问题,但在多线程里,两个线程同时 clone 同一个Rc,就可能在同一个瞬间对计数执行“读取-增加-写回”,导致实际计数少了一次。最终结果可能是内存被提前释放,use-after-free 直接出现。
那为什么Arc<T>可以?因为Arc的引用计数用的是原子操作(AtomicUsize)。原子操作的每一步都被 CPU 保证不可分割,所以多个线程同时加减计数不会出现竞争。
这个例子很好地体现了 Rust 的设计哲学:不是所有共享所有权方案都不可用,而是必须根据场景选择合适的工具。单线程用Rc,多线程用Arc,编译器用类型系统把二者区分开,不给你混淆的机会。
4.2 RefCell 为什么不 Sync:运行时检查不是线程安全的
RefCell<T>提供的是“内部可变性”,它把借用检查从编译期挪到了运行时。你调用borrow_mut时,它检查当前是否已经有活跃的借用,有就 panic,没有就返回可变引用。
这套机制在单线程里没问题,因为同一时刻只有一个线程在执行代码,检查标志位的读写天然是串行的。但多线程共享&RefCell<T>时,两个线程可能同时通过borrow_mut的检查,然后同时拿到可变引用,标志位本身的读写就是竞争的根源。
更关键的是,即使两个线程并不会真正同时访问,你也没法保证这一点,因为你控制不了线程调度。所以编译器直接判定RefCell<T>不是Sync,禁止这种使用方式。
替代方案有两种:如果你真的只需要单线程的可变共享,用RefCell;如果必须跨线程,就用Mutex<T>或者RwLock<T>。这两者的本质区别在于“运行时检查的粒度”。Mutex的锁机制保证了同一时刻只有一个线程能访问,而RefCell没有这个能力。
4.3 裸指针和 FFI 场景:把责任交给人
裸指针*const T和*mut T不实现Send/Sync,这很好理解:编译器对它们指向的数据一无所知。它不知道数据是否可变、生命周期多长、是否线程安全。
但在 FFI 和底层系统编程里,裸指针又是绕不开的。比如你调用一个 C 库,它返回一个句柄(handle),本质就是一个指向内部数据的指针。如果 C 库保证句柄可以跨线程访问,你就需要手动为封装类型实现Send/Sync。
这里特别强调一点:unsafe impl是对编译器最强势的“命令”,你相当于在说“我知道这个类型不安全,但我保证使用时不会出问题”。所以一旦写了,就要在代码注释里写清楚为什么安全、约束是什么。防止后来维护的人误用。
实际做法一般是把裸指针包在一个自定义结构体里,对外暴露安全的方法,同时只对结构体本身标注Send/Sync,内部裸指针的细节不暴露给使用者:
struct FfiContext { inner: *mut FfiInner, } unsafe impl Send for FfiContext {} unsafe impl Sync for FfiContext {} impl FfiContext { fn new() -> Self { FfiContext { inner: unsafe { ffi_create() }, } } } impl Drop for FfiContext { fn drop(&mut self) { unsafe { ffi_destroy(self.inner) }; } }这样的封装既保证了使用安全,又把 unsafe 控制在最小的范围内。经验是:unsafe代码要尽量“局部化”,不要让它污染整个项目的安全边界。
5. 常见问题与排查技巧实录
5.1 “Rc<RefCell > cannot be sent” 的治本方案
这个问题太典型了,我单独拿出来说。很多人写多线程代码时会自动带入单线程思维,随手就是Rc<RefCell<T>>,然后被编译错误打懵。
治本方案不是“把Rc换成Arc+Mutex”就完事了,而是要重新审视数据访问模式。如果多个线程需要同时读写共享数据,用Arc<Mutex<T>>;如果只是读多写少,用Arc<RwLock<T>>;如果数据很小,考虑直接用原子类型,比如Arc<AtomicU64>。
经常有人问我:为什么不能用Arc<RefCell<T>>?因为RefCell不是Sync,而Arc<T>要实现Send/Sync要求T: Send + Sync。RefCell连Sync都不满足,自然不能组合。这一条规则直接锁死了组合方式。
5.2 共享状态:选 Mutex 还是 RwLock 还是原子类型
这是多线程开发里绕不开的选型问题。简单场景可以用优先规则:
- 数据是单个整数或者布尔值,用原子类型(
AtomicU32、AtomicBool等),性能最好; - 数据是复杂结构且读写都比较频繁,用
Mutex,逻辑简单不容易出错; - 读频率远大于写频率,用
RwLock,允许并发读,但写的时候会阻塞所有读者。
很多新手一上来就全用Mutex,其实在热点路径上原子变量的性能优势非常明显。AtomicU64的 fetch_add 在现代 CPU 上只是一个指令的功夫,而Mutex加锁解锁都有额外开销。
但原子类型不是万能的。如果你要操作的数据不是一个整数,或者操作本身需要多个步骤保持原子性(比如读-改-写),Mutex依然是更稳的选择。原子类型的 CAS 循环写起来又累又容易出错,得不偿失。
5.3 async 代码里的 Send 陷阱:Future 为什么突然不 Send 了
async代码风格和多线程经常一起出现,但这里有个隐藏的陷阱。一个Future在.await挂起时,它内部的所有状态(包括已经创建但还没用完的局部变量)都被保存在一个状态机里。这个状态机整体会被当作一个值在线程之间移动,所以Future本身必须实现Send。
如果你的 async 闭包里持有Rc、RefCell或者其他非Send类型,整个Future就不是Send的。于是你会看到一个很奇怪的报错:单线程跑得好好的,一放到线程池或者tokio::spawn里就编译不过。
排查思路很简单:看报错信息里有没有提到具体的非Send类型。通常会告诉你Rc<...>orRefCell<...>无法发送。找到之后要么换成Arc+Mutex,要么调整代码结构,让这些类型不要跨越.await点存活。
有一个小技巧:用tracing或者断言帮你提前发现类型是否Send:
fn assert_send<T: Send>(_: &T) {} assert_send(&my_future);这一行代码就把“能否跨线程”变成了编译期检查。在复杂的 async 代码里,这个技巧能快速定位问题出在哪一层。
5.4 快速检查 Send/Sync 的小工具和断言技巧
除了上面提到的assert_send函数,还有一个反向断言技巧:如果你想确认某个类型“确实不是”Send,可以这样写:
fn assert_not_send<T>(_: &T) { // 这个函数故意不约束 T: Send,所以编译成功 } // 如果你想让它编译失败,可以定义一个 trait 检查 struct NoSend<T>(PhantomData<T>);其实最常见的做法是利用编译器的特性。新建一个函数要求T: Send,传一个带Rc的类型进去,编译器立刻报错,你就能确认这个类型确实不Send。这个方法在调试自定义类型时很好用。
另外,在写较复杂的并发库时,我习惯在测试模块里加上静态断言,防止后来者不小心给共享类型加了一个非Send字段:
const _: () = { fn assert_send<T: Send>() {} fn assert_sync<T: Sync>() {} // 在编译期验证 MySharedType 的线程安全性质 let _ = assert_send::<MySharedType>; let _ = assert_sync::<MySharedType>; };这段代码像一个“测试用例”,一旦MySharedType的字段变成非Send/ 非Sync,下一次编译就会立即失败,比任何文档都直观。
6. 结合所有权与生命周期:Send/Sync 不是孤立概念
很多人把Send/Sync当成两个孤立的 trait 来背,结果遇到实际问题还是不会用。实际上它们和所有权系统、生命周期完全是同一个体系。没有所有权和借用检查,Send/Sync就成了无源之水。
一个经典的例子:你可以把&mut T发送到另一个线程,前提是这个可变引用是独占的。所有权系统保证了同一时刻只有一个&mut T存在,所以把它移动到另一个线程并不会引发竞争。但如果你试图把&T发送到多个线程,就要求T: Sync。这里每一个判断都直接从所有权和借用规则中推出,而不是凭空规定。
生命周期则规定了引用的有效范围。thread::spawn要求闭包是'static,因为它不知道新线程什么时候结束,为了安全必须要求引用活得和进程一样长。thread::scope则允许非'static的借用引用跨线程,因为它能保证所有线程在作用域结束前汇合。这两者都是在用生命周期的信息做并发安全判断。
理解了这一层,再看Send/Sync就不会觉得它们是一堆需要死记硬背的规则,而是一个逻辑自洽的体系。你在写代码时只要问自己一个问题:这个值被多个线程访问时,会不会出现数据竞争?如果所有权、借用、生命周期都已经约束到位,答案自然就清楚了。
7. 实战案例:从零封装一个线程安全计数器
理论讲再多,不如一个完整案例有用。假设我们要实现一个线程安全的计数器,多个线程同时在后台累加,主线程随时可以读取当前值。
第一个版本,用原子变量实现:
use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::thread; struct Counter { value: AtomicU64, } impl Counter { fn new() -> Self { Counter { value: AtomicU64::new(0), } } fn increment(&self) { self.value.fetch_add(1, Ordering::Relaxed); } fn get(&self) -> u64 { self.value.load(Ordering::Relaxed) } } fn main() { let counter = Arc::new(Counter::new()); let mut handles = Vec::new(); for _ in 0..8 { let counter = Arc::clone(&counter); handles.push(thread::spawn(move || { for _ in 0..10000 { counter.increment(); } })); } for handle in handles { handle.join().unwrap(); } println!("final value: {}", counter.get()); }这个例子中,AtomicU64本身是Send + Sync,Counter的所有字段都是Send + Sync,所以Counter自动实现了Send + Sync,Arc<Counter>可以放心地在多个线程间共享。整个过程中没有任何手写unsafe,所有安全都由编译器保证。
第二个版本,如果计数的数据不是数字而是一个复杂结构,就需要Mutex:
use std::sync::{Arc, Mutex}; struct SharedList { data: Vec<u32>, } impl SharedList { fn add(&mut self, item: u32) { self.data.push(item); } } fn main() { let list = Arc::new(Mutex::new(SharedList { data: Vec::new() })); let mut handles = Vec::new(); for i in 0..4 { let list = Arc::clone(&list); handles.push(thread::spawn(move || { let mut guard = list.lock().unwrap(); guard.add(i); })); } for handle in handles { handle.join().unwrap(); } let guard = list.lock().unwrap(); println!("len: {}", guard.data.len()); }Mutex<SharedList>能被多个线程共享,靠的是Mutex的锁机制。这个版本的代价是每次访问都要申请锁,性能肯定不如原子变量,但它能保护任意复杂的数据结构。日常开发里这两种模式基本覆盖了绝大多数共享状态场景。
我在实际项目中见过不少同事为了“性能”强行用原子变量手写复杂数据结构,结果代码又长又难维护,最后 bug 还一堆。我的建议是:先保证正确性,用Mutex或RwLock把逻辑写清楚,等 profiling 确认确实是瓶颈了,再做精细优化。Rust 的类型系统已经帮你挡住了最难缠的并发问题,剩下的优化应该建立在可读性之上。
写到这里,我想起最初学 Rust 时的一个体会:Send和Sync这两个 trait 看上去简单,但它们代表的“编译期并发安全”理念,是 Rust 区别于其他系统级语言最核心的地方。你可以不会写复杂的宏,可以不精通 async 底层细节,但只要吃透了所有权、借用、生命周期和Send/Sync之间的关系,写并发代码时会非常踏实。编译器会在你犯错之前就提醒你,这种体验是其他语言给不了的。如果你刚接触这些概念,建议先跑一遍上面的两个例子,再回去改一改字段类型,看看编译器会报什么错,比看十遍理论都管用。