内部可变性与 Arc
Cell/RefCell把借用检查挪到运行期,Arc把共享所有权带到多线程。
内部可变性:Cell 与 RefCell
为什么需要内部可变性
〈所有权〉一章的借用规则是「共享不可变、可变不共享」。但有些设计天然需要 「拿着 &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 ✓ 借用计数,违反就 panicCell<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
方法对照:
| 方法 | 签名(简化) | 约束 | 说明 |
|---|---|---|---|
get | fn get(&self) -> T | T: Copy | 复制一份值出来(栈拷贝) |
set | fn set(&self, val: T) | 无 | 覆盖旧值,旧值被 drop |
replace | fn replace(&mut self...) 实为 fn replace(&self, val: T) -> T | 无 | 换出新值,不要求 Copy |
take | fn take(&self) -> T | T: 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/set在T: Copy时就是一条普通的内存读写, 没有计数器、没有分支、没有堆分配。它让编译期的「不可变」在类型层面被打破, 但因为不允许&T逃逸(拿不到内部引用),也就不可能产生别名可变引用。Cell是!Sync:非原子操作跨线程会数据竞争,编译器直接禁止。
⚠️ 陷阱:
Cell<T>只能存Copy或能整体替换的类型。想存Vec<T>:get()会报错(Vec不Copy),得用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> | 允许多个 | 已有 &mut 时 panic |
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定位方法(三步):
- 运行程序时加
RUST_BACKTRACE=1,看 panic 点所在的行号:powershell$env:RUST_BACKTRACE = "1"; cargo run - 那一行一定是
borrow()/borrow_mut()。往上文找还有哪个守卫(Ref/RefMut变量)还活着——最常见的是它被写在同一个let里活到了函数末尾。 - 修法有三种:
- 缩短守卫寿命:把
borrow_mut()放进{ }作用域块; - 先读后写:
let snapshot = data.borrow().clone();拿到值再写; - 改成可失败 API:
try_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.70 | static、跨线程共享 | Send + Sync |
std::sync::LazyLock<T> | 1.80 | static,初始化表达式较长 | 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 !Sync | ✅ Send + Sync(当 T: Send + Sync) |
| 典型用途 | 单线程树/图/共享配置 | 线程池任务、跨线程共享状态 |
🧠 原理:
Arc慢的不是「原子指令」本身,而是多个核心同时改同一个缓存行时的 缓存一致性流量(cache line bouncing)。所以高频 cloneArc的场景要考虑减少共享, 或者改用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 与这些类型的关系
| 类型 | Send | Sync | 说明 |
|---|---|---|---|
T(泛型) | T: Send 才 Send | T: Sync 才 Sync | 都是自动 trait,由字段决定 |
&T | T: Sync 时 Send | T: Sync 时 Sync | 共享引用能过线程 = 数据本身可共享 |
&mut T | T: Send 时 Send | T: Sync 时 Sync | 可变引用独占,只要能搬就行 |
Box<T> | T: Send | T: Sync | 随内部类型 |
Rc<T> | ❌ 永远 !Send | ❌ 永远 !Sync | 非原子计数,跨线程会竞态 |
Arc<T> | T: Send + Sync 时 Send | T: Send + Sync 时 Sync | 计数原子,但数据仍需可共享 |
Cell<T> | T: Send | ❌ 永远 !Sync | 无同步的 &self 写入 |
RefCell<T> | T: Send | ❌ 永远 !Sync | 借用计数非原子 |
Mutex<T> | T: Send | ✅ T: Send 时 Sync | 锁保证独占访问 |
RwLock<T> | T: Send | ✅ T: Send + Sync 时 Sync | 读共享、写独占 |
Weak<T> | 随 Rc/Arc | 随 Rc/Arc | std::rc::Weak 不 Send,std::sync::Weak 可以 |
记法:Send = 「能搬到别的线程」,Sync = 「能被多个线程同时引用」。 Rc / RefCell / Cell 在类型层面被标记为 !Send / !Sync, 所以「不小心把单线程数据结构搬到多线程」这件事在 Rust 里是编译错误,而不是数据竞争。
⚠️ 陷阱:
Arc<RefCell<T>>能编译出Arc,但用不了——RefCell不是Sync,Arc<RefCell<T>>就不是Send,thread::spawn照样报 E0277。单线程用Rc<RefCell<T>>, 多线程用Arc<Mutex<T>>,别混。