Rust 的三个智能指针 Box、Rc、Arc,你真的选对了吗?

技术分享 0 次阅读
Rust 的三个智能指针 Box、Rc、Arc,你真的选对了吗?

同样的代码,换个智能指针就从「编译通过」变成「内存泄漏」——这事我踩过,你最好别踩。

从一个场景说起

假设你在开发一个 Rust 服务,需要把一份应用配置共享给多个模块读取。你很自然地写了这样的代码:

struct AppConfig {
    db_url: String,
    max_connections: u32,
}

struct Logger {
    config: AppConfig,  // 每个模块自己持有一份
}

struct DbPool {
    config: AppConfig,  // 又是独立的一份拷贝
}

fn main() {
    let cfg = AppConfig {
        db_url: "postgres://localhost:5432/mydb".into(),
        max_connections: 100,
    };

    let logger = Logger { config: cfg };
    // 下面这行编译不过——cfg 的所有权已经移走了!
    let db_pool = DbPool { config: cfg };
}

编译报错:use of moved value: 'cfg'。因为 AppConfig 没有实现 Copy,传给 Logger 之后所有权就转移了。

你心想:「那我把 cfg.clone() 传进去总行了吧?」

let logger = Logger { config: cfg.clone() };
let db_pool = DbPool { config: cfg.clone() };

能跑了——但每次克隆都复制一整份配置数据,如果配置结构体很大(比如包含几十个字段、配置文件路径、密钥证书),这开销就是白给的。更别说如果后面在循环里创建多个模块,反复克隆的性能损耗会直接反映在延迟上。

问题的本质是:你需要「多个地方共享同一份数据」,而不是「每人复制一份」。这时候,Rust 的三个智能指针就是为你准备的答案。

核心原理

智能指针到底是什么?

在 Rust 里,普通的引用 &T 只是「借」数据,不拥有所有权。而智能指针是「拥有数据 + 额外逻辑」的结构体——典型地,它们实现了 DerefDrop 两个 trait:

  • Deref 让你可以用 *ptr 解引用访问内部数据
  • Drop 让指针离开作用域时自动清理堆数据

你可以把智能指针理解成「自动管理内存的指针盒子」。

Box——最朴素的「堆盒子」

Box<T> 是 Rust 最基础的智能指针,做的事就一件:把值从栈搬到堆,栈上只留一个指针

看内存布局就知道了:

栈上(8字节指针)              堆上
┌──────────────┐            ┌──────────────────┐
│ Box 指针 ——→ │ ————————→ │ 实际数据 (如 i32) │
└──────────────┘            └──────────────────┘

什么时候非用它不可?

场景一:递归类型。Rust 需要在编译时确定每个类型的大小,但像链表这样的递归类型会让大小「无限嵌套」:

// 编译错误:递归类型 `List` 没有确定的大小
enum List {
    Cons(i32, List),  // 每个 Cons 里又装一个 List,无限递归
    Nil,
}

Box 把内部 List 变成指针,大小就固定了(8 字节):

enum List {
    Cons(i32, Box<List>),  // 现在 Cons 的大小是 i32(4) + 指针(8) = 12 字节(对齐后16)
    Nil,
}

fn main() {
    // 构建链表: 1 -> 2 -> 3
    let list = List::Cons(
        1,
        Box::new(List::Cons(
            2,
            Box::new(List::Cons(3, Box::new(List::Nil))),
        )),
    );
    // list 离开作用域时,Box 的 Drop 会递归释放所有节点
}

场景二:Trait 对象(动态分发)。你需要一个集合里存放「不同具体类型但实现了同一 Trait」的对象时:

trait Animal {
    fn speak(&self) -> &'static str;
}

struct Dog { name: String }
struct Cat { name: String }

impl Animal for Dog {
    fn speak(&self) -> &'static str { "汪汪" }
}
impl Animal for Cat {
    fn speak(&self) -> &'static str { "喵喵" }
}

fn main() {
    // Box<dyn Animal> 擦除了具体类型,统一按指针处理(大小固定)
    let animals: Vec<Box<dyn Animal>> = vec![
        Box::new(Dog { name: "旺财".into() }),
        Box::new(Cat { name: "咪咪".into() }),
    ];

    for animal in &animals {
        println!("{}", animal.speak());  // 运行时动态分发
    }
}

Box<dyn Animal> 是一个胖指针(16 字节),包含「数据指针」和「虚函数表指针」,实现运行时的多态。

场景三:大对象搬堆上。栈空间默认只有几 MB,如果一个结构体几百 KB,直接放栈上可能导致栈溢出。用 Box 把它扔到堆上:

struct BigConfig {
    data: [u8; 1024 * 512],  // 512KB 的大数据
}

fn main() {
    // 栈上只放一个 8 字节的指针,512KB 数据在堆上
    let config = Box::new(BigConfig {
        data: [0u8; 1024 * 512],
    });
    // 函数传参时只复制指针,不用复制 512KB 数据
    process_config(config);
}

fn process_config(_cfg: Box<BigConfig>) {}

