Rust Tips No.4 Send, Sync

158 阅读8分钟

学习Rust多线程编程,绕不开的核心概念就有Send,Sync。

我们来看Send的概念:

Types that can be transferred across thread boundaries.

Synd的概念:

Types for which it is safe to share references between threads.

这里我希望讨论几个概念,boundaries边界,共享,可变性。

边界的划定是讨论共享和可变性的前提。


// code1

fn a(){

let mut x = 100u32;

b(&x);

c(&mut x);

// return &x; 不可以

// return &mut x; 不可以

return x; // 可以

}

fn b(a:&u32){}

fn c(a:&mut u32){}

对于x来说,a 函数就是它的边界,在边界内,我们可以共享,也可以有可变性,都可以通过&T活着&mut T来访问一个变量。

但是不可以返回&T或者&mut T,只能通过把x返回的方式,让x离开a函数这个边界,这也是一种边界的跨越。

这是一种函数(过程)边界的跨越,对于x来说,跨越边界的方式只能是把自己完整的transferred,不可以是引用的方式。


struct X{};

fn a()->X{

lat x = X{};

return x;

}

fn b(x:X){}

fn main(){

let x = a();

b(x);

}

这样对于变量x来说,就是从a函数,跨越边界到了b函数。但是当下面这种情况的时候:


struct X{};

fn a()->X{

lat x = X{};

return x;

}

fn b(x:X){}

fn c(x:X){}

fn main(){

let x = a();

b(x);

//c(x);//不可以

}

这里是为了模拟需要共享的情况,当b和c参数为&T的话,就回到code1 的情况了,X当然也可以实现Clone,或者Copy,复制一个变量出来,一样可以让c函数运行。这里我想讨论的是共享,当复制一份变量成本很高或者根本不可能的时候,我们就需要共享变量,比如使用Rc。


struct X{

big_data:[u8;1024],

};

fn a()->Rc<X>{

lat x = Rc::new(X{

big_data:[0u8;1024],

});

return x;

}

fn b(x:Rc<X>){}

fn c(x:Rc<X>){}

fn main(){

let x = a();

b(x.clone());

c(x);

}

这样就可以跨越过程边界共享变量,但是这里的x是只读的,就是共享不可变。如果我们需要修改x的内容,那么就需要用到RefCell。


struct X{

big_data:[u8;1024],

};

fn a()->Rc<RefCell<X>>{

lat x = Rc::new(RefCell(X{

big_data:[0u8;1024],

}));

return x;

}

fn b(x:Rc<RefCell<X>>){

x.borrow_mut().unwrap().big_data[0] = 1;

}

fn c(x:Rc<RefCell<X>>){}

fn main(){

let x = a();

b(x.clone());

c(x);

}

也就是说,跨越过程边界,需要传递值,跨越过程边界共享,需要Rc,跨越过程边界共享可变,需要Rc<RefCell>。

这里其实是三个需求,跨越边界,共享,和可变性,当我们把跨越的边界从跨越过程,扩展到跨越线程的时候,我们就需要Send和Sync。

Send

先来看一下Send的定义:


pub unsafe auto trait Send {

// empty.

}

marker 中一样的套路,一个空的Trait,就是一个标志位,一个开关。这里我们需要注意这个auto关键字。

Auto traits, like Send or Sync in the standard library, are marker traits that are automatically implemented for every type, unless the type, or a type it contains, has explicitly opted out via a negative impl.

意思就是,该Trait会为所有类型自动实现,除非该类型或者包含的类型显式的定义!Trait,这里关键的是什么类型是关闭Send的,也就是什么类型impl !Send for T。

标准库中特意关闭的类型,我罗列一些主要的:


// core

impl<T: ?Sized> !Send for *const T {}

impl<T: ?Sized> !Send for *mut T {}

// alloc

impl<T: ?Sized, A: Allocator> !Send for Rc<T, A> {}

impl<T: ?Sized, A: Allocator> !Send for Weak<T, A> {}

// std

impl<T: ?Sized> !Send for MutexGuard<'_, T> {}

impl<T: ?Sized> !Send for RwLockReadGuard<'_, T> {}

impl<T: ?Sized> !Send for RwLockWriteGuard<'_, T> {}

//

这里要特别关注的是Rc,Weak是Rc伴生的类型,所以我们讨论Rc就好。

我们来看线程的入口函数:


pub fn spawn<F, T>(self, f: F) -> io::Result<JoinHandle<T>>

where

F: FnOnce() -> T,

F: Send + 'static,

T: Send + 'static,

{

unsafe { self.spawn_unchecked(f) }

}

这里就是跨越线程边界的入口,可以看到参数都需要Send,所以其实Send的作用,就是阻止!Send的变量跨越线程边界,也就是不让Rc,Weak,*const T,*mut T,MutexGuard等这些类型跨越边界。所以费这么半天劲,就是为了阻止这么几个类型穿越边界罢了。

也就是跨越边界的类型,只要不包含这些类型,都可以跨越线程边界,这基本包含所有的类型。

这里的'static 可以简单理解为只能传值,不能传递引用,&T,&mut T。(完整的概念是只能传递静态生命周期的值,那又是一个很长的故事,留坑以后填)

Sync

我们来看Sync的定义:


pub unsafe auto trait Sync {

// Empty

}

一样的概念,我们来看哪些类型定义了!Sync:


// core

impl<T: ?Sized> !Sync for *const T {}

