Skip to content
Go back

[Rust] Send & Sync 01 - Introduction 与 Send Trait

Updated:

100% 本人原创文章,非 AI 创作

Table of Contents

Open Table of Contents

Introduction

学习任何东西无非就是这几个问题,Rust 中的 Send 和 Sync 也不例外:

  1. 这东西是什么?
  2. 这东西解决了什么问题?
  3. 为什么选择它,不选择别的?

这是一个系列文章,从浅到深记录我对这两个 Trait 的学习:

本篇文章从第一个问题开始,回答 “什么是 Send Trait?” 。

什么是 Send 和 Sync Trait

Send 和 Sync 是 Rust 标准库中的两个 Marker Trait(标记 Trait)。这两个 Trait 本身并不彻底解决并发代码中的线程安全问题。不过,Rust 中的这种设计确实让写出不线程安全的代码更难了。

对这两个Trait为什么“让写出烂代码变难”的理解会写在第三篇文章中。

什么是 Marker Trait

提到 Send 和 Sync 就绕不过提到 Marker Trait。

所谓 Marker Trait 是一种自身不包含任何函数定义的 Trait。

如果用 TypeScript 做类比,那么就好比是有这么一类 interface:

interface Send {}
interface Sync {}
interface Copy {}

理解 Marker Trait 这个设计归根结底是要理解这么一句话:

一个类型如果被实现了 Marker Trait,那么这种实现本身就有意义。

更加具体的解释是,某些函数上会增加对这些 Marker Trait 的约束,从而满足语言本身的设计目标(例如避免写出来的代码有潜在内存安全或者并发安全问题)。

换句话说,每种 Marker Trait 有着不同的 语义(Semantic,或者说是含义),它们不要求实现 Trait 时实现某个特定的函数,因此每个 Marker Trait 的语义对于它来讲就是全部的意义。

对于 Marker Trait 整体设计的理解需要结合具体的例子,坦诚来讲,Sync 和 Send 并不是这些 Traits 中理解门槛最低的(最好理解的是 Copy)。

最后,需要提到,这两个 Trait 与其它 Marker Trait 一样,都是零开销的。这意味着在最终编译后的代码中,并不会因为实现了这两个 Trait 而产生额外的机器代码开销。

可以这么说,某种程度上,这两个 Trait 是“给编程者看的”。它们对类型添加了标记,利用 Rust 严格的编译器和类型系统,让写出糟糕的代码变得更难了,从而规避了部分由于编程者水平不及格或者粗心大意导致的程序错误。

Sync 和 Send 的语义

既然对于 Marker Trait 来讲,语义是最重要的,那么 Send 和 Sync 的语义是什么?

如果你从 Rustonomicon 文档中 找到关于这两个 Trait 的章节,会找到这么两句话:

A type is Send if it is safe to send it to another thread.

直译:“如果一个类型实现了 Send,那么这个类型可以被安全地发送给另一个线程。”

A type is Sync if it is safe to share between threads (T is Sync if and only if &T is Send).

直译:“如果一个类型实现了 Sync,那么这个类型可以在线程间安全地共享。(当且仅当 &T 是 Send 的,类型 T 才是 Sync 的)”

读完这两句话,我有以下几个问题:

  1. 第一句话中,“这个类型可以被安全地发送给另一个线程”是啥意思?
    • “发送”具体指什么,我意思是 Rust 的代码中怎么写算是“发送”给另一个线程了?
    • 怎么会有“安全”这一说法呢?“不安全”又是指的什么呢?
  2. 第二句话中,“这个类型可以在线程间安全地共享”,这里的“共享”具体指的什么操作?
  3. “类型 T 是 Sync 的,当且仅当 &T 是 Send 的”这个设计有啥用呢?感觉是废话

接下来,我将以这些显而易见甚至事后看起来有些可笑的问题为线索,尽可能回答“它们是什么”这个问题。

Send Trait

先从第一句话开始,理解 Send Trait 的语义:

