☰
Rust智能指针全解析:Box/Rc/Arc/RefCell选型与实战
2026/10/6 9:44:32 网站建设 项目流程

这几年面试 Rust 岗位时,我几乎每次都会问一个问题:“你觉得什么时候该用Rc<T>,什么时候该用Arc<T>?”能答好的人不多。很多人是从 C++ 转过来的,知道shared_ptr,以为换个名字就能直接套;也有人刚学完所有权,拿着Box到处套,结果链表照样写不出来。其实 Rust 的智能指针不是“堆上的另一个引用”那么简单,它是在所有权模型下表达复杂数据结构的核心工具。这篇文章我会把Box、Rc、Arc、RefCell、Cow、Pin逐个拆开讲,从内存原理讲到实战选型,再把循环引用和运行期借用冲突这些坑一个个填平。适合刚做完 Rust 入门、被 borrow checker 卡住的读者,也适合带着 C++ 经验想迁移思维的人。

1. 为什么非用智能指针不可:先搞懂所有权与借用

1.1 写树和链表时,借用检查器为什么总拦住你

如果你写过一遍“用 Rust 写二叉树”,大概率经历过这段挣扎:定义Node时,想写left: Option<&'a Node>,结果生命周期参数'a被引用到一个无法确定时长的对象上,编译器直接红波浪线。换成裸指针,又发现裸指针不能安全地自动释放。最后只能用Box。

这个过程的本质是 Rust 默认的“盒子语义”。普通变量的值是所有者,赋值、传参都是所有权转移,借用则必须遵循“要么多个不可变借用,要么一个可变借用”的规则。链表和树天然需要“多个节点相互指向”,数据本身是共享的。但借用规则不允许你在同一个数据结构里同时存在可变和不可变的交叉访问。这时候就需要一种东西:把值的所有权和访问方式“包装”起来,让共享成为可能。智能指针就是这种东西。

简单类比:普通变量就像你手里的一本书,别人想看只能借用,你规定了“同一时间只能一个改、多个读”。可当你要做一个图书馆时,就得换个管理方式了——书可以有多本副本,也可以在保证安全的前提下让管理员随时调整内容。所有权的核心没有变,变的是“谁在什么条件下拥有访问权”。

1.2 什么才算智能指针:Deref、Drop 与所有权三大特征

先给一个准确定义:智能指针是拥有所有权或管理所有权的数据类型,它实现了Deref和Drop。

  • Deref让它可以被当作普通引用使用。比如*boxed_value可以取到内部的值,方法调用时也会自动解引用。
  • Drop则定义了离开作用域时应该做什么。Box会释放堆内存,Rc会减少引用计数,计数归零时释放堆内存。
  • 所有权这点最容易忽略。普通引用&T只是借用,不拥有数据;智能指针一定拥有自己的数据(Rc是多个所有者共享拥有,Box是唯一所有)。

由此可以给它们分类:单所有权的Box;共享所有权的Rc/Arc;运行时可变性的Cell/RefCell;借出或拥有二选一的Cow;固定地址的Pin。后面各节会逐个讲。

这里也解释一个概念混淆:Rust 的智能指针不是垃圾回收器。Rc/Arc虽然做引用计数,但计数归零立刻释放,而不是像 GC 那样在某个时机统一回收。所以程序员依然要关心释放时机,这也是很多人容易踩坑的地方。

1.3 和 C++ 智能指针对比:相似的名字,不同的安全边界

带着 C++ 经验的人会天然把unique_ptr对应Box,shared_ptr对应Rc/Arc,weak_ptr对应Weak。这个对应关系大方向没错,但有三个必须注意的差异。

第一,C++ 的shared_ptr不是线程安全的,并发修改同一份shared_ptr需要额外加锁;Rust 中Rc直接不让跨线程,Arc则是用原子操作保证计数安全。第二,C++ 里shared_ptr可以从裸指针构造,于是非常容易出现两块计数不知道谁释放的问题;Rust 由所有权系统保证,不存在空悬和二次释放。第三,C++ 的weak_ptr需要lock()拿回shared_ptr,Rust 的Weak需要upgrade()返回Option<Arc<T>>,这个Option就是编译器逼你处理“对象可能已经没了”的情况。

