Skip to content

内部可变性与 Arc

Cell/RefCell 把借用检查挪到运行期,Arc 把共享所有权带到多线程。

内部可变性:CellRefCell

为什么需要内部可变性

〈所有权〉一章的借用规则是「共享不可变、可变不共享」。但有些设计天然需要 「拿着 &self 还要改字段」:

  • 计数器 / 缓存:fn hits(&self) -> u32 想顺手 self.count += 1
  • 图 / 树:多个父节点共享同一子节点,还要改子节点的标签。
  • 观察者模式:fn subscribe(&self, f: F) 要往 self.listeners 里 push。
  • 测试替身(mock):fn record(&self, call: Call) 收集调用记录。

这些场景有个共同点:可变只是实现细节,对外语义上是「只读操作」。 编译器无法证明「虽然拿着共享引用,但实际没人真的并发访问」,于是提供了 内部可变性(interior mutability):把借用检查从编译期挪到运行期

           静态检查(编译期报错)      动态检查(运行期 panic)
Box<T>     ✓ 独占可变                 —
Rc<T>      ✓ 只读,不允许改           —
Cell<T>    不检查,直接整体替换       —(只有 Copy/Default,无法取引用)
RefCell<T> 不检查,允许拿 &mut        ✓ 借用计数,违反就 panic

Cell<T>:零开销的「整体替换」

Cell<T> 通过「不许借内部值,只能整体读写」来保证安全,所以不需要任何运行期检查:

rust
use std::cell::Cell;

fn main() {
    let c = Cell::new(10);

    println!("get = {}", c.get());          // get:要求 T: Copy
    c.set(20);                              // set:整个替换
    println!("set 后 = {}", c.get());

    let old = c.replace(30);                // replace:换出新值(不要求 Copy)
    println!("replace 返回 = {old},现在 = {}", c.get());

    let taken = c.take();                   // take:取走并留下 Default::default()
    println!("take = {taken},现在 = {}", c.get());
}

输出:

get = 10
set 后 = 20
replace 返回 = 20,现在 = 30
take = 30,现在 = 0

方法对照:

方法签名(简化)约束说明
getfn get(&self) -> TT: Copy复制一份值出来(栈拷贝)
setfn set(&self, val: T)覆盖旧值,旧值被 drop
replacefn replace(&mut self...) 实为 fn replace(&self, val: T) -> T换出新值,不要求 Copy
takefn take(&self) -> TT: Default取走并留下 T::default()

Cell 的经典用法是「&self 的计数器」:

rust
use std::cell::Cell;

struct HitCounter {
    hits: Cell<u32>,
}

impl HitCounter {
    fn new() -> HitCounter {
        HitCounter { hits: Cell::new(0) }
    }

    // 注意是 &self,不是 &mut self
    fn hit(&self) {
        self.hits.set(self.hits.get() + 1);
    }

    fn count(&self) -> u32 {
        self.hits.get()
    }
}

fn main() {
    let c = HitCounter::new();
    c.hit();
    c.hit();
    c.hit();
    println!("命中 {} 次", c.count());
}

输出:命中 3 次

🧠 原理Cell<T>get/setT: Copy 时就是一条普通的内存读写, 没有计数器、没有分支、没有堆分配。它让编译期的「不可变」在类型层面被打破, 但因为不允许 &T 逃逸(拿不到内部引用),也就不可能产生别名可变引用。 Cell!Sync:非原子操作跨线程会数据竞争,编译器直接禁止。

⚠️ 陷阱Cell<T> 只能存 Copy 或能整体替换的类型。想存 Vec<T>get() 会报错(VecCopy),得用 replace(Vec::new())take() 把整个 Vec 搬走。

RefCell<T>:运行期借用检查

RefCell<T> 允许你拿到内部值的 &mut,代价是运行期维护借用计数:

rust
use std::cell::RefCell;

fn main() {
    let data = RefCell::new(vec![1, 2, 3]);

    {
        let mut m = data.borrow_mut(); // RefMut<'_, Vec<i32>>:借用守卫
        m.push(4);
        println!("写入时 = {m:?}");
    } // guard 在这里 drop,借用计数归零

    let r = data.borrow(); // Ref<'_, Vec<i32>>:只读守卫
    println!("读出时 len = {},first = {}", r.len(), r[0]);
}

输出:

写入时 = [1, 2, 3, 4]
读出时 len = 4,first = 1

守卫(guard)是关键:Ref / RefMut 是 RAII 的——它们活着,借用就算「占用」。