Rc——单线程的「共享引用计数器」

Box 只解决「堆存储」,不解决「共享」。当需要多个所有者共享同一份堆数据时,就用 Rc<T>(Reference Counted)。

它的底层多了个引用计数字段:

Rc 的内存布局(伪代码):
┌──────────────────────────────────────┐
│ strong_count: usize    // 强引用计数  │
│ weak_count: usize      // 弱引用计数  │
│ data: T                // 实际数据    │
└──────────────────────────────────────┘

核心规则:每次 Rc::clone()strong_count +1;每次 drop 让 strong_count -1;降为 0 时释放数据。

⚠️ 注意:Rc::clone()浅拷贝——只增加引用计数,不复制堆数据。这是它和普通 .clone() 的本质区别。

看一个生产中的经典用法——共享配置:

use std::rc::Rc;

struct AppConfig {
    name: String,
    timeout: u64,
}

fn main() {
    // 创建 Rc,strong_count 初始为 1
    let config = Rc::new(AppConfig {
        name: "MyApp".into(),
        timeout: 30,
    });

    println!("初始引用数: {}", Rc::strong_count(&config));  // 1

    // 模拟三个模块共享配置——clone 只增加计数,不复制数据
    let module_a = Rc::clone(&config);
    println!("A 模块共享后: {}", Rc::strong_count(&config)); // 2

    let module_b = Rc::clone(&config);
    println!("B 模块共享后: {}", Rc::strong_count(&config)); // 3

    {
        let module_c = Rc::clone(&config);
        println!("C 模块共享后: {}", Rc::strong_count(&config)); // 4
    } // module_c 离开作用域,count -1

    println!("C 模块释放后: {}", Rc::strong_count(&config)); // 3

    // 三个模块都能读取同一份配置
    println!("A: {}", module_a.name);
    println!("B: {}", module_b.timeout);
} // module_a, module_b, config 依次 drop,count 到 0 时释放堆上数据

运行输出:

初始引用数: 1
A 模块共享后: 2
B 模块共享后: 3
C 模块共享后: 4
C 模块释放后: 3

Arc——多线程的「原子引用计数器」

Rc 有个致命局限:不能跨线程。因为它的引用计数是普通 usize,多线程同时修改会导致数据竞争,计数错乱。如果你尝试把 Rc 传给 thread::spawn,编译器直接报错:

use std::rc::Rc;
use std::thread;

fn main() {
    let data = Rc::new("不能过线程".to_string());
    // 编译错误:`Rc<String>` 无法在线程间安全传递
    thread::spawn(move || {
        println!("{}", data);
    });
}

这时候需要 Arc<T>——它和 Rc 几乎一样,唯一的区别是:引用计数用 AtomicUsize(原子类型),多线程下修改计数的操作是「不可中断的」,保证安全。

代价是:原子操作比普通内存操作慢一点(大约几个纳秒级别),所以单线程用 Rc,多线程用 Arc

use std::sync::Arc;
use std::thread;

fn main() {
    let config = Arc::new(vec!["线程安全", "共享", "数据"]);

    let mut handles = vec![];
    for i in 0..3 {
        let arc_clone = Arc::clone(&config);
        handles.push(thread::spawn(move || {
            println!("线程 {} 读取: {:?}", i, arc_clone);
        }));
    }

    for h in handles {
        h.join().unwrap();
    }
    // 所有线程共享同一个 Vec,没有拷贝
}

深入细节

陷阱一:Rc 的循环引用导致内存泄漏

Rc 最大的坑是循环引用——A 引 B,B 引 A,彼此的 strong_count 都无法归零,堆数据永远不会释放。

看下面这段看似无害的代码:

use std::rc::Rc;
use std::cell::RefCell;

struct Node {
    name: String,
    children: RefCell<Vec<Rc<Node>>>,  // 子节点
    parent: RefCell<Option<Rc<Node>>>, // 父节点(强引用)
}

fn main() {
    let parent = Rc::new(Node {
        name: "父".into(),
        children: RefCell::new(vec![]),
        parent: RefCell::new(None),
    });

    let child = Rc::new(Node {
        name: "子".into(),
        children: RefCell::new(vec![]),
        parent: RefCell::new(Some(Rc::clone(&parent))),
    });

    // 父节点也引用子节点——形成循环!
    parent.children.borrow_mut().push(Rc::clone(&child));

    println!("parent strong_count: {}", Rc::strong_count(&parent)); // 2(自身+child.parent)
    println!("child  strong_count: {}", Rc::strong_count(&child));  // 2(自身+parent.children[0])
} // 离开作用域后:parent 和 child 的 count 永远停在 1,内存泄漏!

解决方案:用 Weak<T> 打破循环。

Weak<T>Rc 的弱引用——克隆时只增加 weak_count,不增加 strong_count不阻止数据释放。获取弱引用指向的数据需要调用 .upgrade(),如果数据已被释放则返回 None

use std::rc::{Rc, Weak};
use std::cell::RefCell;

