学习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,是为了跨越线程这个边界而特意设定的工具。
我们来总结一下各个边界情况的工具和类型。
- 过程(函数)边界内
-
共享,不可写:&T
-
不共享,可写:T,&mut T
-
共享,可写:&RefCell,&Cell
- 跨越过程边界
-
共享,不可写:Rc,
-
不共享,可写: T,
-
共享,可写:Rc<RefCell>,Rc<Cell>,
- 跨越线程边界
-
共享,不可写:Arc,
-
不共享,可写:T,
-
共享,可写:Arc<Mutex>
然后汇总一下:
-
对于一个不包含共享内容的变量来说,所有权转移,可以跨越任何边界,跨越过去后可写不可写都可以。
-
引用类型只是过程边界内有效的传递机制,要跳出过程边界必须要移交所有权。
-
Rc,Arc都是为了解决共享的问题,与可变性没有关系。
-
Cell,RefCell,Mutex等是为了解决可写性问题,与共享与否没有关系。
共享和可变性是一组正交的概念,Send和Sync也是一组正交的概念,Send标志是为了解决共享问题,阻止非法的共享,Sync标志是为了解决可变性问题,阻止非法的可变性。
那么就会有下面四种情况:
-
Send + Sync 不包含共享可变性工具的数据结构都是这种,大部分时候我们定义的数据结构都满足。
-
!Send + !Sync 比如Rc<RefCell>,单线程数据结构,但是需要共享和可变性,只能在单个线程中使用。
-
Send + !Sync 下面单独讨论。
-
!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,一个特别难理解,但是源代码好简单的东西。