所有权与借用 vs 引用计数
Rust的标志性成就,是在不使用垃圾回收器的情况下实现内存安全。它通过一套严格的所有权系统达成这一目标,但该系统特意设置了一个“逃生出口”:引用计数。
在Rust程序中,每个值在任何给定时刻都只有一个所有者。当该所有者超出作用域时,值就会被丢弃,内存会在执行过程中的已知点被确定性地释放。不会出现垃圾回收暂停、悬空指针或双重释放的问题。编译器会静态地强制执行所有这些规则。
但有些数据确实需要共享,比如图中的一个节点被多条边所拥有、一个配置对象贯穿多个子系统、一个回调持有对周围状态的引用。这时就需要用到引用计数,即 `Rc` 和 `Arc`,它们将所有权逻辑从编译时转移到一个小型的运行时计数器上。
这两者并非相互竞争的特性,而是各有优劣、相互补充的工具。本文将深入剖析这两种机制,包括它们的语义、性能、易用性,并解答一个关键问题:你该如何选择?
所有权与借用
所有权模型是Rust的核心创新。每个堆分配都恰好有一个所有者,即“持有”它的变量绑定。所有权可以转移到另一个绑定,此时原绑定将失效。除非类型实现了 `Copy` 特征,否则它永远不会被隐式复制。
移动语义
代码示例如下:
fn main() {
let s1 = String::from("hello");
let s2 = s1; // 所有权转移 —— s1 现在无效
// println!("{}", s1); ← 编译错误:值在移动后被借用
println!("{}", s2); // 没问题 —— s2 是唯一所有者
} // s2 在此处被丢弃,内存自动释放借用检查器会强制执行所有权规则。当 `s1` 被赋值给 `s2` 时,编译器会知道 `s1` 不能再被使用,因为它已经放弃了所有权。这在零运行时成本的情况下消除了使用后释放的问题。
借用 —— 共享借用 (`&T`) 和可变借用 (`&mut T`)
在任何地方都转移所有权会很麻烦。Rust允许你借用一个值,即获取一个临时的、有作用域的引用,而不转移所有权。
代码示例如下:
fn calculate_length(s: &String) -> usize {
s.len()
} // s 超出作用域,但不会丢弃 String
fn append_world(s: &mut String) {
s.push_str(", world");
}
fn main() {
let mut greeting = String::from("hello");
let len = calculate_length(&greeting); // 共享借用
append_world(&mut greeting); // 可变借用
println!("'{}' has {} characters", greeting, len);
}借用检查器会同时强制执行两个不变性:
- 任意数量的共享借用 (`&T`) 可以共存,因为它们是只读的,不会与可变操作产生别名冲突。
- 同一时间只能有一个可变借用 (`&mut T`),并且不能与任何共享借用共存。
这就是“别名与可变性互斥”原则,它是一条基本规则,能静态地消除一大类错误,如迭代器失效、数据竞争等。
生命周期
引用会附带生命周期注解,用于证明一个引用不会比它所指向的数据存活更久。在大多数代码中,编译器会自动推断生命周期;在复杂的泛型API中,你需要显式地进行注解。
代码示例如下:
// 'a 表示:返回的引用至少和两个输入的生命周期一样长
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() >= y.len() { x } else { y }
}关键特性
所有权与借用是零成本的,所有的安全保证都在编译时进行验证,不会产生运行时开销,没有计数器递增、除值本身外没有额外的堆分配,也没有额外的间接引用。
引用计数:`Rc` 和 `Arc`
所有权是一种严格的单所有者模型。但有些程序需要共享所有权,即程序的多个部分都需要让同一个值保持存活。经典的例子是图,其中多条边指向同一个节点。
`Rc`(引用计数)会在堆上包裹一个值,并附带一对计数器:强引用计数(活跃的所有者数量)和弱引用计数。每次克隆 `Rc` 时,强引用计数会增加;每次丢弃时,强引用计数会减少。当强引用计数变为零时,内部的值会被丢弃。
基本用法
代码示例如下:
use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("shared data"));
let b = Rc::clone(&a); // 增加强引用计数 —— 廉价的指针克隆
let c = Rc::clone(&a);
println!("strong count = {}", Rc::strong_count(&a)); // 3
drop(b);
println!("after drop b = {}", Rc::strong_count(&a)); // 2
} // a 和 c 在此处被丢弃;计数变为 0,String 被释放使用 `RefCell` 实现内部可变性
`Rc` 提供了共享所有权,但只能进行不可变访问。要修改内部值,你需要将它与 `RefCell` 结合使用,`RefCell` 会将借用检查从编译时转移到运行时。
代码示例如下:
use std::rc::Rc;
use std::cell::RefCell;
fn main() {
let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
let clone1 = Rc::clone(&shared);
let clone2 = Rc::clone(&shared);
clone1.borrow_mut().push(4); // 运行时借用检查
clone2.borrow_mut().push(5);
println!("{:?}", shared.borrow()); // [1, 2, 3, 4, 5]
}线程安全的共享:`Arc`
`Rc` 不实现 `Send` 或 `Sync` 特征,因为它的计数器不是原子的,不能跨线程边界使用。对于并发使用,需要使用 `Arc`(原子引用计数),它使用原子CPU操作来实现计数器。要在跨线程场景下实现内部可变性,可以将它与 `Mutex` 或 `RwLock` 结合使用。
代码示例如下:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0u32));
let handles: Vec<_> = (0..8).map(|_| {
let c = Arc::clone(&counter);
thread::spawn(move || {
*c.lock().unwrap() += 1;
})
}).collect();
for h in handles { h.join().unwrap(); }
println!("count = {}", *counter.lock().unwrap()); // 8
}注意引用循环
`Rc` 和 `Arc` 无法自动检测引用循环。如果两个 `Rc` 值相互持有,它们的强引用计数永远不会变为零,从而导致内存泄漏。可以使用 `Weak` 来创建反向引用(例如树中的父指针),以打破循环。
对比分析
| 维度 | 所有权与借用 | `Rc` / `Arc` |
|---|---|---|
| 所有权模型 | 单所有者,严格执行 | 通过计数句柄实现共享所有权 |
| 验证方式 | 编译时 —— 零运行时成本 | 运行时 —— 每次克隆/丢弃时进行计数器操作 |
| 性能 | 零开销 —— 无额外间接引用 | 少量开销:堆分配、计数器、指针解引用 |
| 线程安全性 | 由 `Send`/`Sync` 编译时规则强制执行 | `Rc`:仅单线程;`Arc`:线程安全 |
| 可变性 | `&mut T` —— 独占,静态检查 | 需要 `RefCell`/`Mutex` —— 可能会在运行时发生恐慌 |
| 循环问题 | 不适用 —— 单所有者不会与自身形成循环 | 引用循环会导致内存泄漏 —— 使用 `Weak` 解决 |
| API复杂度 | 学习曲线较陡(涉及生命周期) | 表面上更简单,复杂度转移到运行时 |
| 典型用例 | Rust中几乎所有数据的默认选择 | 具有共享节点的图、树,共享配置,回调 |
| 丢弃时机 | 确定性 —— 在所有者作用域结束时 | 确定性 —— 最后一个句柄丢弃时(可能不明显) |
| 克隆成本 | 深拷贝(或移动 —— 免费) | 指针复制 + 计数器递增 —— O(1) |
实际性能表现
`Rc` 的开销主要体现在三个方面:为控制块额外进行一次堆分配、每次访问时进行一次指针间接引用,以及在克隆和丢弃时进行两次整数递增/递减操作。对于大多数程序来说,这些开销可以忽略不计。但对于每秒执行数百万次操作的热点代码,如游戏引擎、编译器、信号处理器等,优先使用拥有所有权的值更为重要。
`Arc` 会增加额外的成本,因为原子操作使用了CPU内存顺序保证(`SeqCst` 或 `Release`/`Acquire`),这会抑制某些编译器和硬件的重排序。在竞争激烈的多核工作负载中,这种开销可能会变得很明显。
经验法则
当使用复杂的生命周期注解或不安全代码来绕过借用检查器时,可以考虑使用 `Rc`/`Arc`。虽然会有少量的运行时成本,但能为你带来易用且安全的共享所有权,这在大多数应用程序代码中是一个合理的权衡。
决策指南
优先选择所有权 + 借用的情况
- 数据有明确的单一逻辑所有者(大多数结构体、集合、I/O资源)。
- 需要确定性的、低开销的资源销毁(如文件、套接字、锁的RAII机制)。
- 编写库代码,希望调用者能够控制生命周期。
- 性能至关重要,每个分配都很关键。
优先选择 `Rc` / `Arc` 的情况
- 多个所有者确实需要让同一数据保持存活(如图节点、解析树、事件监听器)。
- 数据的生命周期在运行时动态确定,而非静态确定。
- 需要与期望引用语义的外部系统进行交互(如Python扩展、GUI框架)。
- 希望在不复制大型结构的情况下实现跨线程的共享状态(`Arc>`)。
代码示例如下:
// 典型用例:具有共享节点的图
use std::rc::{Rc, Weak};
use std::cell::RefCell;
struct Node {
value: i32,
children: Vec<Rc<RefCell<Node>>>,
parent: Option<Weak<RefCell<Node>>>, // Weak 打破父节点 -> 子节点的循环
}
impl Node {
fn new(value: i32) -> Rc<RefCell<Node>> {
Rc::new(RefCell::new(Node {
value,
children: vec![],
parent: None,
}))
}
}