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

📅 发布时间:2026/7/17 21:45:24 👁️ 浏览次数:
如何理解Rust语言中Send和Sync?
写一段Rust多线程代码编译器报了个错error[E0277]: Rci32 cannot be sent between threads safely你去查文档告诉你RcT没有实现Send。那什么是Send文档说”实现了 Send 的类型可以安全地在线程间转移所有权”。读完之后你还是不知道它到底在”防”什么。这两个 trait 的定义之所以让人困惑是因为它们本身没有任何方法——它们是 marker trait纯粹用来做编译期标记。编译器不是通过它们来”执行”什么逻辑而是通过它们来”拒绝”不安全的代码通过编译。理解 Send 和 Sync与其去背定义不如直接看编译器到底在”拦”什么。Send你能不能把这个值丢给另一个线程use std::thread; use std::rc::Rc; fn main() { let data Rc::new(42); thread::spawn(move || { println!({}, data); }); }编译不过。Rci32没实现Send不能 move 进另一个线程的闭包。为什么Rc的内部是一个引用计数器加一个指向堆数据的指针。Rc::clone()的时候计数器加1Rc被 drop 的时候计数器减1减到0就释放内存。你可以把Rc想成一本图书馆的书上面挂了一张借阅卡每借出去一次就在卡上用铅笔加个1还回来就擦掉一个1卡上数字归零就把书销毁。这套系统在只有一个图书管理员的时候运转得很好。问题在于这个计数器不是原子操作。它就是一个普通的usize用普通的加减指令修改。如果两个线程同时 clone 或 drop 同一个Rc——相当于两个管理员同时拿起同一张借阅卡、同时读到上面写着1、各自加1写回2——但正确值应该是3。更危险的是 drop两个管理员同时读到卡上是1各自减到0各自去销毁同一本书。double free程序直接炸。这就是Rc不能 Send 的原因它的内部状态在并发修改下会损坏。不是”用起来不方便”的问题是”内存会炸”的问题。换成Arc就行了。Arc相当于把借阅卡上的铅笔换成了机械计数器——拨一下就是加1不存在两个人同时写导致数错的问题。底层用的是原子操作AtomicUsizefetch_add和fetch_sub在CPU层面保证了多线程下的正确性。所以ArcT在T: Send Sync的时候实现了Send。一句话总结 Send 的含义把这个值的所有权交给另一个线程之后不会导致内存安全问题。Sync你能不能让多个线程同时持有这个值的共享引用Send 管的是”转移”Sync 管的是”共享”。打个比方Send 是问”这个东西能不能快递到另一个城市”Sync 是问”这个东西能不能放在公共区域让大家一起用”。有的东西可以寄走但不能共用比如一把没有锁的日记本有的东西可以共用但不能寄走下面会讲到。准确地说T: Sync等价于T: Send——如果一个类型的共享引用可以安全地发送到另一个线程那这个类型就是 Sync 的。Celli32是 Send 的你可以把它 move 到另一个线程但它不是 Sync 的。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()就是一个裸的数据竞争。所以编译器禁止CellT被发送到另一个线程。Cell不是 SyncCell不是 Send编译器直接拦住。RefCell同理。它的借用检查是运行时做的一个普通的计数器记录当前有几个借用这个计数器也不是原子的多线程下会坏掉。那MutexT呢MutexT是 Sync 的只要T: Send。Mutex相当于给白板装了一把锁和一支笔——想写的人先拿锁拿到锁才能拿笔写完把锁和笔一起还回去。你可以安全地把MutexT共享给多个线程——它们都能调.lock()但同一时刻只有一个能拿到锁。Send 但不 SyncSync 但不 Send实现了 Send 但没实现 Sync 的类型有。CellT和RefCellT就是。它们可以安全地转移给另一个线程Send但不能被多个线程同时通过共享引用访问非 Sync。原因上面说了——内部可变性的实现没有同步机制。反过来实现了 Sync 但没实现 Send 的类型标准库里的典型例子是MutexGuardT。// MutexGuard 的 trait 实现标准库源码 implT: ?Sized !Send for MutexGuard_, T {} unsafe implT: ?Sized Sync Sync for MutexGuard_, T {}MutexGuard被显式标记为!Send——不能发送到另一个线程。你可以把它理解成一把钥匙门规定”谁锁的谁开”你不能把钥匙寄给别人让别人去开锁。底层原因是某些操作系统比如 POSIX pthread要求 mutex 必须在加锁的同一个线程上解锁。如果你把MutexGuard发到另一个线程它在那个线程被 drop 时会调用 unlock但这个线程不是加锁的那个线程行为就是未定义的。但钥匙可以给别人看一眼——MutexGuardT在T: Sync的时候是 Sync 的。共享引用MutexGuardT只能读取内部数据不能修改因为没有mut只要T本身支持多线程共享读取T: Sync那多个线程持有MutexGuardT就是安全的。所以 Send 和 Sync 是两个独立的维度。一个管”能不能转移”一个管”能不能共享”。它们经常同时出现但互相不蕴含。你不需要自己实现它们大部分时候绝大多数类型不需要手动实现 Send 和 Sync。编译器会自动推导如果一个结构体的所有字段都是 Send 的那这个结构体自动就是 Send 的。Sync 同理。struct MyData { name: String, // Send Sync count: i32, // Send Sync } // MyData 自动就是 Send Sync struct NotSendable { name: String, rc: Rci32, // 不是 Send也不是 Sync } // NotSendable 自动就不是 Send也不是 Sync一个字段不满足整个类型都不满足。就像一箱子东西要过安检里面有一件违禁品整箱都过不了。编译器在这件事上很保守——宁可误拦不可放过。如果你确实知道自己在做什么可以用unsafe impl手动实现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 detectorgo run -race在运行时检测数据竞争。能抓到大部分问题但只能检测到实际执行路径上触发了的竞争没跑到的代码路径里藏着的 race 它看不到。Rust 的方式更彻底但学习曲线也更陡。