方法返回借用状态失败行为
borrow()Ref<'_, T>允许多个已有 &mutpanic
borrow_mut()RefMut<'_, T>唯一已有任何借用时 panic
try_borrow()Result<Ref<'_, T>, BorrowError>允许多个返回 Err,不 panic
try_borrow_mut()Result<RefMut<'_, T>, BorrowMutError>唯一返回 Err,不 panic

🧠 原理RefCell<T> 内部有个 Cell<isize> 当借用标志:正数 = 只读借用数, -1 = 独占借用。borrow / borrow_mut 读改写这个标志,违反规则就 panic!。 计数是非原子的,所以 RefCell<T>!Sync——跨线程共享会导致数据竞争。

违反借用规则:运行期 panic 现场

这是 RefCell 最重要的行为,必须亲眼见过一次。复现代码

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

fn main() {
    let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
    let a = Rc::clone(&shared);
    let b = Rc::clone(&shared);

    let x = a.borrow(); // Ref 守卫一直活到 main 结束
    println!("x = {x:?}");

    let y = b.borrow_mut(); // 与 x 冲突:这里 panic
    println!("y = {y:?}");
}

完整 panic 输出(rustc 1.98.1,Windows 11):

thread 'main' (29712) panicked at src/main.rs:11:15:
RefCell already borrowed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

第二种写法——用 try_borrow_mut().unwrap()——消息会带上类型名,更容易在日志里搜索:

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

fn main() {
    let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
    let a = Rc::clone(&shared);
    let b = Rc::clone(&shared);

    let x = a.borrow();
    println!("x = {x:?}");

    let y = b.try_borrow_mut().unwrap(); // 这一行是 panic 点
    println!("y = {y:?}");
}
thread 'main' (12436) panicked at src/main.rs:11:32:
called `Result::unwrap()` on an `Err` value: BorrowMutError
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

同一个 RefCell 连续两次 borrow_mut() 也是同样结果:

rust
use std::cell::RefCell;

fn main() {
    let c = RefCell::new(1);
    let m1 = c.borrow_mut();
    let m2 = c.borrow_mut(); // panic: RefCell already borrowed
    println!("{} {}", *m1, *m2);
}
thread 'main' (30024) panicked at src/main.rs:7:16:
RefCell already borrowed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

定位方法(三步):

  1. 运行程序时加 RUST_BACKTRACE=1,看 panic 点所在的行号:
    powershell
    $env:RUST_BACKTRACE = "1"; cargo run
  2. 那一行一定是 borrow() / borrow_mut()。往上文找还有哪个守卫(Ref / RefMut 变量)还活着——最常见的是它被写在同一个 let 里活到了函数末尾。
  3. 修法有三种:
    • 缩短守卫寿命:把 borrow_mut() 放进 { } 作用域块;
    • 先读后写let snapshot = data.borrow().clone(); 拿到值再写;
    • 改成可失败 APItry_borrow_mut().ok()?,让调用方决定怎么处理冲突。
rust
use std::cell::RefCell;

fn main() {
    let data = RefCell::new(vec![1, 2, 3]);

    // ❌ 错误:`borrow()` 的临时值活到整条语句结束,`push` 又要可变借用
    // data.borrow().push(4);
    // 报错(运行期 panic):RefCell already borrowed

    // ✅ 修法一:读完就丢,先 clone 出需要的值
    let snapshot = data.borrow().clone();
    if !snapshot.contains(&4) {
        data.borrow_mut().push(4);
    }

    // ✅ 修法二:显式作用域块,让守卫立刻结束
    {
        let _guard = data.borrow_mut();
    }
    data.borrow_mut().push(5);

    println!("{:?}", data.borrow());
}

输出:[1, 2, 3, 4, 5]

⚠️ 陷阱data.borrow().push(4) 看起来像「借一下就用完」,但因为临时值的生命周期 被延长到整条语句结束,push 要的第二个借用一定冲突。同一个 RefCell 在一条语句里 同时 borrow 和 borrow_mut 是必 panic 的

Rc<RefCell<T>>:单线程的共享可变

Rc<T> 的「共享」和 RefCell<T> 的「可变」拼起来,就得到单线程下模拟 Java 引用语义的终极组合:

   Rc<RefCell<Node>>            多个所有者共享同一个可变的 Node
   └┬┘  └──┬───┘
    │      └── 借用的检查推迟到运行期
    └── 所有权由引用计数管理(加计数不是拷贝)

下面是一个真实的「多所有者可变节点」示例——两张表各自的记录共享同一个 Player

