Cell、RefCell 与 Mutex:内部可变性

本页目录

内部可变性允许通过共享引用访问的对象改变部分内部状态,例如缓存、计数和同步队列。它不取消“不能同时存在冲突访问”的要求,而是由封装选择另一种可以证明安全的访问协议。

共享引用的一般不可变约束在 UnsafeCell 所包裹的部分有例外。但 UnsafeCell 本身不提供锁,不允许任意创建相互别名的 &mut,也不允许数据竞争。UnsafeCell

Cell<T>移出、替换或复制值RefCell<T>动态借用 guardMutex<T>互斥锁 guard不发放普通共享借用get 要求 Copy,replace 不要求读者或独占写者冲突返回错误或 panic一次一个持锁者竞争等待或 try_lock 失败共享入口不同,内部访问协议也不同UnsafeCell 是底层原语;安全接口还必须自行维护别名与同步规则。

Cell 并非 Copy 专用

Cell::get 要求 T: Copy,因为它返回内部值的副本。set 和 replace 可以用于非 Copy 类型;replace 把旧值移出来并放入新值,不需要暴露内部借用。take 在 T: Default 时用默认值替换,into_inner 消费 Cell 并取出 T。Cell

use std::cell::Cell;

fn main() {
    let name = Cell::new(String::from("before"));
    let old = name.replace(String::from("after"));
    assert_eq!(old, "before");
    assert_eq!(name.take(), "after");
    assert_eq!(name.into_inner(), "");
}

因此 Cell 是合法而有用的,只是不能调用 get。它没有 RefCell 的动态借用计数,不等于操作绝对零成本:替换 String 仍可能涉及其资源管理。

“Cell 永远不返回引用”也太绝对。持有 &mut Cell 时,get_mut 可以返回 &mut T;这时独占借用已经由编译器保证,无需额外运行时协议。

单线程也需要考虑观察到中间状态

用 Cell::take 临时取出一个 Vec,处理完再 set 回去,看起来避开了借用 guard;但处理中若调用可重入的外部代码,其他入口可能看到默认空 Vec。如果处理中 panic,取出的值可能直接析构,原 Cell 则继续为空。Cell 保证这种访问在内存层面安全,不保证“取出—处理—放回”具有事务语义。

因此 Cell 更适合本来就允许整体替换、且中间状态有明确意义的数据。若必须临时借用内部结构,RefCell 通常能更直接表达访问期间的排他性;若操作可能失败,还应在提交前计算新值,或设计可恢复的状态转换。

RefCell 用 guard 记录动态借用

borrow 返回 Ref,borrow_mut 返回 RefMut。通常允许多个共享借用,或一个独占借用。guard 被释放后,对应借用才结束,不能根据“最后一次读值已经过去”就假定 guard 自动失效。

use std::cell::RefCell;

fn main() {
    let values = RefCell::new(vec![1, 2]);
    let reading = values.borrow();
    assert!(values.try_borrow_mut().is_err());
    assert_eq!(reading.len(), 2);
    drop(reading);
    values.borrow_mut().push(3);
    assert_eq!(&*values.borrow(), &[1, 2, 3]);
}

borrow/borrow_mut 在冲突时 panic;try_borrow/try_borrow_mut 把冲突作为 Result 返回,并不会等待另一个借用结束。容易遗漏的冲突来源包括回调重入,以及在整个 match 或其他较长表达式期间存活的临时 guard。RefCell

RefCell 在 T: Send 时可以整体转移到另一线程,但它不 Sync。Rc<RefCell> 是单线程共享所有权加动态借用,并不是可跨线程共享的组合。

从内部字段返回引用,要把 guard 一起返回

use std::cell::{Ref, RefCell};
struct Profile {
    fields: RefCell<(String, u32)>,
}
impl Profile {
    fn name(&self) -> Ref<'_, str> {
        Ref::map(self.fields.borrow(), |fields| fields.0.as_str())
    }
}
fn main() {
    let profile = Profile { fields: RefCell::new(("Li".into(), 7)) };
    let name = profile.name();
    assert_eq!(&*name, "Li");
    assert!(profile.fields.try_borrow_mut().is_err());
    drop(name);
    profile.fields.borrow_mut().0.push('!');
    assert_eq!(&*profile.name(), "Li!");
}

