你在 Rust 项目里迟早会遇到这样一个时刻:一个值明明已经创建好了,却找不到一个既自然又能通过编译的地方安放它。
链表节点想保存下一个节点,编译器说递归类型大小不确定;两块业务都要长期持有同一份配置,所有权又不能凭空复制;你给缓存加上共享所有权,到了线程里却收到 Rc<T> cannot be sent between threads safely;好不容易换成 RefCell<T>,程序编译通过,运行时又在 borrow_mut() 处直接 panic。
这些挫折看起来各不相干,其实都在问同一件事:这份数据由谁拥有,谁可以访问,谁可以修改,规则是在编译期还是运行时检查,最后又由谁收尾?
智能指针就是对这些问题的一组不同回答。它不是一瓶“往类型外面套一下就会变安全”的万能胶。Box<T>、Rc<T>、RefCell<T>、Arc<T> 和 Mutex<T> 解决的是不同矛盾,也各自带来分配、计数、运行时检查、原子操作或阻塞等成本。把它们学成一串类型名很容易,真正有用的是看到数据关系时,能判断应该支付哪一种成本。
这一章我们会故意先撞上编译错误和运行时 panic,再顺着错误把设计拆开。编译器在这里像一个严格但负责的搭档:它不替你决定数据结构,却会不断追问“你承诺的所有权规则真的成立吗”。
先别急着把“智能指针”等同于“堆指针”。普通引用 &T 当然也是指针,它保存一个指向数据的地址,但它只借用数据,不负责决定数据何时销毁。智能指针通常是一个拥有状态的结构体:除了指向某个值,它还可能保存引用计数、借用状态或同步状态,并在访问与销毁时执行额外行为。
Rust 里最能解释“智能”二字的是两个 trait:Deref 和 Drop。
Deref 决定“拿它当引用用”时,应该走到哪里。Drop 决定“它的生命周期结束”时,需要怎样收尾。前者让包装类型用起来像指针,后者让资源管理跟随作用域自动发生。引用计数、借用检查和加锁等能力则由具体类型额外维护。

