阶段三 · 多线程并发

Send,Sync 与线程安全

一句话总结

SendSync 是两个没有任何方法的标记特征(marker trait), 它们是 Rust 在编译期判断"一个类型能否安全地跨线程"的依据.
Send 意为"可以把所有权转移到另一个线程", Sync 意为"可以让多个线程共享它的引用".
绝大多数类型自动获得它们, 编译器据此在编译期拦下一切数据竞争.

上一篇遗留的问题: 编译器凭什么放行

第 08 篇里, thread::spawn(move || ...) 能把 String 移进线程. 但编译器凭什么认为 String 可以安全地跨线程? 而上一篇又说过 Rc 不能跨线程--编译器又是怎么知道的?

答案就是 SendSync. 它们是类型系统里的两个"许可标签": 贴了 Send 标签的类型才能被移进线程, 贴了 Sync 标签的类型才能被多线程共享引用. thread::spawn 的签名里就写着这个要求(简化):

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T + Send + 'static, // 闭包必须是 Send!
T: Send, // 返回值也必须是 Send

正因 StringSend, 捕获了它的闭包也是 Send, 才能通过这个约束; 而 Rc 不是 Send, 捕获它的闭包就不满足约束, 编译器当场拒绝.

什么是标记特征(marker trait)

SendSync 的定义长这样--空的:

pub unsafe trait Send {} // 没有任何方法!
pub unsafe trait Sync {} // 同样为空

它们不要求实现任何方法, 纯粹是给类型"贴标签"--标记"这个类型具备某种属性". 编译器不靠方法, 而靠"类型有没有这个标签"来做检查. 这种没有方法, 只用于标记属性的 trait, 称为标记特征.

Send/Sync 像贴在货物上的运输许可标签: "可空运"(Send, 能整件运到另一个仓库), "可多人同时取阅"(Sync, 能让多个仓库共享查看).
标签本身不是功能, 而是一种资质认证. 海关(编译器)只看货物有没有对应标签, 就决定放不放行--不需要拆开货物检查内容.

Send 与 Sync 的精确含义

两者极易混淆, 务必分清. 关键区别是"转移所有权"还是"共享引用":

Send Sync
含义 类型 T 的所有权能安全转移到另一线程 类型 T 能被多线程共享引用(&T 可跨线程)
关注 "把它到别的线程, 安全吗" "多个线程同时它, 安全吗"
精确定义 - T: Sync&T: Send

那行精确定义值得记住: T 是 Sync, 当且仅当 &T 是 Send. 换言之, "能被多线程共享引用"等价于"它的引用能被转移到多个线程"--两个概念由此打通.

用例子坐实区别

  • Mutex<T>Send 也是 Sync: 可以搬到别的线程, 也可以多线程共享(它内部用锁保证安全访问).
  • Rc 既不是 Send 也不是 Sync: 它的引用计数不是原子的, 搬到别的线程或多线程共享都会导致计数竞争.
  • MutexGuard(锁守卫)是 Sync不是 Send: 有些锁实现要求"谁加锁谁解锁", 所以守卫不能转移到别的线程释放, 但可以共享引用.

自动派生: 几乎从不手动实现

这是 Send/Sync 设计最精巧的一点. 它们由编译器自动派生: 一个类型, 只要它的所有字段都是 Send, 它就自动是 Send(Sync 同理). 这是一种"组合即安全"的递归推导.

// 自定义的结构体, 编译器自动判断它是否 Send/Sync
struct MyData {
name: String, // String 是 Send + Sync
count: i32, // i32 是 Send + Sync
}
// → MyData 的所有字段都是 Send + Sync, 所以它自动也是 Send + Sync
// 什么都不用写, 就能把 MyData 安全地用于多线程

反过来, 只要混入一个非 Send 的字段, 整个类型就自动失去 Send:

use std::rc::Rc;
struct Bad {
shared: Rc<i32>, // Rc 不是 Send
}
// → Bad 自动不是 Send, 任何想把它跨线程的代码都会编译失败

设计思想: 安全性沿类型结构自动传播

这套机制的精妙在于: 线程安全不需要逐个声明, 而是从最基础的类型(i32, String 是 Send/Sync; Rc, RefCell 不是)出发, 沿着"类型由哪些字段组成"自动向上推导.

写一个普通结构体, 它的线程安全资质自动算出来. 这意味着--整个语言的并发安全, 建立在一组基础类型的标签 + 一条组合规则之上, 无需程序员操心, 也无法被意外绕过.

这就是为什么 Rust 能号称"编译期消灭数据竞争".