所以你会发现,Rust 的智能指针更像“编译器内置的共享所有权框架”,而 C++ 是“标准库里提供的手动工具”。前者能静态挡住一大类错误,后者依赖人的纪律。这也是为什么 Rust 社区常说“一旦编译通过,内存错误基本与你无关”。

2. 五种常用智能指针逐个拆解:Box、Rc、Arc、RefCell、Cow

2.1Box<T>:把值放到堆上,给递归类型一个“句号”

Box的使用场景有三类:递归数据结构、大型栈对象、trait 对象。

递归数据结构是Box最常出现的地方。因为 Rust 需要在编译期确定每个类型的大小,而无限递归的 enum 无法确定大小。Box引入一个间接层:栈上放一个指针,指针指向堆上真正的值,指针大小是固定的。所以enum Expr { Add(Box<Expr>, Box<Expr>) }能编译过。这一点很像 C 语言里在结构体里放一个指向自己的指针来打破无限递归。

大型栈对象方面,默认线程栈通常只有几 MB,一个几十 MB 的结构体只要在函数里被创建,就会栈溢出。把它Box到堆上,栈上只剩一个指针,瞬间解决问题。

trait 对象Box<dyn Trait>也是一个高频用法。此时Box不只是分配内存,还负责把动态分发的 vtable 指针跟数据打包在一起,本质上是一个胖指针。常见的Logger: Box<dyn Log>、Handler: Box<dyn Fn()>都是这种用法。

使用Box时要注意:Box不解决共享问题,不要指望两个Box指向同一份数据。如果你确实需要“把值放堆上但又不确定要不要共享”,可以先Box后Rc。

2.2Rc<T>:单线程里让多个持有者共享同一份数据

Rc是 reference counting 的缩写。Rc的数据存储结构大约是这样:堆内存块里除了真正的T,还有两个计数器,一个 strong count 一个 weak count。每次Rc::clone(),strong count 加一;每次一个Rc被 drop,strong count 减一,减到零时,T才会被析构。

注意Rc::clone是“增加计数”,不是复制数据。构造一个图数据结构时,如果想让多个节点都引用同一个子对象,你会写:

use std::rc::Rc; let shared_data = Rc::new(vec![1, 2, 3, 4]); let a = Rc::clone(&shared_data); let b = Rc::clone(&shared_data); println!("count = {}", Rc::strong_count(&shared_data)); // 3

这里shared_data、a、b三个持有者共享同一块堆内存,写起来很像 C++ 的shared_ptr。

但Rc只能在单线程用。因为非原子的计数操作在多线程下会出数据竞争,编译器直接让Rc不满足Send/Sync,跨线程根本没得编。单线程共享状态、构建 DAG、做缓存、写 UI 内部状态时,Rc很顺手。

Rc也有关键隐患:循环引用会导致内存泄漏。如果 A 持有 B 的Rc,B 也持有 A 的Rc,两个计数都不会归零,数据永远释放不掉。这类问题的解法是Weak,我在第 5 部分会给出完整示例。

2.3Arc<T>:跨线程共享数据,先记住 Send 与 Sync

Arc就是 Atomic Rc,把计数操作换成了原子操作。多线程里每个线程要拿一份共享数据,典型写法:

use std::sync::Arc; use std::thread; let config = Arc::new("shared config".to_string()); let mut handles = vec![]; for _ in 0..4 { let config = Arc::clone(&config); handles.push(thread::spawn(move || { println!("read: {}", config); })); } for handle in handles { handle.join().unwrap(); }

四个线程同时只读config,不需要加锁。Arc内部计数是原子的,所以clone和drop有一点原子操作开销,但远小于大多数业务逻辑的开销。如果多个线程要修改同一份数据,就再组合Mutex:Arc<Mutex<T>>。

Arc<T>最常见的错误是只读场景也加锁,比如把Arc<Mutex<Config>>传给一堆线程,每个线程只读也重复lock。正确做法是“先想清楚可变性是否需要:只读用Arc<T>,要写才用Arc<Mutex<T>>或Arc<RwLock<T>>”。因为互斥锁会把并发读退化成串行,性能差距非常大。

