Send && Sync In Rust

date
slug
tags
summary
type
status
注意:该文章的理解都只是个人理解和推理,欢迎指出错误

Why Send && Sync

SendSync是Rust语言中用于确保并发安全的两个marker特征(marker trait),这意味着Send和Sync本身不提供任何额外功能,只是用于标记。Rust标准库中提供的并发类型会被标记好表示该工具能够用于并发场景,而对于用户自己的实现,当其确保其能够用于并发场景下(这意味着用户需要自己实现互斥等并发安全的操作),可以通过unsafe进行标记。
Rust是如何保证并发安全呢?
Rust通过类型系统进行保证,具体来说,实现SendSync trait的类型表示能够在并发场景下被安全的使用。因此,在涉及到并发场景的函数中,可以通过trait bound来限制传入的参数必须实现以上两个trait,保证其能够安全访问。
例如,thread::spawn函数用来创建新线程并执行某个函数,它要求传递进来的函数的类型必须实现Send特征。
如下面,如果传入的函数中使用了没有实现Send trait的类型,则编译器会报错
本文将从定义,再到具体例子,去探究:为什么Send和Sync可以保证安全性?

定义

  • Send的定义
    • Types that can be transferred across thread boundaries.
      Send特征的定义是,任何被标记为Send的类型都可以安全地传递到另外一个线程。
      在Rust中,将一个值传递到另外一个线程这件事,本质是通过将closure捕获值,然后将closure传递到spawn函数完成的。比如上面的例子,在使用thread::spawn函数创建新线程时,该函数要求传入的闭包(或函数)必须满足Send特征。这意味着,如果你想要在这个闭包中使用某个变量,那么这个变量的类型也必须实现Send特征。这样的要求确保了只有实现了Send特征的类型(即,可以被安全地传递到另外一个线程的类型)才可以被传递到其他线程。
  • Sync的定义通常是:&T is Send if and only if T is Sync
    • 对于这句话的理解,我觉得最直观的方式是直接看实现:
      对于实现了Sync的type,会为其引用类型自动实现Send。因此,我认为Sync的本质是对其引用类型实现Send,如果我们将引用看作一种类型,对于Sync如何保证安全性的讨论其实就等价于Send如何保证安全性的讨论。

安全性保证

💡
前提:以下的分析将不可变引用,可变引用以及类型本身视为三种类型,即T, &T, &mut T是三种类型。
对于并发安全问题,本质是由于一块可变内存被共享,导致内存可能被同时修改,发生了一些在线性执行下不会发生的问题。
将Rust中类型做一个简单归类,分别讨论他们在并发场景下的安全性:
  1. 可变,且共享:&RefCell<Vec<i32>>
  1. 不可变,且共享:&Vec<i32>
  1. 可变,不共享:&mut Vec<i32>
  1. 不可变,不共享:Vec<i32>
这里的共享指的是,当前实例化的值同时也可以被其他值访问到。
对于2,3,4类,由于其不会涉及到并发修改,因此都是并发安全的,下面的程序进行2,3,4的行为,结果是可以通过编译的。
从Vec的定义可以看到,以上的类型确实实现了Send(Sync表示&Vec实现了Send
notion image
而对于1,可变且共享意味着该值会存在并发访问,因此对于这种类型的并发安全性,一般有两种可能
  1. !Send,防止该类型被用于并发场景
  1. Send,对于访问操作需要实现互斥操作
几个具体的例子:
  • RefCell<Vec<i32>>是Send&&!Sync。
    • RefCell:RefCell的值本身可以被传递到另外一个线程,因为RefCell以及Vec<i32>都是对持有的内存进行独占。因此,RefCell本身属于不共享,因此标记了Send,可以被安全的传递到另外一个线程。
    • &RefCell:&RefCell可以用于获取其内部的mutable reference,且没有做任何互斥保护。因此&RefCell属于可变可共享,由于RefCell本身没有做互斥保护,因此需要标记为!Send,即RefCell标记为!Sync。
  • Mutex和RefCell一样,可以通过其引用获取到内部数据的mutable reference。但是,Mutex对这个获取的操作做了互斥保护,可以在运行时保证同一时间只有一个线程会获取mutable reference,因此它是Sync的。
  • Rc是一个带引用计数的智能指针,当Rc被drop时,会减少其引用计数,若为0,则将对象释放掉。但是Rc对其引用计数的操作不是原子的,因此Rc只能用于单线程,Rc是Not Send和Not Sync。
    • 首先Rc会共享引用计数,且在Drop时会操作引用计数。因此Rc本身属于可变可共享,由于Drop操作引用计数的方法不是互斥的,因此Rc是Not Send的。
    • &Rc可以通过Clone来增加引用计数,这意味&Rc同样是可变可共享,同理,其也是!Send,即Rc是!Sync。

Arc

Arc是一个比较特殊例子,因此单独拎出来。
A thread-safe reference-counting pointer. The type Arc<T> provides shared ownership of a value of type T
💡
简单说明下Arc: Arc是一种延长引用生命周期的方式(本质是共享指针,使用了引用计数),在下面的例子中,1.会报错,因为线程运行中会使用来自five的引用,但其无法确保five的生命周期会一直持续到线程结束,因此Rust注明spawn必须使用生命周期为全局的数据。通过Arc,可以使得five的生命周期会一直持续到所有Arc Drop为止。
从上面的例子可以看到,其实可以把Arc的本质看作引用,Arc<T>看作&T,意味着Arc是可共享的。
因此,而由于Arc内部的ref count都是原子实现的,因此Arc本身的数据是并发安全的,因此,可以推断:Arc具体是不是并发安全,取决&T是不是并发安全的。
可以看到Arc对Send和Sync的实现符合推断:

Send for Async block && Closure

关于AsyncBlock和Closure是否为Send的规则,可以参考:
  1. https://rust-lang.github.io/async-book/07_workarounds/03_send_approximation.html
  1. https://doc.rust-lang.org/reference/types/closure.html#other-traits

Async Block

async block其实是状态机,状态机需要存储await之间需要的状态,因此其是否为Send的规则为:当async block在await时持有一个非Send的变量,则不满足Send,(因为该变量需要保存在状态机中)。
接下来通过例子说明,如case1,由于value没有跨越await point,因此第一个case1可以通过;如case2,由于value跨越await point,因此第二个case2无法通过。
此外,若async block捕捉了外部非Send的变量,由于该变量需要记录为状态机的初始状态,因此此时也不满足Send。
如下的case,async block捕获了外部的value,因此不满足Send

Closure

closure可以视为没有await point的async block,因此Send主要取决于捕获的变量。
如下的case,closure捕获了外部!Send的变量,因此其为!Send。

总结

到了这里,我们梳理了Send和Sync是如何保证安全性的:
  1. 通过类型系统,Send和Sync只是一种标记
  1. 会出现并发安全的类型在于可变可共享
  1. 对于可变可共享的类型,如果内部实现了互斥机制,会被标志为Send,如果没有,就通过标记!Send防止其被并发使用

Ref

  1. https://www.youtube.com/watch?v=yOezcP-XaIw&t=1374s&ab_channel=JonGjengset
 
Loading...

© ZENOTME 2021-2025