2个版本

0.1.1 2022年7月5日
0.1.0 2022年7月4日

#465 in 内存管理

Apache-2.0 OR MIT

55KB
1.5K SLoC

Elise

什么是 elise

Für Elise(简称Elise)是一个基于 shifgrethor 的并发垃圾回收尝试。目标是定义一个Rust中精确、跟踪垃圾回收的API,并保持Rust的所有安全保证。使用此库中定义的API的用户不会面临Rust可以预防的任何类型的内存错误。

elise提供对数据什么样的访问?

一些之前的垃圾收集器API通过只允许您从中复制数据而不是直接在托管内存中持有引用来解决垃圾收集的一些安全问题。这对复制收集器来说非常方便,因为它们不需要实现固定。

Elise与这些API不同。使用elise,您可以直接在托管堆中拥有引用。事实上,您可以在栈、托管堆和非托管堆之间有任意的引用。

  • 垃圾收集对象可以拥有在非托管堆中分配的数据,并且当这些对象被收集时,这些数据将被丢弃。
  • 垃圾收集对象可以拥有对栈的引用,并且您在安全代码中保证在它们超出作用域后不能从这些引用中读取。
  • 您可以在堆或栈上存储垃圾收集对象的指针。

所有这些的传递组合都是真实的:例如,一个GC'd对象可以拥有一个堆分配的引用到栈上对象的向量,而这些对象本身包含GC'd对象。

请注意,像Rust中的所有垃圾收集(例如 Rc)一样,elise只为它管理的数据提供不可变访问。有关内部可变性的部分请参阅后续章节。

elise是什么样的垃圾收集器?

Elise提供了一个垃圾收集器,但这不是elise有趣的地方。这里的垃圾收集器是最简单、最未优化的标记和清除。然而,使其安全的API可以应用于更高效的垃圾收集器,特别是具有以下特性

  • 这是一个用于追踪垃圾回收器的API,而不是像引用计数这样的其他垃圾回收技术。
  • 这是一个用于精确追踪的垃圾收集器的API,而不是像Boehme GC这样的保守式收集器。
  • 该API可以轻易地适应支持并发GC,当前的实现提供了一个线程安全的HeapRoot
  • 只要它们实现了固定机制,该API可以支持移动收集器。不支持固定机制的可移动收集器与elise的API目标不兼容。

项目状态如何?

代码已经编写,有时非常紧张。对应该正常工作的基本测试已经完成。没有尝试进行证明。它可能存在明显的错误。它可能段错误、毁掉你的洗衣物、突然停止并着火等。

你不应该将其用于任何依赖的事物(例如“在生产中”)!但如果你想为了乐趣而玩弄它,请尽情享受。

elise将被用于什么?

这目前是我JavaScript引擎的一个实验性项目。

为什么叫elise

“Für Elise”是贝多芬的一个著名作品。在台湾,每当有垃圾车经过时,总会演奏。

elise是如何工作的?

简而言之,像elise这样的精确追踪垃圾收集器是为这样的工作而设计的

  • 从内存的非托管部分(在我们的上下文中是堆栈和堆)到内存的托管部分的所有引用都被追踪。这些被称为“根”。
  • 从这些根开始,收集器“追踪”对象图,以找到所有仍然可以从这些根访问的对象(因此,仍然是“活”的对象)

我们的API需要正确地考虑根对象和追踪它们以到达传递性根对象。

根对象

由于我们的限制(即没有语言支持以及动态的非托管堆的存在),我们必须通过某种侵入式收集来追踪我们的根。因此,我们的根不能被移动。

幸运的是,我们最近在支持侵入式数据结构方面取得了很大的进步,这得益于固定API。根对象层位于底层的固定API之上,我们使用它来确保根以确定的堆栈顺序被丢弃。

根对象是用一个特殊的宏letroot!创建的。使用此宏创建的根带有特殊的生命周期'root,它是它们被创建的作用域的生命周期。您可以使用根的gc方法开始收集某些数据

// root: Root<'root>;
letroot!(root);

let x: Gc<'root, i32> = root.gc(0);

Gc指针是对数据的可复制引用,它证明了数据已被根对象。它带有根对象的生命周期,因此不能比创建它的根对象存活时间更长。

为了从函数返回Gc'd数据,您需要将一个根传递给函数

