Skip to content
Go back

[Rust] Send & Sync 02 - Sync

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

Table of Contents

Open Table of Contents

Introduction

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

本篇文章回答 “什么是 Sync Trait?” 。


上一篇文章的结尾,我讨论了一个问题:“如果 TSend 的,为什么 &mut TSend 的,但官方文档没有说 &T 也是 Send 的?”

最后得出的结论是:“T 可以被发送到另一个线程”和”T 可以被多个线程共享”压根不是一回事。

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

Sync Trait

回到 Rustonomicon 中关于 Sync 的定义:

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

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

在上一篇文章中,我对这句话提出了两个问题:

  1. “这个类型可以在线程间安全地共享”,这里的”共享”具体指的什么操作?
  2. “类型 TSync 的,当且仅当 &TSend 的”这个设计有啥用呢?感觉是废话

接下来就围绕这两个问题展开。

“共享”到底是什么意思

先回答第一个问题:这里说的”共享”,指的是多个线程同时持有同一个数据的不可变引用 &T

在 Rust 中,如果你想让多个线程访问同一份数据,最常见的方式是用 Arc<T>(Atomic Reference Counting,原子引用计数)。Arc 允许多个所有者持有同一份堆上数据的所有权,并且它本身的引用计数操作是原子的,所以在多线程环境下是安全的。

以下是一个简单的例子:

use std::sync::Arc;
use std::thread;

