Send 与 Sync

本页目录

Send 表示一个类型的值可以安全跨线程转移,Sync 表示共享引用可以安全跨线程传递。它们是编译期契约,不会在运行时自动添加锁,也不要求内部完全不可变。

形式上 T: Sync 当且仅当 &T: Send。这个定义让“共享一个对象”可以还原为“把指向它的共享引用发送给另一个线程”。Send、Sync

线程 A:拥有 T把值交给另一个线程线程 B:获得 T要求 T: Send共享同一个 T多个线程持有 &T跨线程传递 &T要求 T: Sync,即 &T: Send两种跨线程使用方式:移动值与传递共享引用这些 trait 允许相应访问方式,不保证业务原子性、无死锁或低延迟。

常见类型的条件

下表讨论常见默认分配器类型,省略部分泛型与生命周期细节:

类型Send 条件Sync 条件原因
Vec、BoxT: SendT: Sync所有权与借用传播到内部元素
Cell、RefCellT: Send不实现 Sync能独占搬走,但不能任意并发共享访问
Rc不实现 Send不实现 Sync非原子引用计数可能与其他克隆共同访问
ArcT: Send + SyncT: Send + Sync引用计数原子化,不自动同步 T
MutexT: SendT: Send通过锁把内部访问串行化
RwLockT: SendT: Send + Sync共享读锁允许多个线程同时读 T
*const T、*mut T默认不实现默认不实现裸指针无法自动证明资源与线程契约

RefCell 能 Send,但不能 Sync,这正说明两者不是同一个“线程安全开关”。Arc<RefCell> 也不会因为加了 Arc 就可以跨线程共享:Arc 只维护所有权计数,RefCell 的借用状态没有变成同步状态。

Mutex 的 Sync 只要求 T: Send,是因为锁允许不同线程先后独占访问 T,而不是同时共享读取 T。std::sync::MutexGuard 又为平台可移植性不实现 Send,避免在另一线程释放某些平台要求同线程释放的锁;这不与 Mutex 自身的条件冲突。

把 RefCell 搬走,与共享 RefCell 的引用

use std::cell::RefCell;
use std::thread;
fn main() {
    let local = RefCell::new(vec![1, 2]);
    let returned = thread::spawn(move || {
        local.borrow_mut().push(3);
        local
    }).join().unwrap();
    assert_eq!(&*returned.borrow(), &[1, 2, 3]);
}

主线程把整个 RefCell 的所有权交给工作线程,工作结束再返回;没有两个线程同时操作其动态借用状态,所以 RefCell<Vec> 可以满足 Send。若改为把 &local 交给另一个线程并在原线程保留访问,就需要 RefCell 满足 Sync,而它不满足。

Rc 的限制更严格:即使当前运行时恰好只有一个强引用,Rc 的类型也不会因此临时实现 Send。编译期约束不能从一次计数查询获得长期所有权证明。可以在唯一时尝试取出 T,再传递 T;或者如果确实需要跨线程共享,选择满足条件的 Arc。

为什么 Mutex 能包住非 Sync 的 T

use std::cell::RefCell;
use std::sync::Mutex;
use std::thread;
fn main() {
    let value = Mutex::new(RefCell::new(0u32));
    thread::scope(|scope| {
        for _ in 0..2 {
            let shared = &value;
            scope.spawn(move || {
                let guard = shared.lock().unwrap();
                *guard.borrow_mut() += 1;
            });
        }
    });
    assert_eq!(value.into_inner().unwrap().into_inner(), 2);
}

Mutex 保证访问 RefCell 的线程先后进入临界区,因此这里不要求 RefCell 本身 Sync。该组合用于演示 trait 推导,实际这个计数只需要 Mutex;已经取得 MutexGuard 的独占访问时,多套一层 RefCell 往往没有必要。

RwLock 的读 guard 则允许多个线程同时取得内部共享引用,因此其 Sync 条件还要求 T: Sync。不能把“加了一把锁”当作所有锁类型都满足同样条件的理由。

逐层推导 Send 与 Sync

把内部类型换成 RefCell,先套 Arc,再套 Mutex,对比 Sync 的结果。关键不是“容器名字看起来线程安全”,而是外层如何访问 T:Arc 允许共享访问,Mutex 让不同线程先后取得独占访问。随后换成 Rc,还会遇到 T 本身不能 Send 的另一道限制。

正在呈现知识画面
逐层推导 Send 与 Sync

Arc 不会把非 Sync 的内部值变成可共享;Mutex 的 Sync 要求内部 T: Send。

用编译器检查推导

use std::cell::RefCell;
use std::sync::{Arc, Mutex};

fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}

fn main() {
    assert_send::<RefCell<u32>>();
    assert_send::<Arc<String>>();
    assert_sync::<Arc<String>>();
    assert_sync::<Mutex<RefCell<u32>>>();
    // assert_sync::<RefCell<u32>>(); // 不满足 Sync
    // assert_send::<Arc<RefCell<u32>>>(); // Arc 不补足内部同步
}

自动 trait 的结构推导是默认规则,还受类型本身显式实现等影响。并不是“除了裸指针、Rc 和几个 Cell,其他所有类型一定都 Send/Sync”;平台句柄、线程关联对象和第三方抽象也可能有限制。

Send 与 'static 是不同轴

