《安全 Rust 的边界在哪?》--- 中文解读
原文:The Edge of Safe Rust --- kyju.org, TokioConf 2026 演讲文字版
这篇文章到底在讲什么?
用一句话总结作者要表达的意思:
一个 Rust 极客在安全 Rust(Safe Rust)里硬是用纯安全代码造了一个追踪式垃圾回收器(Tracing GC),然后问:我们到底把 Rust 逼到了什么程度?
作者干了一件看似自相矛盾的事:用禁止循环引用 的 Rust,来写一个全靠循环引用的垃圾回收器。
一、背景:Rust 的"超能力"也是它的"紧箍咒"
Rust 三大阵营中的第①类
编程语言处理指针(内存管理)的方式大致分三类:
| 类型 | 代表语言 | 原理 |
|---|---|---|
| 所有权+自动析构 | Rust, Swift, C++(理论上) | 谁拥有谁释放,一次只有一个主人 |
| 可达性(GC) | JS, Python, Go, Java, Lua | 从根出发,能到达就活着,不能就回收 |
| 手动管理(野指针满天飞) | C, C++(实际上) | malloc/free,全靠程序员自觉 |
Rust 选择了第①类 ,好处是:确定性、零运行时、性能可预测。
代价呢? → 处理循环引用的时候,Rust 非常痛苦。
一个简单的例子:循环链表
在 Lua 里写循环链表,一行搞定:
lua
a = {}; b = {}; c = {}
a.next = b; b.next = c; c.next = a
在 Rust 里?你试试看。
用 &'a T 引用的话,编译器会告诉你:"这不可能------谁先释放?谁拥有谁?说不清楚,不给过。"
二、Rust 的"解法 1":用 Vec + 下标索引
最朴素的思路:既然指针搞不定,那就别用指针。
rust
struct SlotAlloc<T> {
slots: Vec<Option<T>>, // 存数据
free_list: Vec<usize>, // 空闲位置
}
// "指针"其实就是一个 usize 下标
fn alloc(&mut self, value: T) -> usize { ... }
fn get(&self, index: usize) -> &T { ... }
这能工作,但有两个问题:
- 释放后再用(use-after-free) --- 下标可能指向已经被释放的位置,导致 panic 或更糟
- ABA 问题 --- 释放一个下标后,新分配的数据占用了同一个下标,你以为是原来的数据,结果是新的,可能引发严重错误(类似 Heartbleed)
为了让下标更安全,可以加 generation(代) 和 alloc_id(分配器ID):
rust
struct Index {
alloc_id: u32, // 分配器的唯一ID
generation: u32, // 这一"代"的编号
slot: u64, // 实际位置
}
这样每次访问都会校验:ID 对不对?代对不对?不对就 panic。
作者吐槽: 这不就是在重新发明指针吗?只不过是从硬件层面挪到了软件层面。
三、解法 2:真用引用!但必须保证所有人一起死
这是有趣的部分------你确实可以在 Rust 里用真正的引用造循环链表,但有个前提:
rust
struct Node<'a> {
value: String,
next: Cell<Option<&'a Node<'a>>>,
}
let bump = bumpalo::Bump::new();
let a = bump.alloc(Node::new("1"));
let b = bump.alloc(Node::new("2"));
let c = bump.alloc(Node::new("3"));
a.set_next(Some(b));
b.set_next(Some(c));
c.set_next(Some(a)); // ✅ 这能编译
为什么能编译? 因为 bumpalo::Bump 分配的所有引用拥有完全相同的生命周期。它们必须一起生,一起死。
那怎么销毁?
答案是:不能销毁。 任何尝试单独 drop 的代码都会导致 UB Undefined Behavior(未定义行为)。因为没办法决定 a、b、c 的 drop 顺序------不管你先 drop 谁,另一个都会访问到已释放的内存。
** 为什么会这样?**
好问题!UB = Undefined Behavior(未定义行为)
假设我们有一个循环链表 a -> b -> c -> a,你想手动释放它们。
你写了这样的代码:
rust
// 假设我们手动管理 drop
drop(a); // 先释放 a
drop(b); // 再释放 b
drop(c); // 最后释放 c
问题在哪?
释放 a 之后,a 占用的内存被标记为"空闲"了。但此时:
c的next还指着a的那块内存a那块内存里的数据已经无效了
然后你释放 b,最后释放 c。释放 c 的时候 Rust 得先看看 c.next 指向谁------它指向已经被释放的 a。
这时候就发生 UB 了:你访问了一块已释放的内存。
UB 在 Rust 里意味着什么?
不是简单的"程序崩溃"那么简单。UB 意味着:
| 可能的结果 | 说明 |
|---|---|
| 恰好没事 | 那块内存还没被覆盖,数据还在------但只是运气好 |
| 段错误崩溃 | 程序直接挂了,至少能发现 |
| 数据静默损坏 | 那块内存已经被别的变量覆盖了,读到的是垃圾数据------最可怕的 |
| 编译优化导致诡异行为 | 编译器看到 UB 后会做各种"大胆"的优化,逻辑跳转可能都变了 |
举个例子
rust
let mut a = Box::new(42);
let ptr: *const i32 = &*a;
drop(a);
// 现在 ptr 指向已释放的内存
unsafe { println!("{}", *ptr); }
// 这是 UB!可能打印 42,可能打印垃圾,可能崩掉
// Rust 编译器看到 unsafe 里的 UB 后,可能连 safe 部分的逻辑都改掉
因为你跳出了 Rust 的安全护栏。编译器不再帮你兜底了,出了任何事都是你自己的锅。
四、解法 3:核武器 --- Generativity(生成性类型)
终于到最有意思的部分了。
for<'a> 的正确用法
rust
fn generate_an_id(f: impl for<'a> Fn(Id<'a>)) {
let id: Id<'static> = PhantomData;
f(id);
}
generate_an_id(|a| {
generate_an_id(|b| {
// a = b; 这编译不过!
// b = a; 这也编译不过!
});
});
这里利用了 Rust 的一个特性:函数边界内的生命周期是"新的" ,每次调用 generate_an_id 都会生成一个全宇宙独一无二 的生命周期 'a。这个 'a 不能被缩短或延长,不能跟另一个 'a 合并。
这就像给每个 Arena 分配了一个不可伪造的身份证。所有在这个 Arena 里分配的 Gc 指针都带着这个身份证,拿去别的 Arena 用?编译器不给你过。
五、最终成品:基于 raw pointer 的安全 GC
把上述所有技术组合起来,最终得到:
rust
#[repr(transparent)]
struct Gc<'gc, T> {
ptr: NonNull<T>,
_id: Id<'gc>, // 不可伪造的身份证
}
关键点:
Gc没有实现Deref--- 你不能直接解引用它。你必须在 Arena 的上下文中用它Gc可以Send+Sync--- 因为Gc本质上像一个下标,没有实际数据,只是"索引"的指针版本Arena::enter提供了安全的操作环境 --- 进去之后,所有检查在编译时完成
rust
let arena = Arena::new();
let root = arena.enter(|ctx| {
let a = ctx.alloc(Node::new("1"));
let b = ctx.alloc(Node::new("2"));
let c = ctx.alloc(Node::new("3"));
a.set_next(Some(b));
b.set_next(Some(c));
c.set_next(Some(a)); // 安全地造了一个循环引用!
ctx.gc_to_root::<ArenaNode>(a) // 存根,退出后用
});
// 随时可以再进去操作
arena.enter(|ctx| {
let a = ctx.root_to_gc(&root).unwrap();
let b = a.next().unwrap(); // 安全!
});
六、更大的图景:一千个 GC 可能比一个 GC 更好
作者提出了一个反直觉的观点:
系统编程语言 = 你可以同时拥有任意多个 GC 的语言
传统印象:"加一个 GC 已经够糟糕了,加一千个不是更糟?"
实际上,Erlang 的成功恰恰说明:多个小 GC > 一个大 GC。每个 Erlang 进程有自己的 GC,一个进程的 GC 停顿不影响其他进程,P99 延迟极低。
作者认为 GC 应该是一种数据结构 ,而不是语言运行时的一部分。你需要的时候就拿来用,不需要就不用。就像你用 HashMap 和 Vec 一样。
这在 Rust 里是可以做到的。 gc-arena crate 已经在两个真实项目中运行良好:
- Ruffle(Flash 模拟器)--- 整个历史只出现过 3 次 GC 相关的 bug
- Fields of Mistria(游戏)--- 用 Rust VM 替代了 GMS2 脚本引擎
七、总结:安全 Rust 的边界在哪?
作者的回答很诚实:
边界在不断扩展,但过程非常痛苦。
你可以用纯 Safe Rust 实现追踪式 GC ------ 代码编译通过了,内存安全保证了,但代价是:
- 代码难以理解(作者称之为"个人地狱")
- 大量黑魔法(generativity、GATs、phantom data)
- 模式非常 niche,无法通用
但这就是 Rust 的魅力所在: 当你真的需要它的时候,你可以做到。而且代价(确保内存安全带来的复杂度)比 C 语言的"全靠自觉"要好一万倍。