Rc, RefCell, Arc 和 Mutex 的区别与组合

我们一步步来,从单线程世界走向多线程世界。

核心概念:两个维度

想象一个坐标系,你需要根据两个维度来选择合适的工具:

  1. 所有权维度:这个数据是唯一所有权还是共享所有权?
  2. 可变性维度:我们是需要外部可变性 (&mut T) 还是内部可变性(通过 &T 修改内部)?

第一站:单线程世界 (std::rcstd::cell)

在单线程环境下,我们不需要担心数据竞争,但仍然需要处理复杂的所有权和可变性问题。

1. Rc<T> (Reference Counted) - “共享图书馆借书卡”

  • 解决什么问题? 当你希望一块数据(比如一个图中的节点)在单线程内有多个所有者时使用。普通的 Rust 所有权规则规定一个值只能有一个所有者。
  • 工作原理Rc<T> 在堆上分配一个值 T,并附带一个“引用计数器”。每次克隆 Rc(Rc::clone(&rc)),你得到的不是 T 的深拷贝,而是一个指向相同数据的新指针,同时计数器加一。当一个 Rc 指针被销毁(离开作用域),计数器减一。当计数器归零时,数据 T 会被自动清理。
  • 核心特点
    • 共享所有权。
    • 只能用于单线程。它的引用计数器是非原子的,在多线程下递增/递减会产生数据竞争。
    • Rc<T> 本身是不可变的,你无法通过它获得 &mut T

示例

use std::rc::Rc;

// 想象一个链表,我们希望两个列表能共享一部分节点
let common_node = Rc::new(5);

let list_a = Rc::clone(&common_node);
let list_b = Rc::clone(&common_node);

println!("Reference count: {}", Rc::strong_count(&common_node)); // 输出: Reference count: 3

2. RefCell<T> - “带锁的日记本”

  • 解决什么问题? 当你有一个不可变的值(比如 &T),但你需要修改它内部的数据时。这就是内部可变性。
  • 工作原理RefCell<T> 将 Rust 编译器的静态借用检查,转移到了运行时检查。
    • borrow():在运行时请求一个不可变借用(&T)。可以同时有多个。
    • borrow_mut():在运行时请求一个可变借用(&mut T)。只能有一个。
    • 如果违反了借用规则(比如在已有可变借用的情况下又请求一个),程序会直接 panic!
  • 核心特点
    • 内部可变性。
    • 只能用于单线程。它的借用检查逻辑是非线程安全的。
    • 它不共享所有权,它自己就是数据的所有者。

示例

use std::cell::RefCell;

// config 是一个不可变绑定,但我们想修改它内部的值
let config = RefCell::new(String::from("initial"));

// 通过 .borrow_mut() 获取可变借用
*config.borrow_mut() = String::from("modified");

println!("{}", config.borrow()); // 输出: modified

组合技: Rc<RefCell<T>> - “多人共享的可编辑在线文档”

这是单线程中最强大的组合。Rc 提供了共享所有权,RefCell 提供了内部可变性。

  • 解决什么问题? 你希望一块数据被多个所有者共享,并且任何一个所有者都能修改它。

示例

use std::rc::Rc;
use std::cell::RefCell;

let shared_data = Rc::new(RefCell::new(10));

let owner_1 = Rc::clone(&shared_data);
let owner_2 = Rc::clone(&shared_data);

// 第一个所有者修改数据
*owner_1.borrow_mut() += 5;

// 第二个所有者也能看到修改
println!("{}", owner_2.borrow()); // 输出: 15

第二站:多线程与高并发世界 (std::sync)

进入多线程世界,我们需要能被安全地在线程间发送(Send)和共享(Sync)的类型。RcRefCell 都不符合要求。于是,它们的“线程安全”版本登场了。

1. Arc<T> (Atomically Reference Counted) - Rc 的线程安全版

  • 解决什么问题? 在多线程环境下实现共享所有权。
  • 工作原理:和 Rc 几乎一样,但它的引用计数器是原子的。原子操作是不可分割的,可以保证在多核 CPU 上同时读写计数器也不会出错。
  • 核心特点
    • 线程安全的共享所有权。
    • 性能比 Rc 略低,因为原子操作比普通整数操作更耗时。

2. Mutex<T> (Mutual Exclusion) - RefCell 的线程安全版

  • 解决什么问题? 在多线程环境下提供内部可变性,保证同一时间只有一个线程能访问数据。
  • 工作原理:通过“锁”(Lock)机制。
    • lock():任何线程想访问数据前,必须先调用 lock()。如果锁没被占用,它获得锁并继续执行。如果锁已被其他线程占用,当前线程会阻塞(等待),直到锁被释放。
    • Mutex 返回一个 MutexGuard,这是一个智能指针,它提供了对内部数据的 &mut T 访问。当 MutexGuard 离开作用域时,它会自动释放锁。这个 RAII 模式极大地保证了安全,你几乎不会忘记解锁。
  • 核心特点
    • 线程安全的内部可变性。
    • 通过阻塞等待来实现互斥。

终极组合技: Arc<Mutex<T>> - “银行金库”

这是 Rust 并发编程中最常见、最重要的模式。它允许你将一个可变的状态安全地在多个线程间共享和修改。

  • Arc:让多个线程都能“拥有”一个指向金库(Mutex)的指针(锁)。
  • Mutex:保证同一时间只有一个线程能拿到金库的钥匙,进去操作里面的财宝(数据 T)。

示例

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

// 创建一个被 Arc 和 Mutex 包裹的计数器
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..10 {
    // 为每个线程克隆 Arc 指针
    let counter_clone = Arc::clone(&counter);

    let handle = thread::spawn(move || {
        // 获取锁,如果被占用则等待
        let mut num = counter_clone.lock().unwrap();

        // 修改数据
        *num += 1;

        // MutexGuard 在这里离开作用域,锁被自动释放
    });
    handles.push(handle);
}

// 等待所有线程执行完毕
for handle in handles {
    handle.join().unwrap();
}

// 打印最终结果
println!("Result: {}", *counter.lock().unwrap()); // 输出: Result: 10

总结与速查表

类型 用途 场景 线程安全?
Rc<T> 共享所有权 单线程
RefCell<T> 内部可变性 单线程
Rc<RefCell<T>> 共享所有权 + 内部可变性 单线程
Arc<T> 共享所有权 多线程
Mutex<T> 内部可变性 多线程
Arc<Mutex<T>> 共享所有权 + 内部可变性 多线程