rust
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;

#[derive(Debug)]
struct Player {
    name: String,
    score: i32,
}

type PlayerRef = Rc<RefCell<Player>>;

struct Leaderboard {
    by_id: HashMap<u32, PlayerRef>,
    order: Vec<PlayerRef>, // 和 by_id 指向同一批 Player
}

impl Leaderboard {
    fn new() -> Leaderboard {
        Leaderboard {
            by_id: HashMap::new(),
            order: Vec::new(),
        }
    }

    fn insert(&mut self, id: u32, name: &str) {
        let player: PlayerRef = Rc::new(RefCell::new(Player {
            name: name.to_string(),
            score: 0,
        }));
        self.by_id.insert(id, Rc::clone(&player)); // 加计数,两个容器共享
        self.order.push(player);
    }

    // 从任意一个容器改,另一个容器立刻可见
    fn add_score(&self, id: u32, delta: i32) {
        let p = self.by_id.get(&id).expect("id 不存在");
        p.borrow_mut().score += delta;
    }
}

fn main() {
    let mut board = Leaderboard::new();
    board.insert(1, "Alice");
    board.insert(2, "Bob");

    board.add_score(1, 30);
    board.add_score(2, 10);
    board.add_score(2, 25);

    // 遍历 order,看到的是 by_id 里同一批对象的最新状态
    for p in &board.order {
        let p = p.borrow();
        println!("{} -> {}", p.name, p.score);
    }

    let alice = board.by_id.get(&1).unwrap();
    println!("Alice 的强引用数 = {}", Rc::strong_count(alice));
}

输出:

Alice -> 30
Bob -> 35
Alice 的强引用数 = 2

🚀 进阶Rc<RefCell<T>> 的组合就是「单线程 GC 语言的引用语义」。 Rc 管生命周期,RefCell 管可变性,运行期 panic 就是「借用规则的罚单」。 代价是:编译期检查没了,错误被推迟到运行期——所以能用普通 &mut 的地方就别用它。

现代替代:OnceCell / OnceLock / LazyLock

如果需求只是「初始化一次,之后只读」,别用 Rc<RefCell<Option<T>>>, 标准库有更专门的类型,不需要任何第三方 crate

类型稳定版本使用位置线程安全
std::cell::OnceCell<T>1.70结构体字段(单线程)!Sync,但 OnceCell 本身支持 &self 设置
std::sync::OnceLock<T>1.70static、跨线程共享Send + Sync
std::sync::LazyLock<T>1.80static,初始化表达式较长Send + Sync
once_cell crate第三方需要 get_or_try_init 等扩展
rust
use std::sync::{LazyLock, OnceLock};

// 方式一:OnceLock —— 先声明、后初始化,可以是运行期才拿到的值
static CONFIG: OnceLock<String> = OnceLock::new();

// 方式二:LazyLock —— 声明处直接给初始化表达式,第一次访问时求值
static SQUARES: LazyLock<Vec<i32>> = LazyLock::new(|| (1..=5).map(|x| x * x).collect());

fn main() {
    // get_or_init:只在第一次真正执行闭包;后续调用直接返回已有值
    let v = CONFIG.get_or_init(|| String::from("loaded from disk"));
    println!("config = {v}");

    let again = CONFIG.get_or_init(|| String::from("这个闭包永远不会跑"));
    println!("再次获取 = {again}");

    // LazyLock 像普通静态量一样用(解引用即可)
    println!("squares = {:?}", *SQUARES);

    // 跨线程也安全:多个线程只会有一个执行初始化
    let h = std::thread::spawn(|| SQUARES.len());
    println!("子线程看到 length = {}", h.join().unwrap());
}

输出:

config = loaded from disk
再次获取 = loaded from disk
squares = [1, 4, 9, 16, 25]
子线程看到 length = 5

cargo 侧零配置——这三个类型都在 std 里:

toml
# Cargo.toml 无需任何依赖
[package]
name = "ch8-once"
version = "0.1.0"
edition = "2024"

💡 对照:Java 的 static final 由类加载器保证只初始化一次;Rust 的 LazyLock 是「第一次访问时初始化」(lazy),并且不依赖类加载机制。Python 常用 if _cache is None: 手写单例,LazyLock 就是编译期就线程安全的那一版。


Arc<T> 与线程安全

Arc<T>(Atomically Reference Counted)是 Rc<T> 的多线程版本:计数用原子操作, 因此可以被多个线程同时 clone / drop。

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