不能在函数中创建临时 Ref,再把其中的 &str 当作无 guard 的普通引用返回;函数结束时动态借用凭证会消失。Ref::map 把 guard 的可见范围投影到字段,同时保留整个 RefCell 的共享借用状态。即使只读取 name,也不能同时通过同一个 RefCell 借出独占的数字字段;映射没有把一个 RefCell 自动拆成两个独立借用单元。Ref::map

在调用外部代码前结束动态借用

use std::cell::RefCell;
fn notify(queue: &RefCell<Vec<u32>>, callback: impl FnOnce(usize)) {
    let count = {
        let reading = queue.borrow();
        reading.len()
    };
    callback(count);
}
fn main() {
    let queue = RefCell::new(vec![1, 2]);
    notify(&queue, |count| {
        assert_eq!(count, 2);
        queue.borrow_mut().push(3);
    });
    assert_eq!(&*queue.borrow(), &[1, 2, 3]);
}

这里把需要的标量先取出,释放 guard,再调用 callback,因此回调可以修改队列。如果 callback 在 reading 仍存活时调用 borrow_mut,会发生借用冲突。代价是 count 仅代表取值时的状态,回调执行后不再保证它等于队列当前长度;如果业务必须保持状态稳定,就应限制回调能做什么,或改成提交事件后由外层处理。

不要依赖“单线程所以没有竞争”来忽略重入。单线程不会同时执行两条机器指令流,但外部回调可以在原操作尚未完成时重新进入同一个对象。

最后读取之后,guard 还在吗?

这里刻意把“读取”与“释放 guard”做成不同操作。申请 Ref 后读取一次,再申请 RefMut,仍会失败;只有释放所有共享 guard,才满足独占借用条件。这解释了为什么普通引用的最后使用分析不能直接套在 Ref 或 MutexGuard 上。

正在呈现知识画面
最后读取之后,guard 还在吗?

动态借用由 guard 的生命维护;最后一次读取不是释放 guard。

Mutex 把独占访问扩展到线程间

std::sync::Mutex::lock 等待获得锁,返回 LockResult<MutexGuard>。guard 的释放解锁;锁保护的是遵循同一协议的数据访问,不能让一部分代码绕开锁仍期待一致性。

try_lock 不阻塞,返回的是 TryLockResult,即 Result,错误区分 WouldBlock 和 Poisoned。下面在另一个线程持锁时验证竞争分支,避免同线程递归加锁的未规定行为:

use std::sync::{Mutex, TryLockError, mpsc};
use std::thread;

fn main() {
    let value = Mutex::new(1);
    thread::scope(|scope| {
        let (ready_tx, ready_rx) = mpsc::channel();
        let (done_tx, done_rx) = mpsc::channel();
        let shared = &value;
        scope.spawn(move || {
            let mut guard = shared.lock().unwrap();
            ready_tx.send(()).unwrap();
            done_rx.recv().unwrap();
            *guard += 1;
        });
        ready_rx.recv().unwrap();
        assert!(matches!(value.try_lock(), Err(TryLockError::WouldBlock)));
        done_tx.send(()).unwrap();
    });
    assert_eq!(*value.lock().unwrap(), 2);
}

poison 提醒调用者此前有持锁线程在 panic 展开期间离开临界区,业务状态可能不完整。它是建议性的检测,不是完整的安全证明;恢复时要评估不变量,不能无条件忽略,也不能让 unsafe 代码仅依赖 poison 避免 UB。Mutex

底层实现依赖平台和标准库版本,不能固定说 Linux 上总是 pthread_mutex_t。选择它主要依赖其互斥契约,而非某次实现的内部字段。

获取 guard检查余额与目标上限计算新状态所有可能失败的检查先完成一起提交两个字段释放 guard 后对外可见其他参与者只能看到提交前或提交后的账本前提:所有相关读取与修改都遵循这把锁的协议同一把锁保护一次完整的状态转换互斥保证临界区不交错;业务校验、错误时不修改和外部副作用仍需另外设计。