对比 Go: 编译期 vs 运行期

这正是 Rust 与 Go 在并发安全上的分水岭:

Go                                   Rust
── ────
任何值都能传进 goroutine 只有 Send 类型能进线程
数据竞争靠 -race 在运行时检测 数据竞争在编译期被 Send/Sync 拦截
漏检 → 线上偶发, 极难复现的 bug 编译不过 → 问题在提交前就暴露

Go 的 race detector 像事后查监控: 出了事(且恰好在测试时复现了)才发现谁违规.
Rust 的 Send/Sync 像进门前的资质核验: 没有资质(标签)的类型根本进不了多线程的门.
前者依赖运气和测试覆盖, 后者是编译期的硬保证--这就是 Go 工程师初识 Rust 并发时, 既觉得"啰嗦"又逐渐感到"踏实"的原因.

需要做什么, 不需要做什么

不需要: 手动实现 Send/Sync(那是 unsafe 的, 只在写底层并发原语时才碰).

需要: 看懂当编译器说 "Rc<...> cannot be sent between threads safely" 时, 它在告诉"想跨线程用的某个东西含有非 Send 成分"--顺着它指出的类型, 换成线程安全的对应物(RcArc, RefCellMutex)即可.

快速回顾

  • 定位: Send/Sync 是编译器判断"类型能否跨线程"的依据, thread::spawn 的约束里写着它们.
  • 标记特征: 无方法, 纯粹给类型"贴资质标签", 编译器据标签放行或拦截.
  • 含义: Send = 所有权可转移到别的线程; Sync = 可被多线程共享引用; T: Sync ⟺ &T: Send.
  • 自动派生: 所有字段都 Send/Sync, 则类型自动 Send/Sync; 混入一个非 Send 字段则整体失去--安全性沿类型结构自动传播.
  • 对比 Go: 数据竞争从"运行时靠 -race 检测"变为"编译期被 Send/Sync 拦截", 是硬保证而非靠运气.
  • 实践: 几乎不手动实现; 读懂 "cannot be sent" 报错, 把非 Send 类型换成线程安全对应物(Rc→Arc 等).

动手练习

  1. 用一句话分别说清 Send 和 Sync 的区别, 并举一个"是 Sync 但不是 Send"的例子.
参考答案

Send: 类型的所有权可以安全转移到另一个线程("能搬过去"). Sync: 类型可以被多个线程同时共享引用("能一起看"), 等价于 &T: Send.

"是 Sync 但不是 Send"的例子: MutexGuard. 某些平台的锁要求加锁和解锁在同一线程, 所以守卫不能被转移到别的线程释放(非 Send), 但多个线程持有它的引用是安全的(Sync).

  1. 下面这个结构体是 Send 吗? 为什么?

    struct Task {
    id: u64,
    name: String,
    payload: Vec<u8>,
    }
参考答案

是 Send(也是 Sync). 它的三个字段 u64, String, Vec<u8> 全都是 Send + Sync, 根据自动派生规则--所有字段都 Send, 则该类型自动 Send--Task 自动获得 Send/Sync, 无需任何手动声明就能安全地用于多线程.

  1. 下面代码报错 "Rc<i32> cannot be sent between threads safely". 解释根因并修复.

    use std::rc::Rc;
    use std::thread;
    let data = Rc::new(5);
    let d = Rc::clone(&data);
    thread::spawn(move || println!("{d}"));
参考答案

根因: Rc 的引用计数不是原子操作, 跨线程会产生计数竞争, 因此 Rc 不是 Send; 捕获了它的闭包也不是 Send, 无法通过 thread::spawnF: Send 约束.

修复: 换成线程安全的 Arc(原子引用计数, 是 Send + Sync):

use std::sync::Arc;
use std::thread;
let data = Arc::new(5);
let d = Arc::clone(&data);
thread::spawn(move || println!("{d}")); // OK, Arc 是 Send
  1. 思考: 为什么 Rust 能在编译期就消灭数据竞争, 而 Go 只能靠运行时的 race detector?
参考答案

因为 Rust 把"线程安全"编码进了类型系统: 每个类型都带 Send/Sync 资质(自动派生自其字段), thread::spawn 等并发入口在签名里要求这些资质. 于是"把非线程安全的类型用于多线程"这件事根本无法通过类型检查, 在编译期就被拒.

Go 没有这层类型约束, 任何值都能进 goroutine, 只能在运行时用 race detector 抽样检测--依赖测试是否恰好触发竞争, 漏检就成了线上偶发 bug. 本质差异: Rust 是编译期的类型证明, Go 是运行期的动态检测.