《安全 Rust 的边界在哪?》— 中文解读

《安全 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 { ... }

这能工作,但有两个问题:

  1. 释放后再用(use-after-free) --- 下标可能指向已经被释放的位置,导致 panic 或更糟
  2. 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 占用的内存被标记为"空闲"了。但此时:

  • cnext 还指着 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>,    // 不可伪造的身份证
}

关键点:

  1. Gc 没有实现 Deref --- 你不能直接解引用它。你必须在 Arena 的上下文中用它
  2. Gc 可以 Send + Sync --- 因为 Gc 本质上像一个下标,没有实际数据,只是"索引"的指针版本
  3. 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 应该是一种数据结构 ,而不是语言运行时的一部分。你需要的时候就拿来用,不需要就不用。就像你用 HashMapVec 一样。

这在 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 语言的"全靠自觉"要好一万倍。

相关推荐
kang0x02 小时前
从零开始的勇士之路 - Writeup by AI
安全
kyriewen112 小时前
Next.js:让你的React应用从“裸奔”到“穿衣服”
开发语言·前端·javascript·react.js·设计模式·ecmascript
Nice__J2 小时前
ISO26262功能安全——SafeOS
java·linux·安全
三品吉他手会点灯2 小时前
C语言学习笔记 - 18.C编程预备计算机专业知识 - 什么是变量
c语言·开发语言·笔记·学习
好奇龙猫2 小时前
[大学院-python-base gammer learning2: python base programming ]
开发语言·python
海盗12342 小时前
C#上位机开发-S7协议通信
开发语言·c#
alxraves2 小时前
医疗器械生产制造法规要求
安全·健康医疗·制造
小短腿的代码世界2 小时前
Qt 2D 绘制实战与性能优化深度解析
开发语言·qt·性能优化