fn foo(root: Root<'root>) -> Gc<'root, i32> {
    root.gc(0);
}

您还可以使用一个根来重新根化已经根化一次的数据,延长其生命周期

fn foo(outer: Root<'root1>) -> Gc<'root1, i32> {
    // This root is only alive for the frame of this function call
    //
    // inner: Gc<'root2, i32>
    letroot!(inner);
    let x: Gc<'root2, i32> = inner.gc(0);

    // But you can extend a Gc rooted only for this function using the outer root:
    let x: Gc<'root1, i32> = outer.reroot(x);
    return x;
}

追踪

仅仅能够在Gc中根对象是不够的,您还需要能够从根对象传递性地追踪到其他对象。例如,您可能希望有一个存储在Gc中的结构体,其字段指向其他也在进行垃圾收集的对象。

出现的问题是确保只有在你知道它们确实是从根对象跟踪而来时,才能访问传递性根对象。一些组件帮助我们解决这个问题

  • 首先,要将类型放入垃圾收集器,它必须实现一个特质,该特质定义了如何跟踪它。
  • 其次,我们不仅有 Gc 类型,还有一个第二类型:GcStore
  • 使用派生访问器,我们可以保证一个安全的 API;让我解释一下

Gc 类型实现了 DerefCopy,它功能上像一个普通引用,除了你可以通过重新根化来扩展其生命周期。它不公开一个安全的 API 来构建它:唯一的构造函数是 unsafe 的 Gc::rooted 构造函数:要安全地调用这个构造函数,你必须证明这将在这个生命周期 'root 中根化。

GcStore 类型更像是一个 Box,除了它没有实现 Deref。你可以安全地构造一个 GcStore,它将具有 Box 语义,直到它被根化 - 也就是说,如果你在未先根化的情况下丢弃 GcStore,它将释放你放入其中的内容。

最后,作为实现垃圾收集所需特质的 derive 的一部分,你可以实现一个访问器,将你的 GcStore 字段转换为 Gc 字段。例如

#[derive(GC)]
struct Foo<'root> {
    #[gc] bar: GcStore<'root, Bar>,
}

此代码在 Foo 上生成此方法

fn bar(self: Gc<'root, Foo<'_>>) -> Gc<'root, Bar>

因为 derive 还保证了这个字段被正确跟踪,如果你有一个 Gc<Foo>,你可以安全地从它构建一个 Gc<Bar>

此行为也适用于几种容器类型。例如,你可以以相同的方式将一个 Vec<GcStore<_>> 转换为 Vec<Gc>

#[derive(GC)]
struct Foo<'root> {
    #[gc] vec: Vec<GcStore<'root, Bar>>,
}

// Generates:
fn vec<'root>(self: Gc<'root, Self>) -> Vec<Gc<'root, Bar>>;

析构函数

析构函数对垃圾收集器来说是一个棘手的问题。析构函数是安全的,因为我们能保证在结构体被丢弃时它们会被运行,但垃圾收集的对象实际上不会立即被丢弃(运行析构函数),而是在很久以后。这可能导致两个问题

  • 如果析构函数访问其他 Gc'd 数据,这些数据可能已经被收集器提前释放。
  • 如果析构函数访问栈上的数据,这些数据可能在收集器运行之前栈被弹出时被释放。

因此,GC 不会在其对象上运行析构函数。相反,它在收集每个对象之前运行一个终结器。你可以通过为你的类型实现 Finalize 特质,并添加一个 #[gc(finalize)] 属性来定义终结器中发生的事情

#[derive(GC)]
#[gc(finalize)]
struct Foo;

impl elise::Finalize for Foo {
    fn finalize(&mut self) {
        println!("Collecting a Foo");
    }
}

由于 Finalize 没有提供你的类型的 Gc 指针,你不能访问其他的 Gc 指针(换句话说,你不能“证明根性”,因为你不再在最终化器中有根)。然而,这不足以阻止你访问其他非拥有的数据,如栈引用。

因此,如果你的类型包含除 'root 之外的任何生命周期,尝试实现这样的最终化器将会失败。相反,你需要实现一个不安全的最终化器

#[derive(GC)]
#[gc(unsafe_finalize)]
struct Foo<'a>(&'a i32);

unsafe impl elise::UnsafeFinalize for Foo {
    fn finalize(&mut self) {
        println!("Collecting a Foo");
    }
}

你必须审计这些最终化器,并保证你的最终化器不会从其内部的任何借用引用中读取,否则你的代码是不安全的,并包含未定义的行为。

内部可变性

最后一个问题是内部可变性:你只能获取对 GC 指针的共享引用,理想情况下,你将能够使用某种形式的内部可变性来修改其内部的内容。

这个独特的问题与跟踪有关。假设你的类型中包含一个 RefCell<Option<i32>>>

let x: Gc<RefCell<Option<GcStore<i32>>>>;

let moved: GcStore<i32> = x.borrow_mut().take().unwrap();

// The value behind `x` is now `None`. The `moved` variable is not being traced
// at all, its entirely unrooted!

// Run the garbage collector. Because `moved` is unrooted, it will be
// collected. `moved` is now a dangling pointer
elise::collect();

// Put the moved and dangling pointer back into `x`:
*x.borrow_mut() = Some(moved);

// Observe `x`, which is now dangling. Segfault!
println!("{}", x);

我们不允许你在没有任何其他根化机制的情况下移动跟踪的 GcStore 指针。

因此,elise 目前只提供了对内部可变性的部分支持

  • 存在一个名为 NullTrace 的单独特质,它表示通过此类型的跟踪是无效操作(即它不包含任何 GC 指针)。你可以自由地拥有包含 NullTrace 数据的 CellRefCell 类型。
  • PinCell 是跟踪安全的,因为它不允许你移动它提供的数据。如果你不能移动数据,你就不能取消根化。

换句话说,你可以自由地对不包含 GC 指针的任何内容的正常内部可变性,你也可以对包含 GC 指针的内容有部分内部可变性(只有固定的可变引用)。

请注意,PinCell 为复制收集器引入了一些问题,因为它提供了一个 Pin<&mut T>,其他代码(例如异步/等待代码)可能依赖于 内存 稳定性(与语义稳定性相对,我们依赖于语义稳定性)。

找到新的可抽象的 API 以允许仅在跟踪内存位置之间移动数据是一个开放问题,这将允许你安全地移动 GC 指针。

依赖关系

~3–8.5MB
~71K SLoC