解惑rust中的 Send/Sync(译)
当我开始学习 Rust 时,每个人都警告我所有权和借用会有多难。但说实话?一旦我动手实践,这些概念理解得比我想象的要快得多。
直到我遇到了 trait 中的 Send 和 Sync —— 仍然是所有权和借用,但是在多线程上下文中。我花了几个小时阅读文档,结果却比刚开始时更加迷茫。有一次,我真的想过要换回 Go,以避免头大。
Rustonomicon 的官方定义只会让事情变得更糟:
-
A type is Send if it’s safe to send it to another thread.
如果将其发送到另一个线程是安全的, 则类型为 Send 。 -
A type is Sync if it’s safe to share between threads.
如果可以在线程之间安全地共享, 则类型为Sync 。
这个定义让我很失望,因为它只告诉我“是什么”,却从不告诉我“为什么”。我可以机械地理解例子,但无法推理出新的类型。当然,我能记住 Rc 不是 Send ,而 Arc 却是 Send——但我不知道为什么。我感觉自己只是在收集一些随机的信息,而不是真正理解任何东西。
这促使我深入挖掘并找到今天将在这篇文章中向您展示的基本原则。
我终于明白了:这些 trait 并非随意设定的——它们是 Rust 将特定线程安全问题 编码到 类型系统中的方式。当我不再死记硬背,开始理解其背后的危险时,那些困惑顿时豁然开朗。要理解 Send 和 Sync ,我们首先需要了解线程安全的原因。
Send and Sync Are About Thread Safety
发送和同步与线程安全有关
多线程编程的核心风险是数据争用 。当多个线程同时访问同一内存,且至少有一个线程更改数据,而没有其他机制协调该访问时,就会发生数据争用。
想象一下,当两个用户同时访问一个网站时,当前访问量计数 1000 次。两个请求都读取“1000”,计算结果都为“1001”,写入结果也都为“1001”。预期结果:两个访问者访问后: 1002 。实际结果:1001。
Rust 的 Send 和 Sync 特性旨在通过将线程安全规则直接编码到类型系统中来防止数据竞争:
-
Send :你可以将此值move到另一个线程,而不会引起数据争用。(思考: 唯一所有权 + 线程安全)
-
Sync :多个线程可以持有此值的引用,而不会引起数据争用。(思考: 共享访问 + 线程安全)
关键在于, Send 指的是 跨线程 的唯一所有权 ,而 Sync 指的是 跨线程(非在同一个线程中) 的共享访问权限 。当你 move 一个值 ( Send ) 时,你将完全控制权move给了另一个线程。当你共享引用 ( Sync ) 时,多个线程可以访问,但需要协调以避免冲突。
让我们通过具体的例子来看一下这一点。
A Type That’s Both Send and Sync
既发送又同步的类型
以 String 为例 ,它既是 Send 又是 Sync 。原因如下:
Send example: 发送示例:
// Send: Safe to move to another thread
let s = String::from("hello");
thread::spawn(move || {println!("{}", s); // s is now owned by this thread
});
// s is no longer accessible in the original thread
为什么这是安全的 :Move 赋予新线程对字符串数据的唯一所有权 。由于一次只有一个线程可以访问它,因此不存在数据争用的可能性。原始线程无法再接触该字符串,因为它的所有权已被完全转移。
Sync example: 同步示例:
// Sync: Safe to share references across threads
let s = String::from("hello");
let s_ref = &s;
// thread::scope(|scope| { ... }); 使用了 Rust 1.63 引入的线程作用域(scope)API,它创建了一个作用域,
// 在这个作用域内创建的线程会在作用域结束前自动 join,确保线程不会超过所引用数据的生命周期
thread::scope(|scope| {scope.spawn(|| {println!("{}", s_ref); // Reading via shared reference});scope.spawn(|| {println!("{}", s_ref); // Multiple threads can read simultaneously});
});
为什么这是安全的 :多个线程共享访问该字符串,但它们只能读取它。由于没有人可以修改数据(你需要使用 &mut 来实现),因此共享访问是线程安全的。多个读取不会相互冲突。
从这个字符串示例中,我们可以提取出一个通用模式 。大多数类型同时是 Send 和 Sync, 因为:
-
Send :move 会在新线程中创建唯一的所有权。由于同一时刻只有一个线程可以访问数据,因此不存在冲突的可能性。
-
Sync :共享引用仅允许读取。多个线程同时读取同一数据不会造成问题。
这个简单的模型完美适用于 i32 、 String 、 Vec<T>
等基本类型以及您将创建的大多数结构体。线程安全性源自 Rust 的所有权规则:要么一个线程拥有数据(Send),要么多个线程可以读取但都不能写入(Sync)。
我真希望 Rust 的类型系统也能这么简单。但 Rust 当然不会让我们这么轻易就放弃。 内部可变性打破了我们所有关于“移动 = 唯一所有权” 和 “共享 = 只读” 的美好假设。让我们深入探讨一下。
What Makes a Type !Sync
什么构成了 !Sync 类型
这就是我们简单的 “共享引用 = 只读” 规则失效的地方。Rust 中的某些类型允许内部可变性 ——即使只有一个共享引用,它们也允许你更改数据。如果你想更深入地了解内部可变性以及 Rust 为何需要它,请在评论区留言——我会写一篇专门的文章来解释它。现在,你只需要理解它违反了通常的“共享引用 = 只读”规则即可。
为了理解为什么这是有问题的,让我们看看 Cell<u64>
:
use std::cell::Cell;let cell = Cell::new(42);
let cell_ref = &cell; // Just a shared reference, no &mut
cell_ref.set(100); // But we can still modify the value inside!
println!("{}", cell_ref.get());
注意这里发生了什么:我们有一个共享引用( &cell ),而不是一个可变引用( &mut cell ),但我们仍然可以通过 set() 更改其中的数据 。这打破了 Rust 共享引用只读 的通常规则。
Cell<T>
的存在是因为即使您只拥有包含结构的 共享 访问权限,有时也需要修改简单值(例如计数器或标志)。当您需要跟踪简单状态(例如命中计数器、标志或在其他 不可变结构中 重试次数)时,它尤其有用。Cell 允许 以原子方式替换整个值,而无需复杂的借用。
现在出现了线程安全问题: Cell<u64>
允许你通过共享引用更改数据,但它无法防止多个线程同时执行此操作。如果两个线程都尝试调用 set() ,就会引发数据争用:
// This WILL NOT compile
let cell = Cell::new(0);
thread::scope(|scope| {// error[E0277]: `Cell<i32>` cannot be shared between threads safelyscope.spawn(|| cell.set(1)); // Thread 1 writingscope.spawn(|| cell.set(2)); // Thread 2 writing simultaneously
});
这就是为什么 Cell<T>
是 **!Sync **的原因(Sync 表示可共享, !Sync 表示不可共享) ,它无法在线程之间安全地共享。内部可变性打破了我们“共享引用是安全的”的假设。
但是内部可变性并不会自动使类型成为 !Sync (默认都是Sync 的,需要显式实现 !Sync trait )。如果类型可以避免数据竞争,它仍然可以是 Sync 的。 有些类型通过添加自己的同步机制(协调机制)来解决这个问题。AtomicU64
和 Mutex<T>
就是这种情况 :
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;// AtomicU64: Uses hardware-level atomic operations
let atomic = AtomicU64::new(0);
thread::scope(|scope| {scope.spawn(|| atomic.store(1, Ordering::Relaxed)); // Safescope.spawn(|| atomic.store(2, Ordering::Relaxed)); // Safe
});// Mutex: Uses locking to serialize access
let mutex = Mutex::new(0);
thread::scope(|scope| {scope.spawn(|| *mutex.lock().unwrap() = 1); // Safescope.spawn(|| *mutex.lock().unwrap() = 2); // Safe
});
两种类型都 不精允许内部可变性 ,并能防止数据争用,因此它们保持Sync 。关键区别在于 AtomicU64
使用 单步执行 的硬件级原子操作,而 Mutex<T>
则使线程 轮流执行——一次只能有一个线程持有锁,因此更改是依次发生的,而不是同时发生的。
The refined model for Sync:
Sync 的改进模型:
-
如果共享引用无法更改数据→ Sync (可以共享 但是只能读取 无法更改)
-
如果共享引用可以在 没有同步 的情况下更改数据 → !Sync
-
如果共享引用可以通过 同步(原子、锁) 更改数据 → Sync
(也就说 Sync 的意义是 :可以共享 ,但是必须保证安全,不管是使用 只读共享 还是 原子修改 或锁 机制 能够保证共享的情况下 访问是线程安全的 )
下边轮到 !Send 了 — — go。
What Makes a Type !Send
!Send 类型由什么构成
你可能会认为 Send 比 Sync 更简单 。毕竟,当你将一个值move到其他线程时,你就赋予了该线程完全的所有权——这还能出什么问题呢?
问题在于,某些类型即使在 move 后仍会保留隐藏的共享状态 。move 操作会传输 明显的数据,但可能存在与原始线程的不可见连接,从而引发线程安全问题。
经典的例子是 Rc<T>
(引用计数指针):
use std::rc::Rc;let rc1 = Rc::new(42);
let rc2 = rc1.clone(); // Both point to the same data// If we could send rc1 to another thread (we can't!)...
thread::spawn(move || {drop(rc1); // Decrements reference count
});// ...while rc2 exists on the original thread
drop(rc2); // Also decrements the same reference count
具体过程如下: Rc<T>
的工作原理是保留一个引用计数器——克隆一个 Rc 时 ,计数器会增加;删除一个 Rc 时 ,计数器会减少。当计数器归零时,数据会被清除。
关键在于:即使将 rc1 move 至另一个线程,您也不拥有引用计数器的唯一所有权 ——它仍然与原始线程上的 rc2 共享 。由于 Rc 使用非原子引用计数器,如果两个线程同时更改该计数器,就会引发数据争用。
这就是为什么 Rc<T>
是 !Send 的原因 ,move 它实际上并不会创建所有相关数据的唯一所有权。隐藏的共享状态(引用计数器)仍然是不安全的。
那么我们该如何解决这个问题呢?如何实现跨线程的引用计数?
解决方案是 Arc<T>
(原子引用计数指针),它使共享引用计数器线程安全:
use std::sync::Arc;let arc1 = Arc::new(42);
let arc2 = arc1.clone();// This works because Arc makes the shared state thread-safe
thread::spawn(move || {drop(arc1); // Atomically decrements counter
});drop(arc2); // Also atomically decrements counter
Arc 通过使引用计数器原子化来解决这个问题,多个线程可以安全地同时增加和减少它而不会产生数据争用。
注意: 只有当 T
同时为 Send + Sync 时 ,Arc<T>
才为 Send + Sync 。例如, Arc<Cell<T>>
不是 Sync ,因为 Cell<T>
不是 Sync 。Arc 使计数器线程安全,但它包含的数据仍然取决于内部类型的线程安全属性。我不会在这篇文章中深入探讨这个问题——如果您想写一篇专门的文章来讨论这个问题,请在评论区告诉我。
注意:还有另一类具有线程本地约束( thread-local constraints )的 !Send 类型(例如 MutexGuard<T>
),但 Rc<T>
样式的共享状态是您会遇到的最常见的情况。*
The refined model for Send:
Send 的改进模型:
-
如果move 创建了真正的唯一所有权(没有隐藏的共享状态)→ Send
-
如果move仍然留下非线程安全的共享状态 → !Send
-
如果move留下了线程安全的共享状态 → Send
现在我们了解了基本原理,让我们将这些见解转化为实用的决策工具。
Practical Decision Tree: Is Type X Send/Sync?
实用决策树:类型 X 是Send/Sync吗?
无需记住每种类型的Send/Sync 状态,您可以系统地进行推理。这是我使用的思维框架,基于我们刚刚探讨的原则构建而成:
专业提示 :您可以尝试跨线程使用该类型进行仔细检查。如果不是 Send / Sync 类型,编译器会通过清晰的错误消息提示您!
Key Takeaway 关键要点
以下是使Send和Sync可推测的原则 :
它们是 Rust 将线程安全编码到类型系统中的方式。
-
Send asks: “Can I move this to another thread without creating hidden shared state that isn’t thread-safe?”
Send 询问:“我可以将其移动到另一个线程而不创建非线程安全的隐藏共享状态吗?” -
Sync asks: “Can multiple threads hold references to this without racing on mutations?”
Sync 询问:“多个线程是否可以保存对此的引用,而不会发生突变竞争?”
从这个角度思考,这些特征就变得合乎逻辑,而非任意。Rc 不是 Send ,因为move它仍然会与原始线程保持共享(非原子)状态。Cell 不是 Sync ,因为它允许通过共享引用进行非同步的修改。
一旦你内化了这个思维模型,你就会发现自己只需理解类型的功能就能正确预测它的Send / Sync状态。当你设计自己的类型时,你自然会从头开始考虑线程安全。
What I didn’t cover 我没有涉及的内容
这篇文章重点介绍了最常见的Send / Sync模式。虽然也有一些边缘情况,例如 MutexGuard<T>
(由于线程本地 约束而非共享状态,因此被误认为是 !Send )和原始指针,但这里的思维模型可以处理 95% 的实际情况。
相关文档和原文地址:
This Send/Sync Secret Separates Professional From Amateur Rust Developers
String vs str in Rust: The Only Guide You’ll Ever Need