struct Node {
    name: String,
    children: RefCell<Vec<Rc<Node>>>,
    parent: RefCell<Option<Weak<Node>>>,  // 父节点改为弱引用
}

fn main() {
    let parent = Rc::new(Node {
        name: "父".into(),
        children: RefCell::new(vec![]),
        parent: RefCell::new(None),
    });

    let child = Rc::new(Node {
        name: "子".into(),
        children: RefCell::new(vec![]),
        parent: RefCell::new(Some(Rc::downgrade(&parent))), // Rc → Weak
    });

    parent.children.borrow_mut().push(Rc::clone(&child));

    println!("parent strong_count: {}", Rc::strong_count(&parent)); // 1(仅自身)
    println!("parent weak_count:   {}", Rc::weak_count(&parent));   // 1(来自 child.parent)

    // 通过 Weak 访问父节点
    let child_parent = child.parent.borrow();
    if let Some(weak_ref) = &*child_parent {
        if let Some(p) = weak_ref.upgrade() {
            println!("子节点的父节点是: {}", p.name);
        }
    }
} // 离开作用域:parent count→0 释放 → child 的 count→0 释放 → 无泄漏

设计原则:在树形结构中,「父→子」用强引用(Rc),「子→父」用弱引用(Weak)。类似地,双向链表、图结构中的反向引用都用 Weak

陷阱二:Arc 只能共享不可变数据

Arc 保证的是「跨线程安全共享」,但如果你需要在多个线程中修改共享数据,光靠 Arc 不够——需要配合 MutexRwLock

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    // Arc<Mutex<T>>:共享 + 可变 + 线程安全
    let counter = Arc::new(Mutex::new(0));

    let mut handles = vec![];
    for _ in 0..5 {
        let c = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            let mut num = c.lock().unwrap(); // 加锁
            *num += 1;                       // 修改(其他线程会阻塞在这)
            println!("当前值: {}", *num);
        })); // 离开作用域时自动解锁
    }

    for h in handles {
        h.join().unwrap();
    }
    println!("最终结果: {}", *counter.lock().unwrap()); // 5
}

如果是「多读少写」的场景,用 Arc<RwLock<T>> 性能更好——允许多个线程同时读,写时独占。

对比表:一张图选对智能指针

维度 Box<T> Rc<T> Arc<T>
所有权模型 单一所有权 共享所有权 共享所有权
线程安全 单线程(不涉及) ❌ 禁止跨线程 ✅ 原子计数
计数方式 usize(无原子开销) AtomicUsize(轻微开销)
克隆行为 深拷贝(复制数据) 浅拷贝(仅 +1 计数) 浅拷贝(原子 +1 计数)
主要用途 堆存储、递归类型、动态分发 单线程共享只读数据 多线程共享数据
可变性 默认不可变(Box::new(mut) 可解引用修改) 需配合 RefCell 需配合 Mutex/RwLock

最佳实践

1. 能做「借用」就别上智能指针

很多人看到需要共享数据,第一反应就用 Rc。但很多时候,用普通的引用 &T 就够了:

// ❌ 过度设计:没必要用 Rc
fn process(config: Rc<AppConfig>) { ... }

// ✅ 更好:如果调用方能控制生命周期,用引用即可
fn process(config: &AppConfig) { ... }

智能指针是用来解决「所有权冲突」的,不是用来替代引用的。

2. 选择顺序:优先用最简单的

能用 &T(引用) → 不用智能指针
非要用智能指针 → 单线程用 Rc,多线程用 Arc
Rc 要可变 → Rc<RefCell<T>>
Arc 要可变 → Arc<Mutex<T>>
Arc 多读少写 → Arc<RwLock<T>>

3. 避免不必要的原子开销

// ❌ 单线程场景,不需要 Arc
let data: Arc<Vec<u8>> = Arc::new(vec![1, 2, 3]);

// ✅ 单线程用 Rc 就够了
let data: Rc<Vec<u8>> = Rc::new(vec![1, 2, 3]);

4. 用 Box<[T]> 而不是 Box<Vec<T>>

如果你需要一个固定长度的堆数组且不会扩容,用 Box<[T]> 而非 Box<Vec<T>>——少一层间接性,内存布局更紧凑:

// 更优:直接分配到堆上的固定数组
let data: Box<[u8]> = vec![1, 2, 3, 4, 5].into_boxed_slice();

// 避免:Vec 本身有堆分配,外面又套一层 Box
let data: Box<Vec<u8>> = Box::new(vec![1, 2, 3, 4, 5]);

总结

  1. Box 解决「堆存储」——递归类型、Trait 对象、大对象,记住「单一所有权 + 固定大小指针」就够了。
  2. Rc 解决「单线程共享」——靠引用计数实现多个所有者,但小心循环引用,反向引用用 Weak 打破闭环。
  3. Arc 解决「多线程共享」——本质是 Rc 的原子版本,需要加 Mutex/RwLock 才能实现可变共享,单线程场景别用它。

选智能指针没有银弹——理解每个指针「解决什么问题,有什么代价」,才能在真实项目中做出正确的权衡。