头图

引言

本文介绍一下 Rust 并发安全相关的几个概念:Send、Sync、Arc,Mutex、RwLock 等之间的联系。这是其中的上篇,主要介绍 Send、Sync 这两个trait。

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 的约束作用

于是,如果一个类型会被多个线程所使用,是需要明确说明其共享属性的。Send 和 Sync 这两个 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 {} 这一行去掉,代码就能编译通过了。
以上还有一个知识点:所有类型默认都是满足 Send、Sync 约束的,直到显示声明不满足这个约束,比如上面的 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-send1),导致类型 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

理解 Send 和 Sync trait

继续回到 Send 和 Sync 这两个 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 T(mut表示能修改这个引用,甚至于删除即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 类型,大多是同时满足 Send 和 Sync 这两个约束的,因为这些类型的共享引用(&)既能在多个多个线程中使用,同时也能在多个线程中被修改(&mut )。

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

参考资料

  • Arc and Mutex in Rust | It's all about the bit2
  • Sync in std::marker - Rust3
  • Send in std::marker - Rust4
  • Send and Sync - The Rustonomicon5
  • rust - Understanding the Send trait - Stack Overflow6
  • Understanding Rust Thread Safety7
  • An unsafe tour of Rust’s Send and Sync | nyanpasu64’s blog8
  • Rust: A unique perspective9

关于 Databend

Databend 是一款开源、弹性、低成本,基于对象存储也可以做实时分析的新式数仓。期待您的关注,一起探索云原生数仓解决方案,打造新一代开源 Data Cloud。

图片
文章首发于公众号:Databend


databend
20 声望10 粉丝

Databend 旨在成为一个 开源、弹性、可靠 的无服务器数仓,查询快如闪电,与 弹性、简单、低成本 的云服务有机结合。数据云的构建,从未如此简单!