普通 thread::spawn 通常要求闭包 Send + 'static。Send 管跨线程使用,'static 管捕获的借用不能比独立线程更早失效;'static 不意味着这个值必须永远活着。

把 String move 进闭包可满足没有短借用的条件,move 一个 &str 则只移动引用,不延长被引用对象的寿命。对于明确在作用域结束前完成的线程,可以使用 thread::scope 借用外层数据,见 线程与同步。

async Future 是否 Send 同样取决于它可能持有的状态。非 Send 值跨越挂起点存活,往往会让 Future 不能交给可跨线程调度的 spawn;不要为了消除错误就随意 unsafe impl Send。可以缩小值或锁 guard 的作用域,或按需求使用局部任务执行机制。

作用域线程放宽生命周期,不放宽 Send/Sync

use std::thread;
fn main() {
    let mut values = [1, 2, 3, 4];
    thread::scope(|scope| {
        let (left, right) = values.split_at_mut(2);
        scope.spawn(move || { for value in left { *value *= 10; } });
        scope.spawn(move || { for value in right { *value += 1; } });
    });
    assert_eq!(values, [10, 20, 4, 5]);
}

split_at_mut 建立不重叠区域,线程分别取得独占引用;scope 保证这些线程结束后才离开作用域,因此可以借用局部数组。既没有复制整个数组,也不需要用 Arc 延长其拥有关系。作用域线程

但 scope 不会让 Rc 变成 Send,也不会让 &RefCell<T> 变成可安全跨线程共享。它解决的是借用能否保持有效,不是共享访问的同步问题。

被转移的东西所需的核心条件
TT: Send
&TT: Sync
&mut TT: Send;原线程不能同时用这份独占借用
带借用的闭包捕获的实际字段满足线程要求,借用覆盖线程执行期

Future 的状态要看挂起点,也要看初始捕获

use std::rc::Rc;
fn assert_send<T: Send>(_: T) {}
async fn local_work() {
    {
        let local = Rc::new(String::from("temporary"));
        assert_eq!(local.len(), 9);
    }
    std::future::ready(()).await;
}
fn main() {
    assert_send(local_work());
}

这里 Rc 在 async 函数开始被 poll 后创建,且在 await 前结束使用并释放,Future 可以满足 Send。示例只检查 Future 的类型,没有运行执行器;函数体中的断言只有被 poll 才会执行。

如果 Rc 是从外面被 async move 捕获,那么 Future 在第一次 poll 前就拥有它,即使函数体准备在第一个 await 前 drop,也不能简单套用“没有跨 await 就一定 Send”。诊断时同时检查初始捕获、挂起时保存的局部值以及返回值要求。具体运行时的 spawn 还可能要求 Future::Output: Send,这与 Future 本身 Send 是两个约束。

PhantomData 表达逻辑关系,不提供同步

use std::marker::PhantomData;
use std::rc::Rc;

struct ThreadBound {
    handle: usize,
    _thread_bound: PhantomData<Rc<()>>,
}
fn main() {
    let resource = ThreadBound { handle: 7, _thread_bound: PhantomData };
    assert_eq!(resource.handle, 7);
}

即使 handle 只是整数,标记也能表达这个封装不应自动 Send/Sync。PhantomData 不保存一个真正的 Rc,但会参与自动 trait、方差和析构检查相关推导;换一种 PhantomData 类型可能同时改变多种含义,不是只调一个线程属性按钮。

反过来,添加 PhantomData 不会让一个原本含裸指针的容器自动证明 Send。确需手写 unsafe impl 时,要证明移动/共享所有公开操作、回调和析构都遵守线程契约,而不是“底层库大概线程安全”。

入口要求spawn 需要 Send + 生命周期闭包 / Future 捕获捕获的是 T、&T 还是 Arc<T>具体字段计数、内部借用、线程绑定调整拥有关系、缩短借用范围或选择合适的同步协议遇到线程约束错误,沿实际传递的类型逐层定位修改发生在模型层;unsafe impl 是证明义务,不是消除报错的开关编译器报告的最终非 Send 字段通常最有价值;外层错误位置未必是设计问题所在。

unsafe impl 要覆盖析构和别名,不只是一条读写操作

封装外部句柄时,即使所有方法都带锁,如果底层要求在创建线程销毁该句柄,允许把拥有者移动到任意线程仍可能违反契约。类似地,某个方法返回绕过同步的内部指针,或回调能在锁外访问同一状态,也会破坏看似受保护的模型。

应逐项核对谁拥有资源、哪些别名可以存在、哪个线程执行析构,以及所有公开操作是否维护同一规则。若底层 API 本来就线程绑定,应把任务发回拥有该句柄的线程处理,或保留明确的非 Send 封装。手写 unsafe impl 必须有与这些路径相符的证明,不能只引用“用了 Mutex”。

能编译不等于并发业务正确

Arc<Mutex<账户>> 能避免未同步的数据访问,却不能自动把跨两个账户的操作变成原子事务,也不能消除锁顺序死锁。Sync 对内部可变性的要求是安全同步,不是保证每组多步操作满足业务不变量。

设计接口时先决定共享、转移还是消息传递,再让 trait 条件反映实际模型。出现 Send/Sync 错误通常是在提示某个资源或生命周期决策还没有说清。