在 Rust 生态中,垃圾回收(GC)一直是一个微妙的话题。Rust 的所有权系统与借用检查器本身就是为了替代传统 GC 而设计的,但仍有场景需要更灵活的内存管理方式。传统上,实现 GC 库需要大量 unsafe 代码来处理原始指针操作,然而社区中存在一个有趣的探索方向:能否完全不依赖 unsafe 实现一个完整的垃圾回收系统?答案是肯定的,其中最具代表性的实现是 safe-gc crate,它实现了零 unsafe 代码的 GC,为 Rust 的内存管理提供了一种全新的工程视角。
安全 GC 的核心设计:Heap 与 Arena
safe-gc 的核心思路是将 GC 对象存储在 Heap 中,而不是直接使用 Rust 的原生引用。与传统 GC 库不同,访问 GC 对象不是通过解引用指针,而是通过索引到 Heap 中获取。这种设计使得实现可以完全遵守 Rust 的所有权与借用规则,从而避免任何 unsafe 代码。
let mut heap = Heap::new();
let a = heap.alloc(List { value: 42, prev: None, next: None });
let b_value = heap[&a].value; // 通过索引访问,而非解引用
每个 Heap 内部维护一个 HashMap<TypeId, Arena<T>>,不同类型的对象存储在各自的 Arena 中。Arena 本质上是一个基于 Vec 的自由列表分配器,使用索引而非原始指针来管理内存。这种设计虽然不如手动内存管理高效,但完全符合 Rust 的安全约束。
根集管理与 Root 与 Gc 的区分
GC 的核心任务是从根集出发,标记所有可达对象。safe-gc 引入了一个关键概念:根集。每个 Arena 都有自己的 RootSet<T>,用于追踪「活跃」的 GC 对象。重要的是,库区分了两种指针类型:Gc<T> 和 Root<T>。前者是非根化的引用,在 GC 期间可能被回收;后者是根化的引用,保证对象在 GC 期间保持存活。
这种设计解决了 Rust GC 库的传统难题:在其他 GC 库中,用户必须手动决定何时需要对 GC 引用进行根化,否则可能在使用过程中出现悬垂引用。而在 safe-gc 中,分配返回的是 Root<T>,自动保证安全。Root<T> 通过包装 Rc<RefCell<FreeList<Gc<T>>>> 实现克隆语义,使得根集可以动态增长和缩减。
标记 - 清除算法的安全实现
safe-gc 实现了经典的标记 - 清除(Mark-Sweep)算法,但全部使用安全代码完成。标记阶段从根集开始,遍历所有可达对象并设置标记位;清除阶段则回收未标记的对象。关键在于,标记过程不需要修改被标记对象的结构,而是使用独立的位图来记录存活状态。
每个 Arena 维护自己的标记位图,标记过程使用两层循环:外层循环处理所有类型的管理堆,内层循环处理单个类型的标记栈。这种设计避免了单一全局标记栈的类型统一问题,因为 Heap 本身是类型异构的,无法直接遍历任意类型的对象。
清除阶段遍历 Arena 中的所有对象,检查对应标记位是否为设置。若未设置,则认为对象不可达,执行析构并释放其槽位到自由列表。清除完成后,如果可用空间不足容量的四分之一,Arena 会自动扩容,从而摊销 GC 的开销,避免频繁触发全量收集。
为什么 Trace 不是 unsafe trait
传统 Rust GC 库通常将 Trace trait 标记为 unsafe,因为实现者可能遗漏 GC 边的遍历,导致 Collector 错误地回收仍被引用的对象,引发 use-after-free 漏洞。这确实是一个关键的安全不变量,但 safe-gc 的设计将其从内存安全问题降级为普通的程序错误。
当用户使用悬垂的 Gc<T> 索引到 Heap 时,可能出现三种情况:运气好,其他引用保持了对象存活,访问成功;槽位已被释放但未复用,触发 panic;槽位已被新对象复用,访问到错误对象(ABA 问题)。第三种情况虽然隐蔽,但不会导致内存安全问题,只是逻辑错误。通过为 Arena 添加代际计数器可以将其转换为显式 panic,但会增加运行时开销。
这种设计的核心洞察是:Rust 的类型系统已经保证了内存安全,GC 库不需要再通过 unsafe 来强制正确性。即使 Trace 实现遗漏了某些边,最坏结果也只是程序 panic 或逻辑错误,而非内存损坏。
工程权衡:与 Rc/Arc 的对比
在工程实践中,完全避免 unsafe 的 GC 是否值得?答案取决于具体场景。传统的 Rc<T>(单线程)与 Arc<T>(多线程)通过引用计数提供共享所有权,无需 GC,但无法处理循环引用。打破循环需要手动使用 Weak<T>,这在复杂数据结构中容易遗漏。
safe-gc 提供了真正的 GC 语义,可以自动处理循环数据结构,用户的编程模型更接近于传统 GC 语言。但代价是:对象必须存储在 Heap 中而非堆上;访问需要通过索引而非直接解引用;每次分配都可能触发 GC;性能不如手动所有权或纯 Rc/Arc。
对于需要图结构且难以维护 Weak 引用的场景,safe-gc 提供了更简洁的解决方案。但如果性能是首要考量,或者数据结构本身可以避免循环,Rust 的所有权系统配合 Rc/Arc 仍是更高效的选择。
结论
safe-gc 证明了在 Rust 中实现零 unsafe 代码的垃圾回收完全可行,尽管性能不是最优的。这种探索的价值不在于替代现有的内存管理方案,而在于拓展了 Rust 内存管理的可能性边界。对于需要 GC 语义且重视安全性的场景,如嵌入式解释器或特定领域的运行时环境,这类库提供了独特的工程选择。