100% 本人原创文章,非 AI 创作
Table of Contents
Open Table of Contents
Introduction
这是一个系列文章,从浅到深记录我对这两个 Trait 的学习:
本篇文章回答 “什么是 Sync Trait?” 。
在上一篇文章的结尾,我讨论了一个问题:“如果 T 是 Send 的,为什么 &mut T 是 Send 的,但官方文档没有说 &T 也是 Send 的?”
最后得出的结论是:“T 可以被发送到另一个线程”和”T 可以被多个线程共享”压根不是一回事。
这里的”可以被多个线程共享”正是 Sync Trait 的语义。
Sync Trait
回到 Rustonomicon 中关于 Sync 的定义:
A type is
Syncif it is safe to share between threads (TisSyncif and only if&TisSend).
直译:“如果一个类型实现了 Sync,那么这个类型可以在线程间安全地共享。(当且仅当 &T 是 Send 的,类型 T 才是 Sync 的)”
在上一篇文章中,我对这句话提出了两个问题:
- “这个类型可以在线程间安全地共享”,这里的”共享”具体指的什么操作?
- “类型
T是Sync的,当且仅当&T是Send的”这个设计有啥用呢?感觉是废话
接下来就围绕这两个问题展开。
“共享”到底是什么意思
先回答第一个问题:这里说的”共享”,指的是多个线程同时持有同一个数据的不可变引用 &T。
在 Rust 中,如果你想让多个线程访问同一份数据,最常见的方式是用 Arc<T>(Atomic Reference Counting,原子引用计数)。Arc 允许多个所有者持有同一份堆上数据的所有权,并且它本身的引用计数操作是原子的,所以在多线程环境下是安全的。
以下是一个简单的例子:
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(42);
let mut handles = vec![];
for _ in 0..3 {
// Arc::clone 并不是真的拷贝数据
// 而是增加了引用计数,让多个 Arc 指向同一份数据
let data = Arc::clone(&data);
let handle = thread::spawn(move || {
println!("线程读取到: {}", *data);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}
这段代码能编译通过,因为 i32 是 Sync 的。三个子线程通过各自的 Arc 同时持有对同一个 i32 的引用,但它们都只是在读取,没有修改,不存在数据竞争。
这就是”共享”的含义:同一份数据,多个线程同时持有它的引用。
那如果我把 i32 换成一个不是 Sync 的类型呢?
use std::cell::Cell;
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(Cell::new(42));
let mut handles = vec![];
for _ in 0..3 {
let data = Arc::clone(&data);
// ERROR:不会编译通过
let handle = thread::spawn(move || {
// Cell 允许通过 &T 修改数据
data.set(data.get() + 1);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}
这段代码不会编译通过。原因是 Cell<i32> 没有实现 Sync。
为什么 Cell 不是 Sync 的?因为 Cell 实现了 Interior Mutability(内部可变性)——它允许你通过不可变引用 &Cell<T> 修改里面的值。如果 Cell 是 Sync 的,那三个线程就可以同时通过 &Cell<i32> 对同一个 i32 进行读写,而 Cell 的 get 和 set 操作并没有任何同步机制(比如锁或者原子操作)来保护,这就是典型的 Data Race(数据竞争)。
Rust 编译器通过类型系统阻止了这种代码通过编译,而不是让你在运行时才发现问题。
T is Sync iff &T is Send
现在来回答第二个问题:“T 是 Sync 的,当且仅当 &T 是 Send 的”——这个定义是不是废话?
初看的时候我觉得是废话,但仔细想想其实不是。这句话把一个比较抽象的概念(“可以在线程间共享”)精确地归约到了一个已经理解的概念(“可以安全地发送到另一个线程”)上。
拆解一下这个逻辑:
- 假设一个类型
T是Sync的 - 那么
&T就是Send的 &T是Send的意味着什么?意味着&T可以被安全地发送给另一个线程- 既然
&T能被安全地发送给另一个线程,那多个线程就可以各自持有&T - 也就是说,多个线程可以同时引用
T——这正是”共享”的意思
反过来也成立:
- 如果
&T不能被安全地发送给另一个线程(&T不是Send的) - 那么
T就不是Sync的 - 这也合理——如果引用都没法安全地跨线程传递,那”共享”更无从谈起
所以这个定义并不是废话,它其实是对”共享”这个概念的一种精确的形式化定义。Sync 并没有引入一套全新的规则,而是把”线程间共享”这件事归约到了 Send 的语义上:能不能共享一个 T,取决于能不能把 T 的引用发送给别的线程。
这种设计也带来了一个好处:Send 和 Sync 并不是两个完全独立的概念。理解了 Send,Sync 就只是在此基础上的一层延伸。
哪些类型不是 Sync 的
在上面 Cell 的例子中已经看到了一个不是 Sync 的类型。再来系统地过一遍。
标准库中不是 Sync 的类型主要有这几个:
Cell<T> 和 RefCell<T>
这两个类型都提供了 Interior Mutability,允许通过 &T 修改内部数据。区别在于 Cell 通过值复制来操作,RefCell 则是在运行时检查借用规则。
它们不是 Sync 的原因是一样的:它们的内部修改操作没有任何线程同步机制。如果多个线程同时通过 &RefCell<T> 调用 borrow_mut(),RefCell 的运行时借用检查在多线程下会直接失效,因为那个借用计数器本身就不是线程安全的。
Rc<T>
Rc 既不是 Send 也不是 Sync。原因在上一篇文章中提到过:Rc 的引用计数操作不是原子的。如果多个线程同时对同一个 Rc 进行 clone 或 drop,引用计数就会出错,导致 use-after-free 或者内存泄漏。
那对应地,如果我确实需要在多线程间共享可修改的数据,应该怎么做?
答案是用线程安全的类型来代替上面的那些类型:
- 用
Arc<T>代替Rc<T>——Arc的引用计数是原子操作 - 用
Mutex<T>或RwLock<T>代替RefCell<T>——它们提供了锁机制来保证线程安全
一个常见的组合就是 Arc<Mutex<T>>:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let data = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..3 {
let data = Arc::clone(&data);
let handle = thread::spawn(move || {
// lock() 获取锁,确保同一时间只有一个线程能修改数据
let mut num = data.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("结果: {}", *data.lock().unwrap());
}
Mutex<T> 是 Sync 的(前提是 T 是 Send 的),因为 Mutex 的锁机制确保了在任何时刻,最多只有一个线程能够访问内部的数据。这样一来,通过 &Mutex<T> 进行的操作就是线程安全的。
从 Blanket Implementation 的角度看也很清晰:
// 截取自官方文档
unsafe impl<T: ?Sized + Send> Sync for Mutex<T> {}
如果 T 是 Send 的,那么 Mutex<T> 就是 Sync 的。这很合理——Mutex 的锁机制保证了被保护的数据 T 在任意时刻最多只被一个线程独占访问,而 T 是 Send 的意味着 T 本身可以安全地在线程间转移所有权,那么”独占访问”自然也没问题。
Sync 的自动实现
和 Send 类似,Rust 为 Sync 也提供了自动实现的逻辑:如果一个类型的所有字段都是 Sync 的,那么这个类型也自动是 Sync 的。
比如有这么一个 struct:
struct MyData {
count: i32,
name: String,
}
i32 和 String 都是 Sync 的,所以 MyData 自动就是 Sync 的。
但如果某个字段不是 Sync 的:
struct MyData {
count: Cell<i32>, // Cell 不是 Sync 的
name: String,
}
那么 MyData 也不会自动实现 Sync。
如果你正在写 unsafe 代码,并且你能够保证你的类型在多线程共享时是安全的,那么可以手动实现 Sync:
unsafe impl Sync for MyType {}
这个 unsafe 关键字的意思是:“编译器没法自动验证这件事是安全的,我(程序员)向编译器保证这个类型确实可以安全地在线程间共享。“手动实现 Sync 意味着线程安全的责任从编译器转移到了你自己身上。
到这里,我们已经回答了”什么是 Send”和”什么是 Sync”这两个问题。
在下一篇文章中,我会讨论这两个 Trait 这种设计到底好在哪,它们解决了什么问题,以及为什么 Rust 选择了这种方式而不是别的。