如果一个类型实现了 Send,那么这个类型可以被安全地发送给另一个线程。

在多线程程序中,线程之间传递信息非常常见。就如同在 Rust 圣经 “Fearless Concurrency” 章节中提到的,Rust 中,线程之间沟通主要有两种方式:

  1. Message Passing - 信息传递(更推荐的方式)
  2. State Sharing - 状态共享

实际上,关于 Send 说明的这句话中“…这个类型可以被安全地发送给另一个线程”的这个“发送”,指的就是这两种方式。


以信息传递为例。

Rust 的标准库中,信息传递主要是使用 std::sync::mpsc。

正如缩写名称所说明的那样,这是一个“Multi-Producer Single Consumer”(多个信息产生者,单个信息接收者)的通道。

当一个类型被标记为 Send 时,意味着这个类型的数据可以通过通道由一个线程发送给另一个线程。以下是一个简单的例子:

use std::sync::mpsc;
use std::thread;

fn main() {
    // 创建通道
    // tx 是发送方应该拿到的 Sender,rx 是接收方应该拿到的 Receiver
    let (tx, rx) = mpsc::channel();

    // 创建一个线程,用作演示
    thread::spawn(move || {
        let val = String::from("hi");
        // 注意,tx 的所有权通过 move 关键字,被转移到了闭包内部
        // 调用 tx 上面的 send 函数,将 val 发送给主线程
        tx.send(val).unwrap();
    });

    // 主线程利用 rx 接收子线程发送过来的 "hi"
    let received = rx.recv().unwrap();
    println!("Got: {received}");
}

这段代码中,val 存储了一个字符串“hi”,毫无疑问它是 Send 的。更详细来讲,val 这个字符串通过通道发送给了主线程。

在这个过程中,只存在简单的数据所有权转移(val 的所有权从子线程转移到了主线程)。这样的代码当然是安全的,不会有问题。

接下来,让我举另一个一定会出问题的例子:

use std::sync::mpsc;
use std::thread;

fn main() {
    // 创建通道
    // tx 是发送方应该拿到的 handle,rx 是接收方应该拿到的 handle
    let (tx, rx) = mpsc::channel();

    // 创建一个线程,用作演示
    thread::spawn(move || {
        // ERROR:不会编译通过
        // 这里我对 val 进行了修改,让 val 成为了一个指向栈内存的裸指针
        let some_data: usize = 1024;
        let val = &some_data as *const usize;
        // 注意,tx 的所有权通过 move 关键字,被转移到了闭包内部
        // 调用 tx 上面的 send 函数,将 val 发送给主线程
        tx.send(val).unwrap();
    });

    // 主线程利用 rx 接收子线程发送过来的数据
    let received = rx.recv().unwrap();
    let received = unsafe { *received };
    println!("Got: {received}");
}

需要注意,这段代码不会编译通过,因为这里存在一种显而易见的错误,并且 Rust 本身的设计得足够好,从而避免这段代码通过编译。

这里的错误是,val 指向了 thread::spawn() 创建的子线程中栈内存上的一段地址。随后,程序将 val 发送给了主线程。在子线程执行完毕后,它的栈内存会被回收,被发送过去的栈内存地址已经不再有效,以下两行对该地址的解引用并且访问是非法的:

// ...
    let received = unsafe { *received };
    println!("Got: {received}");
// ...

这里还需要理解的是,Rust 如何确保这段代码不会编译通过:

首先,上述代码中,tx 的类型是 std::sync::mpsc::Sender,这个类型有一个 Blanket Implementation:

// 截取自官方文档
unsafe impl<T: Send> Send for Sender<T> {}

所谓 Blanket Implementation 可以一句话总结为,为所有满足特定 Trait 约束(英文叫 Trait Bound)的类型,都实现这个 Trait。

上面这段代码具体的含义就是:

如果一个类型 T 实现了 Send Trait,那么为 Sender<T> 也实现 Send Trait

也就是这个意思:

