多线程代码最难的地方,往往不是“会不会开线程”,而是“哪些数据能跨线程传、哪些引用能共享”。Rust 把这件事前移到编译期,用 Send 和 Sync 两个 trait 参与类型系统判断,让不少隐蔽的数据竞争在运行前就暴露出来。
这篇文章不只解释概念,还会把自动推导规则、常见失败案例、PhantomData 的控制方式,以及 unsafe impl 的边界放到一起看。读完你可以更快判断:一次报错到底是在提醒你换成 Arc,还是在要求你重新审视类型本身的线程语义。
为什么 Send 和 Sync 会成为并发代码的分水岭
写多线程程序时,真正棘手的不是“加哪一把锁”,而是“哪里原本就不该共享”。如果把这件事完全交给开发者,代码可能在生产环境跑很久才因为一次罕见调度触发数据竞争,而这类问题通常极难复现。
Rust 的做法是把线程安全验证放进编译器:当一个类型要跨线程移动,或者要被多个线程共享引用时,编译器会根据它是否满足 Send 和 Sync 来决定放行还是报错。它不依赖运行时检查,也不把全部风险留给代码审查和测试覆盖。
这也是为什么很多 Rust 初学者第一次接触并发时,会被一长串 trait bound 错误卡住。报错本身不是重点,重点是编译器在借类型结构告诉你:这份数据的线程语义并不成立。
Send 和 Sync 到底分别表示什么
graph TD
A[类型 T] --> B{所有字段都是 Send?}
B -->|是| C[T 自动实现 Send]
B -->|否| D{T 包含裸指针
或 Rc?}
D -->|是| E[T 不实现 Send]
A --> F{所有字段都是 Sync?}
F -->|是| G[T 自动实现 Sync]
F -->|否| H{T 包含内部可变性
且非线程安全?}
H -->|是| I[T 不实现 Sync]
H -->|否| G
style C fill:#16213e,stroke:#0f3460,color:#fff
style G fill:#16213e,stroke:#0f3460,color:#fff
style E fill:#1a1a2e,stroke:#e94560,color:#ffff
style I fill:#1a1a2e,stroke:#e94560,color:#ffff
Send:所有权能不能安全转移到另一个线程
Send 表示类型 T 的所有权可以安全地从一个线程移动到另一个线程。自动推导的基本规则很直接:如果类型 T 的所有字段都实现了 Send,那么 T 通常也会自动实现 Send。

