Rust Send 与 Sync
-
Send
Send表示一个类型的值可以在线程间移动(被传递)。这意味着这个类型的值在被移动到另一个线程后,原来的线程不能再访问这个值。Send主要是为了表示“线程安全的move”。
典型的Send类型有:- 基本类型(i32, bool, char等)
- 不可变类型(不包含任何非Send类型的字段)
- Rc(当T也是Send的时候)
-
Sync
Sync表示一个类型的值可以在线程间共享。这意味着这个类型的值可以同时被多个线程访问(通常是通过共享引用&T)。Sync主要是为了表示“线程安全的share”。
典型的Sync类型有:- 基本类型
- Atomic类型(AtomicBool等)
- Mutex(当T也是Send的时候)
一个类型同时也可以是Send和Sync。两个性质并不冲突,表示这个类型的值可以同时在线程间安全的移动和共享。
-
sync 想要一个类型的同一个变量可以在不同线程同时拥有它的不可变引用,则必须实现Sync
-
send 想要一个类型可以在线程之间移动,则必须实现Send
-
send 表示跨线程move,sync表示跨线程share data,两者基本就是ownership和borrow的区别
所以,Send和Sync这两个marker trait定义了Rust的线程安全类型系统,它让编译器可以在编译期确保我们的程序没有数据竞争或者其他线程不安全的操作。
理解Send和Sync的区别与联系,可以让我们更清晰的理解Rust的并发模型,并编写出更加优雅的多线程程序。
如果一个类型可以安全地传递给另一个线程,这个类型是 Send
如果一个类型可以安全地被多个线程共享 (也就是 &T 是 Send),这个类型是 Sync
Send 和 Sync 是 Rust 并发机制的基础。因此,Rust 赋予它们许多的特性,以保证它们能正确工作。首当其冲的,它们都是非安全 trait。这表明它们的实现也是非安全的,而其他的非安全代码则可以假设这些实现是正确的。由于它们是标志 trait(它们没有任何关联的方法),“正确地实现” 仅仅意味着实现满足它所需要的内部特征。不正确地实现 Send 和 Sync 会导致未定义行为。
Send 和 Sync 还是自动推导的 trait。和其他的 trait 不同,如果一个类型完全由 Send 或 Sync 组成,那么这个类型本身也是 Send 或 Sync。几乎所有的基本类型都是 Send 和 Sync,因此你能见到的很多类型也就都是 Send 和 Sync。
Send 与 Sync 可能是Rust多线程以及异步代码种最常见到的约束。在前面一篇讨论多线程的文章中介绍过这两个约束的由来。但是,真正书写比较复杂的代码时,还是会经常遇到编译器的各种不配合。这里借用我的同事遇到的一个问题再次举例谈一谈 Send 与 Sync 的故事。
基本场景
C/C++中不存在Send/Sync的概念,数据对象可以任意在多线程中访问,只不过需要程序员保证线程安全,也就是所谓“加锁”。而在Rust中,由于所有权的设计,不能直接将一个对象分成两份或多份,每个线程都放一份。一般地,如果一份数据仅仅子线程使用,我们会将数据的值转移至线程中,这也是Send的基础含义。因此,Rust代码经常会看到将数据clone(),然后move到线程中:
let b = aa.clone();
thread::spawn(move || {
b...
})
假如,数据需要在多线程共享,情况会复杂一些。我们一般不会在线程中直接使用外部环境变量引用。原因很简单,生命周期的问题。线程的闭包要求‘static,这会与被借用的外部环境变量的生命周期冲突,错误代码如下:
let bb = AA::new(8);
thread::spawn( || {
let cc = &bb; //closure may outlive the current function, but it borrows `bb`, which is owned by the current function
});
Rc与Arc都是线程安全的引用计数智能指针,但有以下主要区别:
-
Arc是原子操作的(Atomically Reference Counted),它使用原子操作来增加和减少引用计数,所以它是线程安全的,可以在多线程中使用。
而Rc使用非原子操作来修改引用计数,所以它是非线程安全的,只能在单线程中使用。 -
Arc使用更为昂贵的原子操作,所以性能稍差于Rc。所以当不需要跨线程共享时,优先选择Rc。
-
Arc实现了Send与Sync特性,所以它的值可以在线程间移动和共享。
而Rc只实现了Send,所以它的值只能在线程间移动,不能共享给多个线程。
所以总结来说,主要区别是:
Rc:- 单线程
- 非原子操作
- 只实现Send
- 性能更好
use std::rc::Rc; struct Foo { x: i32 } impl Send for Foo {} let foo = Rc::new(Foo { x: 5 }); // 克隆两份foo let foo1 = Rc::clone(&foo); let foo2 = Rc::clone(&foo); // 分别移动到两个线程中 thread::spawn(move || do_something(foo1)); thread::spawn(move || do_something(foo2));
这里,通过Rc我们可以创建foo的值的多个“克隆体”,并将它们移动到不同的线程中,实现跨线程的数据共享。
所以,当数据只在某线程内使用时,我们会使用move将其值移动到线程中。而当需要跨线程共享数据时,我们会先使用Rc/Arc等创建多份“克隆体”,然后分别move到各个线程中。
这也正体现了Send特性的真正含义:线程安全的move。Arc:
- 多线程
- 原子操作
- 实现Send + Sync
- 性能稍差
use std::rc::Rc; use std::sync::Arc; let rc = Rc::new(5); // Rc只能在单线程使用 let arc = Arc::new(5); // 可以在线程间共享arc let arc_clone = Arc::clone(&arc); thread::spawn(move || { // do something with arc_clone }); // 也可以在线程间移动arc的值 thread::spawn(move || { let inner = Arc::try_unwrap(arc).unwrap(); });
所以选择Rc还是Arc,主要依据是否需要在多线程中共享数据。理解他们的区别,可以帮助我们选择最适合的容器,编写更加优雅的代码。
Arc提供了共享不可变引用的功能,也就是说,数据是只读的。如果我们需要访问多线程访问共享数据的可变引用,即读写数据,那么还需要在原始数据上先包裹Mutex,类似于RefCell,提供内部可变性,因此我们可以获取内部数据的&mut,修改数据。当然,这需要通过Mutex::lock() 来操作。
let b = Arc::new(Mutex::new(aa));
let b1 = b.clone();
thread::spawn(move || {
let b = b1.lock();
...
})
为什么不能直接使用RefCell完成这个功能?这是因为RefCell不支持 Sync,没办法装入Arc。注意Arc的约束:
unsafe impl<T: ?Sized + Sync + Send> Send for Arc<T> {}
若 Arc<T>是Send,条件是 T:Send+Sync。RefCell不满足 Sync,因此 Arc<RefCell<>> 不满足Send,无法转移至线程中。错误代码如下:
let b = Arc::new(RefCell::new(aa));
let b1 = b.clone();
thread::spawn(move || {
^^^^^^^^^^^^^ `std::cell::RefCell<AA<T>>` cannot be shared between threads safely
let x = b1.borrow_mut();
})
异步代码:跨越 await 问题
如上所述,一般地,我们会将数据的值转移入线程,这样只需要做正确的 Send和Sync 标记即可,很直观,容易理解。典型的代码如下:
fn test1<T: Send + Sync + 'static>(t: T) {
let b = Arc::new(t);
let bb = b.clone();
thread::spawn( move|| {
let cc = &bb;
});
}
根据上面的分析,不难推导出条件 T: Send + Sync + 'static 的来龙去脉:Closure: Send + 'static ⇒ Arc: Send + ’static ⇒ T: Send + Sync + 'static。
然而,在异步协程代码中有一种常见情况,推导过程则显得比较隐蔽,值得说道说道。考察以下代码:
struct AA<T>(T);
impl<T> AA<T> {
async fn run_self(self) {}
async fn run(&self) {}
async fn run_mut(&mut self) {}
}
fn test2<T: Send + 'static>(mut aa: AA<T>) {
let ha = async_std::task::spawn(async move {
aa.run_self().await;
});
}
test2 中,限定 T: Send + ‘static,合情合理。async fn 生成的 GenFuture 要求 Send + ‘static,因此被捕获置入 GenFuture 匿名结构中的 AA 也必须满足 Send + ‘static,进而要求AA 泛型参数也满足Send + ‘static。
然而,类似的方式调用 AA::run() 方法,编译失败,编译器提示 GenFuture 不满足 Send。代码如下:
fn test2<T: Send + 'static>(mut aa: AA<T>) {
let ha = async_std::task::spawn(async move {
^^^^^^^^^^^^^^^^^^^^^^ future returned by `test2` is not `Send`
aa.run().await;
});
}
原因在于,AA::run()方法的签名是 &self,所以run()是通过 aa 的不可变借用 &AA 来调用。而run()又是一个异步方法,执行了await,也就是所谓的&aa 跨越了 await,故而要求GenFuture匿名结构除了生成aa之外,还需要生成 &aa,示意代码如下:
struct {
aa: AA
aa_ref: &AA
}
正如之前探讨过,生成的 GenFuture需要满足 Send,因此 AA 以及 &AA 都需要满足 Send。而&AA满足 Send,则意味着 AA 满足 Sync。这也就是各种 Rust教程中都会提到的那句话的真正含义:
对于任意类型 T,如果 &T是 Send ,T 就是 Sync 的
之前出错的代码修改为如下形式,增加 Sync标记,编译通过。
fn test2<T: Send + Sync + 'static>(mut aa: AA<T>) {
let ha = async_std::task::spawn(async move {
aa.run().await;
});
}
另外,值得指出的是上述代码中调用 AA::run_mut(&mut self) 不需要 Sync 标记:
fn test2<T: Send + 'static>(mut aa: AA<T>) {
let ha = async_std::task::spawn(async move {
aa.run_mut().await;
});
}
这是因为 &mut self 并不要求 T: Sync。参见以下标准库中关于Sync定义代码就明白了:
mod impls {
#[stable(feature = "rust1", since = "1.0.0")]
unsafe impl<T: Sync + ?Sized> Send for &T {}
#[stable(feature = "rust1", since = "1.0.0")]
unsafe impl<T: Send + ?Sized> Send for &mut T {}
}
可以看到,&T: Send 要求 T: Sync,而 &mut T 则 T: Send 即可。
总结
总而言之,Send约束在根源上是由 thread::spawn() 或是 task::spawn() 引入的,因为两个方法的闭包参数必须满足 Send。此外,在需要共享数据时使用Arc会要求 T: Send + Sync。而共享可写数据,需要Arc<Mutex>,此时 T: Send 即可,不再要求Sync。
异步代码中关于 Send/Sync 与同步多线程代码没有不同。只是因为GenFuture 的特别之处使得跨越 await 的变量必须是 T: Send,此时需要注意通过 T 调用异步方法的签名,如果为 &self,则必须满足 T:Send + Sync。
最后,一点经验分享:关于 Send/Sync 的道理并不复杂,更多时候是因为代码中层次比较深,调用关系复杂,导致编译器的错误提示很难看懂,某些特定场合编译器可能还会给出完全错误的修正建议,这时候需要仔细斟酌,追根溯源,找到问题的本质,不能完全依靠编译器提示。