锁保护的是不变量,不只是一个变量

下面用一把锁保护两个账户余额,转账必须保持总额,并在余额不足或目标溢出时保持原状。先算出所有新值,再提交,可避免第一字段已修改、第二字段校验才失败。

use std::sync::Mutex;
#[derive(Debug, PartialEq, Eq)]
enum TransferError { Insufficient, Overflow }
fn transfer(balances: &Mutex<(u64, u64)>, amount: u64)
    -> Result<(), TransferError>
{
    let mut state = balances.lock().unwrap();
    let left = state.0.checked_sub(amount).ok_or(TransferError::Insufficient)?;
    let right = state.1.checked_add(amount).ok_or(TransferError::Overflow)?;
    *state = (left, right);
    Ok(())
}
fn main() {
    let balances = Mutex::new((100, 20));
    assert_eq!(transfer(&balances, 30), Ok(()));
    assert_eq!(*balances.lock().unwrap(), (70, 50));
    assert_eq!(transfer(&balances, 80), Err(TransferError::Insufficient));
    assert_eq!(*balances.lock().unwrap(), (70, 50));
    let full = Mutex::new((10, u64::MAX));
    assert_eq!(transfer(&full, 1), Err(TransferError::Overflow));
    assert_eq!(*full.lock().unwrap(), (10, u64::MAX));
}

示例将中毒视为不可恢复错误并 unwrap,适合展示算术与提交顺序;实际接口可另行返回锁错误或定义恢复策略。若给每个余额单独配一把锁,先后解锁会暴露中间状态;同时持有两把锁又需要统一加锁顺序来避免死锁。两个原子整数也不会自动构成一次原子转账。

锁内应包含维护不变量必需的读取和提交,但应慎重放入慢 I/O、未知回调或长时间计算。把计算移到锁外可缩短持锁时间,却要检查计算依赖的旧状态是否已经变化;必要时记录版本并在重新加锁后验证,不能默认锁外计算仍对应最新数据。

中毒不会回滚已经写入的数据

PoisonError 可以交还 guard,使调用者有机会检查和修复状态。仅调用 into_inner 是取出访问凭证;clear_poison 是清除标记,都不会自动恢复业务不变量。下面构造一次可控的线程 panic,再用已知完整状态替换损坏状态。

use std::sync::Mutex;
use std::thread;
fn main() {
    let state = Mutex::new((50u32, 50u32));
    thread::scope(|scope| {
        let shared = &state;
        let failed = scope.spawn(move || {
            let mut guard = shared.lock().unwrap();
            guard.0 = 0;
            panic!("模拟只更新了一个字段");
        });
        assert!(failed.join().is_err());
    });
    assert!(state.is_poisoned());
    {
        let mut guard = match state.lock() {
            Ok(_) => panic!("预期收到中毒状态"),
            Err(error) => error.into_inner(),
        };
        assert_eq!(*guard, (0, 50));
        *guard = (50, 50); // 教学中已知可信完整状态;实际恢复须有可信依据。
        state.clear_poison();
    }
    assert!(!state.is_poisoned());
    assert_eq!(*state.lock().unwrap(), (50, 50));
}

这个示例依赖 panic 展开并通过 join 观察线程失败;panic=abort 下进程会终止,无法按该流程恢复。现实中的余额不能凭空重置,需要可信快照、日志或业务对账。中毒检测本身也不是所有 panic 场景都必定触发的可靠性边界。Mutex poisoning

三者的选择边界

问题CellRefCellMutex
通过 &self 修改支持替换等操作通过动态借用通过持锁 guard
访问冲突不维持普通共享借用返回借用错误或 panic等待或 try_lock 返回错误
T: Send 时整体 Send是是是
可实现 Sync否否T: Send 时是

只替换整个值时,Cell 往往简单;需要暂时借出内部结构且限定单线程时,RefCell 更合适;线程间维护多个字段共同不变量时,Mutex 通常容易推理。异步场景还要考虑等待是否阻塞执行器,以及 guard 是否跨 await,见 Tokio runtime。