如何理解Rust语言中Send和Sync?

写一段Rust多线程代码,编译器报了个错:

bash 复制代码
error[E0277]: `Rc<i32>` cannot be sent between threads safely

你去查文档告诉你 Rc<T> 没有实现 Send。那什么是 Send?文档说"实现了 Send 的类型可以安全地在线程间转移所有权"。

读完之后你还是不知道它到底在"防"什么。

这两个 trait 的定义之所以让人困惑,是因为它们本身没有任何方法------它们是 marker trait,纯粹用来做编译期标记。编译器不是通过它们来"执行"什么逻辑,而是通过它们来"拒绝"不安全的代码通过编译。

理解 Send 和 Sync,与其去背定义,不如直接看编译器到底在"拦"什么。

Send:你能不能把这个值丢给另一个线程

bash 复制代码
use std::thread;
use std::rc::Rc;

fn main() {
    let data = Rc::new(42);
    thread::spawn(move || {
        println!("{}", data);
    });
}

编译不过。Rc<i32> 没实现 Send,不能 move 进另一个线程的闭包。

为什么?Rc 的内部是一个引用计数器加一个指向堆数据的指针。Rc::clone() 的时候,计数器加1;Rc 被 drop 的时候,计数器减1;减到0就释放内存。

你可以把 Rc 想成一本图书馆的书,上面挂了一张借阅卡,每借出去一次就在卡上用铅笔加个1,还回来就擦掉一个1,卡上数字归零就把书销毁。这套系统在只有一个图书管理员的时候运转得很好。

问题在于这个计数器不是原子操作。它就是一个普通的 usize,用普通的加减指令修改。如果两个线程同时 clone 或 drop 同一个 Rc------相当于两个管理员同时拿起同一张借阅卡、同时读到上面写着1、各自加1写回2------但正确值应该是3。

更危险的是 drop:两个管理员同时读到卡上是1,各自减到0,各自去销毁同一本书。double free,程序直接炸。

这就是 Rc 不能 Send 的原因:它的内部状态在并发修改下会损坏。不是"用起来不方便"的问题,是"内存会炸"的问题。

换成 Arc 就行了。Arc 相当于把借阅卡上的铅笔换成了机械计数器------拨一下就是加1,不存在两个人同时写导致数错的问题。底层用的是原子操作(AtomicUsize),fetch_addfetch_sub 在CPU层面保证了多线程下的正确性。所以 Arc<T>T: Send + Sync 的时候实现了 Send

一句话总结 Send 的含义:把这个值的所有权交给另一个线程之后,不会导致内存安全问题

Sync:你能不能让多个线程同时持有这个值的共享引用

Send 管的是"转移",Sync 管的是"共享"。打个比方,Send 是问"这个东西能不能快递到另一个城市",Sync 是问"这个东西能不能放在公共区域让大家一起用"。有的东西可以寄走但不能共用(比如一把没有锁的日记本),有的东西可以共用但不能寄走(下面会讲到)。

准确地说,T: Sync 等价于 &T: Send------如果一个类型的共享引用可以安全地发送到另一个线程,那这个类型就是 Sync 的。

Cell<i32> 是 Send 的(你可以把它 move 到另一个线程),但它不是 Sync 的。

bash 复制代码
use std::cell::Cell;

let cell = Cell::new(0);
let r = &cell;  // 共享引用

// 如果 Cell 是 Sync,那 &Cell 就是 Send
// 两个线程就可以同时持有 &cell
// 然后同时调用 r.set(1) 和 r.set(2)

Cell::set() 通过共享引用 &self 就能修改内部的值,这是 Rust 的"内部可变性"。它靠的是 UnsafeCell------直接对内存做写操作,没有任何同步机制。

就像一块白板,谁拿到笔都能往上面写字。一个人用的时候没问题,但要是两个人同时往同一个位置写,写出来的就是乱码。Cell 就是这块没上锁的白板------单线程下没问题,但如果两个线程同时调 set(),就是一个裸的数据竞争。

所以编译器禁止 &Cell<T> 被发送到另一个线程。Cell 不是 Sync,&Cell 不是 Send,编译器直接拦住。

RefCell 同理。它的借用检查是运行时做的(一个普通的计数器记录当前有几个借用),这个计数器也不是原子的,多线程下会坏掉。

Mutex<T> 呢?Mutex<T> 是 Sync 的(只要 T: Send)。Mutex 相当于给白板装了一把锁和一支笔------想写的人先拿锁,拿到锁才能拿笔,写完把锁和笔一起还回去。你可以安全地把 &Mutex<T> 共享给多个线程------它们都能调 .lock(),但同一时刻只有一个能拿到锁。

Send 但不 Sync,Sync 但不 Send

实现了 Send 但没实现 Sync 的类型?

有。Cell<T>RefCell<T> 就是。

它们可以安全地转移给另一个线程(Send),但不能被多个线程同时通过共享引用访问(非 Sync)。原因上面说了------内部可变性的实现没有同步机制。

反过来,实现了 Sync 但没实现 Send 的类型?

标准库里的典型例子是 MutexGuard<T>

