周刊(第19期):Rust并发安全相关的几个概念(上)

2022-06-19
5分钟阅读时长

引言:本文介绍Rust并发安全相关的几个概念:SendSyncArcMutexRwLock等之间的联系。这是其中的上篇,主要介绍SendSync这两个trait


Rust并发安全相关的几个概念(上)

Rust的所有权概念

在展开介绍并发相关的几个概念之前,有必要先了解一下Rust的所有权概念,Rust对值(value)的所有权有明确的限制:

  • 一个值只能有一个owner。
  • 可以同时存在同一个值的多个共享的非可变引用(immutable reference)。
  • 但是只能存在一个值的可变引用(mutable reference)。

比如下面这段代码,user在创建线程之后,被移动(move)到两个不同的线程中:

fn main() {
    let user = User { name: "drogus".to_string() };

    let t1 = spawn(move || {
        println!("Hello from the first thread {}", user.name);
    });

    let t2 = spawn(move || {
        println!("Hello from the second thread {}", user.name);
    });

    t1.join().unwrap();
    t2.join().unwrap();
}

由于一个值只能有一个owner,所以编译器报错,报错信息如下:

error[E0382]: use of moved value: `user.name`
  --> src/main.rs:15:20
   |
11 |     let t1 = spawn(move || {
   |                    ------- value moved into closure here
12 |         println!("Hello from the first thread {}", user.name);
   |                                                    --------- variable moved due to use in closure
...
15 |     let t2 = spawn(move || {
   |                    ^^^^^^^ value used here after move
16 |         println!("Hello from the second thread {}", user.name);
   |                                                    --------- use occurs due to use in closure
   |
   = note: move occurs because `user.name` has type `String`, which does not implement the `Copy` trait

Send和Sync的约束作用

于是,如果一个类型会被多个线程所使用,是需要明确说明其共享属性的。SendSync这两个trait作用就在于此,注意到这两个trait都是std::marker,实现这两个trait并不需要对应实现什么方法,可以理解为这两个trait是类型的约束,编译器通过这些约束在编译时对类型进行检查。到目前为止,暂时不展开对两个概念的理解,先来看看两者是如何在类型检查中起约束作用的。

比如std::thread::spawn()的定义如下:

pub fn spawn<F, T>(f: F) -> JoinHandle<T> 
where
    F: FnOnce() -> T,
    F: Send + 'static,
    T: Send + 'static,

可以看到,对于spawn传入的函数和返回的类型,都要求满足Send这个约束。结合前面Send的定义:

  • 函数类型F需要满足Send约束:这是因为创建线程之后,需要把函数类型传入新创建的线程里,于是要求所有权能够在线程之间传递。
  • 返回类型需要满足Send约束:这是因为创建线程之后,返回值也需要转移回去原先的线程。

有了对类型的约束,编译器就会在调用std::thread::spawn函数时针对类型进行检查,比如下面这段代码:

#[derive(Debug)]
struct Foo {}
impl !Send for Foo {}

fn main() {
    let foo = Foo {};
    std::thread::spawn(move || {
        dbg!(foo);
    });
}

类型Foo标记自己并不实现Send这个trait,于是在编译的时候报错了:

error[E0277]: `Foo` cannot be sent between threads safely
   --> src/main.rs:7:5
    |
7   |       std::thread::spawn(move || {
    |  _____^^^^^^^^^^^^^^^^^^_-
    | |     |
    | |     `Foo` cannot be sent between threads safely
8   | |         dbg!(foo);
9   | |     });
    | |_____- within this `[closure@src/main.rs:7:24: 9:6]`
    |
    = help: within `[closure@src/main.rs:7:24: 9:6]`, the trait `Send` is not implemented for `Foo`
    = note: required because it appears within the type `[closure@src/main.rs:7:24: 9:6]`
note: required by a bound in `spawn`

如果把impl !Send for Foo {}这一行去掉,代码就能编译通过了。

以上还有一个知识点:所有类型默认都是满足SendSync约束的,直到显示声明不满足这个约束,比如上面的impl !Send就是这样一个显示声明。这就带来一个疑问:能不能跟编译器耍一些心思,明明某个类型就不满足这个约束,睁一只眼闭一只眼看看能不能在编译器那里蒙混过关?

答案是不能,编译器会检查这个类型中所有包含的成员,只有所有成员都满足这个约束,该类型才能算满足约束。可以在上面的基础上继续做实验,给Foo结构体新增一个Rc类型的成员:

#[derive(Debug)]
struct Foo {
    rc: Option<std::rc::Rc<i32>>,
}

fn main() {
    let foo = Foo { rc: None };
    std::thread::spawn(move || {
        dbg!(foo);
    });
}

由于Rc并不满足Send约束(即显示声明了impl !Send,见:impl-send),导致类型Foo并不能蒙混过关满足Send约束,编译上面代码时报错信息如下:

error[E0277]: `Rc<i32>` cannot be sent between threads safely
   --> src/main.rs:8:5
    |
8   |       std::thread::spawn(move || {
    |  _____^^^^^^^^^^^^^^^^^^_-
    | |     |
    | |     `Rc<i32>` cannot be sent between threads safely
9   | |         dbg!(foo);
10  | |     });
    | |_____- within this `[closure@src/main.rs:8:24: 10:6]`
    |
    = help: within `[closure@src/main.rs:8:24: 10:6]`, the trait `Send` is not implemented for `Rc<i32>`
    = note: required because it appears within the type `Option<Rc<i32>>`
note: required because it appears within the type `Foo`

因此:一个类型要满足某个约束,当且仅当该类型下的所有成员都满足该约束才行。

理解Send和Sync trait

继续回到SendSync这两个trait中来,两者在rust官方文档中定义如下:

  • Send:Types that can be transferred across thread boundaries。
  • Sync:Types for which it is safe to share references between threads。

上面的定义翻译过来:

  • Send标记表明该类型的所有权可以在线程之间传递。
  • Sync标记表明该类型的引用可以安全的在多个线程之间被共享。

我发现上面的这个解释还是有点难理解了,可以换用更直白一点的方式来解释这两类约束:

  • Send

    • 满足Send约束的类型,能在多线程之间安全的排它使用(Exclusive access is thread-safe)。
    • 满足Send约束的类型T,表示T&mut Tmut表示能修改这个引用,甚至于删除即drop这个数据)这两种类型的数据能在多个线程之间传递,说得直白些:能在多个线程之间move值以及修改引用到的值。
  • Sync

    • 满足Sync约束的类型,能在多线程之间安全的共享使用(Shared access is thread-safe)。
    • 满足Sync约束的类型T,只表示该类型能在多个线程中读共享,即:不能move,也不能修改,仅仅只能通过引用&T来读取这个值。
  • 有了上面的定义,可以知道:一个类型T的引用只有在满足Send约束的条件下,类型T才能满足Sync约束(a type T is Sync if and only if &T is Send)。即:T: Sync ≡ &T: Send

对于那些基本的类型(primitive types)而言,比如i32类型,大多是同时满足SendSync这两个约束的,因为这些类型的共享引用(&)既能在多个多个线程中使用,同时也能在多个线程中被修改(&mut )。

了解了SendSync这两类约束,就可以接着看在并发安全中的运用了,这是下一篇的内容。

参考资料