- 示例工程
- 教程
【免费下载链接】100-exercises-to-learn-rust
A self-paced course to learn Rust, one exercise at a time.
本文以 100-exercises-to-learn-rust 课程中线程章节(07_threads)的配套讲义 book/src/07_threads/06_interior_mutability.md 为核心,从标准库通道Sender::send(&self)的签名出发,讲清"共享引用并不等于不可变引用"这一 Rust 关键心智模型。读完本文,你将掌握内部可变性(interior mutability)的定义、其底层基石UnsafeCell的作用,以及Rc、RefCell两个标准库典型实现,并能独立完成配套练习DropTracker的实现。
一个耐人寻味的签名:send(&self)如何改变通道状态
在课程的通道章节(book/src/07_threads/05_channels.md)中,我们搭建了一个客户端-服务器架构:一个长期运行的服务器线程管理票据状态,多个客户端线程通过std::sync::mpsc通道向它发送命令。其中Sender的send方法签名如下:
impl<T> Sender<T> { pub fn send(&self, t: T) -> Result<(), SendError<T>> { // [...] } }这里存在一个明显的矛盾:send接收的是&self(共享引用),但它显然在"改变"通道的状态——把一条新消息推进通道内部队列。更关键的是,Sender是可克隆的,多个客户端线程可以各自持有一份Sender克隆,在同一时刻并发地向同一个通道写入数据。
这正是支撑整个客户端-服务器架构的核心性质。但问题也随之而来:它为什么能工作?这不是违反了 Rust 的借用规则吗?我们是怎样通过一个不可变引用完成变更的?
答案就是本讲的主题:内部可变性(interior mutability)。
更准确的命名:共享引用(&T)与独占引用(&mut T)
在借位检查器(borrow-checker)章节中,我们习惯把两种引用称为:
- 不可变引用(
&T) - 可变引用(
&mut T)
但如果追求精确,更准确的叫法应当是:
- 共享引用(shared references,
&T) - 独占引用(exclusive references,
&mut T)
"不可变/可变"这套心智模型在绝大多数场景下都成立,也是初学 Rust 时最实用的抓手。但它并不是故事的全部——正如send(&self)所示,&T实际上并不保证其指向的数据不可变。
不必担心:Rust 依然信守承诺。只是这些术语的含义比乍看起来更微妙:当多个线程共享同一份数据时,Rust 需要一种机制,让"共享访问"与"内部变更"能够共存而不破坏借用规则。内部可变性正是这把钥匙。
UnsafeCell:告诉编译器"这个共享引用确实可变"
Rust 编译器的默认假设是:共享引用指向的数据是不可变的。基于这一假设,编译器会大胆优化——重排操作、缓存取值、做各种让代码更快的小动作。
当你需要推翻这个假设时,标准库提供了底层工具UnsafeCell:把数据包进UnsafeCell,就等于告诉编译器"不,这个共享引用实际是可变的"。因此,凡是允许通过共享引用修改数据的类型,其内部必然直接或间接地含有UnsafeCell——这是识别内部可变性类型的一个可靠判据。借助UnsafeCell、裸指针(raw pointers)和unsafe代码,你就可以通过共享引用修改数据。
但必须澄清:UnsafeCell并不是让你无视借位检查器的魔法棒!unsafe代码依然受 Rust 借用与别名(aliasing)规则的约束。它只是一个(进阶的)工具,用于构建类型系统无法直接表达其安全性的安全抽象。每当你写出unsafe关键字,你实际上是在对编译器说:"我知道我在做什么,我不会违反你的不变量,请相信我。"
这也意味着:每次调用unsafe函数,其文档都会说明安全前置条件(safety preconditions)——在什么情况下执行其中的unsafe块是安全的。UnsafeCell自身的前置条件收录在std文档中。本课程不会直接使用UnsafeCell,也不会编写unsafe代码,但理解它"为什么存在、与日常使用的类型有什么关系"依然非常重要——它是所有内部可变性类型的地基。
标准库中的关键实例
让我们考察两个std中借助内部可变性实现的类型。它们在日常 Rust 代码中相当常见,尤其是当你翻阅某些库的实现细节时。
引用计数:Rc
Rc(reference-counted pointer)是一个引用计数指针。它包裹一个值,并记录当前存在多少引用;当最后一个引用被丢弃时,值才被释放。被Rc包裹的值本身是不可变的:你只能获得对它的共享引用。真正的可变性发生在引用计数上:
use std::rc::Rc; let a: Rc<String> = Rc::new("My string".to_string()); // 目前只有一份对字符串数据的引用。 assert_eq!(Rc::strong_count(&a), 1); // 调用 `clone` 时,字符串数据并没有被复制! // 相反,`Rc` 的引用计数被递增。 let b = Rc::clone(&a); assert_eq!(Rc::strong_count(&a), 2); assert_eq!(Rc::strong_count(&b), 2); // ^ `a` 和 `b` 指向同一份字符串数据, // 并且共享同一个引用计数器。Rc在内部使用UnsafeCell,让共享引用得以安全地递增、递减引用计数——这正是内部可变性的典型应用。需要留意的是,Rc是单线程使用的引用计数指针,并不跨线程共享;在多线程场景下需要换成Arc(原子引用计数),这一点在后续线程相关课程中会进一步展开。
RefCell:运行时借用检查
RefCell是 Rust 中最常见的内部可变性示例之一:即使你只持有对RefCell本身的不可变引用,也可以修改其中包裹的值。实现机制是运行期借用检查(runtime borrow checking):
RefCell在运行时跟踪当前对内部值的引用数量与类型。如果在已经存在不可变借用的情况下再尝试可变借用,程序会直接 panic,从而保证 Rust 的借用规则始终被强制执行——只是把检查从编译期推迟到了运行期:
use std::cell::RefCell; let x = RefCell::new(42); let y = x.borrow(); // 不可变借用 let z = x.borrow_mut(); // Panics! 已存在活跃的不可变借用。这段代码编译是可以通过的,但运行到borrow_mut()时会触发 panic,因为运行时发现存在活跃的不可变借用。这正是"编译期检查 vs 运行期检查"两种策略的取舍:RefCell用运行期代价换取更灵活的使用方式。
动手实践:用Rc<RefCell<T>>实现DropTracker
课程为这一讲配备了配套练习 exercises/07_threads/06_interior_mutability/src/lib.rs,要求使用Rc和RefCell实现DropTracker<T>——一个包装T类型值的结构体,每次被包装的值被丢弃(drop)时,递增一个共享的usize计数器。练习骨架如下:
use std::cell::RefCell; use std::rc::Rc; pub struct DropTracker<T> { value: T, counter: todo!(), } impl<T> DropTracker<T> { pub fn new(value: T, counter: todo!()) -> Self { Self { value, counter } } } impl<T> Drop for DropTracker<T> { fn drop(&mut self) { todo!() } }结合本讲知识,counter字段的类型应为Rc<RefCell<usize>>:Rc让多个DropTracker实例共享同一份计数器数据(对应"共享引用"),RefCell则允许我们在只有共享引用时也能修改计数器(对应"内部可变性")。drop实现中通过*counter.borrow_mut() += 1完成递增。
配套测试(位于同一文件的tests模块内)恰好验证了这一设计:
#[test] fn it_works() { let counter = Rc::new(RefCell::new(0)); let _ = DropTracker::new((), Rc::clone(&counter)); assert_eq!(*counter.borrow(), 1); } #[test] fn multiple() { let counter = Rc::new(RefCell::new(0)); { let a = DropTracker::new(5, Rc::clone(&counter)); let b = DropTracker::new(6, Rc::clone(&counter)); } assert_eq!(*counter.borrow(), 2); }可以看到:it_works中DropTracker::new接收Rc::clone(&counter)(只复制指针、递增计数,不复制数据),当临时值被丢弃时计数器变为 1;multiple中两个DropTracker在作用域结束后被依次丢弃,共享计数器变为 2。这正是"多个共享引用 + 单一可变状态"的经典组合。
该练习的Cargo.toml(exercises/07_threads/06_interior_mutability/Cargo.toml)声明了独立的interior_mutability包;在仓库根目录运行cargo test -p interior_mutability即可执行上述测试。练习同样以todo!()占位,完成实现后测试将从 panic 转为通过。
回到通道:内部可变性在客户端-服务器架构中的位置
理解了内部可变性之后,我们就能完整解释开篇的问题:std::sync::mpsc::Sender的send(&self)之所以能通过共享引用推进消息入队,正是因为其内部实现借助了UnsafeCell系的同步原语(channel 的底层缓冲与状态被封装在可安全共享的结构中),从而在不违背借用规则的前提下支持多线程并发写入。
这一点在课程代码中有着清晰的呼应。在 exercises/07_threads/05_channels/src/lib.rs 中,launch()创建通道后把Receiver移动进服务器线程,向调用方返回Sender<Command>;Sender可以被任意数量的客户端线程克隆共享。而后续 exercises/07_threads/08_client/src/lib.rs 中可以看到完整形态:服务器线程在loop里receiver.recv()等待命令,独占地拥有TicketStore(let mut store = TicketStore::new()),客户端则通过持有共享的Sender克隆来并发提交Insert/Get命令并通过响应通道取回结果。这套架构之所以成立,正是建立在"共享引用 + 内部可变性"这一基础之上——&self的send让并发客户端可以共享地、安全地向同一通道写入。
延伸:内部可变性的家族与选型
理解了UnsafeCell→Rc/RefCell这条主线后,你会更容易理解标准库中其他"共享引用可变"的类型族:
- 单线程组合:
Rc(共享所有权)+RefCell(运行期借用检查),如本讲练习所示;Cell提供对Copy类型的简单内部可变性。 - 多线程组合:
Arc(原子引用计数)+Mutex/RwLock(互斥锁与读写锁,把借用检查转化为加锁/阻塞),以及std::sync::atomic中的原子类型;这些会在后续线程课程(07_threads 的 locks、rw_lock、sync 等章节)中逐一登场。 - 惰性初始化:
OnceLock、LazyLock等类型同样依赖内部可变性实现"首次写入、此后只读"的语义。
选型要点很朴素:能用编译期借用检查解决的,就不要引入运行期检查或锁;RefCell把借用冲突推迟到运行期(以 panic 为代价),Mutex则把冲突转化为线程阻塞——二者都是"安全但有代价"的权衡。
小结
本讲澄清了 Rust 中最重要的心智模型修正之一:
&T应称为共享引用而非"不可变引用",它不保证数据不可变;- 内部可变性 = 通过共享引用修改数据的能力,其底层基石是
UnsafeCell; Rc通过UnsafeCell在共享引用下维护引用计数,RefCell用运行期借用检查换取灵活的可变性;- 本课程不使用
unsafe,但理解这些底层机制有助于读懂你所依赖的每一个标准库类型。
掌握了内部可变性,你就能解释Sender::send(&self)的"魔法",也能在需要"多份共享引用 + 一处可变状态"时自信地组合Rc与RefCell(单线程)或Arc与Mutex(多线程)。
- 示例工程
- 教程
【免费下载链接】100-exercises-to-learn-rust
A self-paced course to learn Rust, one exercise at a time.
相关推荐
comprehensive-rust 深入解析:Rust 内部可变性(Interior Mutability)——用 Cell 与 RefCell 在共享引用背后安全修改数据
comprehensive rust 深入解析:Rust 内部可变性(Interior Mutability)——用 Cell 与 RefCell 在共享引用背
文档教程Rust 智能指针与内部可变性实战:从 C/C++ 内存管理到 `Box<T>`、`Rc<T>`、`Cell<T>`/`RefCell<T>`
Rust 智能指针与内部可变性实战:从 C/C++ 内存管理到 Box<T 、 Rc<T 、 Cell<T / RefCell<T 本文导读: 本文以 Rust
文档教程Pop GTK+主题的国际化与本地化:多语言支持实现指南
Pop GTK+主题的国际化与本地化:多语言支持实现指南 Pop GTK+主题作为System76开发的Linux桌面环境主题,以其现代化设计和流畅体验受到众多
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考