如果一个类型可以被安全地发送给另一个线程,那么针对这个类型的发送器(Sender)也应该可以被安全地发送给另一个线程。

其次,Rust 中的几乎所有原始类型(Primitive Types)都实现了 Send,也就是说,大多数类型都可以被安全地发送给另一个线程,但是以下这些情况是例外:

  • 裸指针(Raw Pointer)没实现 Send 或 Sync
  • Rc(引用计数类型)没实现 Send 或 Sync

上面编译不通过的例子正是包含了裸指针:

let val = &some_data as *const usize;

裸指针之所以不能被安全地发送给另一个线程的原因非常显而易见,我们不能确保裸指针指向的内存在另一个线程访问时一定合法。

最终,当上述两个设计组合在一起时,Rust 的类型系统就开始真正发挥它的魔法,阻止这样的烂代码通过编译:

  1. 由于 val 是一个指向 usize 类型的裸指针(*const usize)
  2. 裸指针类型并没有实现 Send
  3. 那么对于 Sender<*const usize> 来讲,它也没有实现 Send
  4. 因此,当程序尝试将 tx 的所有权转移到闭包中时(也就是在这里 tx.send(val).unwrap();),编译器就会报错

Rust 为 Send Trait 添加了自动实现的逻辑,当一个类型完全由已经实现了 Send 的类型构成时,这个类型也被自动实现了 Send Trait。

最后,需要提到一点,虽然所有裸指针并不能安全地在线程间发送(没有实现 Send),但是引用却完全不同。在 std 文档中 说明了这种情况:

  • 当且仅当一个类型 T 是 Send 时, &mut T 也是 Send 的

当一个类型 T 是 Send 的时候,为什么它裸指针不是 Send 的,但是 &mut T 却是 Send 的?

因为相比于裸指针,Rust 会追踪引用的生命周期标记,从而避免引用出现悬垂指针的问题。

例如,对于上述例子中,std::thread::spawn() 函数的签名:

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

对于闭包 F,它有 'static 的生命周期约束。它的含义是:

“闭包不能包含生命周期短于 'static 的引用。”

这是因为对于传递给 spawn 函数的闭包,我们没法精确地确定这个闭包执行的时机以及其中引用被使用的时机。因此最保险的方式就是确保所有引用都必须“尽可能长”。

Rust 编译器会根据这个签名对所有传入的闭包包含的引用的生命周期进行检查,从而最终避免悬垂指针的问题发生。

除此之外,官方文档中的这句话引出了我的另一个问题:

官方文档中只说了,如果 T 是 Send 的那么 &mut T 也是 Send 的;为什么没也说 &T 是 Send 的呢?

我是这么思考的,Rust 中对于可变引用和不可变引用的关键区别是:

  • &mut T 是“排他”的,当你创建了一个可变引用后,在这个引用还生效期间,你便无法再通过其它方式对这个变量进行修改;
  • &T 是“共享”的,你可以创建无限多个普通引用;大多数情况下,这些普通引用无法对被引用数据进行修改;

因为 &mut T 的排他性:在 T 是 Send 时,将 &mut T 发送给另一个线程,会导致只有那一个线程可以对 T 进行修改。这种情况是安全的,不存在数据竞争的情况。

对于普通引用 &T,大多情况下我们确实无法通过它对被引用的数据修改,但是对于 Rust 中实现了“Interior Mutability”(内部可变性)的数据类型而言,通过 &T 也可以对被引用的数据进行修改。对于这一类特殊的类型,如果我们将 可以随意复制的 &T 发送到其它线程,意味着允许 T 被多个线程同时访问(读/写) 的情况发生,这简直就是数据竞争(Data Racing)的定义。

在我们上述的讨论中,仅仅假设了 T 本身是 Send 的,也就是仅仅假设了 T 是可以被安全发送给另一个线程;显然,“T 可以被发送到另一个线程”和“T 可以被多个线程共享”压根不是一回事。

这里的 “T 可以被多个线程共享” 正是 Sync Trait 的语义。