fn main() {
    let data = Arc::new(vec![1, 2, 3, 4]);

    let mut handles = Vec::new();
    for i in 0..3 {
        let shared = Arc::clone(&data); // 惯例写法,明确「只是加计数」
        handles.push(thread::spawn(move || {
            let sum: i32 = shared.iter().sum();
            println!("线程 {i}: sum = {sum}");
        }));
    }

    for h in handles {
        h.join().unwrap();
    }
    println!("主线程: strong = {}", Arc::strong_count(&data));
}

输出(线程顺序可能不同):

线程 0: sum = 10
线程 1: sum = 10
线程 2: sum = 10
主线程: strong = 1

Rc vs Arc:原子计数的代价

Rc<T>Arc<T>
计数操作普通加减(非原子)fetch_add / fetch_sub + 内存序
单次 clone 成本~1 条指令通常 10~20 周期(有缓存争用会更慢)
能否跨线程!Send !SyncSend + Sync(当 T: Send + Sync
典型用途单线程树/图/共享配置线程池任务、跨线程共享状态

🧠 原理Arc 慢的不是「原子指令」本身,而是多个核心同时改同一个缓存行时的 缓存一致性流量(cache line bouncing)。所以高频 clone Arc 的场景要考虑减少共享, 或者改用 Arc<[T]>、把大对象切分到各个线程。

Arc<Mutex<T>>:跨线程共享可变状态

Arc 自己也是只读的,要在多线程里改数据,得再套一层 Mutex(互斥锁)或 RwLock

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

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = Vec::new();

    for _ in 0..4 {
        let c = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            for _ in 0..1000 {
                // lock() 返回 MutexGuard,离开作用域自动解锁
                *c.lock().unwrap() += 1;
            }
        }));
    }

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

    println!("最终计数 = {}", *counter.lock().unwrap());
}

输出:最终计数 = 4000

这里的 unwrap() 是在处理「锁中毒(poisoning)」:持锁线程 panic 时锁会被标记为 poisoned, lock() 返回 Err。生产代码更稳的写法是:

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

fn safe_increment(counter: &Arc<Mutex<i32>>) {
    let mut guard = counter.lock().unwrap_or_else(|e| e.into_inner()); // 无视中毒继续用
    *guard += 1;
}

fn main() {
    let counter = Arc::new(Mutex::new(0));
    safe_increment(&counter);
    println!("{}", counter.lock().unwrap());
}

输出:1

🚀 进阶Mutex / RwLock / Condvar / 通道 / 原子类型的完整用法、锁粒度与死锁避免, 见 并发与线程安全。本章只需要记住 「Arc 负责共享、Mutex 负责可变」这一条组合律。

⚠️ 陷阱Arc<Mutex<T>> 里的 T 必须 Send,锁才能跨线程;MutexGuard!Send 的(不能把守卫搬到另一个线程再解锁),这是防死锁的设计。

Send / Sync 与这些类型的关系

类型SendSync说明
T(泛型)T: SendSendT: SyncSync都是自动 trait,由字段决定
&TT: SyncSendT: SyncSync共享引用能过线程 = 数据本身可共享
&mut TT: SendSendT: SyncSync可变引用独占,只要能搬就行
Box<T>T: SendT: Sync随内部类型
Rc<T>❌ 永远 !Send❌ 永远 !Sync非原子计数,跨线程会竞态
Arc<T>T: Send + SyncSendT: Send + SyncSync计数原子,但数据仍需可共享
Cell<T>T: Send❌ 永远 !Sync无同步的 &self 写入
RefCell<T>T: Send❌ 永远 !Sync借用计数非原子
Mutex<T>T: SendT: SendSync锁保证独占访问
RwLock<T>T: SendT: Send + SyncSync读共享、写独占
Weak<T>Rc/ArcRc/Arcstd::rc::Weak 不 Send,std::sync::Weak 可以

记法:Send = 「能搬到别的线程」,Sync = 「能被多个线程同时引用」。 Rc / RefCell / Cell类型层面被标记为 !Send / !Sync, 所以「不小心把单线程数据结构搬到多线程」这件事在 Rust 里是编译错误,而不是数据竞争。

⚠️ 陷阱Arc<RefCell<T>> 能编译出 Arc,但用不了——RefCell 不是 SyncArc<RefCell<T>> 就不是 Sendthread::spawn 照样报 E0277。单线程用 Rc<RefCell<T>>, 多线程用 Arc<Mutex<T>>,别混。


内容以 rustc 1.98.1 · Rust 2024 edition 为基准