fn main() {
    let data = Arc::new(42);

    let mut handles = vec![];

    for _ in 0..3 {
        // Arc::clone 并不是真的拷贝数据
        // 而是增加了引用计数,让多个 Arc 指向同一份数据
        let data = Arc::clone(&data);
        let handle = thread::spawn(move || {
            println!("线程读取到: {}", *data);
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }
}

这段代码能编译通过,因为 i32Sync 的。三个子线程通过各自的 Arc 同时持有对同一个 i32 的引用,但它们都只是在读取,没有修改,不存在数据竞争。

这就是”共享”的含义:同一份数据,多个线程同时持有它的引用。

那如果我把 i32 换成一个不是 Sync 的类型呢?

use std::cell::Cell;
use std::sync::Arc;
use std::thread;

fn main() {
    let data = Arc::new(Cell::new(42));

    let mut handles = vec![];

    for _ in 0..3 {
        let data = Arc::clone(&data);
        // ERROR:不会编译通过
        let handle = thread::spawn(move || {
            // Cell 允许通过 &T 修改数据
            data.set(data.get() + 1);
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }
}

这段代码不会编译通过。原因是 Cell<i32> 没有实现 Sync

为什么 Cell 不是 Sync 的?因为 Cell 实现了 Interior Mutability(内部可变性)——它允许你通过不可变引用 &Cell<T> 修改里面的值。如果 CellSync 的,那三个线程就可以同时通过 &Cell<i32> 对同一个 i32 进行读写,而 Cellgetset 操作并没有任何同步机制(比如锁或者原子操作)来保护,这就是典型的 Data Race(数据竞争)。

Rust 编译器通过类型系统阻止了这种代码通过编译,而不是让你在运行时才发现问题。

T is Sync iff &T is Send

现在来回答第二个问题:“TSync 的,当且仅当 &TSend 的”——这个定义是不是废话?

初看的时候我觉得是废话,但仔细想想其实不是。这句话把一个比较抽象的概念(“可以在线程间共享”)精确地归约到了一个已经理解的概念(“可以安全地发送到另一个线程”)上。

拆解一下这个逻辑:

  1. 假设一个类型 TSync
  2. 那么 &T 就是 Send
  3. &TSend 的意味着什么?意味着 &T 可以被安全地发送给另一个线程
  4. 既然 &T 能被安全地发送给另一个线程,那多个线程就可以各自持有 &T
  5. 也就是说,多个线程可以同时引用 T——这正是”共享”的意思

反过来也成立:

  1. 如果 &T 不能被安全地发送给另一个线程(&T 不是 Send 的)
  2. 那么 T 就不是 Sync
  3. 这也合理——如果引用都没法安全地跨线程传递,那”共享”更无从谈起

所以这个定义并不是废话,它其实是对”共享”这个概念的一种精确的形式化定义。Sync 并没有引入一套全新的规则,而是把”线程间共享”这件事归约到了 Send 的语义上:能不能共享一个 T,取决于能不能把 T 的引用发送给别的线程

这种设计也带来了一个好处:SendSync 并不是两个完全独立的概念。理解了 SendSync 就只是在此基础上的一层延伸。

哪些类型不是 Sync

在上面 Cell 的例子中已经看到了一个不是 Sync 的类型。再来系统地过一遍。

标准库中不是 Sync 的类型主要有这几个:

Cell<T>RefCell<T>

这两个类型都提供了 Interior Mutability,允许通过 &T 修改内部数据。区别在于 Cell 通过值复制来操作,RefCell 则是在运行时检查借用规则。

它们不是 Sync 的原因是一样的:它们的内部修改操作没有任何线程同步机制。如果多个线程同时通过 &RefCell<T> 调用 borrow_mut()RefCell 的运行时借用检查在多线程下会直接失效,因为那个借用计数器本身就不是线程安全的。

Rc<T>

Rc 既不是 Send 也不是 Sync。原因在上一篇文章中提到过:Rc 的引用计数操作不是原子的。如果多个线程同时对同一个 Rc 进行 clone 或 drop,引用计数就会出错,导致 use-after-free 或者内存泄漏。


那对应地,如果我确实需要在多线程间共享可修改的数据,应该怎么做?

答案是用线程安全的类型来代替上面的那些类型:

  • Arc<T> 代替 Rc<T>——Arc 的引用计数是原子操作
  • Mutex<T>RwLock<T> 代替 RefCell<T>——它们提供了锁机制来保证线程安全

一个常见的组合就是 Arc<Mutex<T>>

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let data = Arc::new(Mutex::new(0));

    let mut handles = vec![];

    for _ in 0..3 {
        let data = Arc::clone(&data);
        let handle = thread::spawn(move || {
            // lock() 获取锁,确保同一时间只有一个线程能修改数据
            let mut num = data.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("结果: {}", *data.lock().unwrap());
}

Mutex<T>Sync 的(前提是 TSend 的),因为 Mutex 的锁机制确保了在任何时刻,最多只有一个线程能够访问内部的数据。这样一来,通过 &Mutex<T> 进行的操作就是线程安全的。

从 Blanket Implementation 的角度看也很清晰:

// 截取自官方文档
unsafe impl<T: ?Sized + Send> Sync for Mutex<T> {}

如果 TSend 的,那么 Mutex<T> 就是 Sync 的。这很合理——Mutex 的锁机制保证了被保护的数据 T 在任意时刻最多只被一个线程独占访问,而 TSend 的意味着 T 本身可以安全地在线程间转移所有权,那么”独占访问”自然也没问题。

Sync 的自动实现

Send 类似,Rust 为 Sync 也提供了自动实现的逻辑:如果一个类型的所有字段都是 Sync 的,那么这个类型也自动是 Sync 的。

比如有这么一个 struct:

struct MyData {
    count: i32,
    name: String,
}

i32String 都是 Sync 的,所以 MyData 自动就是 Sync 的。

但如果某个字段不是 Sync 的:

struct MyData {
    count: Cell<i32>,  // Cell 不是 Sync 的
    name: String,
}

那么 MyData 也不会自动实现 Sync

如果你正在写 unsafe 代码,并且你能够保证你的类型在多线程共享时是安全的,那么可以手动实现 Sync

unsafe impl Sync for MyType {}

这个 unsafe 关键字的意思是:“编译器没法自动验证这件事是安全的,我(程序员)向编译器保证这个类型确实可以安全地在线程间共享。“手动实现 Sync 意味着线程安全的责任从编译器转移到了你自己身上。


到这里,我们已经回答了”什么是 Send”和”什么是 Sync”这两个问题。

下一篇文章中,我会讨论这两个 Trait 这种设计到底好在哪,它们解决了什么问题,以及为什么 Rust 选择了这种方式而不是别的。