Box<T>表示堆上的单一所有权,Rc<T>表示单线程共享所有权,Arc<T>用原子计数把共享所有权带到线程之间;Weak<T>只观察对象,不延长对象的生存期。
Arc<T>只保证所有权计数可在线程间安全更新,并不会自动让T可变或线程安全;Rc和Arc的强引用环也不会自动回收。
先根据所有权关系选择最弱的工具:单一所有者用Box,单线程共享用Rc,跨线程共享用Arc,非拥有反向边用Weak;可变性另行选择RefCell、Mutex或RwLock。
是什么,为什么存在
智能指针(smart pointer) 是一个行为像指针、同时携带所有权语义的类型。Box<T>、Rc<T>和Arc<T>都拥有它们指向的T,并在自身被丢弃时按各自规则处理该值。Weak<T>不拥有T,但可以在目标仍然存活时尝试取得一个新的强引用。
这组类型解决的是普通拥有型值和借用无法独自表达的关系。递归类型需要固定大小的间接层;一个GUI节点或解析树可能需要多个读取者;线程任务可能需要共享配置;父子结构的反向链接则不应让父节点永久存活。这些都是所有权建模问题,不只是“把数据放到堆上”。
Box<T>只有一个所有者。移动Box会移动所有权,但通常不会移动堆上的T;最后一个所有者离开作用域时,T和相应分配一起释放。它适合递归类型、拥有型trait对象,以及确实需要间接层的较大值。
Rc<T>是单线程的引用计数(reference counting)指针。调用Rc::clone会创建另一个所有者并增加强计数,不会克隆T。最后一个强所有者消失时,T被丢弃。由于计数更新不是原子的,Rc既不实现Send,也不实现Sync。
Arc<T>使用原子操作维护相同的共享所有权模型,因此可以跨线程使用。只有当T本身满足相应的Send与Sync约束时,Arc<T>才实现这些trait。原子计数保护的是分配的生存期,不是T内部的读写。
Rc::downgrade与Arc::downgrade产生Weak<T>。弱引用不增加强计数,所以不会让T继续存活。调用upgrade()会返回Option<Rc<T>>或Option<Arc<T>>;目标已被丢弃时结果为None。
选择时先问两个问题:这个值有一个所有者还是多个所有者,共享是否跨越线程。然后再单独判断是否需要修改。把共享所有权与可变性拆开思考,能避免把Arc误当作锁,也能避免无条件使用Arc<Mutex<T>>。
| 所有权需求 | 线程范围 | 常用类型 | 说明 |
|---|---|---|---|
| 单一所有者、需要间接层 | 任意 | Box<T> | 没有引用计数 |
| 多个所有者 | 单线程 | Rc<T> | 非原子强计数与弱计数 |
| 多个所有者 | 多线程 | Arc<T> | 原子强计数与弱计数 |
| 非拥有链接 | 与强指针一致 | std::rc::Weak<T>或std::sync::Weak<T> | 使用前必须upgrade() |
工作原理
Box<T>本身是一个拥有型指针。对于普通非零大小的T,值位于堆分配中,而Box值保存指向它的指针;零大小类型等边界情况不应靠“必然发生一次分配”来推理。Box实现Deref和DerefMut,因此许多借用操作会自动解引用到T。
Box让递归定义变成有限大小。例如,枚举分支若直接包含同类型的下一节点,编译器无法算出枚举大小;把下一节点改成Box<Node>后,该位置只需要保存固定大小的指针。Box<dyn Trait>也把动态大小的trait对象放在拥有型指针后面。
Rc<T>与Arc<T>的分配在概念上包含一个 引用计数控制块(reference-count control block) 和T。控制块至少跟踪强所有者与显式弱引用;确切字段、布局和内部哨兵属于标准库实现细节,不应写进应用逻辑。strong_count与weak_count适合诊断,但不是生命周期规则的替代品。
克隆Rc或Arc只克隆指针身份。Rc::clone(&value)和value.clone()对包装指针具有相同语义,但前一种写法把“增加共享所有者”表达得更清楚。要深拷贝内部T,必须显式克隆解引用后的值,或使用满足业务语义的复制方法。
最后一个强引用被丢弃时,T立即进入析构流程。若仍有显式Weak,控制块需要继续存在,使后续upgrade()能可靠地返回None;最后一个弱引用消失后,剩余分配才可释放。这就是弱引用能够观察生存状态、却不会延长T生存期的原因。
Arc的原子操作让不同线程可以并发克隆、丢弃和升级指针。它不会把RefCell<T>变成线程安全类型,所以Arc<RefCell<T>>通常仍不能发送到线程。共享可变状态一般使用Arc<Mutex<T>>或Arc<RwLock<T>>,具体锁语义由对应类型负责。
下图把指针与分配的关系放在一起。实线表示强所有权,虚线表示不会让T存活的弱链接。
一条边是否应为强引用,取决于它是否表达所有权。树通常由父节点强拥有子节点,而子节点到父节点只是导航,因此反向边用Weak。若双向边都用强引用,外部根节点即使消失,环内强计数仍不会归零。
共享所有权与共享访问也不是同一件事。拿到Rc<T>或Arc<T>后,通常只能通过共享引用读取T。当确实需要修改时,先看是否能够保持唯一所有者;否则再选择运行时借用检查或同步锁,并把它们的失败模式纳入接口。
解引用与容器所有权
解引用强制转换只产生对内部T的借用,不会克隆容器,也不会增加引用计数。把&Rc<T>传给只需要&T的函数时,编译器可以连续解引用,但调用方仍保留原来的Rc所有权。借用结束后,计数和分配身份都没有变化。
接收Rc<T>或Arc<T>的函数会取得一个拥有型句柄,因此调用方通常需要显式克隆句柄后再传入。这个签名表达函数可能把共享所有权保存到调用结束之后。若函数只在调用期间读取值,接收&T能给调用方更多选择,也避免无意义的计数更新。
拥有型智能指针也不会自动产生'static引用。只要所有强所有者都被丢弃,内部T仍会结束;从容器借出的引用不能比提供借用的句柄活得更久。需要跨线程或任务保存时,应移动或克隆拥有型句柄,而不是伪造更长的借用生存期。
Deref让方法调用看起来像直接操作T,但容器API仍通过关联函数区分。例如,Rc::clone、Rc::downgrade与Rc::ptr_eq操作的是所有权容器,不是内部值。阅读生成代码时,应确认每个.clone()究竟复制句柄还是复制T。
Drop与资源释放
Box<T>只有一条拥有路径,所以它被丢弃时会对内部T恰好运行一次析构。随后才释放相应存储。移动Box只是把这份析构责任交给新绑定,不会提前销毁内部值。
每个Rc<T>或Arc<T>句柄自身都会离开作用域,但内部T只在强计数降到零时析构一次。克隆句柄不会让T多执行一次Drop。这一区别对持有文件、套接字或事务守卫的类型尤其重要。
丢弃Weak<T>只会放弃观察控制块的能力,不会析构T。反过来,T已经析构后,仍存在的弱句柄也不能恢复它。upgrade()创建的是新的强所有者,不是访问已销毁值的逃生通道。
强引用环阻止计数降到零,因此环内值的Drop不会自动运行。需要及时释放外部资源的结构更应避免靠程序退出回收。用Weak建模非拥有边,通常比编写手动“断环”清理协议更可靠。
示例
下面四个程序均使用Rust 1.98.0实际编译并执行。输出块保留了真实运行结果;线程示例由主线程按句柄创建顺序打印,因此输出顺序固定。
用Box定义递归表达式
没有Box时,Expr::Add直接包含两个Expr,类型大小会无限递归。两个拥有型间接层让每个枚举值具有可计算的固定大小。
#[derive(Debug)]
enum Expr {
Number(i64),
Add(Box<Expr>, Box<Expr>),
}
impl Expr {
fn evaluate(&self) -> i64 {
match self {
Expr::Number(value) => *value,
Expr::Add(left, right) => left.evaluate() + right.evaluate(),
}
}
}
fn main() {
let expression = Expr::Add(
Box::new(Expr::Number(20)),
Box::new(Expr::Add(
Box::new(Expr::Number(2)),
Box::new(Expr::Number(3)),
)),
);
println!("expression: {expression:?}");
println!("result: {}", expression.evaluate());
}expression: Add(Number(20), Add(Number(2), Number(3)))
result: 25每个Box独占一个子表达式。匹配&self时,解引用强制转换让left.evaluate()与right.evaluate()直接调用内部Expr的方法。整个根表达式离开作用域后,所有节点按所有权树释放。
用Rc共享不可变配置
Rc::clone让两个句柄指向同一分配。第二个句柄被丢弃后,Rc::get_mut才能证明primary是唯一访问路径,并返回可变引用。
use std::rc::Rc;
#[derive(Debug)]
struct Catalog {
region: String,
}
fn main() {
let mut primary = Rc::new(Catalog {
region: String::from("eu-west"),
});
let read_view = Rc::clone(&primary);
println!("owners: {}", Rc::strong_count(&primary));
println!("same allocation: {}", Rc::ptr_eq(&primary, &read_view));
drop(read_view);
Rc::get_mut(&mut primary)
.expect("primary is now the only owner")
.region
.push_str("-backup");
println!("owners after drop: {}", Rc::strong_count(&primary));
println!("region: {}", primary.region);
}owners: 2
same allocation: true
owners after drop: 1
region: eu-west-backupRc::ptr_eq检查两个句柄是否指向同一分配,和比较两个Catalog的值不同。get_mut的成功依赖当前唯一性,不应把前面打印的计数当成证明;这里的drop(read_view)才建立了所需条件。
用Weak表示父链接
父节点强拥有子节点,子节点只弱引用父节点。这样既能向上导航,又不会形成保持整棵树存活的强引用环。
use std::cell::RefCell;
use std::rc::{Rc, Weak};
#[derive(Debug)]
struct Node {
name: &'static str,
parent: RefCell<Weak<Node>>,
children: RefCell<Vec<Rc<Node>>>,
}
impl Node {
fn new(name: &'static str) -> Rc<Self> {
Rc::new(Self {
name,
parent: RefCell::new(Weak::new()),
children: RefCell::new(Vec::new()),
})
}
fn attach(parent: &Rc<Self>, child: &Rc<Self>) {
*child.parent.borrow_mut() = Rc::downgrade(parent);
parent.children.borrow_mut().push(Rc::clone(child));
}
}
fn main() {
let root = Node::new("root");
let report = Node::new("report.csv");
Node::attach(&root, &report);
println!("root strong: {}", Rc::strong_count(&root));
println!("root weak: {}", Rc::weak_count(&root));
let parent_name = report.parent.borrow().upgrade().unwrap().name;
println!("report parent: {parent_name}");
drop(root);
println!("parent alive: {}", report.parent.borrow().upgrade().is_some());
}root strong: 1
root weak: 1
report parent: root
parent alive: falseRefCell只负责单线程内部可变性,使attach能通过共享的Rc<Node>更新链接。第一次upgrade()成功是因为root仍是强所有者;丢弃root后,第二次升级失败。生产代码应匹配Option,示例中的unwrap只用于已由同一作用域保证的第一次检查。
用Arc在线程间共享只读数据
每个工作线程克隆一个Arc,而不是克隆整个向量。工作线程返回结果,主线程按句柄顺序打印,所以调度顺序不会改变输出。
use std::sync::Arc;
use std::thread;
fn main() {
let measurements = Arc::new(vec![4, 6, 8, 9, 12]);
let handles: Vec<_> = [2, 3, 4]
.into_iter()
.map(|divisor| {
let measurements = Arc::clone(&measurements);
thread::spawn(move || {
let sum = measurements
.iter()
.copied()
.filter(|value| value % divisor == 0)
.sum::<i32>();
(divisor, sum)
})
})
.collect();
for handle in handles {
let (divisor, sum) = handle.join().unwrap();
println!("divisible by {divisor}: {sum}");
}
println!("owners after join: {}", Arc::strong_count(&measurements));
}divisible by 2: 30
divisible by 3: 27
divisible by 4: 24
owners after join: 1线程闭包使用move取得各自Arc的所有权。连接完所有线程后,这些克隆都已被丢弃,只剩主线程的句柄。内部向量从未被修改,所以不需要锁。
陷阱
把Arc当作线程安全开关
修复方法: 只读共享直接使用Arc<T>。共享修改需要根据访问模式选择Arc<Mutex<T>>、Arc<RwLock<T>>或原子类型,并处理锁中毒、争用和临界区边界;若任务根本不跨线程,保留Rc<RefCell<T>>往往更准确。
用强反向边形成引用环
修复方法: 明确谁拥有谁,把父链接、缓存观察者或其他非拥有反向边改为Weak。测试时丢弃根所有者,并断言保留的弱句柄无法升级;不要把strong_count的某个快照当作回收测试。
无条件解包Weak::upgrade
修复方法: 把None作为正常生存期分支处理,必要时从注册表移除失效条目。若业务保证目标存活,应让函数接收强引用或借用来表达该保证,而不是靠一次较早的计数检查。
在锁内调用未知代码
修复方法: 在短临界区内升级或克隆所需句柄、清理失效弱引用,然后释放守卫再调用用户代码。不要为了缩短源码而延长锁的生存期;尤其要检查守卫是否跨越.await或外部函数调用。
把Box当作固定地址或性能优化
修复方法: 递归大小、trait对象或所有权边界确实需要间接层时再使用Box。地址不能移动时使用表达该约束的Pin<Box<T>>,并理解Unpin;性能选择必须来自测量,而不是“堆更快”之类的猜测。
控制块的生存期
引用计数(reference counting) 把销毁决定绑定到强所有者数量。Rc在单线程中更新计数,Arc使用原子操作协调多个线程;两者都在最后一个强所有者消失时丢弃T。这套机制是确定性的,但只对可达的计数变化负责,并不检测强引用环。
强计数与弱计数服务于不同生存期。强计数控制T是否存活,显式弱引用则要求控制块在T被丢弃后继续存在。此时弱句柄仍可被克隆或丢弃,但升级结果为None,也不能访问已经析构的T。
升级std::sync::Weak<T>时,操作可能与其他线程丢弃最后一个强引用发生竞争。标准库用原子协议保证结果只有两种:取得一个保持T存活的新Arc,或者得到None。应用不需要也不应该先读取强计数再决定是否升级。
引用计数不会把内存泄漏变成未定义行为,因此强引用环通常表现为资源长期不释放,而不是立即崩溃。但被保留的值可能持有文件描述符、缓存条目或其他稀缺资源。所有权图审查应检查析构是否可达,而不只是内存安全。
strong_count和weak_count返回观察时的计数。对于Arc,其他线程可以在函数返回后立刻改变它们,所以这些值适合日志、断言受控的单线程测试和教学,不适合作为无锁安全决策。需要唯一访问时调用提供原子语义保证的库API。
唯一访问与写时复制
Rc::get_mut和Arc::get_mut接收包装指针的可变引用,并只在没有其他强指针或弱指针指向同一分配时返回Some(&mut T)。这一条件确保不存在可以随后升级或读取旧值的其他路径。若唯一性不能成立,它们返回None,不会等待其他所有者消失。
Rc::make_mut和Arc::make_mut为实现了Clone的T提供写时复制(copy-on-write)。存在其他强所有者时,它们克隆内部值,使当前句柄转向独立分配,再返回可变引用。这样修改当前逻辑副本不会改变其他强所有者看到的值。
只有弱指针、没有其他强所有者时,make_mut可以让弱指针与当前值解除关联,而不克隆T。这些旧Weak随后无法升级。代码若把弱句柄当作稳定身份令牌,就必须注意这个边界;make_mut的语义是取得可变值,不是保留分配身份。
try_unwrap处理的是取得T的所有权。只要没有其他强所有者,它可以成功,即使弱句柄仍存在;取出T后,那些弱句柄不能再升级。需要“取出或克隆”语义时可考虑unwrap_or_clone,但是否复制仍应在API边界清楚表达。
值相等也不代表分配相同。Rc<T>和Arc<T>的常规相等比较会比较T的值,而ptr_eq检查两个句柄是否指向同一分配。缓存键、图节点身份或去重逻辑必须先确定需要值语义还是身份语义。
动态大小值与地址稳定性
Box<T>、Rc<T>和Arc<T>都能在适当的强制转换后拥有动态大小值,例如Box<dyn Trait>、Rc<str>或Arc<[T]>。这类指针需要携带访问值所需的元数据,例如切片长度或trait对象的虚表信息。不能据此假定所有智能指针在所有T上都只有一个机器字。
移动Box<T>通常只移动指针,堆中值的地址保持不变,但类型系统不会仅凭Box<T>承诺值永远不可移动。代码仍可从Box中取出或替换T。自引用结构或异步状态机需要地址稳定性时,应通过Pin及相关不变量表达约束。
间接层还会影响API设计。函数只读取T时通常接收&T,让调用方通过解引用强制转换传入Box<T>、Rc<T>或Arc<T>,无需把具体所有权容器写进接口。只有函数需要克隆、降级、取得所有权或跨线程保存句柄时,参数才应暴露相应包装类型。
4个问题 · 2道输出预测题 · 1道找错题