在真实项目里,Arc几乎出现在所有全局状态、线程池任务共享、插件实例管理这类代码中。比如一个基于 Rust 的 AI Agent 框架,多个 worker 共享同一个模型句柄和会话状态,用Arc<AppState>是很自然的;Tauri + Rust 的桌面应用里,后端状态也经常用Arc<Mutex<T>>包一层,供普通线程和 UI 线程共享。

2.4RefCell<T>:把编译期检查挪到运行期的“内层可变”

RefCell是整个标准库里最容易被误解的类型。它解决的不是“共享所有权”,而是“共享可变性”。当你在编译期没法证明借用规则时,RefCell把检查推迟到运行时。

外部可变性是指普通变量可以直接修改它拥有的数据。内部可变性则指一个值在外部看是共享的,内部却可以修改自己的内容。RefCell内部保存一份运行时借用状态,borrow()时检查是否已有可变借用,borrow_mut()时检查是否已有任何借用。

use std::cell::RefCell; let value = RefCell::new(vec![1, 2, 3]); { let b1 = value.borrow(); // let mut b2 = value.borrow_mut(); // panic! already borrowed } let mut b2 = value.borrow_mut(); b2.push(4);

所以RefCell单独使用并没什么大用,它的价值在于和Rc/Arc组合。Rc让你有多个人共享引用,RefCell让共享引用可以安全地修改内容。最常见的写法是Rc<RefCell<T>>,或者连环结构Rc<RefCell<Vec<Node>>>。

对Copy类型,更轻量的选择是Cell<T>。Cell用get()/set()直接替换值,不涉及 borrow 检查和 panic,但只支持Copy。它适合计数器、标志位这类小数据。

RefCell的坑也很典型:运行时 panic 比编译期报错难定位得多,而且 panic 只是“already borrowed”这类很抽象的提示。解决方案不是绕过它,而是把借用范围缩到最小,避免在一次函数调用栈里同时持有多个借用。

2.5Cow<T>与Pin<Box<T>>:特殊场景的最后一公里

Cow全称是 clone-on-write,字面意思是“写时才复制”。Cow<'a, B>内部是一个枚举,要么持有借用&'a B,要么持有 ownedB。它的典型使用场景是:大部分情况不需要修改数据,只是借用;少数情况要改动,这时候才克隆一份。

用Cow写一个规范化字符串的函数:

use std::borrow::Cow; fn normalize(s: &str) -> Cow<'_, str> { if s.contains(" ") { Cow::Owned(s.replace(" ", " ").to_string()) } else { Cow::Borrowed(s) } }

调用方拿到Cow后,如果不修改,不产生任何堆分配;只有当有空格要替换时才分配新字符串。这在解析器、编译器的 AST 处理、配置读取等频繁处理字符串的场景里省内存非常明显。

Pin就更抽象了。Pin<Box<T>>把T的地址固定下来,不允许移动。它主要出现在异步编程里:Future 内部可能有自引用结构(比如引用自己另一个字段的地址),如果 Future 在 poll 时被移动,自引用就悬空了。因此 async 块生成的自引用 Future 被Pin固定后才能安全轮询。你平时可能不会直接写Pin,但用 async/await 时底层每天都在用它。

3. 源码层看懂智能指针:Deref、Drop 与内存布局

3.1 Deref:为什么Rc<T>可以直接调用 T 的方法

你可能会好奇:为什么rc_string.trim()能编译过?String有trim,Rc<String>没有。关键在于Rc<T>实现了Deref<Target=T>,而&*Rc<T>可以自动转换成&T。

Rust 的方法解析规则里有一条:编译器在找方法时,会一路自动解引用。比如rc_string.trim()等价于(*rc_string).trim(),由于Deref返回&String,这个表达式成立。这也叫解引用强制多态:&Rc<String>可以自动变成&String,&String又能自动变成&str。

因此,几乎所有智能指针都可以“伪装”成内部 T 的引用。这也带来一个设计上的提醒:你自定义类型实现Deref时,一定保证 Target 类型和语义一致。比如String的Deref目标是str,它希望你在用字符串的地方直接用。如果你把一个明明语义不同的类型通过Deref伪装成另一类,方法名冲突和逻辑混乱会让人非常痛苦。

3.2 Drop:引用计数归零那一刻,内存怎么释放

Droptrait 只有一个方法fn drop(&mut self),当变量离开作用域时自动调用。对于普通类型,它更多是释放资源、写日志或断开连接;对于智能指针,它负责执行内存释放逻辑。

Box的Drop会直接释放堆内存;Rc的Drop会先递减 strong count,如果减到 0 且 weak count 也为 0,就释放整个堆块;Arc同样通过原子递减来决定释放时机。可以看到,Drop是整个 Rust 释放时机的总开关。理解这点,你就明白为什么“智能指针不是 GC”:释放是确定性的,不依赖某个后台收集器。

这里有个容易犯的错:想手动提前释放时不要调用drop方法(编译器也不让你显式调用),而是使用std::mem::drop(value)。std::mem::drop就是单纯移动并立即销毁一个值,在值上触发Drop。

let rc = Rc::new(String::from("temp")); Rc::clone(&rc); std::mem::drop(rc); // explicit cleanup early

在实际调试中,给自定义类型实现Drop并打印日志,是确认引用计数和释放时机的常用手段。

3.3 一个指针到底占多少内存:Rc 与 Arc 的开销差异

很多面试喜欢问:“Rc 到底占多大内存?”我梳理一下。

Box<T>在栈上就一个指针大小(64 位下 8 字节),它指向堆上的 T。Rc<T>在栈上也是一个指针大小,但堆上除了 T 本身,还额外存了一个 strong count 和一个 weak count,各占usize(8 字节)。也就是说每个Rc的堆块比纯 T 多 16 字节。

Arc<T>更特殊:计数是原子操作,所以计数类型通常是AtomicUsize,还是 8 字节一个,一共也是 16 字节。差别在于读写计数时要走原子指令,比普通加加减减慢一些,多线程竞争不激烈时通常影响可忽略。

你还可以用std::mem::size_of::<T>()验证栈上大小,理解它对你评估“这个共享对象要存一亿份”是否可行很有帮助:多出的 16 字节计数和原子开销,在少数对象上无所谓,在百万级小对象上会变得很明显。

4. 选型与实战:三种真实场景下的智能指针落地

4.1 五步选型:先回答这几个问题再写代码

面对一个具体需求,别急着套Rc或Arc,先回答五个问题:

  1. 需要共享所有权吗?不需要,只是想放在堆上、或者需要递归结构,用Box。
  2. 在同一个线程里共享吗?是,用Rc;需要跨线程,用Arc。
  3. 共享的同时需要修改吗?需要,在Rc外再套RefCell,在Arc外再套Mutex或RwLock。
  4. 只读场景却需要复制一份吗?是,考虑Cow减少复制。
  5. 会不会出现互相持有?会,把其中一方的引用改成Weak。

这套流程几乎能覆盖 95% 的日常场景。更严格地说,你还可以先想“能不能不共享”:很多时候把数据结构和函数拆一下,用生命周期就能解决,根本不用涉及智能指针。共享所有权是需求,不是默认选项。

4.2 用 Box 构造表达式树并实现求值

来看一个完整例子:解析并计算(1 + 2) * (3 + 4)。

enum Expr { Num(i32), Add(Box<Expr>, Box<Expr>), Mul(Box<Expr>, Box<Expr>), } impl Expr { fn eval(&self) -> i32 { match self { Expr::Num(v) => *v, Expr::Add(l, r) => l.eval() + r.eval(), Expr::Mul(l, r) => l.eval() * r.eval(), } } } fn main() { let expr = Expr::Mul( Box::new(Expr::Add(Box::new(Expr::Num(1)), Box::new(Expr::Num(2)))), Box::new(Expr::Mul(Box::new(Expr::Num(3)), Box::new(Expr::Num(4)))), ); println!("result = {}", expr.eval()); }

注意最外层的expr是在栈上,但每个 Add/Mul 的子树都放在堆。遍历时我们只用&self,没有发生任何所有权问题。如果未来要改成可变的表达式树,比如支持节点替换,就需要换Rc<RefCell<Expr>>之类的结构了。

这可以说是Box最典型的应用:递归数据结构。只要出现 enum 或 struct 里有“自己包含自己”的类型,基本就是Box的主场。

4.3 多线程任务队列:Arc<Mutex<T>>与只读Arc<T>

假设你在写一个 Tauri + Rust 桌面的任务管理器,有一个全局任务列表,主线程负责新增任务,后台线程负责执行并且更新状态。任务列表需要跨线程共享且可变,标准答案就是Arc<Mutex<Vec<Task>>>。

use std::sync::{Arc, Mutex}; struct Task { id: u64, state: String, } fn main() { let tasks = Arc::new(Mutex::new(Vec::<Task>::new())); let tasks_push = Arc::clone(&tasks); std::thread::spawn(move || { tasks_push.lock().unwrap().push(Task { id: 1, state: "queued".into() }); }); let tasks_exec = Arc::clone(&tasks); std::thread::spawn(move || { let mut guard = tasks_exec.lock().unwrap(); if let Some(task) = guard.iter_mut().find(|t| t.id == 1) { task.state = "running".into(); } }); }

lock().unwrap()的意思是先拿互斥锁,拿到可变引用,再做修改。unwrap处理的是“锁被毒化”的情况:线程在持锁期间 panic,锁会进入 poison 状态,后续 lock 返回 Err。生产代码里通常会做一个自定义错误处理,但你要知道它为什么会出现。

如果只是多个线程读取一份配置,完全没有写操作,那应该用Arc<Config>,而不是Arc<Mutex<Config>>。我在 2.3 里已经强调了,锁会让并发读变成串行,本来可以并行执行的线程都在等锁。这种性能问题在大流量服务里非常致命。

4.4 单线程缓存:Rc<RefCell<T>>的黄金组合

写一个单线程缓存接口,缓存对象是HashMap<String, Vec<u8>>,多个调用方共享同一个缓存实例。用Rc<RefCell<_>>就是典型解法:

use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; type SharedCache = Rc<RefCell<HashMap<String, Vec<u8>>>>; fn get_or_load(cache: &SharedCache, key: &str) -> Vec<u8> { let mut map = cache.borrow_mut(); if let Some(v) = map.get(key) { return v.clone(); } let data = vec![0u8; key.len()]; // simulate loading map.insert(key.to_string(), data.clone()); data }

这里为什么不直接传&mut HashMap?因为调用方是多个模块分别持有SharedCache,谁也无法拥有唯一的可变引用。Rc负责共享,RefCell负责可变性,两者组合正好补上所有权和借用规则的缺口。

但要注意,borrow_mut()的借用范围要尽量短。如果get_or_load内部在map.get(key)和map.insert之间调用了另一个需要借用cache的函数,就会发生 already borrowed panic。这种 panic 完全可以在代码 review 时通过“保持借用作用域最小化”避免。

5. 常见问题与排查技巧实录

5.1 内存泄漏:Rc 循环引用与 Weak 的破解方法

最典型的内存泄漏例子是双向链表或父节点持有子节点、子节点又持有父节点。我用一个简化模型演示:

use std::cell::RefCell; use std::rc::Rc; struct Node { value: i32, parent: Option<Rc<RefCell<Node>>>, children: Vec<Rc<RefCell<Node>>>, }

构建 A 和 B,让A.children持有 B,B.parent持有 A。此时 A 的 strong count 是 1(来自 B.parent)加外部变量 1 = 2,B 的 strong count 是 1(来自 A.children)加外部变量 1 = 2。作用域结束,外部变量释放后,A 和 B 的计数各剩 1,谁也不会释放自己。这是真实的 Rust 内存泄漏:不是安全问题,但确实存在。

破解方式是把parent改成Weak:

use std::rc::Weak; struct Node { value: i32, parent: Option<Weak<RefCell<Node>>>, children: Vec<Rc<RefCell<Node>>>, }

Weak不增加 strong count,B 通过parent访问父节点时,需要先upgrade(),返回Option<Rc<Node>>。为什么是Option?因为父节点可能已经不存在。这是 Rust 逼你把“可能没有”写进代码里。

我建议在写完任何带Rc的图结构后,顺手打印Rc::strong_count检查一轮,尤其是涉及互相引用的时候。这个习惯能救你很多次。

5.2 RefCell 运行期借用冲突:panic 后的排查思路

RefCell的 panic 信息往往只有一句“already borrowed: BorrowMutError”。新手看到会懵,因为它没有告诉你哪个调用栈出了问题。我的排查路径是三步。

第一步,设置环境变量RUST_BACKTRACE=1再运行,拿到完整栈,定位到所有 borrow 的上下文。第二步,检查同一段作用域内是否存在连续的borrow和borrow_mut。比如:

let guard = cache.borrow_mut(); let data = guard.get(key).cloned(); // 这里如果调用别的需要 borrow 的函数,就会 panic

第三步,把借用收窄到最小作用域,或者改用先 clone 再操作外部数据的方式。如果发现多个嵌套调用都依赖同一个RefCell,就要考虑是否应该拆分数据结构,而不是继续嵌套。

还有一个原则:不要把RefCell<T>当成“免检车道”。它只是把检查从编译期挪到运行期,不代表可以随便违规。能用生命周期解决的,优先用生命周期;实在共享且可变,才上RefCell。

5.3 Arc 的三种过度使用:性能浪费、死锁与误用

第一种是只读共享也用 Mutex。已经反复强调过,这里只补充一个现象:在有竞争的热路径上,全局锁的吞吐量可能比无锁版本低 10 到 100 倍。如果你的多线程只是读一份大配置,优先Arc<T>或Arc<RwLock<T>>。

第二种是Arc<Mutex<T>>在持锁期间调用另一个需要同一把锁的方法,逻辑上就是死锁。Rust 的Mutex不是可重入的,同一个线程连续lock两次会直接死锁(不是 panic)。排查方法是把持锁范围拆小,凡是能提前算出来的东西先算完,再锁。

第三种是循环内无谓Arc::clone。比如在 for 循环每轮都 clone 一个 Arc 传给短生命周期线程,虽然正确,但原子计数操作会不断发生。更高效的方式是在循环外 clone 一次,或直接把Arc放进容器,让容器持有它。

记住,Arc的价值是“多个线程安全地共享所有权”,不是“给每个线程一份副本再复制”。当你发现代码里Arc::clone的次数异常多,通常就是设计走偏了。

5.4 问题速查表与调试工具

我整理了一张速查表,基本覆盖日常遇到的智能指针问题。

症状可能原因排查手段解决方案
数据结构编译不过,错误是 recursive type has infinite size递归类型没加 Box看错误信息提示的循环定义在递归字段加Box
程序退出时内存没释放,强计数不为 0循环引用打印strong_count,检查持有关系一侧改Weak
运行时 panic: already borrowedRefCell 同时有可变/不可变借用RUST_BACKTRACE=1定位栈缩小借用作用域,拆分数据
多线程编译报错:Rc cannot be sent用 Rc 跨线程确认线程边界换Arc
性能比预期差很多全局锁串行化或重复 clone用计时对比定位热点只读用Arc,写用RwLock
调用方数据突然被修改内部可变性暴露过度审查 RefCell 借用范围封装访问方法,限制可见域

工具方面,最常用的是RUST_BACKTRACE=1配合 panic 输出;要追踪内存泄漏,使用valgrind或自定义Drop日志。实际项目里,先写Drop日志往往比上工具更快定位。

最后想说一个小体会。我刚开始用 Rust 时,总把智能指针当成绕过借用检查器的“后门”,风格是到处Rc<RefCell<>>,代码又丑又慢。后来才意识到,智能指针要在所有权模型里表达真实的数据结构,而不是绕开规则。你越理解每种指针的“托管责任边界”,写出来的代码就越贴合 Rust 的设计哲学。建议拿一个真正的小项目去练:先写粗暴版本,再看哪里可以换成更合适的智能指针,对比一下编译错误和运行时的 panic,收获远比看十篇概念文章大。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询