impl<T: ?Sized> !Sync for *mut T {}

impl<T: ?Sized> !Sync for Cell<T> {}

impl<T: ?Sized> !Sync for RefCell<T> {}

impl<T: ?Sized> !Sync for UnsafeCell<T> {}

impl<T> !Sync for OnceCell<T> {}

// alloc

// Note that this negative impl isn't strictly necessary for correctness,

impl<T: ?Sized, A: Allocator> !Sync for Rc<T, A> {}

impl<T: ?Sized, A: Allocator> !Sync for Weak<T, A> {}

// std

// std::sync::mpsc

impl<T> !Sync for Receiver<T> {}

简单来说,Sync的目的就是为了阻止Cell的跨线程共享。

共享,可变

从最底层的&T,&mut T,甚至更基础的,let a:T,let mut a:T,Rust 一直在管理的都是变量的共享和可变性。当变量需要跨越边界的时候,Rust提供了各种工具来达成跨越边界,并且不破坏共享不可变,可变不共享原则,而Send和Sync,是为了跨越线程这个边界而特意设定的工具。

我们来总结一下各个边界情况的工具和类型。

  1. 过程(函数)边界内
  • 共享,不可写:&T

  • 不共享,可写:T,&mut T

  • 共享,可写:&RefCell,&Cell

  1. 跨越过程边界
  • 共享,不可写:Rc,

  • 不共享,可写: T,

  • 共享,可写:Rc<RefCell>,Rc<Cell>,

  1. 跨越线程边界
  • 共享,不可写:Arc,

  • 不共享,可写:T,

  • 共享,可写:Arc<Mutex>

然后汇总一下:

  1. 对于一个不包含共享内容的变量来说,所有权转移,可以跨越任何边界,跨越过去后可写不可写都可以。

  2. 引用类型只是过程边界内有效的传递机制,要跳出过程边界必须要移交所有权。

  3. Rc,Arc都是为了解决共享的问题,与可变性没有关系。

  4. Cell,RefCell,Mutex等是为了解决可写性问题,与共享与否没有关系。

共享和可变性是一组正交的概念,Send和Sync也是一组正交的概念,Send标志是为了解决共享问题,阻止非法的共享,Sync标志是为了解决可变性问题,阻止非法的可变性。

那么就会有下面四种情况:

  1. Send + Sync 不包含共享可变性工具的数据结构都是这种,大部分时候我们定义的数据结构都满足。

  2. !Send + !Sync 比如Rc<RefCell>,单线程数据结构,但是需要共享和可变性,只能在单个线程中使用。

  3. Send + !Sync 下面单独讨论。

  4. !Send + Sync 比如Rc<Mutex>,这种是没有意义的,可以直接用Rc<RefCell>来代替。

Send + !Sync

先来看一段代码:


let x = RefCell::new(100);

thread::spawn(move || {

*x.borrow_mut() = 200;

});

x 是一个Send + !Sync 的变量,但是可以安全的穿越线程边界,因为thread::spawn 的函数签名中只要求Send,并没有要求Sync。再看这个:


let x = Arc::new(RefCell::new(100));

thread::spawn(move || { // 不可以

*x.borrow_mut() = 200;

});

这样就不可以了,原因在Arc的代码中:


unsafe impl<T: ?Sized + Sync + Send, A: Allocator + Send> Send for Arc<T, A> {}

对于Arc来说,只有内部的值Sync的时候,Arc才会有Send,否则Arc是没有Send的,那么也就不可以跨越线程的边界。

其实thread::spawn做的事情就是,你给我的参数有没有共享数据,没有共享那OK你随意,如果有共享数据,那么你共享的数据是不是只读的,是的,那么OK你随意,如果不是,那么你的共享数据有没有用Mutex这类同步元语包住来保证线程安全,如果有,那么OK你随意,如果没有,那对不起,不行。

其实Send和Trait就是两个空的Trait,在运行时它们根本就不存在,零成本,所有的线程安全都是这些概念的组合一起完成的,就是把可能犯错的缺口全部堵死,以此来保证安全性。

语言本身就是检查一个一个的空Trait标签,函数要求你impl这个Trait,你没有impl,那么就报错,并没有额外的魔法。

一切魔法都是抽象出正交的一系列概念,然后通过这些正交概念的组合来完成的。

&T &mut T

在core::marker中,有这样一行代码:


unsafe impl<T: Sync + ?Sized> Send for &T {}

是的,对于拥有Sync的类型来说,它的&T时Send的,而且,在Sync的文档中,也特意提到 &T,&mut T,如果T:Sync,那么这两个类型也都是Sync的。

抛开这些复杂的逻辑,这里核心的问题是,thread::spawn的那个'static。如果参数是一个闭包的情况,在堆栈上的所有变量,生命周期都是跟着函数过程的,要满足'static这个生命周期基本就是代表你把值全转移给我,不能是引用。符合这个条件的引用类型基本就只剩下静态函数。

特意提到这个是因为光看文档会特别绕,特别是引用的Send和Sync,其实加上'static后,thread::spawn的参数基本就全卡死了,不用unsafe根本绕不过。

总结

文章肯定还有很多错误,我能写的只能是我现在所能理解的理解。

我想表达的是,其实没什么魔法,一切都在标准库的源代码里面,零成本抽象,正交的概念,通过组合来实现目的。

下一期想聊marker里面的最后一个概念,Unpin,一个特别难理解,但是源代码好简单的东西。