bash 复制代码
// MutexGuard 的 trait 实现(标准库源码)
impl<T: ?Sized> !Send for MutexGuard<'_, T> {}
unsafe impl<T: ?Sized + Sync> Sync for MutexGuard<'_, T> {}

MutexGuard 被显式标记为 !Send------不能发送到另一个线程。你可以把它理解成一把钥匙,门规定"谁锁的谁开",你不能把钥匙寄给别人让别人去开锁。底层原因是某些操作系统(比如 POSIX pthread)要求 mutex 必须在加锁的同一个线程上解锁。如果你把 MutexGuard 发到另一个线程,它在那个线程被 drop 时会调用 unlock,但这个线程不是加锁的那个线程,行为就是未定义的。

但钥匙可以给别人看一眼------MutexGuard<T>T: Sync 的时候是 Sync 的。共享引用 &MutexGuard<T> 只能读取内部数据,不能修改(因为没有 &mut),只要 T 本身支持多线程共享读取(T: Sync),那多个线程持有 &MutexGuard<T> 就是安全的。

所以 Send 和 Sync 是两个独立的维度。一个管"能不能转移",一个管"能不能共享"。它们经常同时出现,但互相不蕴含。

你不需要自己实现它们(大部分时候)

绝大多数类型不需要手动实现 Send 和 Sync。编译器会自动推导:如果一个结构体的所有字段都是 Send 的,那这个结构体自动就是 Send 的。Sync 同理。

bash 复制代码
struct MyData {
    name: String,    // Send + Sync
    count: i32,      // Send + Sync
}
// MyData 自动就是 Send + Sync
struct NotSendable {
    name: String,
    rc: Rc<i32>,     // 不是 Send,也不是 Sync
}
// NotSendable 自动就不是 Send,也不是 Sync

一个字段不满足,整个类型都不满足。就像一箱子东西要过安检,里面有一件违禁品,整箱都过不了。编译器在这件事上很保守------宁可误拦,不可放过。

如果你确实知道自己在做什么,可以用 unsafe impl 手动实现:

bash 复制代码
struct MyWrapper(*mut u8);

unsafe impl Send for MyWrapper {}
unsafe impl Sync for MyWrapper {}

这行 unsafe 的意思是你在跟编译器签军令状------"我保证这个类型在多线程下是安全的"。签错了,后果就是数据竞争和未定义行为,编译器不会再帮你兜底。

标准库里 Arc 的 Send 和 Sync 实现就是 unsafe impl。Arc 内部用了裸指针,裸指针默认既不是 Send 也不是 Sync,但 Arc 的作者通过原子操作保证了多线程安全性,所以用 unsafe impl 手动声明。实际写业务代码的时候,你大概率永远不需要写这行 unsafe impl------用标准库和社区里的类型组合就够了。

其他语言为什么没有这套东西

Java 里你往 new Thread() 的 Runnable 里传一个 HashMap,编译器一句话都不会说。两个线程同时往里面 put,运行时偶发一个 ConcurrentModificationException,而且是"偶发"------测试环境跑一百遍没事,上了生产流量一大就炸。你查三天日志,最后发现是某个 HashMap 没加锁。

C++ 更狠。你自己写个非原子计数器的智能指针,往多线程里传,编译器不拦你,运行时也不报异常------直接内存损坏,程序行为变得不可预测。可能跑了三个月都没事,某天凌晨三点突然 segfault。

这两种语言的哲学是"我信任程序员"。Rust 的哲学是"我不信任程序员,但我给你绕过检查的能力(unsafe)"。

说白了,Send 和 Sync 做的事情很简单:把"运行时才能发现的多线程 bug"变成"编译时直接报错"。

代价是你偶尔得跟编译器较劲。好处是凡是 cargo build 能通过的代码,不会出现数据竞争。死锁还是会有,逻辑 bug 也还是会有,但"两个线程同时读写同一块内存导致的未定义行为"这一类问题,从你写下第一行 Rust 代码开始就不存在了。

Go 选择了另一条路------不在类型系统里做约束,而是提供 race detector(go run -race)在运行时检测数据竞争。能抓到大部分问题,但只能检测到实际执行路径上触发了的竞争,没跑到的代码路径里藏着的 race 它看不到。Rust 的方式更彻底,但学习曲线也更陡。

相关推荐
用户298698530142 小时前
.NET 文档自动化:Spire.Doc 设置奇偶页页眉/页脚的最佳实践
后端·c#·.net
序安InToo3 小时前
第6课|注释与代码风格
后端·操作系统·嵌入式
xyy1233 小时前
C#: Newtonsoft.Json 到 System.Text.Json 迁移避坑指南
后端
洋洋技术笔记3 小时前
Spring Boot Web MVC配置详解
spring boot·后端
JxWang053 小时前
VS Code 配置 Markdown 环境
后端
navms3 小时前
搞懂线程池,先把 Worker 机制啃明白
后端
JxWang053 小时前
离线数仓的优化及重构
后端
Nyarlathotep01133 小时前
gin01:初探gin的启动
后端·go
JxWang053 小时前
安卓手机配置通用多屏协同及自动化脚本
后端