Rust 的 Send 与 Sync 到底在保证什么
Rust 的 Send 和 Sync 是 Rust 并发编程中最重要的两个 Trait。尤其是在研究 Rust 底层系统、内存安全分析器时,理解它们能帮助你进一步理解 Rust 如何在编译期阻止数据竞争,以及 Arc、Mutex、线程之间的数据传递究竟是怎么回事。
先记住最核心的区别:
- Send:一个值能不能安全地转移到另一个线程。
- Sync:一个值能不能安全地被多个线程通过共享引用访问。
Rust 官方定义是:
T: Send:类型 T 的值可以安全地在线程之间转移。T: Sync:类型 T 可以安全地在线程之间共享引用。严格来说,T: Sync当且仅当&T: Send。
下面从语言语义、内存模型、源码示例和底层实现逐层拆解。
一、先用一张表理解 Send 和 Sync
| 对比 | Send | Sync |
|---|---|---|
| 核心问题 | 值能否转移到另一个线程? | 共享引用能否被多个线程安全使用? |
| 关注对象 | 所有权和值的转移 | 共享引用与并发访问 |
| Rust 定义 | T: Send | T: Sync 当且仅当 &T: Send |
| 典型用途 | thread::spawn 传递闭包与数据 | 多线程共享 Arc<T> 等数据 |
| 不满足时意味着什么 | 不能安全地将该值转移到其他线程 | 不能安全地让多个线程共享对该值的引用 |
| 是否自动实现 | 通常是 | 通常是 |
最简单的记忆方法:
- Send = 移交所有权。
- Sync = 安全共享引用。
不过,这只是帮助理解的直觉。Send 不代表值只能被一个线程使用,Sync 也不意味着类型内部完全没有可变状态。
二、Send:值能否跨线程转移?
2.1 从所有权开始理解
看一个简单的例子:
use std::thread;
fn main() {
let value = String::from("hello");
let handle = thread::spawn(move || {
println!("{value}");
});
handle.join().unwrap();
}
这里有三个重要步骤。
第一步:创建 String
let value = String::from("hello");
在常见的 64 位 Rust 实现中,String 可以概念性地理解为:
String
┌─────────────────┐
│ ptr │ ──────┐
│ len │ │
│ capacity │ ▼
└─────────────────┘ ┌─────────────┐
│ hello │
│ UTF-8 bytes │
└─────────────┘
这只是帮助理解的示意布局,不应当把具体字段顺序或布局当成 Rust 的稳定 ABI 保证。
第二步:使用 move
thread::spawn(move || {
println!("{value}");
});
move 使闭包取得它捕获的 value 的所有权。原线程不能再直接使用已经移交的 value。
第三步:新线程使用这个值
新线程拿到了 String,并可以读取其中的数据。
为什么这是安全的?
因为 String 实现了 Send,编译器允许它的所有权跨线程转移。
在这里,Rust 的所有权系统和 Send Trait 配合起来,保证了线程之间的值传递满足相应的安全约束。
2.2 Send 不等于复制
这里非常容易产生误解。
let value = String::from("hello");
String 并不是因为实现了 Send 才能够被复制。事实上,String 通常不实现 Copy。
这几个概念要区分开:
| 机制 | 含义 |
|---|---|
Copy | 某些值可以按位复制,复制后原变量仍然可用 |
Clone | 类型提供显式的克隆操作 |
Send | 值可以安全地跨线程转移 |
Sync | 类型可以安全地通过共享引用跨线程访问 |
例如:
let value = String::from("hello");
let another = value;
// println!("{value}"); // 编译错误:borrow of moved value
println!("{another}");
这是所有权转移。
而 Send 决定这种值能否在满足其他线程约束的情况下被安全地传递到另一个线程。
三、Sync:为什么共享引用需要单独的安全保证?
现在考虑另一种情况。
我们不想把数据所有权交给新线程,而是希望多个线程同时读取同一个对象。
例如:
use std::sync::Arc;
use std::thread;
fn main() {
let value = Arc::new(String::from("hello"));
let a = Arc::clone(&value);
let b = Arc::clone(&value);
let t1 = thread::spawn(move || {
println!("{a}");
});
let t2 = thread::spawn(move || {
println!("{b}");
});
t1.join().unwrap();
t2.join().unwrap();
}
这里的情况与之前不同。
我们创建了两个 Arc 智能指针,它们指向同一个底层分配的对象。
堆上的 String
┌────────────────┐
│ "hello" │
└────────────────┘
▲
│
Arc 管理的共享分配
▲
┌───────┴───────┐
│ │
Arc clone 1 Arc clone 2
│ │
线程 1 线程 2
这时就出现了一个关键问题:
如果两个线程同时访问同一个对象,Rust 怎么知道这种访问是安全的?
答案是:需要满足相关的线程安全约束。
对于 Arc<T>,它可以安全地共享所有权,但这不意味着任意 T 都能被安全地并发访问。
要通过 Arc<T> 在不同线程之间共享数据,通常需要 T: Send + Sync。
如果只需要在不同线程之间转移一个值,而不需要共享引用,那么所需要的约束可能不同。
例如,Mutex<T> 正是为此设计的:它可以通过锁来安全地保护一个不满足 Sync 的 T,前提是 T: Send。
这说明:
- Send 关注值的跨线程转移。
- Sync 关注共享引用的跨线程安全性。
- 智能指针和同步原语会组合这些约束,形成不同的线程安全能力。
四、Send 和 Sync 的数学关系
这是理解它们最重要的部分。
Rust 官方给出了一个非常精确的定义:
T: Sync ⟺ &T: Send
换句话说:
如果类型 T 的共享引用 &T 可以安全地转移到另一个线程,那么 T 就是 Sync。
为什么?
假设:
let value: T = /* ... */;
let reference: &T = &value;
现在我们把 reference 传递给另一个线程。
线程 A 线程 B
T
│
▼
&T ────────────────────────► &T
我们没有转移 T 的所有权,只是转移了一个指向它的共享引用。
如果 &T 能够安全地跨线程转移,就说明这种共享访问满足 Rust 的安全要求。
因此:
T: Sync ⟺ &T: Send
这个关系不是一种经验规则,而是 Rust 对 Sync 的正式定义。
4.1 那么 &T: Send 需要什么条件?
&T: Send ⟺ T: Sync
因此,下面两个结论是等价的:
T 实现 Sync
⇕
&T 实现 Send
这也是为什么 Send 和 Sync 必须一起理解。
4.2 &mut T 又是什么情况?
对于可变引用:
&mut T: Send ⟺ T: Send
也就是说,只要 T 是 Send,它的独占可变引用就可以在线程之间转移。
但注意,转移 &mut T 的前提是引用的生命周期与所有权关系仍然合法。这不是说可以随意把一个普通的借用引用塞进一个 'static 的线程闭包,Rust 还需要检查生命周期。
官方文档对这些引用类型与 Send、Sync 的关系有明确说明。
五、为什么 Rc<T> 不是 Send,也不是 Sync?
这是一个理解底层实现的绝佳例子。
Rust 有两种常见的引用计数智能指针:
Rc<T>Arc<T>
它们都可以让多个所有权句柄指向同一份堆数据,但它们的线程安全属性不同。
5.1 Rc 的基本原理
可以把 Rc<T> 的内部结构简化理解为:
Rc<T>
│
▼
┌───────────────────────┐
│ strong_count: usize │
│ weak_count: usize │
│ value: T │
└───────────────────────┘
这是概念模型,不是对标准库内部布局的承诺。
当你执行 Rc::clone(&a) 时引用计数会增加;当 Rc 被销毁时,引用计数会减少。如果最后一个强引用被销毁,底层值就可以被释放。
问题在于:普通 Rc 使用非原子引用计数。
如果两个线程同时修改引用计数,就可能发生数据竞争。例如:
初始 strong_count = 2
线程 A 线程 B
读取 strong_count = 2 读取 strong_count = 2
计算 2 + 1 计算 2 + 1
写入 3 写入 3
最终可能错误地得到 3,而不是 4
这是一个简化的竞态示意。
实际情况下,两个线程必须先能够合法地访问同一个 Rc 内部状态,才会出现这样的竞争;Rust 正是通过 Rc 不实现 Send 和 Sync 来阻止普通安全代码构造这种跨线程共享。
因此,Rust 不允许把普通 Rc<T> 当作线程安全的共享所有权指针。
官方文档也明确指出,Rc 不实现 Send,原因之一就是它的引用计数没有使用原子操作。
六、Arc 为什么可以跨线程共享?
Arc<T> 是 Atomic Reference Counted 的缩写。
与 Rc<T> 不同,Arc<T> 使用原子操作管理共享引用计数。
use std::sync::Arc;
fn main() {
let a = Arc::new(100);
let b = Arc::clone(&a);
println!("{a}");
println!("{b}");
}
概念上:
Rc<T>:
引用计数更新
│
▼
普通内存读写
│
▼
不能安全地并发更新
Arc<T>:
引用计数更新
│
▼
原子操作
│
▼
能够安全地并发更新引用计数
不过,Arc 解决的是共享所有权与引用计数的线程安全问题,不会自动让内部的任意数据变成线程安全的。
例如:
use std::cell::RefCell;
use std::sync::Arc;
fn main() {
let value = Arc::new(RefCell::new(10));
// 不能直接将这个 Arc<RefCell<i32>>
// 作为普通的 Send + Sync 共享状态传给多个线程。
}
为什么?
因为 RefCell<T> 的动态借用检查不是线程安全的同步机制。Arc 的原子引用计数无法保护 RefCell 内部的借用状态。
因此:
Arc<T>
│
├── 共享所有权安全
│
└── 不代表 T 内部的访问自动安全
这是一个特别重要的原则。
七、Mutex 为什么能够保护一个不是 Sync 的类型?
现在我们来解决一个看似矛盾的问题。
我们知道:
RefCell<T>不是 Sync。Arc<T>不能让任意类型自动变成 Sync。
但下面的类型却可以用于多线程共享:
Arc<Mutex<RefCell<T>>>
只要 T 满足适当的条件,例如 T: Send。
这是为什么?
因为 Mutex 提供了独占访问。
这个组合在工程上其实是冗余的(
Mutex已经提供了互斥,内层的RefCell没有意义)。这里只是用它演示类型系统的推导路径。
先看一个更简单的例子:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..4 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut value = counter.lock().unwrap();
*value += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("{}", *counter.lock().unwrap());
}
输出:
4
这个例子使用了三个机制:
Arc:多个线程共享同一个锁对象的所有权。Mutex:保证同一时刻只有一个线程通过锁访问受保护的值。Send和Sync:确保相关类型可以在线程之间安全地传递和共享。
Mutex<T> 的关键特性是:
- 如果
T: Send,那么Mutex<T>可以实现Send。 - 如果
T: Send,那么Mutex<T>可以实现Sync。
注意这里需要的是 T: Send,而不是必须要求 T: Sync。
为什么?
因为 Mutex 通过独占访问保护内部数据。它不要求多个线程能够直接同时访问 &T。
Rust 标准库的文档明确解释了 Mutex<T> 的这些约束。
7.1 Mutex 的底层逻辑
可以把访问过程理解为:
线程 A 线程 B
│ │
▼ ▼
lock() lock()
│ │
▼ ▼
获得锁 等待锁
│ │
▼ │
访问 T │
│ │
▼ │
释放锁 │
▼
获得锁
│
▼
访问 T
Mutex 并没有把 T 变成一种天然可以同时读写的类型。它只是建立了一种同步规则,让不同线程通过受保护的访问路径操作 T。
这也是 Rust 并发模型的一个核心思想:
线程安全不仅依赖数据类型本身,还可以通过正确的同步原语来建立。
八、Send 和 Sync 为什么会自动实现?
你可能注意到,Rust 程序里经常不需要手动为结构体实现 Send 和 Sync。
例如:
struct User {
id: u64,
name: String,
}
这个结构体通常自动实现 Send 和 Sync。
为什么?因为它的字段 u64、String 都满足对应的线程安全条件。
Rust 将这种机制称为 Auto Traits。Send 和 Sync 都是 Auto Traits。
编译器会根据类型的组成部分判断它们是否适合自动实现这些 Trait。可以把规则概念化为:
struct User {
id: u64,
name: String,
}
编译器检查:
u64: Send + Sync
String: Send + Sync
因此,通常可以推导出:
User: Send + Sync
再看另一个例子:
use std::rc::Rc;
struct User {
data: Rc<String>,
}
由于 Rc<String> 不满足 Send 和 Sync 的要求,这个 User 通常也不会自动实现它们。
这就是 Auto Trait 的重要特点:
一个结构体是否能够跨线程传递和共享,往往取决于它内部所有字段的线程安全属性。
九、哪些常见类型实现 Send 和 Sync?
下面这张表非常实用。
| 类型 | Send | Sync | 说明 |
|---|---|---|---|
i32 | 是 | 是 | 基本数值类型 |
String | 是 | 是 | 所有权数据,可安全转移和共享不可变引用 |
Vec<T> | 当 T: Send 时 | 当 T: Sync 时 | 取决于元素类型 |
Box<T> | 当 T: Send 时 | 当 T: Sync 时 | 取决于内部类型 |
Rc<T> | 否 | 否 | 非原子引用计数 |
Arc<T> | 当 T: Send + Sync 时 | 当 T: Send + Sync 时 | 原子引用计数,不自动保证任意内部数据安全 |
Cell<T> | 当 T: Send 时 | 否 | 非线程安全的内部可变性 |
RefCell<T> | 当 T: Send 时 | 否 | 动态借用检查不是线程同步机制 |
Mutex<T> | 当 T: Send 时 | 当 T: Send 时 | 通过锁实现受保护的独占访问 |
RwLock<T> | 当 T: Send 时 | 当 T: Send + Sync 时 | 允许多个读者或一个写者 |
&T | 当 T: Sync 时 | 当 T: Sync 时 | 共享引用的线程安全取决于 T |
&mut T | 当 T: Send 时 | 当 T: Sync 时 | 独占可变引用的约束不同 |
这张表中的条件比单纯的「是」或「否」更重要。
例如:
Vec<Rc<String>>
因为 Rc<String> 不实现 Send,所以这个 Vec 也不会自动实现 Send。
但:
Vec<String>
通常可以安全地跨线程转移。
十、一个容易混淆的细节:为什么 &mut T 也可能是 Sync?
这个问题非常值得单独讲。
你可能会认为:
&mut T 可以修改数据,所以如果它是 Sync,多个线程岂不是能同时修改同一个对象?
这里忽略了 Rust 对引用的约束。
首先,&mut T 代表独占可变引用。在安全 Rust 中,同一时间不能随意创建多个指向同一个对象的独占可变引用。
其次,Sync 的含义是:可以安全地在线程之间共享对这个类型的引用。
当我们有:
&&mut T
时,外层引用是共享引用。通过这个共享引用,不能直接取得内层 &mut T 的独占访问权,它不会允许你随意修改 T。
因此,在满足 T: Sync 的条件下,&mut T 可以实现 Sync。
更准确的关系是:
&T: Send ⟺ T: Sync
&mut T: Send ⟺ T: Send
&T: Sync ⟺ T: Sync
&mut T: Sync ⟺ T: Sync
这正是 Rust 引用模型与 Send、Sync 相互配合的结果。
十一、为什么原始指针默认不是 Send 和 Sync?
例如:
let ptr: *const i32;
你可能会想:指针只不过是一个内存地址,为什么不能直接跨线程传递?
因为指针本身不能证明它指向的对象是否满足线程安全要求。
例如:
struct RawData {
ptr: *mut i32,
}
这个结构体包含一个可变原始指针。编译器无法仅凭这个字段判断:
- 指针是否有效。
- 指针指向的数据是否还活着。
- 是否存在其他别名。
- 其他线程是否正在修改同一块内存。
- 是否存在数据竞争。
因此,Rust 的原始指针默认不实现 Send 和 Sync。这样可以防止包含原始指针的结构体被自动判定为线程安全。
这并不是说原始指针永远不能用于多线程,而是说:如果要让包含原始指针的类型跨线程安全使用,开发者必须通过适当的抽象和安全保证来证明其行为。
Rust 官方的 Nomicon 对此有详细说明。
十二、Send 和 Sync 是怎么在编译期起作用的?
现在我们看一个典型的编译错误。
use std::rc::Rc;
use std::thread;
fn main() {
let value = Rc::new(100);
thread::spawn(move || {
println!("{value}");
});
}
这段代码不能通过编译,报错是:
error[E0277]: `Rc<i32>` cannot be sent between threads safely
原因不是 move 写错了,也不是 Rc 不能被移动——你完全可以在同一个线程内移动一个 Rc 的所有权。
问题在于:thread::spawn 要求闭包满足相应的 Send 约束,而这个闭包捕获了不满足 Send 的 Rc<i32>。
概念上,thread::spawn 的签名可以简化理解为:
fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
// ...
}
这不是完整源码,只是为了展示关键约束。
这里 F: Send 意味着闭包本身必须可以安全地传递到新线程;而闭包是否实现 Send,通常取决于它捕获的值。
因为 Rc<i32> 不实现 Send,所以这个闭包不能满足约束,编译器就在编译期拒绝了这段代码。
如果改成:
use std::sync::Arc;
use std::thread;
fn main() {
let value = Arc::new(100);
let handle = thread::spawn(move || {
println!("{value}");
});
handle.join().unwrap();
}
就可以正常编译。Arc<i32> 满足相关线程安全约束,因此闭包能够传递到新线程。
这就是 Send 的实际价值:它不是运行时检查,而是编译期的线程安全约束。
十三、Send 和 Sync 是 unsafe Trait,意味着什么?
Send 和 Sync 都是 unsafe auto trait。
这意味着:
- 它们是编译器可以自动推导的 Trait。
- 开发者在特殊情况下可以手动实现它们。
- 手动实现时,必须确保类型确实满足相应的线程安全保证。
例如:
struct MyType {
// 某些底层数据
}
理论上,你可以为自定义类型手动实现 Send:
unsafe impl Send for MyType {}
但如果你的类型内部存在不安全的共享可变状态,而你错误地声称它是 Send 或 Sync,就可能让安全 Rust 程序产生未定义行为。
因此,不能把它们当作普通的标记随便添加。
对于普通业务代码,通常不需要手动实现这两个 Trait。让编译器根据字段自动推导,往往是更安全的方式。
只有在封装底层资源、原始指针或特殊并发结构时,才需要认真考虑手动实现的必要性。
十四、放到 Rust 系统开发中,应该怎么理解?
结合底层系统和分析器项目,可以这样理解。
假设你需要构建一个多线程的代码分析任务系统,每个任务包含:
struct AnalysisTask {
id: u64,
source: String,
}
这个类型通常可以实现 Send + Sync。因此,你可以:
- 把一个任务转移给工作线程。
- 将不可变的任务数据共享给多个分析线程。
- 在满足其他所有权与生命周期条件的情况下,使用
Arc<AnalysisTask>共享任务。
如果任务状态需要并发修改,可以使用:
use std::sync::{Arc, Mutex};
struct TaskState {
status: String,
}
let state = Arc::new(Mutex::new(TaskState {
status: "ready".to_string(),
}));
这时:
Arc负责共享所有权。Mutex负责同步对任务状态的访问。Send和Sync负责在类型层面约束跨线程使用是否合法。
不过,如果任务对象本身包含非线程安全的资源,例如某些线程绑定的句柄,那么它可能无法直接满足 Send。
这时应当考虑让资源留在所属线程,由其他线程发送消息请求它执行操作,而不是强行把资源跨线程移动。
这种设计在编译器服务、运行时、事件循环和 Agent Scheduler 中都很常见。
十五、最后用三个问题彻底记住它们
以后遇到 Rust 并发问题,可以依次问自己三个问题。
问题一:这个值能不能被移动到另一个线程?
看 T: Send。如果不满足,检查它是否包含 Rc、非线程安全的内部可变状态、原始指针或其他不能自动跨线程转移的成员。
问题二:这个对象能不能被多个线程通过共享引用访问?
看 T: Sync。如果不满足,检查它是否存在没有适当同步保护的内部可变状态。
问题三:我究竟需要转移所有权,还是共享同一个对象?
- 只需要把值交给另一个线程:重点关注
Send。 - 需要多个线程共享同一份数据:通常需要
Sync,并根据共享方式使用Arc、Mutex或RwLock。 - 需要多个线程修改共享状态:必须使用适当的同步机制,不能仅凭
Arc就认为线程安全。
最后浓缩成一句话:
Send保证值可以安全地跨线程转移;Sync保证共享引用可以安全地跨线程传递。Rust 再通过所有权、借用、自动 Trait 推导和同步原语,把大量线程安全问题提前到编译期检查。