堆内存 ------ 数据如何逃逸栈的约束
一、栈的约束:引用只能"向下"传递
1.1 栈是如何工作的
所有程序都使用栈(stack)管理函数调用。每次调用分配一个栈帧,函数返回时释放:
scss
调用栈(从高地址向低地址增长):
┌──────────────────┐ ← 高地址
│ main() │
│ ├─ 局部变量 x │
├──────────────────┤
│ func_a() │
│ ├─ 局部变量 a │
│ ├─ 参数 p │ ← p 指向 main 中的 x(向下传递)
├──────────────────┤
│ func_b() │
│ ├─ 局部变量 b │
├──────────────────┤
│ func_c() │
│ ├─ 局部变量 c │
└──────────────────┘ ← 低地址(栈顶)
每个栈帧在函数返回时销毁。你不能持有一个指向已销毁栈帧的指针。
1.2 引用的"向下传递"
一个引用可以从调用者传递到被调用者------这是安全的,因为被调用者的生命周期包含在调用者的生命周期之内:
rust
fn callee(x: &i32) { // x 来自上层(调用者)
println!("{}", x);
}
fn caller() {
let val = 42; // val 在 caller 的栈帧上
callee(&val); // 将 &val 向下传给了 callee
// callee 返回后,val 仍在 caller 中,仍然有效
}
引用从调用者流向被调用者,这是"向下"传递的意思。
1.3 但是引用不能"向上"传递
一个函数不能返回对自身局部变量的引用:
rust
fn bad() -> &i32 {
let x = 42; // x 在 bad() 的栈帧上
&x // ❌ 编译错误:返回对局部变量的引用
} // bad() 返回后栈帧销毁,x 消失
// error: returns a reference to a local variable `x`
这正是栈的约束:数据死在它的栈帧里,你不能把它带出那个栈帧。
1.4 C 中同样的问题
C 编译器对此发出警告,但这不是错误:
c
int* bad() {
int x = 42;
return &x; // ⚠️ 编译器警告,但代码仍然编译
} // x 被销毁,返回的指针成为悬垂指针
void caller() {
int* p = bad();
printf("%d", *p); // ❌ 未定义行为(可能恰好打印 42,也可能崩溃)
}
C 与 Rust 的差异:
| 维度 | C | Rust |
|---|---|---|
| 返回局部变量地址 | 警告(可被忽略) | 编译错误(不可绕过) |
| 悬垂指针的防范 | 无 | 生命周期系统保证 |
| 替代方案 | 返回静态变量、malloc、或由调用者传入缓冲区 | 返回栈上的值本身、或堆分配 |
1.5 "向下传递"的严格形式
引用只能向下传递的严格形式是生命周期约束:
rust
fn example<'a>(x: &'a i32) -> &'a i32 {
// ↑ ↑
// 返回的引用的生命周期 = 传入引用的生命周期
// 这意味着:返回的引用不会超过传入引用的存活时间
// 编译器在调用点检查这一约束
x
}
生命周期标注 'a 是编译器追踪引用"能活多久"的方式。 它确保引用永远不会向上逃逸出它的数据所在的作用域。
1.6 引用的本质:值的别名
引用(&T / &mut T)是 Rust 中访问数据的主要方式。理解引用和值的关系,是理解堆内存的关键。
引用是什么?
-
&T:对类型T的值的不可变引用------你可以读,但不能写 -
&mut T:对类型T的值的可变引用------你可以读,也可以写
rust
let x = 42;
let r: &i32 = &x; // r 是 x 的不可变引用
// *r = 43; // ❌ 不能通过 &T 写入
let mut y = 42;
let r: &mut i32 = &mut y; // r 是 y 的可变引用
*r = 43; // ✅ 可以通过 &mut T 写入
引用不拥有值,它只是借用。这意味着引用离开作用域时不会释放值的内存------释放是所有者的事。
引用本身也是一个值。
引用占用内存(一个机器字),有地址,可以 Copy(&T 是 Copy):
rust
let x = 42;
let r1 = &x; // 对 x 的引用
let r2 = r1; // 复制引用(&T 是 Copy 的)
// r1 和 r2 都指向 x,两者都有效
1.7 值 vs 引用------两组正交的概念
理解 Rust 的关键:值有值的属性,引用有引用的属性,两者是正交的。
第一轴------值的可变性(在声明时决定):
rust
let x = 42; // x 的值不可变
let mut y = 42; // y 的值可变
第二轴------引用的类型(在借用时决定):
rust
let r1 = &x; // 不可变引用 ------ 只能读
let r2 = &mut y; // 可变引用 ------ 可读可写
正交表:
scss
数据不可变(let x) 数据可变(let mut y)
& (不可变引用) ✅ ✅
&mut (可变引用) ❌ ✅
每个格子的解读:
ini
第一格:let x = 42; let r = &x;
→ 数据不可变,引用也不可变 → 只能读
→ ✅ 安全共享
第二格:let mut y = 42; let r = &y;
→ 数据可变,但引用不可变 → 只能读
→ ✅ 安全:借出只读访问,所有权仍可写
第三格:let x = 42; let r = &mut x;
→ ❌ 编译错误!数据本身不可变时,不能借出可变引用
→ 因为 &mut 意味着"我要修改数据",但数据声明为不可变
第四格:let mut y = 42; let r = &mut y;
→ 数据可变,引用也可变 → 可读可写
→ ✅ 唯一写入路径
核心规则:&mut 要求数据本身是可变的。
这条规则的含义:
let x = 42之后,不能写let r = &mut x------编译器拒绝let mut x = 42之后,&mut x和&x都可以- 可变性在数据声明时就决定了,引用只能"映射"这一属性
1.8 Box/Rc/Arc 如何产生引用
堆包装类型通过 Deref trait 自动产生对被包装数据的引用。
Deref trait 的定义:
rust
pub trait Deref {
type Target: ?Sized;
fn deref(&self) -> &Self::Target;
}
当你对 Box<T>、Rc<T>、Arc<T> 使用 * 或 . 操作符时,编译器自动调用 deref():
rust
let b = Box::new(42);
// 显式解引用:
let r: &i32 = &*b; // *b 通过 Deref 得到 i32,再取引用
let val: i32 = *b; // *b 得到 i32 的值(i32 是 Copy)
// 隐式解引用(方法调用时自动发生):
// . 操作符会自动尝试 deref 直到找到匹配的方法
println!("{}", b); // 自动 &b → &Box<i32> → deref → &i32 → Display
这就是"智能指针"的含义:Box / Rc / Arc 像一个指针一样工作------你通过它们访问堆上的数据,语法和直接使用引用一样。
scss
栈上: 堆上:
┌──────────┐ ┌──────┐
│ Box<i32> │ deref()│ i32 │
│ ptr──────────────▶│ 42 │
└──────────┘ └──────┘
你的代码: b + 1
编译器理解: (*b) + 1
等价于: *(b.deref()) + 1
最终: 42 + 1
Rc<T> 和 Arc<T> 的 Deref 实现完全一样 ------它们只是管理引用计数的方式不同,但最终都通过 deref() 返回 &T。
rust
let r = Rc::new(42);
let a = Arc::new(42);
println!("{}", r); // ✅ 通过 Deref 变成 &i32
println!("{}", a); // ✅ 同上
总结引用与堆包装类型的关系:
| 类型 | 拥有数据 | Deref 目标 | 通过 Deref 得到 |
|---|---|---|---|
Box<T> |
唯一 | T |
&T |
Rc<T> |
共享(引用计数) | T |
&T |
Arc<T> |
共享(原子引用计数) | T |
&T |
&T |
不拥有(借用) | --- | 本身就是引用 |
&mut T |
不拥有(借用) | --- | 本身就是引用 |
1.9 引用的"向下传递"与堆的关系
综合以上:引用是访问堆数据的通道,但引用本身是栈上的值。
rust
let b = Box::new(42); // b(栈)→ 堆上的 42
let r: &i32 = &*b; // r(栈)→ 同一块堆内存
// 引用可以向下传递:
fn read(r: &i32) {
println!("{}", r);
}
read(r); // ✅ 引用向下传给 read
// 但引用不能向上传递(1.4 节的规则同样适用):
fn try_escape() -> &i32 {
let b = Box::new(42);
&*b // ❌ 编译错误!b 在函数返回时销毁
}
// 即使数据在堆上,引用本身受栈约束------Box 是局部变量
这里有一个微妙但重要的点:堆数据本身可以逃逸栈(返回 Box<T>),但引用不能------因为你返回引用时,引用指向的"东西"必须还在。 返回 Box<T> 是把拥有权转给调用者,调用者接收后堆数据继续存活。返回 &T 只是"借",借的东西得有人一直持有。
rust
// ✅ 正确:把拥有权转给调用者
fn make() -> Box<i32> {
let b = Box::new(42);
b // 所有权随 Box 转移
}
// ❌ 错误:引用无法逃逸
fn borrow() -> &i32 {
let b = Box::new(42);
&*b // b 被 Drop 后,引用悬空
}
// ✅ 正确:引用从传入的参数来
fn reborrow<'a>(b: &'a Box<i32>) -> &'a i32 {
&*b // 返回的引用寿命跟传入参数一样长
}
1.10 那如果需要"向上"传递怎么办?
有三种方式可以将数据从被调用者传递给调用者:
rust
// 方式 1:返回值本身(栈上传递)
fn make_value() -> i32 {
42 // ✅ 返回栈上的值
}
// 方式 2:由调用者传入写目标
fn fill(buf: &mut [i32]) {
buf[0] = 42; // ✅ 写入调用者提供的缓冲区
}
// 方式 3:堆分配(本章的主题)
fn make_on_heap() -> Box<i32> {
Box::new(42) // ✅ 在堆上分配,返回所有权
}
方式 1 和 2 仍然受栈约束------数据最终还在调用者的栈上。方式 3 是唯一真正"逃逸"栈的方式:数据在堆上,它的生命周期不受创建它的函数控制。
二、堆:跳出栈约束的唯一方式
2.1 堆的本质
堆(heap)是一块独立于调用栈的内存区域:
内存布局:
┌────────────────────┐
│ 代码段 │ 程序的指令
├────────────────────┤
│ 栈 │ 每次函数调用分配,返回时释放(LIFO)
├────────────────────┤
│ 堆 │ 手动请求分配,手动或自动释放
├────────────────────┤
│ 数据段 │ 全局变量、静态变量
├────────────────────┤
│ 常量区 │ 字符串常量等
└────────────────────┘
栈和堆的根本区别:
| 维度 | 栈 | 堆 |
|---|---|---|
| 分配方式 | 自动(函数调用时) | 手动请求(malloc / Box::new) |
| 释放时机 | 函数返回时 | 手动 free 或自动 Drop |
| 生命周期 | 绑定于栈帧 | 独立于栈帧 |
| 速度 | 极快(一条指令) | 较慢(分配器查找空闲块) |
| 大小限制 | 小(通常几 MB) | 大(取决于物理内存) |
2.2 堆解决了"栈的约束"
当数据在堆上时,它的生命周期不受创建它的栈帧约束:
rust
fn make_string() -> Box<String> {
let s = Box::new(String::from("hello"));
// s 在堆上,Box 是栈上的指针
s // 返回 Box(栈上的指针),堆上的 String 仍然存活
} // 调用者的栈帧接收 Box,堆上的 String 继续存在
fn caller() {
let s = make_string(); // s 是 Box<String>
println!("{}", s); // ✅ 堆上的字符串仍然有效
} // 当 s 离开 caller 的作用域时,
// Box 的 Drop 释放堆上的 String
堆数据可以安全地在函数间传递,因为:栈上只传递指针,数据本身在堆上不动。谁拥有指针,谁就拥有数据。
2.3 C 中的堆:malloc/free------原始、直接、危险
c
#include <stdlib.h>
char* make_string() {
char* s = malloc(100);
if (!s) return NULL;
strcpy(s, "hello");
return s; // 返回堆内存,调用者必须 free
}
void caller() {
char* s = make_string();
printf("%s", s);
free(s); // 必须记得------否则内存泄漏
}
C 中堆分配的五个问题:
c
// 1. 可能返回 NULL ------ 必须检查
char* p = malloc(100);
if (!p) return -1;
// 2. 必须记得手动 free ------ 容易漏掉
// (漏了就是内存泄漏)
// 3. free 后还能用 ------ use-after-free
free(p);
p[0] = 'a'; // ❌ 未定义行为(编译器不说 NO)
// 4. 可能 double-free
free(p);
free(p); // ❌ 未定义行为
// 5. 不知道 malloc 的是谁、该谁 free
// char* process_data(const char* input);
// 返回的指针是 malloc 过的吗?需要 free 吗?
// 只能看文档,文档可能不准确
小结:
rust
C 的堆分配: Rust 的堆分配:
malloc → free Box::new → Drop::drop
↑ 手动配对 ↑ 自动配对
↑ 易错 ↑ 编译器保证
2.4 Rust 的堆分配哲学
Rust 把堆内存的分配和释放封装在包装类型中,通过所有权和 Drop 确保安全:
rust
// 所有堆内存操作都通过类型完成
let b = Box::new(42); // 堆上分配一个 i32
let r = Rc::new(42); // 堆上分配一个引用计数的 i32
let a = Arc::new(42); // 堆上分配一个原子引用计数的 i32
// 释放时------自动
// b、r、a 离开作用域时,各自的 Drop 自动释放堆内存
三种类型的核心理念:
| 类型 | 所有权模式 | 线程安全 | 开销 | C 的对应 |
|---|---|---|---|---|
Box<T> |
唯一所有权 | Send(如果 T: Send) | 无额外开销 | malloc + free |
Rc<T> |
共享所有权(引用计数) | 非 Send(单线程) | 非原子计数 | 手动 refcount |
Arc<T> |
共享所有权(原子引用计数) | Send + Sync | 原子计数 | __sync_fetch_and_add |
以下三节逐一详解。
三、Box<T> ------ 唯一所有权的堆分配
3.1 C 的对应:malloc + free
c
int* p = (int*)malloc(sizeof(int));
if (p == NULL) return -1; // 必须检查 NULL
*p = 42;
// 使用 p ...
free(p); // 必须记得释放
// p 仍然可以访问(悬垂指针)
C++ 中的等效:std::unique_ptr<int>(new int(42))------同样也是唯一所有权、自动释放。
3.2 Rust 的 Box
rust
let b = Box::new(42); // 堆分配,值初始化为 42
// 使用 b ...
println!("{}", b); // 自动解引用:显示 42
// b 离开作用域 → Drop 自动释放堆内存
Box<T> 是一个拥有所有权的指针:
rust
栈上: 堆上:
┌──────────┐ ┌──────┐
│ Box<i32> │ ─────▶ │ i32 │
│ ptr │ │ 42 │
└──────────┘ └──────┘
3.3 Box 解决了 C 的哪些问题
rust
// 问题 1:Box 不可能为 NULL
let b = Box::new(42);
// let b: Box<i32> = ??? // 不可能为空
// 问题 2:离开作用域自动 Drop------不会忘记 free
{
let b = Box::new(42);
} // ✅ 自动释放
// 问题 3:所有权唯一------不会 double-free
let b1 = Box::new(42);
let b2 = b1; // move:所有权给 b2
// b1 已失效
// b2 离开时 Drop 只调用一次 ✅
// 问题 4:use-after-free 不可能
let b = Box::new(42);
std::mem::drop(b); // 显式释放
// println!("{}", b); // ❌ 编译错误:b 已移出
3.4 Box<T> 的典型使用场景
场景 1:将大对象移到堆上
rust
// 栈上的大数组
let large_array: [u8; 1024 * 1024] = [0; 1024 * 1024]; // 1MB 栈空间!
// 可能导致栈溢出
// 堆上的大数组
let large_box: Box<[u8; 1024 * 1024]> = Box::new([0; 1024 * 1024]); // 堆上
// 栈上只有指针,安全
场景 2:递归类型
rust
// Rust 中递归类型必须使用 Box
enum List<T> {
Cons(T, Box<List<T>>), // Box 打破循环------大小固定
Nil,
}
// 如果没有 Box,编译器不知道 List 有多大(无限递归)
场景 3:Trait 对象(动态分发)
rust
// 不同大小的类型通过 Box 统一
trait Animal {
fn speak(&self);
}
struct Dog;
impl Animal for Dog {
fn speak(&self) { println!("woof"); }
}
struct Cat;
impl Animal for Cat {
fn speak(&self) { println!("meow"); }
}
let animals: Vec<Box<dyn Animal>> = vec![
Box::new(Dog),
Box::new(Cat),
]; // ✅ 不同的类型通过 Box<dyn Animal> 统一存储
场景 4:转移堆数据的所有权
rust
fn process(data: Box<Vec<i32>>) {
// 处理 data ...
} // data 在这里自动释放
fn main() {
let v = Box::new(vec![1, 2, 3]);
process(v); // ✅ 所有权转移给 process
// v 已失效
}
3.5 零成本抽象:Box 编译后就是 malloc/free
rust
let b = Box::new(42);
// 编译为 ≈
// int* b = malloc(sizeof(int));
// *b = 42;
// (如果 malloc 失败,程序 panic)
drop(b);
// 编译为 ≈
// free(b);
Box<T> 没有添加任何额外的检查或元数据。它的开销就是 malloc / free 的开销------和 C 完全一致。多出来的只是编译期的安全检查。
3.6 C vs Rust 对比
| 维度 | C (malloc + free) |
Rust (Box<T>) |
|---|---|---|
| 分配失败 | 返回 NULL(必须检查) | panic(不可忽略) |
| 释放时机 | 手动 free() |
自动 Drop |
| 所有权 | 无(多个指针可指向同一块内存) | 唯一(赋值 = move) |
| 空悬指针 | 可能(free 后仍可解引用) | 不可能(移出后编译器拒绝访问) |
| 双重释放 | 可能 | 不可能 |
| 类型安全 | void* 需手动 cast |
完整类型信息 |
| 运行时开销 | 与 Box 相同(都是 malloc/free) | 与 C 相同 |
四、Rc<T> ------ 单线程引用计数共享所有权
4.1 C 中的对应:手动引用计数
当多个部分需要共享同一块数据时,唯一所有权(Box)不够用。C 中常用引用计数:
c
struct RefCounted {
int refcount;
char* data;
// ...
};
void ref_inc(struct RefCounted* obj) {
obj->refcount++;
}
void ref_dec(struct RefCounted* obj) {
obj->refcount--;
if (obj->refcount == 0) {
free(obj->data);
free(obj);
}
}
// 使用时:
struct RefCounted* p = create_object();
ref_inc(p); // 新用户加入
// ... 使用 ...
ref_dec(p); // 用户离开
ref_dec(p); // 原始用户离开 → 释放
C 中手动引用计数的问题:
c
// 1. 忘记 ref_inc → 提前释放
// 2. 忘记 ref_dec → 内存泄漏
// 3. 线程安全问题:非原子操作下多线程同时 inc/dec → 计数错误
// 4. 循环引用:A 引用 B,B 引用 A → 两者引用计数永不为 0
C++ 中的等效:std::shared_ptr<T>------与 Rc 类似,但 std::shared_ptr 是线程安全的(原子计数),因此更像 Arc。
4.2 Rust 的 Rc<T>
rust
use std::rc::Rc;
let original: Rc<i32> = Rc::new(42);
let shared1 = Rc::clone(&original); // 或 original.clone()
let shared2 = Rc::clone(&original); // 或 original.clone()
println!("{}", shared1); // ✅ 三个 Rc 共享同一个 i32
println!("{}", shared2); // ✅
// 引用计数 = 3
// 当 original、shared1、shared2 离开作用域时
// 引用计数递减,最后一次 Drop 释放堆内存
4.3 Rc::clone vs Box 的 clone
关键区别:
rust
let b = Box::new(42);
let b2 = b.clone(); // ✅ 深拷贝:新分配堆内存,复制值
let r = Rc::new(42);
let r2 = Rc::clone(&r); // ✅ 浅拷贝:只递增引用计数,不复制值
// 两个 Rc 指向同一个堆内存
对于 Rc,clone() 不是"复制数据",而是"增加了一个共享者"。这是 C 的手动引用计数中 ref_inc 做的事。
4.4 Rc 与 C 的引用计数对比
| 维度 | C 手动 refcount | Rust Rc<T> |
|---|---|---|
| 递增 | 手动调用 ref_inc(obj) |
Rc::clone(&rc) 自动递增 |
| 递减 | 手动调用 ref_dec(obj) |
Drop 自动递减 |
| 释放条件 | 手动检查 refcount == 0 | 自动:计数到 0 时 Drop 释放 |
| 忘记递增 | 可能提前释放 | 不可能------clone 是唯一方式 |
| 忘记递减 | 内存泄漏 | 不可能------Drop 保证 |
| 循环引用 | 需要手动打破 | Weak<T> 解决 |
4.5 Weak<T>:打破循环引用
当两个 Rc 互相引用时,它们的引用计数永远不会降为 0:
rust
use std::rc::Rc;
// 这种情况会导致内存泄漏:
{
let a = Rc::new(42);
let b = Rc::clone(&a); // 只是普通的共享
} // ✅ 引用计数 2→1→0,正确释放
// 但如果是互相持有:
struct Node {
value: i32,
next: Option<Rc<Node>>,
}
// 如果 Node A.next = Node B, Node B.next = Node A
// A 的 refcount = 1(B 持有),B 的 refcount = 1(A 持有)
// 两者都不会释放 → 内存泄漏
Weak<T> 解决这个问题:
rust
use std::rc::{Rc, Weak};
struct Node {
value: i32,
parent: RefCell<Weak<Node>>, // parent 用 Weak------不增加引用计数
children: RefCell<Vec<Rc<Node>>>,
}
// 当父节点释放后,子节点通过 Weak::upgrade() 获取父节点引用
// 返回 Option<Rc<T>>------如果父节点已释放,返回 None
Weak<T> 的意义:"我想引用你,但我不决定你的生命周期。"
4.6 适用场景
rust
// 1. 图的共享节点
// 2. 树的子节点需要访问父节点(父用 Weak)
// 3. 缓存:多个消费者共享同一数据
// 4. 不可变数据的多方共享
// 注意:Rc<T> 中的数据是不可变的!
// 如果需要可变性,需要 Rc<RefCell<T>>(但在单独章节讨论)
五、Arc<T> ------ 多线程引用计数共享所有权
5.1 C 中的对应:原子引用计数
多线程环境下,普通的 refcount++ 不是线程安全的------两个线程可能同时读到旧值,都加 1,结果计数只增加了 1(而不是 2):
c
// 错误的做法(非原子操作):
void ref_inc(struct RefCounted* obj) {
obj->refcount++; // ❌ 可能被两个线程同时执行
}
// 正确的做法(使用原子操作):
#include <stdatomic.h>
void ref_inc_atomic(struct RefCounted* obj) {
atomic_fetch_add(&obj->refcount, 1);
// 或 __sync_fetch_and_add(&obj->refcount, 1)
}
C 中原子引用计数的问题:
-
需要正确的内存序(memory order)------
memory_order_relaxedvsacquirevsrelease -
容易选错顺序 → 难以调试的并发 bug
-
C 标准在 C11 之前没有原子操作(只能用编译器扩展)
5.2 Rust 的 Arc<T>
rust
use std::sync::Arc;
use std::thread;
let shared: Arc<i32> = Arc::new(42);
let mut handles = vec![];
for _ in 0..10 {
let arc_clone = Arc::clone(&shared);
let handle = thread::spawn(move || {
println!("{}", arc_clone); // ✅ 多线程共享
});
handles.push(handle);
}
// 引用计数 = 1(原始) + 10(线程中的 clone) = 11
for handle in handles {
handle.join().unwrap();
}
// 线程结束后,引用计数递减 → 最后降为 0 → Drop 释放
Arc 与 Rc 的唯一区别:Arc 使用原子操作管理引用计数,Rc 使用非原子操作。
rust
// Rc:高效,但不是线程安全的
// 不能跨线程传递(!Send)
let rc = Rc::new(42);
// thread::spawn(move || { println!("{}", rc); }); // ❌ Rc 不是 Send
// Arc:有原子操作的开销,但线程安全
let arc = Arc::new(42);
thread::spawn(move || { println!("{}", arc); }); // ✅ Arc 是 Send
5.3 Rc vs Arc 的性能差异
rust
// Rc::clone() 的开销 = 一次非原子加法
// 在现代 CPU 上:~1 纳秒
// Arc::clone() 的开销 = 一次原子加法(含内存屏障)
// 在现代 CPU 上:~10-20 纳秒
// 在单线程场景下,总是用 Rc
// 在多线程场景下,只能用 Arc
5.4 C vs Rust 对比
| 维度 | C 原子引用计数 | Rust Arc<T> |
|---|---|---|
| 操作方式 | 手动 atomic_fetch_add + 判断 |
Arc::clone() + 自动 Drop |
| 内存序 | 程序员手动选择(易错) | 自动选择正确顺序 |
| 线程安全 | 取决于是否正确使用原子操作 | 强制:Arc 实现了 Send + Sync |
| 循环引用 | 同 Rc 的问题 | Weak<T> 解决 |
| 开销 | 原子操作 | 同样开销 |
六、补充主题
6.1 栈 vs 堆:性能与选用原则
选用顺序(从优到劣):
php
1. 优先栈分配
let x = T { ... }; // 最快,一条指令
2. 如果需要堆 + 唯一所有权
Box::new(val) // 零开销包装
3. 如果需要堆 + 单线程共享
Rc::new(val) // 引用计数(非原子)
4. 如果需要堆 + 多线程共享
Arc::new(val) // 引用计数(原子)
性能对比:
scss
栈分配:mov rsp, [rsp - size] → ~1 周期
堆分配:malloc() → ~50-100 周期(取决于分配器)
堆释放:free() → ~50-100 周期
Box/Rc/Arc 的分配和释放与 C 的 malloc/free 成本相当。Rust 没有 GC,所以也没有 GC 暂停的问题。
6.2 裸指针不是堆包装
Rust 中有与 C 指针一一对应的裸指针,但它们不是管理堆内存的工具:
rust
// *const T = C 的 const T*
// *mut T = C 的 T*
let mut x = 42;
let ptr: *mut i32 = &mut x; // 裸指针可以指向栈
let raw: *const i32 = &x; // 也可以指向堆
// 但裸指针:
// ❌ 不拥有内存
// ❌ 解引用需要 unsafe
// ✅ 主要用于 FFI 与 C 交互
// 日常堆管理中不要使用裸指针
// 应使用 Box / Rc / Arc
6.3 堆包装类型的零成本抽象
Rust 的堆包装类型编译后与 C 代码等价:
rust
// Rust 源码
let b = Box::new(42);
println!("{}", b);
drop(b);
c
// 编译后 ≈ C 代码
int* b = malloc(sizeof(int));
if (b == NULL) panic(); // OOM 处理
*b = 42;
printf("%d\n", *b);
free(b);
Rust 在这里没有添加任何额外的运行时。零成本抽象的意思是:你得到的所有"安全检查"都在编译期完成,运行时与手写 C 代码相同。
6.4 自定义分配器
C 中可以通过 LD_PRELOAD 替换 malloc/free。
Rust 中可以通过 #[global_allocator] 替换全局分配器:
rust
use std::alloc::{GlobalAlloc, Layout};
struct MyAllocator;
unsafe impl GlobalAlloc for MyAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
// 自定义分配逻辑
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
// 自定义释放逻辑
}
}
#[global_allocator]
static ALLOCATOR: MyAllocator = MyAllocator;
// 之后所有的 Box::new / Rc::new / Arc::new 都会使用 MyAllocator
这与 C 的 LD_PRELOAD 不同------Rust 的分配器替换是类型安全的,只在 Rust 代码中有效,而 LD_PRELOAD 会拦截所有 malloc 调用(包括 C 库内部的)。
6.5 三种类型的完整对比表
| 维度 | Box<T> |
Rc<T> |
Arc<T> |
|---|---|---|---|
| 所有权 | 唯一 | 共享(引用计数) | 共享(原子引用计数) |
| 线程安全 | Send(如果 T: Send) | !Send(单线程) | Send + Sync |
| clone 行为 | 深拷贝数据 | 递增引用计数 | 递增原子引用计数 |
| drop 行为 | 释放堆内存 | 递减引用计数,到 0 释放 | 递减原子引用计数,到 0 释放 |
| 额外开销 | 无 | 一个非原子计数器 | 一个原子计数器 |
| 循环引用 | 不可能(唯一所有权) | 可能(用 Weak 解决) | 可能(用 Weak 解决) |
| 数据可变性 | 通过 &mut T |
仅共享引用(不可变) | 仅共享引用(不可变) |
| C 的直接对应 | malloc + free |
手动非原子引用计数 | 手动原子引用计数 |
| C++ 相似 | std::unique_ptr |
--- | std::shared_ptr |
七、与 C 程序员的对话
对话一:"Box 不就是 malloc 包装了一下?"
C 程序员 :"
Box::new就是调用了 malloc 吧?那和直接 malloc 有什么区别?"
Rust :"运行时的确调用了 malloc,区别不在运行时在编译期。Box让编译器知道你拥有这块堆内存------因此它会自动释放,不会 double-free,不会 use-after-free。这些 C 中要花几周调试的问题,Rust 在编译期就排除了。"
c
// C ------ 编译通过,运行崩溃
int* p = malloc(sizeof(int));
free(p);
free(p); // ❌ 编译通过,运行崩溃
rust
// Rust ------ 编译不通过
let p = Box::new(42);
drop(p);
drop(p); // ❌ 编译错误:p 已被移出
对话二:"Rc 就是手动引用计数的自动化"
C 程序员 :"我在 C 中用过引用计数,就是
refcount++/refcount--。Rc 不就是把它自动化了吗?"
Rust:"是的,但自动化就是全部的区别。手动引用计数中,你每一次 clone(refcount++)和 drop(refcount--)都需要你亲自写。在大型项目中,你总会漏掉一个------要么忘记 ++ 导致过早释放,要么忘记 -- 导致泄漏。Rust 让编译器替你管理这些配对操作。"
c
// C ------ 手动 refcount,漏了一个 ++ 或 -- 就是 bug
struct User* u = create_user();
ref_inc(u); // 传给另一个模块
ref_inc(u); // 自己再保留一份
process(u);
ref_dec(u); // 自己用完
// 但另一个模块中的 ref_dec 呢?如果忘记 ------ 泄漏
rust
// Rust ------ clone 自动 ++,drop 自动 --
let u = Rc::new(User::new());
let u2 = Rc::clone(&u); // refcount++
let u3 = Rc::clone(&u); // refcount++
// u2、u3、u 各自自动 Drop → refcount 递减到 0 → 释放
对话三:"那 Arc 不就是加了原子操作的 Rc?"
C 程序员:"理解了。Rc 是非原子引用计数(单线程用),Arc 是原子引用计数(多线程用)。那和 C 中的原子操作比有什么优势?"
Rust :"和 C 的原子引用计数一样的性能开销。但 C 中你需要自己选择正确的内存序------relaxed、acquire、release、acq_rel。选错了就是诡异的并发 bug。Rust 的标准库内部已经为你选好了,你不用操心。"
c
// C ------ 内存序选错可能导致 bug
atomic_fetch_add(&refcount, 1, memory_order_relaxed);
// ↑ 这个 relaxed 可能合适,但也可能不
// 如果错了,调试极其困难
rust
// Rust ------ 内部使用正确顺序
let a = Arc::new(data);
let a2 = Arc::clone(&a); // ✅ 内部使用正确内存序
八、小结
8.1 核心逻辑链
swift
引用只能"向下"传递(调用者 → 被调用者)
↓
不能返回对局部变量的引用(栈帧约束)
↓
要跳出栈的约束 → 堆内存
↓
堆内存通过包装类型管理:
Box<T> = 唯一所有权 ≈ malloc + free
Rc<T> = 共享(单线程) ≈ 手动 refcount
Arc<T> = 共享(多线程) ≈ 原子 refcount
8.2 解决的核心 C 问题
| C 的问题 | 如何解决 |
|---|---|
malloc / free 手动配对 |
Box / Rc / Arc 的 Drop 自动释放 |
| 返回局部变量地址 | 堆分配 + 返回 Box<T> |
| 谁该 free 不确定 | 从返回类型直接看出:Box = 唯一拥有者 |
| 忘记 free | 无法忘记------离开作用域自动触发 |
| double-free | 所有权唯一 + move 后原值失效 |
| use-after-free | 移出后编译器禁止访问 |
| 手动 refcount 配对 | Rc::clone / Drop 自动配对 |
| 原子 refcount 内存序 | Arc 内部使用正确顺序 |
8.3 C 程序员的三句话
Box<T>:就是malloc+free,但自动配对,无法忘记。
Rc<T>:就是手动引用计数,但 clone 自动++、drop 自动--,不会漏。
Arc<T>:就是原子引用计数,但内存序自动选好,不用你操心。
堆的整体:当数据需要逃逸栈帧时,堆是唯一路径。Rust 的堆包装类型让这条路径是安全的。