
1. 认识Send与Sync先搞清楚它们在解决什么问题很多刚开始认真学Rust的朋友在读过所有权和借用规则之后会觉得这语言太安全了随便写都不怕出内存问题。这种感受没错但一旦代码真正跑进多线程领域光靠所有权和生命周期就不够了。比如你写一个线程想往里面传一个自定义的结构体、一个可变引用甚至一个带有内部可变性的共享状态编译器可能会突然甩出一句the trait boundMyStruct: Sendis not satisfied。这时候如果只是照着报错乱试你会发现怎么都绕不过去——因为你还没真正理解Rust线程安全模型里最核心的两位门神Send和Sync。用一句话来点破这两个trait的职责Send决定了一个值能不能安全地被移动移交到另一个线程Sync决定了一个值能不能安全地被多个线程同时共享引用。Send对应的是值的跨线程所有权转移Sync对应的是值的跨线程借用共享。前者是move语义在多线程领域的延伸后者是借用规则在多线程领域的延伸。这两个trait不是给你手动实现的功能接口而是编译器用来评估线程安全性的内部标记——它们被叫作marker traits因为它们本身没有任何方法纯粹是给类型打标签用的。为什么Rust要设计出这两个概念答案是Rust的安全承诺不能止步于单线程内存安全还必须覆盖并发场景。语言层面的所有权机制能保证同一时刻只有一个所有者但线程与线程之间靠消息传递、共享状态等方式协作时数据会在执行流之间迁移和暴露。编译器想要在编译期就判断这段多线程代码是否安全就必须先回答两个问题这个类型能不能安全地跨线程移交这个类型能不能安全地被多个线程同时看到Send和Sync就是这两个问题的标准化答案。这篇内容我打算围绕两个trait的定义、编译器行为、常见类型的实现情况、手动实现的场景以及几个真实的报错排查案例来展开。只要认真跟完这条线以后再遇到编译器的线程安全报错就不会再头皮发麻了。2. 深入理解这两个trait的底层逻辑2.1 Send的本质跨线程移交一个值的所有权通行证Send的定义在标准库里非常简单一句话如果一个类型实现了Send那么该类型的值可以安全地被移动move到另一个线程中继续使用。这里的安全是关键词。什么情况下跨线程移动一个值会不安全最典型的就是某些类型内部持有操作系统级别的资源句柄比如裸指针、文件描述符、socket连接等等这些资源往往绑定着特定的线程上下文或底层运行时状态。如果直接把这个值丢给另一个线程使用资源的释放、访问时机就可能错乱甚至产生竞态。新手最容易踩的误区是把Send理解为类型本身能被发送实际上它是该类型的一个值可以整体搬家到别的线程。搬家意味着原线程这边不再拥有它完全移交所有权这正是Rust所有权机制的跨线程版本。单线程里你可以通过Box::new把堆上的数据传来传去多线程里你要跨线程传数据就得问这个类型够不够格被搬走——够格就是Send不够格编译器直接拦下。举一个简单例子use std::thread; fn main() { let numbers vec![1, 2, 3, 4]; let handle thread::spawn(move || { println!({:?}, numbers); }); handle.join().unwrap(); }Veci32实现了Send所以这里把numbers移动进新线程是完全合法的。编译器和运行时都不会出问题。如果Vec不是Send那Rust就根本不会让你把数据移动到thread::spawn的闭包里。这个例子看起来平平无奇但它揭示了所有跨线程通信的底层基础你往一个线程里传任何东西传递的载体都必须满足Send。Send的反例中最著名的是RcT。RcT是单线程引用计数指针它维护的引用计数不是原子的没有加锁。如果多个线程同时克隆Rc、修改计数计数的读写就会产生数据竞争可能导致计数错乱、内存提前释放或者永不被释放。Rust的做法就是不给RcT实现Send编译器直接拒绝让你把Rc跨线程移动。而对应的ArcT是原子引用计数它内部用原子操作更新计数于是ArcT就实现了Send可以放心送进别的线程。2.2 Sync的本质跨线程共享引用的并行访问许可证Sync的定义也需要仔细品如果一个类型实现了Sync那么指向该类型的不可变引用T可以安全地在多个线程之间共享。注意这里共享的是引用不是值本身。多个线程同时拿着T对同一个底层数据做只读访问编译器要保证这种并行只读不会出问题。Sync和Send很容易纠缠在一起很多人以为Sync比Send更严格所以Sync一定蕴含Send实际上不是。这两个trait是两个维度的判断。你可以定义一个类型只实现Sync而不实现Send也可以反过来只实现Send而不实现Sync。它们之间既不是包含关系也不是等价关系。更准确地说它们是独立的两个属性各自约束不同的操作场景。再深入一层Sync的核心约束其实是该类型内部不包含任何允许被并发修改的非原子状态。如果类型内部有一个CellT或者RefCellT这种内部可变性结构那它的T引用就不能被多个线程同时拿着——因为多个线程可能通过同一个共享引用去修改内部状态而Cell的修改不是原子操作数据竞争随之而来。所以CellT和RefCellT都没有实现Sync。而MutexT实现了Sync因为它通过加锁机制保证了内部状态的并发修改是互斥的、有序的。这里有一个经典的直觉测试什么样的类型会是Send !Sync最常见的就是std::sync::mpsc::SenderT。Sender可以被克隆克隆后的多个Sender可以被移动到不同线程里所以它满足Send。但如果你试图让多个线程共享同一个Sender的引用去并发发送消息就需要额外的同步手段因为Sender内部的发送缓冲管理和通道状态本身不是线程安全的并发访问结构。Rust标准库设计者就只给它实现了Send不实现Sync。这个例子能帮你彻底区分两个trait的语义方向。2.3 二者与引用类型的关系T与mut T的联动Send和Sync之间有一条非常实用的定理Rust标准库文档里写得清清楚楚如果T: Sync那么T: Send。反过来如果T: Send那么mut T: Send。这些联动规则是编译器做自动推导时非常依赖的基石。为什么会有这种推导关系想想T的职责它是一份不可变借用可以被复制。如果底层类型T是Sync的就意味着多个线程可以同时共享对这个T的只读引用那么一个T本身被移动到某个线程里自然也不会破坏这种安全承诺。所以T是Send的。类似地mut T是一次性独占可变借用如果T本身能安全地跨线程移动即T: Send那把这个独占可变引用移交给另一个线程也不会产生并发问题——毕竟可变借用同一时刻只有一个不存在竞争。这些推导关系在实际编码中非常重要。例如thread::spawn的闭包如果从环境中借用了一些数据编译器就会检查这些借用是否能安全地跨线程。如果闭包里捕获的是一个Vecu8那么编译器会检查Vecu8: Sync是否成立因为Vecu8: Send等价于Vecu8: Sync。一旦底层类型不是Sync闭包就不能被移动进新线程。3. 编译器如何利用这两个trait做安全检查3.1thread::spawn的约束闭包必须满足SendRust标准库中thread::spawn的签名约束如下pub fn spawnF, T(f: F) - JoinHandleT where F: FnOnce() - T Send static, T: Send static,重点看两个Send约束第一传入的闭包F本身必须是Send的第二闭包的返回值T也必须是Send的。为什么闭包必须是Send因为thread::spawn会把闭包整个移动进新的线程闭包捕获的所有变量都随之搬家。闭包是否Send取决于它捕获的所有变量是否Send。只要有一个捕获变量不满足Send整个闭包就不满足Sendthread::spawn直接编译失败。这就解释了网上很多人贴的报错场景一个闭包里捕获了Rc::new(...)结果spawn报错说不满足Send。因为RcT不是Send闭包带着这个捕获变量进入新线程时跨线程转移了非Send的资源这是Rust绝对不允许的。返回值T要求Send也很好理解新线程执行完之后返回值要通过JoinHandle送回原线程这个跨线程回传的操作和spawn时把闭包送出去是对称的。如果返回值不满足Send比如新线程里构造了一个Rc你想通过join拿回来在主线程使用编译器一样会拒绝。3.2 自动推导机制绝大多数情况下你不需要手动实现Send和Sync最令人省心的特点在于它们是自动traitauto traits。什么意思就是只要一个类型的组成成分都满足Send或Sync这个类型就自动继承对应的trait不需要你写任何impl代码。绝大多数标准库类型、自定义的普通结构体、枚举都通过这种自动推导获得了Send或Sync能力。举个例子struct MyData { name: String, values: Veci32, }因为String和Veci32都是Send Sync的所以MyData自动就是Send Sync的。你可以放心地把它移动进线程或者在线程之间共享引用。编译器自动推导的过程本质上是在递归检查结构体每个字段的trait实现情况。某个字段不是Send整个结构体就不是Send。同样枚举也是按所有变体来推导的。只要有一个变体包含了非Send的字段整个枚举就不是Send。这条规则导致了实际开发里一个很常见的现象你定义了一个枚举平时用的变体都是普通数据结果为了某个功能加了一个变体里面包含了一个Rc或裸指针然后整个枚举突然就“失去”了Send能力所有相关线程代码全部编译失败。排查这类问题的时候不能只看报错的那一行要顺着类型定义递归检查每个字段每个变体。自动推导的另一个关键点是只要你自己手动实现了Send或Sync编译器就不再自动推导而是以你的实现为准。这个特性让unsafe impl Send成为可能——也就是我向编译器承诺这个类型就是能安全跨线程移动的你信我。3.3unsafe impl手动实现trait的正确姿势与风险既然Send和Sync通常都是自动推导的那什么时候需要手动实现答案是你自己封装了一个包含裸指针、或包含操作系统资源句柄、或底层通过FFI绑定C语言库的类型时。自动推导机制看到裸指针或UnsafeCell时会给类型打上不满足Send/Sync的标签。这当然是保守的因为裸指针本身不带任何安全承诺编译器不能擅自假设它能跨线程。但很多情况下你自己知道这个指针指向的数据实际上是安全的——比如这个指针背后是一个由锁保护的堆分配对象或者生命周期完全由外部机制保证。这时你需要手动unsafe impl Send来声明安全性。手动实现的写法struct MyUnsafeWrapper { ptr: *mut u8, } unsafe impl Send for MyUnsafeWrapper {} unsafe impl Sync for MyUnsafeWrapper {}注意Send和Sync的impl必须用unsafe关键字修饰。原因很直接普通impl意味着编译器能验证安全性但Send和Sync是一种内存安全保证编译器检验不了你的承诺时你就必须用unsafe impl把这个责任扛到开发者自己身上。如果你乱承诺把一个实际上不安全的类型标成了Send那多线程代码里的数据竞争和未定义行为就全是你自己制造的雷。我见过不少团队把unsafe impl Send当逃生通道编译报错就手动impl反正编译器通过了就万事大吉。这种做法极其危险。正确判断依据是先想清楚这个类型的内部状态到底会不会在多线程访问下出问题如果内部有共享可变状态是否已经通过Mutex、RwLock、原子操作等手段做了同步。只有确认了真的不会产生数据竞争才值得用unsafe impl。4. 常见类型的Send与Sync实现速查掌握各个常见类型的Send/Sync情况能让你在写代码之前就对“这句代码能不能编译”有预判。下面这张速查表整理了我平时最常用的一些类型类型SendSync说明i32、u64、f64等基本标量类型是是纯内存数据无内部状态天然安全String是是内部就是堆上的字节数组加长度无特殊句柄VecT是否取决于T是否取决于T跟随元素类型的trait实现BoxT是否取决于T是否取决于T等价于T的属性RcT否否非原子引用计数跨线程会导致计数竞争ArcT是是否取决于T原子引用计数保证计数安全内部数据需另行判断MutexT是否取决于T是内部加锁保证并发访问安全但T如果是非Send则整体不能SendCellT是否非原子的内部可变性共享引用并发调用set会造成竞争RefCellT是否同Cell且运行期借用检查不是线程安全的Boxdyn Fn是否函数对象内部可能持有任意状态默认不承诺共享引用mpsc::SenderT是否可克隆可移动但共用引用发送需要同步mpsc::ReceiverT是否接收端不允许多线程同时消费*const T、*mut T否否裸指针没有安全承诺需自行判断封装这张表里有几个容易让人困惑的点值得展开说一说。第一个是ArcT。很多人以为Arc就是线程安全的Rc所以ArcT一定是Send Sync。这个理解对了一半。Arc的引用计数部分是原子的所以跨线程克隆和释放计数不会出问题这保证了ArcT本身可以Send。但ArcT能不能Sync取决于内部T本身能不能被并发共享引用。如果T是RefCellu8那么ArcRefCellu8是Send的你可以把这个Arc移动进另一个线程但不是Sync的你不能让多个线程同时拿着ArcRefCellu8去共享引用。这种细微差别在实战中经常绊倒新手特别是他们尝试用Arc RefCell做多线程共享状态时会发现编译根本过不去。正确的多线程共享可变状态方案应该是ArcMutexT。第二个是MutexT。MutexT实现了Sync但这不代表MutexT就是无条件的Send。MutexT: Send需要T: Send成立。理由很清晰Mutex内部持有T如果这个Mutex被移动到另一个线程内部的T也跟着移动了自然要求T能安全跨线程移动。第三个是函数指针和闭包。fn()函数指针通常是Send Sync的因为函数指针就是一个地址没有捕获状态。但闭包就不一定了闭包是否Send完全取决于它捕获的环境变量。如果闭包捕获了一个Rc那这个闭包就不是Send如果捕获的是一个Arc那闭包大概率是Send的。建立一个直觉模型很有用看一个类型是否Send就想象这个值能不能整个端起来搬到另一张桌子上看一个类型是否Sync就想象这个值能不能同时摆在多张桌子上让不同人看只读、“或者轮流用” 加锁。“搬起来”和“让人看”是完全不同的两种操作不要混在一起判断。5. 两个trait如何协同解决实际并发问题5.1 从数据竞争到编译器拦截一份可复现的实践流程下面我用一个最经典的多线程场景完整演示这两个trait在编译期是怎么一层层把关的。假设我们要写一个程序多个线程各自统计自己的一部分数据最后汇总到主线程。最直接的写法是使用thread::spawn加joinuse std::thread; fn main() { let mut handles vec![]; for i in 0..4 { let handle thread::spawn(move || { let sum: i32 (0..1000).filter(|x| x % 4 i).sum(); sum }); handles.push(handle); } let total: i32 handles.into_iter().map(|h| h.join().unwrap()).sum(); println!(total {}, total); }这段代码里move ||闭包捕获了循环变量i。i是i32天然是Send所以闭包满足Send能成功进入spawn。每个线程返回的sum也是i32满足返回值T: Send的要求所以join能拿到结果。整个程序编译运行都没问题。这里能看到Send自动推导带来的便利你并没有写任何和Send相关的代码编译器就已经帮你完成了全部安全检查。接着把代码升级一下改用ArcMutexVeci32做多线程共享结果收集。这是多线程代码里非常常用的模式use std::sync::{Arc, Mutex}; use std::thread; fn main() { let results Arc::new(Mutex::new(Vec::new())); let mut handles vec![]; for i in 0..4 { let results Arc::clone(results); let handle thread::spawn(move || { let sum: i32 (0..1000).filter(|x| x % 4 i).sum(); results.lock().unwrap().push(sum); }); handles.push(handle); } for h in handles { h.join().unwrap(); } println!(results {:?}, results.lock().unwrap()); }这里的关键链路是Veci32是Send Sync的所以MutexVeci32是Send Sync的于是ArcMutexVeci32也是Send Sync的。Arc::clone克隆出来的新引用被移动进每个线程闭包捕获的是Arc整个闭包能满足Send。编译器这一长串推导全部自动完成没有任何报错。反过来如果你把ArcMutexVeci32换成RcRefCellVeci32那Rc非Send的问题会立刻爆出来因为Rc根本不允许跨线程克隆。5.2 基于Send与Sync的三种数据共享方案选型实际工程里选哪种多线程数据共享方案本质上也是在选“这个数据结构能不能满足Send/Sync的约束”。我根据自己的经验整理了一个选型思路方案一消息传递所有权转移适合“数据一次性给某个线程处理处理完不再需要主线程这个副本”的场景。用std::sync::mpsc::channel配合Sender把数据直接移动到工作线程。这种模式下数据只需满足Send不需要考虑Sync因为数据的所有权从一个线程转移到另一个线程不存在共享访问。它的优点是没有锁竞争性能好缺点是一份数据只能被一个线程消费不能多读多改。方案二共享不可变数据只读并发适合配置数据、静态表、加载完成后的只读资源等场景。用ArcT把数据包装后克隆到多个线程每个线程只做读操作。这要求T: Sync Send。读取不需要加锁并发能力非常强。如果T内部有可变缓存那么就不能用纯只读方案得换成方案三。方案三共享可变数据加锁互斥适合多个线程需要并发修改同一份状态的场景。用ArcMutexT或ArcRwLockT。这要求T: Send不要求T: Sync因为Mutex本身提供了同步屏障并生成了Sync能力。RwLock适合读多写少的场景Mutex适合读写频率接近或者临界区很短的场景。这种方案的缺点是加锁有竞争开销临界区设计不好会直接影响吞吐量。选型时最怕的是“凭感觉混用”数据明明需要多线程只读共享却套上Mutex导致不必要的锁竞争数据明明只需要一次性转移却硬要套Arc造成引用计数的额外开销。Send和Sync就像一把尺子先把你的数据结构量一量再决定走哪条路写出来的并发代码才会又稳又高效。6. 实战报错场景与排查实录6.1 报错一RcRefCellT无法跨线程传递这是一道非常经典的新手必踩坑。代码大致长这样use std::rc::Rc; use std::cell::RefCell; use std::thread; fn main() { let counter Rc::new(RefCell::new(0)); let handle thread::spawn(move || { *counter.borrow_mut() 1; }); handle.join().unwrap(); }编译时报错信息核心是the trait bound RcRefCelli32: Send is not satisfied这个报错背后的原因链条非常清楚Rc的引用计数不是原子的跨线程克隆和释放会引发计数竞态而RefCell的运行时借用检查也不是线程安全的多个线程同时访问同一个借用状态会造成数据竞争。所以RcRefCellT既不Send也不Sync没有资格进spawn。修复办法是换成ArcMutexTuse std::sync::{Arc, Mutex}; use std::thread; fn main() { let counter Arc::new(Mutex::new(0)); let counter_clone Arc::clone(counter); let handle thread::spawn(move || { let mut guard counter_clone.lock().unwrap(); *guard 1; }); handle.join().unwrap(); println!(counter {:?}, counter.lock().unwrap()); }同样是共享可变状态Mutex通过加锁把并发访问变成互斥的、有序的所以MutexT是Sync的Arc通过原子引用计数保证克隆和释放安全所以ArcMutexT是Send Sync的。这个替换是从根因上解决了问题不是绕过报错。6.2 报错二*mut T裸指针字段导致类型失去Send自定义结构体字段里包含裸指针是另一个高频报错来源struct Buffer { ptr: *mut u8, len: usize, } fn send_buffer() { let buffer Buffer { ptr: std::ptr::null_mut(), len: 0, }; std::thread::spawn(move || { // 使用buffer }); }报错信息*mut u8 cannot be sent between threads safely底层原因是裸指针没有实现Send。编译器不知道这个指针指向的内存是否绑定当前线程的上下文、是否在另一个线程里访问是安全的所以它直接拒绝。如果你确实知道这个指针指向的数据在跨线程场景下是安全的比如指向一个静态存储区或者由外部生命周期机制保证有效性可以手动执行unsafe impl Send。但一定要先说清楚安全依据而不是无脑加impl。6.3 排查技巧顺着字段递归检查trait约束遇到Send或Sync相关报错时我建议按下面几步排查能节省大量时间。第一步看报错指向的类型是谁。这个类型是直接定义在报错位置的还是作为某个字段被子结构带出来的如果是后者沿着字段定义一路往下找直到找到那个“罪魁祸首”字段。第二步判断这个字段为什么不是Send/Sync。是Rc、RefCell、裸指针、还是某个第三方库里没有实现trait的自定义类型根据具体原因决定修复方案。Rc换成Arc通常就够了RefCell不能跨线程共享可变状态需要用Mutex裸指针则需要重新审视设计是否真的需要跨线程传递指针所指的数据能不能改用其他的安全抽象。第三步检查是否涉及内部的泛型参数。比如ArcT或MutexTT的类型变化会直接改变整体trait实现状态。ArcString显然是Send Sync的但如果T是一个持有RefCell的类型那ArcT的Sync能力就没有了。这类问题要让编译器把完整约束链打出来尽量用cargo check而不是cargo build因为check只做类型检查不生成产物速度快得多反复试错时体验好很多。第四步确认unsafe impl的必要性别把逃生通道当常规操作。手动实现Send或Sync意味着你向编译器承诺这份安全由你担保。做了这个承诺之后编译器不会再帮你自动检查内部字段的trait实现。后续一旦有人往结构体里加了新的非Send字段编译器也不会再警告隐患会潜伏到运行期才爆发。因此每次写unsafe impl都要附上注释说清楚安全依据是什么方便自己和同事以后复盘。6.4 关于Send与Sync约束的常见误解辨析很多初学者在理解这两个trait时会形成一些错误的直觉模型我挑几个最常见的澄清一下。误解一“Sync的类型一定是Send的。”错。前面讲过SenderT就是典型的Send !Sync。反过来也存在Sync !Send的类型比如某些封装了共享但不可移动资源的句柄类型。两个维度是独立的不要互相推断。误解二“只要用了Arc多线程就安全了。”错。Arc只保证引用计数的线程安全不保证内部数据的并发访问安全。ArcRefCellT在多线程共享可变状态时仍然是错误的编译器会通过Sync缺失来拒绝它。Arc只是“能安全移动和共享引用”真正的数据保护还得靠Mutex、RwLock或原子的内部结构。误解三“裸指针只要标记了Send和Sync代码就安全了。”错。unsafe impl只是一种声明不会改变底层内存访问的实际行为。如果你的裸指针指向的数据确实存在并发写冲突标记Send只会让编译器闭嘴但运行期的数据竞争不会消失反而变成未定义行为。unsafe impl的前提是“开发者已经通过外部机制保证了安全”而不是“开发者想让编译器不管这件事”。误解四“std::thread::spawn是唯一受Send约束的地方。”错。很多标准库并发原语、第三方异步运行时、线程池库在接收任务时都会要求闭包和任务满足Send。比如一些线程池的spawn接口会在编译期检查任务闭包是否可跨线程执行。换句话说只要数据可能跨线程移动Send都有可能被检查到。7. 我的实操体会与延伸思考Send和Sync最让我感慨的一点是Rust把并发安全性从“运行时崩溃”提前到了“编译期拒绝”。在其它语言里一个对象被错误地共享到多个线程往往是运行到某个瞬间才崩而且崩得毫无规律在Rust里这个问题直接在编译阶段用一个错误信息告诉你“这个类型不满足Send”。这一设计带来的开发体验转变是巨大的——你不再需要靠反复压测去发现竞态而是在写代码的那一刻就被迫把数据结构设计清楚。我在实际项目中养成的一个习惯是每当定义一个自定义结构体如果用到了裸指针、FFI句柄、内部可变性容器或者任何和线程相关的字段都会主动在类型定义的旁边加注释标明“这个类型是否是Send和Sync以及为什么”。这个习惯帮我避过好几次坑也为同行review代码提供了清晰的上下文。最后分享一个调试小技巧当遇到复杂的Send/Sync报错时试着把报错类型拆成最小复现案例——一个结构体、一个spawn调用去掉所有无关业务代码。然后逐步往结构体里加字段观察哪一步开始坏。这个过程往往能在几分钟内定位到罪魁祸首字段比盯着长篇大论的报错反复推理高效得多。如果你把上面的概念都消化透了再去拆这种复现案例基本一眼就能看出问题出在哪个类型上。想对这两个trait有更深的感觉除了做题和写demo还可以去读一读标准库里std::marker模块的源码注释那里面对Send和Sync的形式化定义和最核心的推导规则写得非常清楚。读一遍源码注释比我在这里用大白话解释一百遍都更管用。