Deref 让包装类型参与解引用先写一个极简包装器。它离真正的智能指针还很远,但足够把解引用的过程照亮:
use std::ops::Deref;
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(value: T) -> Self {
Self(value)
}
}
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
fn main() {
let answer = MyBox::new(42);
assert_eq!(*answer, 42);
}表达式 *answer 在概念上会变成 *(answer.deref())。deref() 返回的是引用,不是把内部值搬出来。这个区别很关键:如果解引用就取得内部值的所有权,那么读取一个智能指针可能会意外移动数据,许多用法都无法成立。
Deref 还会参与解引用强制转换。假设函数只接受 &str:
fn greet(name: &str) -> String {
format!("你好,{name}")
}
fn main() {
let name = Box::new(String::from("小林"));
assert_eq!(greet(&name), "你好,小林");
}这里传入的是 &Box<String>,函数需要的是 &str。编译器沿着 Box<String> -> String -> str 的解引用关系找到目标,因此你不用手写 greet(&(*name)[..])。这种便利不是随便发生的隐式转换,而是由目标类型和 Deref::Target 约束出来的。
也要留意边界。Deref 适合让一个类型表现得像它包装的目标,不适合单纯为了少写几个方法调用就滥用。自动解引用会让 API 很顺手,也可能隐藏真正发生的访问路径。设计自己的类型时,如果“这个类型就是某个目标的指针式包装”并不成立,提供普通方法通常更清楚。
如果包装类型还希望在独占借用下允许修改,可以实现 DerefMut。不过 DerefMut 不是凭空制造可变性的工具:调用者首先得拿到包装器的 &mut Self,实现才能返回目标的 &mut Target。Box<T> 在所有者可变且不存在冲突借用时可以这样做;Rc<T> 不能通过普通解引用提供 &mut T,因为它天生允许多个所有者。
这一点也能帮你分清后面几个类型。RefCell<T> 的 borrow_mut() 返回守卫后,守卫实现 DerefMut,所以用起来像 &mut T;MutexGuard<T> 也是同样的手感。但真正保证独占的机制不同:一个查看运行时借用状态,一个等待互斥锁。DerefMut 只是最终访问数据的统一接口,不负责建立那些安全前提。
方法调用也会自动尝试解引用。例如 Box<String> 可以直接调用 String::len(),Rc<Vec<T>> 可以直接调用 Vec::is_empty()。方便归方便,调试时仍要知道方法到底属于哪一层。Rc::clone(&value) 操作引用计数,value.clone() 可能因为自动解引用和方法候选而让读者多想一步;涉及资源语义时,写清类型名往往更好。
Drop 把清理动作绑到作用域资源不只有堆内存。文件句柄、网络连接、临时目录、锁的守卫,都需要在某个时刻释放。要求调用者在每条返回路径上手动清理,很快就会变成事故现场。Drop 让类型把收尾规则写在自己身上:
struct Session {
name: String,
}
impl Drop for Session {
fn drop(&mut self) {
println!("关闭会话:{}", self.name);
}
}
fn main() {
let _a = Session { name: String::from(
处理中
关闭会话:B
继续处理
关闭会话:A_b 先离开内层作用域,所以先清理;_a 到 main 结束时清理。你不能直接调用 _a.drop() 提前析构,因为那会让编译器在作用域结束时再次调用析构逻辑。需要提前释放时,使用 std::mem::drop(_a),把值移动进标准库的 drop 函数,让它在那次调用中结束生命周期。
这也解释了锁为什么可以自动释放。Mutex::lock() 返回一个守卫,守卫离开作用域时执行 Drop,锁随之解开。哪怕函数中途用 ? 返回,已经创建的局部值仍会按规则销毁。智能指针的可靠感,很大一部分来自这种“资源与值同生共死”的安排。
多数类型不需要亲自实现 Drop。结构体销毁时,它的字段本来就会继续按规则销毁:字段里的 String 释放字符缓冲区,Box 释放堆分配,Vec 逐个销毁元素。只有类型直接管理一种标准字段无法自动表达的资源,或收尾时必须执行额外协议,才需要自定义析构逻辑。
实现 Drop 后,类型不能再实现 Copy。原因不难理解:Copy 允许值被悄无声息地按位复制,如果每个副本离开作用域都执行同一份资源清理,清理次数就失去可靠含义。需要复制资源管理器时,通常应显式实现 Clone,并清楚定义这是深复制资源,还是像引用计数句柄那样共享资源。
析构代码也应尽量短小可靠。drop 没有返回错误的通道,而且可能在另一次 panic 的栈展开中执行。把可能失败的网络提交、复杂业务持久化全押在析构上,会让错误很难处理。常见做法是提供显式 close() 或 finish() 返回 Result,Drop 只承担不能遗漏的最低限度清理。这样正常路径能处理失败,提前返回时仍有兜底。
Deref 管的是访问体验,Drop 管的是生命周期结束时的动作。实现了其中一个 trait 的类型不一定就是智能指针,智能指针也可能维护更多状态。把它们理解成智能指针常用的两块基础设施,比死背定义更准确。
Box<T>:一个所有者,一份堆上数据你可能最先在这段代码里认识 Box<T>:
fn main() {
let score = Box::new(95);
println!("{}", *score);
}95 被放进堆上的一块分配中,栈上的 score 保存管理这块分配所需的指针。Box<T> 拥有内部的 T;score 离开作用域时,先销毁 T,再释放对应的堆分配。这里没有垃圾回收器定期扫描,也不用你手动 free。
但如果讲到这里就给出“数据大就用 Box”的口诀,后面很容易选错。
堆分配有成本:需要向分配器申请空间,多一次间接访问,也可能让数据在内存中更分散。栈空间确实有限,超大的局部数组也确实可能造成栈溢出,但“看起来很大的结构体”不是使用 Box 的充分理由。
先问几个更具体的问题:
String 和 Vec<T> 本身就是小型管理结构,字符与元素通常已经在堆上。再写一层 Box<Vec<T>> 不会神奇地让那批元素“更在堆上”,只会增加一层分配和间接访问。当然,某些 API 需要 Box、某些枚举需要缩小自身尺寸、某些值需要稳定地址时,这层包装仍可能有明确价值。关键是说明那个价值,而不是拿“大对象”三个字代替分析。
Box 不等于复制内部对象另一个常见说法是“Box 移动很快,因为只复制一个指针”。这句话容易让人误会成移动之后两个变量都还能用。实际发生的是所有权转移:管理堆分配的那份 Box 状态移动到了新变量,旧变量失效;堆上的 T 没有因此克隆,也不会出现两个所有者。
fn main() {
let first = Box::new(String::from("一份很长的报告"));
let address_before = (&*first) as *const String;
let second = first;
let address_after = (&*second) as *const String;
assert_eq!(address_before, address_after);
移动前后,内部 String 对象仍在同一处分配中。你只是把“谁负责这块分配”的凭证交给了 second。如果此后访问 first,会得到 borrow of moved value。编译器不是在为难你,它是在阻止两个 Box 都以为自己应该释放同一块内存。
Box<T> 自己实现 Clone 的前提是 T: Clone。调用 boxed.clone() 时会克隆内部值,创建另一份独立的 Box 分配;这与普通的所有权移动是两回事。
移动一个 Box<T> 句柄时,当前堆分配中的 T 地址通常保持不变。这对需要减少大值搬动、把节点地址交给短期内部操作的代码很有用。但普通 Box<T> 并没有承诺内部值此后绝对不会被移动。只要你仍拥有它,就可以把 T 从 Box 中移出,或者替换内部值;旧地址上的那个值自然不再存在。
如果某种类型的安全性依赖“初始化后地址再也不变”,单靠 Box 不够,需要研究固定语义,例如 Pin<Box<T>>,并同时满足被固定类型的约束。这个话题比堆分配更严格:堆回答“现在存在哪里”,固定回答“之后是否允许搬走”。把两件事混在一起,写自引用结构时尤其危险。
还有一个常见误会是“Box 一定只有一个机器字”。对于普通 Box<T>,句柄通常类似一个指针;但 Box<str>、Box<[T]> 和 Box<dyn Trait> 还需要携带长度或动态分派元数据,属于宽指针。课程里把它想成“尺寸已知的间接层”已经足够,工程上却不要据此硬算所有 Box 的布局。需要精确布局时,应使用标准库提供的尺寸信息和目标平台实际结果。
考虑一个看起来很自然的链表定义:
enum List {
Node(i32, List),
End,
}
fn main() {}编译器会报告类似下面的错误:
error[E0072]: recursive type `List` has infinite size问题不是 Rust 不允许递归,而是它必须在编译期知道每个值占多少字节。为了算一个 List::Node 的大小,需要知道其中 List 的大小;计算下一层又需要下一层,永远没有结束点。
Box<List> 的大小是已知的。它把“下一个节点的完整数据”替换为“指向下一个节点的固定尺寸管理器”:
#[derive(Debug, PartialEq)]
enum List {
Node(i32, Box<List>),
End,
}
fn main() {
let list = List::Node(
1,
Box::new(List::Node(2, Box
现在枚举的尺寸可以确定:一个判别信息、一个 i32,再加一个固定尺寸的 Box。递归深度转移到了运行时的堆分配链上。

这并不表示所有树和链表都应该逐节点 Box。逐节点分配的局部性可能很差,实际业务里使用 Vec 保存节点、用索引表达关系,常常更紧凑。Box 解决“尺寸递归”,不自动解决“数据布局最优”。
下面的实验可以切换递归层级,观察没有间接层和加入 Box 后,编译器计算类型尺寸的思路怎样变化。先拖动参数看结构,再回头对照刚才的链表定义,会更容易理解“尺寸已知”到底指什么。
trait object 的具体实现类型到运行时才确定,因此 dyn Trait 是动态尺寸类型,不能直接作为普通局部值使用。Box<dyn Trait> 给它一个尺寸已知的外壳,同时保留动态分派:
trait Job {
fn run(&self) -> String;
}
struct Backup;
struct Cleanup;
impl Job for Backup {
fn run(&self) -> String {
String::from("备份完成")
}
}
每个 Box<dyn Job> 都能以统一尺寸放进 Vec,内部可以是不同具体类型。这里选择 Box,是因为集合需要拥有这些任务。如果函数只临时查看已有对象,&dyn Job 可能更合适;如果多个地方要共同拥有,再考虑 Rc<dyn Job> 或 Arc<dyn Job>。指针类型表达的所有权关系,比 dyn Trait 本身更值得先确定。
动态分派的代价也要如实看待。调用 job.run() 时,程序根据 trait object 保存的元数据找到具体实现,编译器通常无法像静态泛型那样直接为每种类型内联。很多业务任务里这点成本微不足道,换来的异构集合和更稳定的接口边界更有价值;在极热循环里则应该测量。Box<dyn Trait> 不是“高级版泛型”,它是在运行时多态和编译期单态化之间做了不同选择。
Rc<T>:找不到唯一主人时共享所有权假设界面上的目录树和搜索索引都要长期持有同一份只读元数据。把元数据交给目录树,搜索索引就拿不到;克隆整份数据又浪费空间。这个场景真正需要的不是“借一下”,因为两个使用者都可能比创建它们的函数活得更久;需要的是多个所有者。
Rc<T> 用引用计数表达单线程中的共享所有权:
use std::rc::Rc;
fn main() {
let metadata = Rc::new(String::from("Rust 智能指针课程"));
assert_eq!(Rc::strong_count(&metadata), 1);
let sidebar = Rc::clone(&metadata);
let
Rc::clone(&metadata) 只创建一个新的强引用并增加计数,不会克隆内部 String。写成 metadata.clone() 结果相同,但显式写 Rc::clone 更能提醒读者:这次 clone 的对象是 Rc 句柄,不是底层数据。
强引用计数大于零时,内部值保持存活;最后一个强引用销毁时,内部值被销毁。引用计数管理的是“还有几个所有者”,不等于记录“还有几个普通借用”。从 Rc<T> 得到临时的 &T 不会增加强计数,借用仍由正常的生命周期规则约束。

拿到多个 Rc<T> 后,你通常只能读取内部值。下面的尝试不会通过编译:
use std::rc::Rc;
fn main() {
let names = Rc::new(vec![String::from("小林")]);
let _other_owner = Rc::clone(&names);
names.push(String::from("小周"));
}错误会指向不能通过 Rc<Vec<String>> 取得可变访问。原因很直接:如果任意一个所有者都能随手拿到 &mut T,其他所有者手里的读取引用可能立刻失效。“大家都拥有”解决了生命周期问题,却让独占修改更难,而不是更容易。
如果当前确实只有一个强引用,Rc::get_mut 可以返回 &mut T;一旦存在其他 Rc 或相关 Weak 指针,它就可能返回 None。Rc::make_mut 则使用写时复制:需要修改且存在其他强所有者时,先克隆内部值,让当前 Rc 指向自己的副本。它适合“平时共享,偶尔分叉修改”的数据,不适合所有所有者都要看到同一份实时变更的场景。
use std::rc::Rc;
fn main() {
let mut original = Rc::new(vec![1, 2]);
let snapshot = Rc::clone(&original);
Rc::make_mut(&mut original).push(3);
如果所有者之间必须观察同一份变化,单线程里常见的组合是 Rc<RefCell<T>>。不过在把两个类型套到一起之前,先看清 RefCell 把哪条检查从编译期挪到了运行时。
Rc 能自动回收,很容易让人把它和垃圾回收器混为一谈。两者最大的差别在于,引用计数只看局部计数是否归零,不会遍历对象图寻找“虽然互相指着、但外界已经够不到”的孤岛。它的释放时机通常很确定,最后一个强引用销毁时就开始销毁内部值;代价则是每次克隆和销毁句柄都要更新计数,而且处理不了强引用环。
计数本身也不是业务状态。Rc::strong_count 很适合教学、诊断和某些严格受控的优化,却不该用来判断“当前恰好有两个用户,所以执行某业务动作”。计数会随着临时 clone、函数传参和实现调整变化。只要一个辅助函数多保留了片刻 Rc,这种业务判断就会悄悄改变。
如果你想在唯一所有时取回内部值,可以考虑 Rc::try_unwrap。它在强引用只有当前这一份时返回内部 T,否则把原来的 Rc 还给你。这个 API 把“我希望唯一”变成可处理的结果,而不是偷偷复制数据:
use std::rc::Rc;
fn main() {
let value = Rc::new(String::from("独占结果"));
let inner = Rc::try_unwrap(value).expect("没有其他强所有者");
assert_eq!(inner, "独占结果");
let shared = Rc
这种“共享一段时间,最后尝试收回所有权”的过程有时很实用,但 API 必须准备好失败。不能因为当前测试中计数恰好为一,就把唯一性当成永远成立的架构保证。
Rc<T> 的计数不是原子更新,因此它不实现跨线程所需的 Send 和 Sync。这不是“恰好少实现了一个 trait”,而是安全边界:两个线程同时修改同一份非原子计数,可能破坏计数本身。单线程共享用 Rc,跨线程共享再考虑 Arc。
Weak<T>:能找到对象,但不负责养活对象引用计数有一个很隐蔽的陷阱:每个局部变量都离开了作用域,内存却仍然没有释放。
想象两个节点互相用 Rc 指向对方。外部变量销毁后,A 仍被 B 的强引用拥有,B 也仍被 A 的强引用拥有。双方都在等对方先消失,强计数永远到不了零。Rust 允许内存泄漏,所以这不会触发未定义行为,也不一定有编译错误;程序只是失去回收那批内存的机会。
要处理这个问题,需要区分两种关系:
Rc::downgrade 创建 Weak<T>。弱引用有自己的计数,但不会阻止内部值销毁。使用前需要调用 upgrade(),得到 Option<Rc<T>>:目标还活着就是 Some,已经销毁就是 None。这个 Option 不是多余的麻烦,而是弱关系的真实语义。

树是最典型的例子。父节点拥有子节点,所以父到子使用强引用;子节点只是希望必要时找到父节点,不应该反过来养活父节点,所以子到父使用弱引用:
use std::cell::RefCell;
use std::rc::{Rc, Weak};
#[derive(Debug)]
struct Node {
name: String,
parent: RefCell<Weak<Node>>,
children: RefCell<Vec<Rc<
内层作用域里,root 变量是一份强引用,临时升级得到的 parent 是另一份,所以断言时强计数为 2。作用域结束后,两份强引用都销毁,root 的内部值可以正常释放。leaf.parent 仍保存弱关系,但升级结果变成 None。
这里还组合了 RefCell,因为建立父子关系时需要在已有 Rc<Node> 的情况下修改 parent 和 children。组合类型可以从里向外读:Weak<Node> 是不拥有父节点的关系,RefCell 允许运行时受检的修改,外层 Rc<Node> 让节点被单线程内多个结构共享。
如果你还分不清强计数、弱计数和真正释放数据的时机,可以在下面的实验里逐步创建、克隆和销毁句柄。重点观察最后一个强引用离开后,upgrade() 的结果如何变化。
Weak 当成永不失效的引用缓存、观察者列表和图结构也常用 Weak。例如缓存可以保存弱引用:外界仍使用对象时,缓存能升级并复用;没有强所有者时,对象自动释放,缓存下次发现 None 再重新创建。
代价是每次升级都必须处理目标已不存在的分支。不要先 upgrade().unwrap(),再假装目标不可能消失,除非你的数据结构确实有更强的不变量保证它存活。Weak 的意义正是允许目标先走一步。
同样,不是看到双向关系就机械地规定“反向一定用 Weak”。你要先判断哪条边表示所有权。一个任务管理器可能拥有任务,而任务不拥有管理器;一个会话也可能由多个组件共同拥有。方向来自生命周期,不来自图画上箭头朝左还是朝右。
最后一个强引用销毁时,内部 T 会被销毁,之后所有弱引用升级都得到 None。但只要还有 Weak 句柄,保存计数等管理信息的那部分分配可能仍需保留,因为这些弱句柄还要能够安全地执行升级失败和自身销毁。最后一个弱句柄也离开后,剩余管理分配才能回收。
这不会让已经析构的 T 被访问到。强引用负责内部值的生存期,弱引用只让控制信息继续存在。理解这两阶段,有助于解释为什么 Weak 不算所有者,却仍然有创建、克隆和销毁成本。
观察者列表如果长期保存大量已经失效的 Weak,也可能积累“空壳条目”。目标数据不会因此保活,但列表自身会越来越长,每次通知都要尝试升级一批 None。实际实现通常会在遍历时或定期清理失效项。Weak 解决所有权环,不会顺手替你维护集合卫生。
Cell<T>:用整值读写绕开引用冲突有时你拿到的是 &self,却需要更新一个计数器、状态位或最近访问时间。直接修改普通字段会失败:
struct Meter {
hits: u32,
}
impl Meter {
fn record(&self) {
self.hits += 1;
}
}
fn main() {}编译器会告诉你不能给 self.hits 赋值,因为 self 是不可变引用。最直接的修复通常是把方法改成 &mut self。只有当外部语义确实应该保持共享引用,而某个内部字段需要独立变化时,才轮到内部可变性。
Cell<T> 的做法很干脆:它不把内部 &T 或 &mut T 交给你,而是以整个值为单位进行读写。因此不会出现某人握着内部引用,另一边又替换同一值的情况。
use std::cell::Cell;
struct Meter {
hits: Cell<u32>,
}
impl Meter {
fn record(&self) {
self.hits.set(self.hits.get() + 1);
}
get() 需要 T: Copy,因为它返回内部值的副本。不过,把 Cell<T> 说成“只能存 Copy 类型”是不准确的。set() 可以放入新的任意 T 并丢弃旧值;replace() 可以换入新值并返回旧值;into_inner() 在消费整个 Cell 后取出内部值。某些方法有各自的 trait 约束,Cell<T> 本身并不要求 T 一律实现 Copy。
use std::cell::Cell;
fn main() {
let title = Cell::new(String::from("草稿"));
let old = title.replace(String::from("定稿"));
assert_eq!(old, "草稿");
assert_eq!(title.
Cell 很适合小型、整体替换的状态。如果你需要借用内部复杂结构、原地修改 Vec 中某个元素,强行用“取出—修改—放回”会很别扭,这时 RefCell<T> 更贴近需求。
还要区分“逻辑上不变”和“比特位完全不变”。缓存一次计算结果、累加调用次数、记录是否已经访问,可能不改变对象对外表达的业务身份,所以 &self 加内部可变字段很自然。反过来,一个银行账户的余额变化显然是核心业务变化,仅仅为了让调用处少写 mut 就藏进 Cell,会让 API 对修改行为撒谎。
好的内部可变性通常被窄方法包住。调用者看到 record_hit() 或 invalidate_cache(),不用直接接触 Cell。如果把 pub Cell<T> 暴露出去,外部任何地方都能替换状态,类型很难继续维护更高层不变量。
内部可变性并没有取消借用规则。它只是利用类型内部受控的实现,给外部提供一种安全 API。Cell 通过不暴露内部引用来避免冲突;RefCell 则选择保留引用能力,在运行时记账。
RefCell<T>:编译通过不代表借用一定正确RefCell<T> 最容易制造一种危险错觉:“加上它,借用检查器就管不到我了。”更准确的说法是,借用规则没有消失,检查时机从编译期推迟到了运行时。
规则仍然是熟悉的两条:
borrow() 返回 Ref<T>,borrow_mut() 返回 RefMut<T>。这两个守卫存在多久,对应的运行时借用就存在多久。守卫离开作用域后通过 Drop 归还借用状态。

下面的代码能够通过编译,却会 panic:
use std::cell::RefCell;
fn main() {
let queue = RefCell::new(vec!["任务 A"]);
let reading = queue.borrow();
queue.borrow_mut().push("任务 B");
println!("{reading:?}");
}reading 仍在后面被使用,所以它持有的共享借用覆盖了 borrow_mut()。运行时检查发现你试图在共享借用期间创建独占借用,程序会报告类似 already borrowed: BorrowMutError 的 panic。
这正是 RefCell 的痛点:编译器无法替你证明所有运行路径都合法,测试没覆盖到的分支可能在生产环境第一次触发冲突。它换来了表达能力,也把一部分责任交还给程序设计和测试。
如果借用冲突属于可预期的业务情况,不要用 panic 当流程控制。try_borrow() 和 try_borrow_mut() 会返回 Result:
use std::cell::RefCell;
fn main() {
let state = RefCell::new(String::from("处理中"));
let reading = state.borrow();
let result = state.try_borrow_mut();
assert!(result.is_err());
这里显式 drop(reading),目的是尽早结束借用。更常见的写法是用一对花括号缩短守卫作用域。不要让 Ref 或 RefMut 穿过大段无关逻辑,更不要随手塞进长期存活的结构体;守卫活得越久,后续冲突越难看懂。
下面的实验把 borrow()、borrow_mut() 和守卫释放变成可操作的时间线。你可以故意制造冲突,再尝试通过缩短守卫作用域把状态恢复到可借用。
Rc<RefCell<T>> 把两种承诺叠在一起单线程界面模型、测试替身或某些图结构里,多个所有者确实需要修改同一份数据。Rc<RefCell<T>> 能表达这个关系:
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let messages = Rc::new(RefCell::new(Vec::new()));
let producer = Rc::clone(&messages);
let viewer =
外层 Rc 允许三个所有者共同让数据保持存活,内层 RefCell 允许它们在运行时申请独占修改。两层都不是装饰:去掉 Rc 就失去共享所有权,去掉 RefCell 就只能共享读取。
这个组合也更容易形成引用环。节点之间若都用 Rc<RefCell<Node>> 强引用对方,运行时借用完全合法,强计数却可能永不归零。借用规则防止悬垂引用和冲突访问,并不负责判断你的所有权图是否会泄漏。所有权反向边仍应按生命周期考虑 Weak。
RefCell如果把函数参数从 &T 改成 &mut T 就能自然解决问题,优先使用编译期借用。编译期错误虽然当下烦人,却比线上偶发 panic 便宜得多。
RefCell 更适合这样的条件:
try_ 方法妥善处理。如果你的真实目标是跨线程修改共享数据,RefCell 不是那把锁。它的借用计数不是线程安全的,编译器也会阻止你把这套组合当成线程同步机制。
假设某个组件只接收日志接口的共享引用,因为记录日志在调用者看来不应该要求独占整个记录器。测试时,我们又希望把收到的消息保存起来验证。这就是 RefCell 很自然的落点:变化属于记录器内部实现,接口仍然只需要 &self。
use std::cell::RefCell;
trait Logger {
fn log(&self, message: &str);
}
struct FakeLogger {
messages: RefCell<Vec<String>>,
}
impl Logger for FakeLogger {
fn log(
这个例子仍需要控制借用范围。如果测试代码先保存 let messages = logger.messages.borrow(),再调用会记录日志的 run(&logger),就会在运行时冲突。接口的不可变外观没有暂停借用规则,测试代码同样要尊重守卫生命周期。
对于复杂结构,还可以把守卫映射到某个字段,避免暴露整个内部值;但这不会缩短借用。映射后的守卫只允许访问更小的视图,底层 RefCell 在它销毁前仍处于被借用状态。阅读代码时应追踪守卫,而不只是追踪它最后指向哪个字段。
内部可变性不是“从不可变引用偷出一个可变引用”。Cell 和 RefCell 都在维护原来的独占访问不变量,只是维护方式不同。看到 RefCell panic 时,应该缩短守卫、重新安排访问阶段或改用 try_borrow_mut,而不是继续套更多 RefCell 掩盖冲突。
Arc<T>:原子计数只保护共享所有权把单线程代码改成多线程时,你可能会自然地把现成的 Rc 克隆一份塞进线程:
use std::rc::Rc;
use std::thread;
fn main() {
let config = Rc::new(String::from("生产配置"));
let worker_config = Rc::clone(&config);
thread::spawn(move || {
println!("{worker_config}");
})
.join()
.unwrap();
}编译器会指出 Rc<String> 不能在线程之间安全传递,核心错误是它没有实现 Send。Rc 更新计数时使用普通的非原子操作:一个线程加一、另一个线程减一,如果同时发生,计数可能被破坏。
Arc<T> 把引用计数更新换成原子操作,让多个线程可以安全地共同拥有同一份分配:

use std::sync::Arc;
use std::thread;
fn main() {
let config = Arc::new(String::from("生产配置"));
let mut handles = Vec::new();
for id in 0..3 {
let
这里的原子性只覆盖 Arc 自己的强弱计数,不会自动让内部 T 的所有操作变成线程安全。Arc<RefCell<T>> 不能因为外层换成 Arc 就跨线程共享;RefCell<T> 仍然不是 Sync。同理,把任何非线程安全对象塞进 Arc,都不会得到一层“安全镀膜”。
Arc<T> 是否实现 Send 和 Sync,取决于内部 T 是否满足相应边界。你可以把这理解成一条不肯替内部数据做假担保的规则:Arc 只承诺自己的计数没问题,不替 T 承诺并发访问没问题。
Arc既然 Arc 更强,为什么不把 Rc 全换掉?因为原子操作要协调多个执行者,对优化和硬件缓存都有额外约束。单线程根本不存在并发更新计数的问题,支付这份成本没有收益。
差距是否影响你的程序,要靠实际测量,不必为了几个计数操作过早焦虑。但类型选择本身仍应准确:明确只在一个线程使用就选 Rc;所有权确实要跨线程才选 Arc。这让类型同时记录设计意图。
Arc 也有强弱引用Arc::downgrade 会创建 std::sync::Weak<T>,用途与 Rc 版本相同:打破跨线程所有权环、建立不负责保活的注册关系。升级仍返回 Option<Arc<T>>,因为最后一个强引用可能已在另一个线程销毁。
并发环境会让“刚检查过还存在”更容易过时,所以应直接围绕一次 upgrade() 返回的 Arc 完成当前操作。只要这份升级后的强引用还在,本次使用期间目标就不会消失;下一次使用时重新升级,不依赖先前检查。
Arc<T> 的常态是共享读取,但这不表示内部值从创建那一刻起永久不可变。如果你持有可变的 Arc<T>,并且当前没有其他强弱句柄能观察同一内部值,Arc::get_mut 可以给出 &mut T。这在“先单线程构建,完成后再克隆给多个线程”的初始化阶段很好用。
Arc::make_mut 也提供写时复制:出现其他强所有者时,当前分支克隆内部值后再修改。它适合配置快照、不可变语法树等读多、分叉少的数据。如果需求是所有线程立刻观察同一变化,写时复制就答非所问,应该用锁或原子状态。
这说明 Arc<Mutex<T>> 并不是 Arc<T> 的固定搭档。数据创建后永不修改,单独的 Arc<T> 已经够了;每个线程修改自己的副本,在线程启动前 clone T 可能更简单;只有跨线程共同修改同一逻辑对象,才需要同步内部可变性。少一层锁,往往也少一类等待和中毒处理。
Mutex<T>:把修改权交给锁守卫Arc<T> 解决“多个线程都要拥有”,并不解决“多个线程都要修改”。共享计数器、任务队列或状态机需要在每次修改时取得独占访问权,Mutex<T> 就是最常见的选择。
先看标准组合:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0_u32));
let mut handles = Vec::new();
for _ in 0
类型从外到内读:Arc 让多个线程共同拥有,Mutex 协调独占访问,u32 是受保护的数据。lock() 成功后返回 MutexGuard。守卫通过 Deref 和 DerefMut 表现得像 &T 或 &mut T,所以可以写 *guard += 1;守卫通过 Drop 在离开作用域时自动解锁。
如果这里忘了 Arc,试图在循环中把同一个 Mutex 多次移动进线程,会先遇到 borrow of moved value。如果换成 Rc<Mutex<T>>,又会遇到 Rc 不实现 Send。这些错误逐层指出了缺失的承诺:Mutex 负责访问互斥,不负责产生多个所有者;Rc 能产生多个所有者,却不负责跨线程计数;Arc<Mutex<T>> 才同时回答两个问题。
下面的调度实验会把多个线程克隆 Arc、等待锁和释放守卫的过程排成时间线。你可以调整线程数量与临界区长度,直观看到共享所有权和互斥访问各自解决了哪一层问题。
锁守卫存活期间,其他需要这把锁的线程可能一直等待。下面这种写法虽然安全,吞吐却可能很差:
use std::sync::Mutex;
fn render(input: &str) -> String {
input.to_uppercase()
}
fn main() {
let queue = Mutex::new(vec![String::from("report")]);
let job
我们只在取任务时持锁,耗时渲染发生在守卫销毁之后。真实项目中的网络请求、磁盘操作、日志格式化和大段计算,都不该无意间被包在锁作用域里。
也不要依赖“非词法生命周期应该能猜到我不用守卫了”来表达关键的解锁时机。编译器常能提前结束普通借用,但带 Drop 的守卫何时销毁关系到程序行为。使用小作用域或显式 drop(guard),能让并发设计对读代码的人一眼可见。
如果线程持有 MutexGuard 时 panic,守卫在栈展开中仍会被销毁,锁本身通常能够释放。但临界区可能只完成了一半:余额扣了,流水还没记;列表长度更新了,索引尚未同步。标准库会把这种锁标记为中毒,之后的 lock() 返回 PoisonError。
很多示例直接写 lock().unwrap(),于是发现中毒后继续 panic。这对“不变量一旦可疑就立即停止”的程序是合理策略,却不是唯一选择。中毒是建议性的警报,错误里仍带着守卫。你可以检查、修复或覆盖数据,再决定是否继续:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let state = Arc::new(Mutex::new(vec![1, 2, 3]));
let worker_state = Arc::clone(&
这里能继续运行,不代表所有中毒都该忽略。恢复策略必须来自数据不变量:能否验证当前状态,能否从可靠来源重建,半完成操作是否可回滚。若答不上来,传播错误通常更诚实。
内存安全不等于不会卡死。线程 A 先拿锁一再等锁二,线程 B 先拿锁二再等锁一,双方就能永久等待。Rust 能保证守卫不会凭空绕过互斥,却不知道你的业务锁顺序。
常用防线很朴素:
try_lock(),明确处理 WouldBlock。把一把大锁拆成很多小锁可能提升并行度,也会增加锁顺序和跨锁不变量的复杂度。反过来,一把大锁更容易推理,却可能让无关操作互相阻塞。这里没有万能答案,只有数据一致性、吞吐和可维护性的取舍。
给结构体里每个字段各放一个 Mutex,看起来粒度很细,却可能破坏需要同时成立的关系。比如 available 和 reserved 两个数字必须相加等于总库存;若它们由两把锁分别保护,读取者可能在一次转移的中间看到旧的 available 和新的 reserved。每个整数都没有数据竞争,整体状态仍然不一致。
锁的边界应该围住需要原子观察和更新的不变量。若几个字段总要一起改,把它们放进同一个受锁保护的结构更清楚:
use std::sync::Mutex;
#[derive(Debug)]
struct Stock {
available: u32,
reserved: u32,
}
fn main() {
let stock = Mutex::new(Stock { available: 10, reserved: 0 });
{
这会让所有库存操作争用同一把锁,但它也让正确性边界很明确。如果后续测量证明这把锁真是瓶颈,再根据业务分片,例如按商品编号分成互不相干的库存单元。先建立能证明的正确版本,再用数据指导拆分,通常比一开始追求最细粒度更稳。
RwLock<T>:读者可以并行,写者仍需独占配置中心里,大量请求只读取同一份路由表,偶尔才由后台线程更新。若使用 Mutex,读与读之间也必须排队。RwLock<T> 把访问分成两类:多个读守卫可以同时存在,写守卫必须独占。

use std::sync::{Arc, RwLock};
use std::thread;
fn main() {
let config = Arc::new(RwLock::new(vec![String::from("v1")]));
let reader_config = Arc::clone(&
这个例子中的线程调度顺序并不确定,但结果是确定的:读线程要么在写入前取得读锁并读到已有的 v1,要么在写入后取得读锁,首个元素仍是 v1;最终主线程能看到两个版本标签。
read() 返回的读守卫可以解引用为共享引用,write() 返回的写守卫可以解引用为可变引用。它们同样依靠 Drop 解锁。写锁持有期间,新的读写访问都必须等待;存在读守卫时,写入也必须等待。
RwLock 一定更快读写锁要维护更复杂的状态,操作本身可能比互斥锁贵。临界区很短、竞争很低,或者写操作并不少时,Mutex 反而可能更快。读操作虽然数量多,但如果每次都只读一个整数,锁管理成本也可能盖过并行收益。
还有调度策略问题。等待中的写者是否阻止新读者进入,取决于底层实现,标准接口不保证固定优先级。持续涌入的读者可能让写者等待,偏向写者的实现又可能增加读延迟。选择 RwLock 后,仍要用目标平台和真实负载测量,而不是只看“九成请求都是读”就宣布优化完成。
重复获取读锁也可能产生意外等待。一个线程持有读锁,另一个写者开始等待;第一个线程还没释放原读锁又尝试再取读锁,底层策略可能为了照顾写者而阻塞新的读请求,于是形成僵局。最稳妥的习惯仍是让守卫短命,并避免在持锁期间再次获取同一把锁。
RwLock 也没有提供一个通用、无等待的“把读守卫升级成写守卫”操作。你通常要先释放读锁,再申请写锁。两次操作之间,其他线程可能已经修改数据,所以写入前必须重新检查条件:
use std::sync::RwLock;
fn main() {
let names = RwLock::new(vec![String::from("Rust")]);
let missing = {
let reader = names.read().unwrap();
!reader.iter
第二次检查不是啰嗦。在释放读锁到取得写锁的空档,另一个写者完全可能先插入 Cargo。锁能保证单次临界区互斥,不会自动把两个分开的临界区拼成一次原子事务。
RwLock 的中毒规则略有不同RwLock 通常只会在写守卫持有期间发生 panic 时中毒,因为写者可能把数据留在不一致状态。读守卫期间 panic 并没有修改受保护数据,所以通常不会让锁中毒。后续的 read() 和 write() 都返回 Result,恢复思路与 Mutex 相同:要么传播,要么基于明确不变量修复。
try_read() 与 try_write() 提供非阻塞尝试。它们适合允许降级、跳过或稍后重试的工作,不等于可以用忙循环持续抢锁。持续自旋会浪费 CPU,也可能加剧争用。
RwLock 优化的是“多个读者能够重叠”这一种等待。如果真正瓶颈是写锁持有太久、缓存行争用或锁内 I/O,把 Mutex 改名为 RwLock 不会治好问题。先测量等待发生在哪里,再决定是否值得支付更复杂的锁语义。
Send 与 Sync:编译器追问的两句话并发错误里经常出现 Send 和 Sync。它们没有一堆需要调用的方法,而是标记类型能否满足线程边界。先用两句直觉来记:
Send:这个值的所有权能不能安全地交给另一个线程?Sync:多个线程能不能安全地通过 &T 共享访问这个值?
一个类型 T 是 Sync,可以近似理解为 &T 是 Send。也就是说,把对 T 的共享引用交给别的线程不会造成不安全访问。
大多数只由线程安全成员组成的普通类型会自动获得这些标记,不需要手写实现。String、Vec<T> 在元素满足条件时通常可以移动到另一个线程;普通不可变数据也常能共享引用。
反例更能说明边界:
Rc<T> 既不是 Send 也不是 Sync,因为它的引用计数不是原子的。Cell<T> 和 RefCell<T> 不是 Sync,因为它们允许通过共享引用修改内部状态,而运行时状态维护不是线程同步。Arc<T> 不会无条件变成 Send + Sync;内部 T 也必须满足相应条件。Mutex<T> 能把对内部数据的共享访问变成一次一个线程取得守卫,但内部值至少得能够安全地在线程之间转移。下面的思路看似层层加固,其实仍过不了编译器:把 RefCell 放进 Arc,期待原子计数顺便保护运行时借用。问题是 Arc 只同步计数,两个线程仍可能同时操作同一个 RefCell 的借用状态。
需要跨线程修改时,应该选择专门进行同步的内部可变性,如 Mutex<T>、RwLock<T>,或者适用于简单数值状态的原子类型。类型组合应当让每一层各负其责:
Arc<Mutex<T>>
│ │ └─ 真正的数据
│ └─────── 独占修改与线程等待
└─────────── 跨线程共享所有权这也是编译错误很有价值的地方。看到 T cannot be sent between threads safely,不要只搜索“怎样让报错消失”。沿着完整类型往里看:是哪一层不实现 Send,它维护了什么非线程安全状态,你真正需要的是移动、共享读取,还是共享修改?答案往往会直接导向正确的替换。
Send 和 Sync 可以手动实现,但这种实现需要 unsafe,因为编译器必须相信你在类型内部维护了全部并发不变量。一个计数是否原子、别名引用是否可能同时修改、析构是否会访问线程局部状态,都可能让声明失真。
业务代码几乎总能通过组合标准库并发类型获得自动实现。若你正在实现底层同步原语,手动标记才可能合理,而且需要把不变量、内存顺序和析构路径一起审查。把 unsafe impl Send 当成消除 E0277 的快捷方式,相当于把编译器递来的检查清单直接撕掉。
你也可以在小型辅助函数中让编译器替你验证某个类型的边界:
use std::sync::{Arc, Mutex};
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
fn main() {
assert_send::<String>();
assert_sync::<String>();
assert_send::<Arc<Mutex
这种断言没有运行时行为,却能在重构成员类型后及时暴露边界变化。库作者若承诺某个公开类型可跨线程使用,也可以把类似检查放进测试。它比文档里只写一句“线程安全”更具体,因为最终由 trait 约束核对。
智能指针套娃很容易越写越厚:Arc<Mutex<Box<dyn Trait>>> 看起来防护拉满,实际每一层都在收费。Arc 有原子计数,Mutex 可能阻塞,Box 有独立堆分配,trait object 有动态分派。它们都可以合理,也都需要理由。
选择时先不要看类型名,先回答三个问题。
如果只有一个明确所有者,普通值通常已经够用。需要把值放到堆上以打断递归尺寸、拥有动态尺寸对象或满足特定地址需求,再选 Box<T>。
如果同一线程内确实有多个长期所有者,选 Rc<T>。如果多个所有者分布在线程间,选 Arc<T>。某条关系只需要找到对象,却不应该延长对象生命,就从 Rc 或 Arc 降级成对应的 Weak。
生命周期能用普通借用表达时,优先用 &T 或 &mut T。引用没有计数分配成本,也更直接地告诉读者“这里不拥有”。不要因为生命周期标注让人头疼,就立刻把每个引用改成 Arc。有时编译器的阻拦正是在提醒你:对象关系还没想清楚。
唯一所有者可直接通过 &mut T 修改,不需要内部可变性。
单线程共享引用下的小值整体替换,可以考虑 Cell<T>;需要借用内部值时,考虑 RefCell<T>,并接受冲突在运行时变成 Result 或 panic。
跨线程共享修改使用同步原语。所有访问都必须独占时用 Mutex<T>;大量读取确实需要重叠,且测量证明有收益时用 RwLock<T>;简单计数或标志也可能适合原子类型,而不必为一个整数加互斥锁。
Box 的最后一个所有者销毁时释放内部值和堆分配;Rc、Arc 的最后一个强所有者销毁时释放内部值;Weak 不参与内部值保活;Ref、RefMut、MutexGuard 和 RwLock 守卫销毁时归还借用或释放锁。
如果所有权图有强环,局部变量都销毁也不等于强计数归零。如果守卫活得太久,函数还在运行也不等于其他线程能取得锁。理解 Drop 发生在哪个值上、那个值实际活到哪里,往往比盯着 new() 更能发现问题。
下面这张表可以做初筛,但它不是替代思考的配方:
公开函数是否直接返回 Arc<Mutex<T>>,要看调用者是否真的应该知道并操作这套共享机制。若业务对象要求每次更新同时维护多个字段,最好提供 reserve()、cancel() 这类方法,把加锁和不变量检查收在类型内部。调用者如果到处写 lock().unwrap() 再随意改字段,锁虽然阻止了数据竞争,却拦不住业务状态被改坏。
隐藏实现不表示掩盖可能失败或等待的事实。一个方法内部会阻塞获取锁,文档和命名应该让调用者知道;可能因为中毒或业务校验失败,也要选择合适的返回类型。把所有错误都 unwrap() 在封装层里,外部 API 看起来干净,故障策略却被悄悄固定成 panic。
克隆行为也应该清楚。一个类型内部持有 Arc,派生或实现 Clone 后,clone 出来的对象很可能共享内部状态;另一个看起来相似的类型也许会深拷贝数据。两者都能叫 clone(),业务含义差得很远。可以通过类型命名、构造方法和必要说明,把“快照副本”与“同一对象的另一个句柄”区分开。
如果组合类型很长,类型别名能改善可读性:
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
type SharedRoutes = Arc<RwLock<HashMap<String, String>>>;
fn lookup(routes: &SharedRoutes, path: &str) -> Option<
不过类型别名只是起了短名字,没有创建新的抽象边界。调用者仍能取得写锁、清空整个表。若你需要限制行为,应定义新结构体并提供窄方法,而不是希望别名自动带来封装。
Box<Arc<T>> 往往是可疑信号:Arc<T> 自己已经通过间接分配管理 T,外层 Box 又增加一份分配,却没有改变共享语义。Arc<Mutex<Box<T>>> 中的 Box 也可能多余,除非你需要动态尺寸、固定相关约束、减小锁内值的尺寸,或必须适配现有接口。
反过来,也不能只凭“层数多”就删除。Arc<Mutex<Box<dyn Job + Send>>> 每层都可能有明确职责:Arc 让工作项被多个线程拥有,Mutex 允许安全修改任务状态,Box 容纳动态尺寸的具体任务,Send 约束任务可以跨线程。优化类型之前,逐层说出它建立的保证;说不出作用的那层,才值得重点怀疑。
同样,智能指针不应该替代领域边界。若所有模块都直接交换 Arc<Mutex<HashMap<...>>>,任何地方都能锁住全局表并任意修改。定义一个仓库或状态服务类型,把锁留在实现里,通常更容易测试,也更方便以后换成消息传递、分片锁或数据库。智能指针解决资源关系,不能替你完成模块设计。
一个好用的判断顺序是:先定所有权,再定可变性,最后才定线程边界。不要从“我想用智能指针”出发;从“谁拥有、谁修改、在哪个线程、失败怎么处理”出发,类型通常会自己浮出来。
读完概念后,最有用的练习不是再背一遍类型表,而是预测代码在哪一层失败。先在脑子里回答,再展开解析。
Box 后发生了什么fn main() {
let first = Box::new(vec![1, 2, 3]);
let second = first;
println!("{:?}", first);
println!("{:?}", second);
}Rc<RefCell<T>> 仍然进不了线程use std::cell::RefCell;
use std::rc::Rc;
use std::thread;
fn main() {
let value = Rc::new(RefCell::new(0));
let worker_value = Rc::clone(&value);
thread::spawn(move || {
*worker_value.borrow_mut() += 1;
});
}设想 A 的字段里保存 Rc<B>,B 的字段里又保存 Rc<A>。创建它们的函数返回后,没有任何外部变量持有 A 或 B。
RefCell 为什么有时要主动 drop 守卫use std::cell::RefCell;
fn main() {
let values = RefCell::new(vec![1, 2]);
{
let first = values.borrow();
assert_eq!(first[0], 1);
}
values.borrow_mut
RwLock 为什么不是读多场景的自动答案一个配置值每秒读取百万次、每分钟写一次,听起来很适合 RwLock。但每次读取只取一个布尔值,临界区极短,而且性能分析显示主要时间花在别处。
到这里,你不需要把每个类型都用在同一个项目里。更实际的目标是形成一种停顿:看到共享、可变、递归或跨线程时,先别急着套类型,花十秒把所有权图画清楚。Rust 的智能指针并没有替你消灭复杂性,它把复杂性变成了可以写进类型、可以被编译器追问、也可以由运行时明确报告的规则。
下一次遇到编译器说 cannot be sent between threads safely,或程序在 already borrowed 处 panic,不妨把它当成搭档递来的问题单:谁在保活这份数据?谁正在借用?这次修改需要独占到什么时候?计数和锁在哪个线程里变化?把这些问题答清楚,正确的类型通常就不再神秘了。