典型的非 Send 类型包括:
Rc:引用计数依赖非原子操作,多线程同时修改可能造成计数错误*const T/*mut T:裸指针本身不提供线程安全保证UnsafeCell:它是内部可变性的基础原语,但自身不负责同步
Sync:不可变引用能不能被多个线程共享
Sync 表示 &T 可以安全地在线程之间共享。换句话说,如果某个类型是 Sync,就意味着它的不可变引用可以被多个线程同时持有而不破坏安全性。
常见的非 Sync 类型包括:
Cell/RefCell:提供内部可变性,但没有同步保护MutexGuard:持锁状态不应该被随意跨线程传递,否则容易引出更复杂的并发问题
最容易混淆的一点:Send 和 Sync 不是一回事
Send: T 可以 move 到另一个线程 Sync: &T 可以被多个线程同时持有
两者的关注点不同:Send 看的是“值本身能不能搬过去”,Sync 看的是“同一个值的共享引用能不能同时给多个线程看”。
因此,一个类型可以是 Send 但不是 Sync。原文给出的例子是 Mutex:它的所有权可以转移,但它的线程语义依赖受控访问,而不是把裸引用随便共享出去。至于“Sync 但不 Send”的情况则很少见,通常也不符合直觉上的常规设计。
几个最常见的编译期验证案例
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use std::thread;
/// 案例一:Rc vs Arc —— Send 推导差异
///
/// 为什么这段代码编译失败:
/// Rc 的引用计数用 Cell 实现(非原子操作)
/// Cell 不实现 Sync,因此 Rc 不实现 Send
/// 编译器拒绝在 thread::spawn 中捕获 Rc
fn rc_cannot_cross_thread() {
let rc = Rc::new(42);
// 编译错误:`Rc` cannot be sent between threads safely
// thread::spawn(move || {
// println!("{}", rc);
// });
// 正确做法:使用 Arc(原子引用计数)
let arc = Arc::new(42);
thread::spawn(move || {
println!("{}", arc); // Arc 实现了 Send,编译通过
});
}
这里最典型的区别就是 Rc 和 Arc。两者都做引用计数,但 Rc 只面向单线程,计数更新不是原子的,所以它不能安全跨线程移动;Arc 则专门为并发共享设计,可以满足跨线程传递的要求。

/// 案例二:Mutex 是 Send 但不是 Sync
///
/// 这个例子展示了 Send 和 Sync 的微妙区别
mod case_mutex_send_not_sync {
use std::sync::Mutex;
use std::thread;
pub fn demonstrate() {
let mutex = Mutex::new(0);
// Mutex 实现了 Send:可以把所有权转移到另一个线程
// 这对用 Mutex 保护跨线程共享数据至关重要
thread::spawn(move || {
let mut guard = mutex.lock().unwrap();
*guard += 1;
}).join().unwrap();
// 但是 &Mutex 不能随意跨线程共享
// 因为 Mutex 允许通过不可变引用获取可变访问(内部可变性)
// 编译器需要确保引用传递是安全的
}
}
Mutex 相关场景容易让人误判。它之所以重要,不是因为“加了锁就万事大吉”,而是因为它展示了 Rust 如何区分“所有权转移”和“引用共享”这两类不同的线程行为。
/// 案例四:Arc> 的自动推导链 /// /// 为什么 Arc > 同时是 Send 和 Sync(当 T: Send 时): /// - Arc : 当 T: Send + Sync 时,Arc 是 Send + Sync /// - Mutex : 当 T: Send 时,Mutex 是 Send + Sync /// - 因此 Arc >: 当 T: Send 时,同时是 Send + Sync /// /// 这个组合是 Rust 中最常用的线程安全共享模式 fn arc_mutex_auto_derivation() { let shared = Arc::new(Mutex::new(vec![1, 2, 3])); let shared_clone = Arc::clone(&shared); thread::spawn(move || { // Arc >> 实现了 Send,可以 move 进闭包 let mut data = shared_clone.lock().unwrap(); data.push(4); }).join().unwrap(); // Arc >> 实现了 Sync // 可以安全地在多个线程间共享不可变引用 let guard = shared.lock().unwrap(); println!("Final: {:}", *guard); }
Arc 之所以常见,不是因为它神奇,而是因为推导链条完整闭合了:Arc 负责跨线程共享所有权,Mutex 负责串行化内部访问,只要内部的 T: Send,这个组合通常就能同时满足 Send 和 Sync。
这些案例背后的系统价值
这些例子最值得看的地方,不是某个技巧本身,而是 Rust 的整体思路:默认让编译器替你兜底,只有在确实需要突破自动规则时,才显式写出 unsafe impl Send 或 unsafe impl Sync,把责任清楚地放回开发者手里。
当自动推导不够用时,如何用 PhantomData 精细控制
/// 案例三:自定义类型 Send/Sync 推导的精细控制
///
/// 使用 PhantomData 标记来手动控制自动推导
use std::marker::PhantomData;
/// 一个包装了 C 库句柄的类型
/// C 库的句柄通常是线程不安全的,但可能在某些条件下安全使用
pub struct ForeignHandle {
raw: *mut std::ffi::c_void,
/// 为什么用 PhantomData<*const u8>:
/// *const u8 既不 Send 也不 Sync
/// 编译器会阻止自动推导 ForeignHandle 的 Send/Sync
/// 这比标记 !Send 更灵活——可以在确认安全后手动 unsafe impl
_not_send: PhantomData<*const u8>,
}
// 确认句柄在特定条件下可安全跨线程传递后,
// 手动 unsafe impl Send
//
// 为什么需要 unsafe impl 而非让编译器自动推导:
// 编译器看到 *mut c_void 就会拒绝推导 Send
// 但这可能是误报——如果 C 库文档明确声明线程安全
unsafe impl Send for ForeignHandle {}
自动推导依赖的是结构信息,也就是“字段长什么样”。可现实里的线程安全很多时候取决于语义约束,例如底层 C 库的文档、句柄是否绑定线程、API 是否要求单线程初始化等。这些信息编译器本身并不知道。
这时 PhantomData 就成了一个很实用的工具。它不一定真的持有某个值,但能把某种语义影响带进类型系统,让编译器停止对 Send/Sync 做过于乐观的自动推导。
/// 案例五:通过 newtype 模式阻断 Send 推导 /// /// 为什么需要阻断自动推导: /// 某些类型在语义上不应跨线程,但字段碰巧都是 Send 的 /// 如:绑定到特定 OS 线程的句柄(信号处理、TLS 数据) pub struct ThreadBound{ inner: T, /// PhantomData > 阻止 Send 推导 /// 因为 Rc<()> 不实现 Send /// 这不是真正持有 Rc,只是借用其 Send 否定语义 _not_send: PhantomData >, } impl ThreadBound { pub fn new(inner: T) -> Self { Self { inner, _not_send: PhantomData } } pub fn get(&self) -> &T { &self.inner } }
这种做法的价值在于,它让“这个类型不该跨线程”成为类型定义的一部分,而不是散落在文档注释或团队约定里。对于线程绑定资源、TLS 数据、特定 OS 句柄之类对象,这种约束往往比事后排查 bug 更可靠。
自动推导的边界,以及 unsafe impl 真正危险的地方
自动推导很强,但它不是万能的。编译器只能检查字段组合,无法理解业务语义,也无法替你阅读第三方 C 库的并发说明。因此,“字段看起来能跨线程”并不等于“这个抽象真的适合跨线程使用”。

一旦写下 unsafe impl Send,你实际上是在承诺:这个类型暴露出来的全部公共 API,在多线程环境下都不会制造未定义行为、数据竞争或违反底层库约束。这个承诺没有额外的编译器保护,出错后的问题通常也更隐蔽。
原文里提到几个高频误用点,值得单独记住:
- 对所有 FFI 类型不加区分地写
unsafe impl Send/Sync - 遇到共享需求就直接用
Mutex包一层,而不先理清数据流 - 过度依赖
Arc,最后把设计问题变成锁争用问题>
这也是理解自动推导规则的真正意义:不是为了“绕过编译器”,而是为了知道编译器何时在替你挡风险,何时已经把责任交还给你自己。
写 Rust 并发代码时可以怎样快速判断
把整套机制压缩成实战判断,大致可以遵循下面几个思路:
- 先区分需求到底是“跨线程转移所有权”还是“跨线程共享引用”,对应的问题分别是
Send和Sync - 看到
Rc、Cell、RefCell、裸指针、UnsafeCell这类成分时,先默认它们会阻断自动推导 - 需要共享可变数据时,优先理解
Arc为什么成立,而不是把它当成固定模板照搬> - 一旦涉及 FFI、系统句柄或线程绑定资源,就把重点从“字段是否可推导”转向“语义是否真的线程安全”
- 只有在证据充分时再写
unsafe impl,并把安全前提明确固化在类型和 API 设计里
理解这些规则后,你面对编译器关于 Send/Sync 的报错时,通常就不再只是“修到能过编译”,而是能判断自己到底是在修复一个抽象问题,还是在补上一条真正的线程安全边界。







