锈病安全跟踪GC设计之旅

2021-06-19 19:44:04

自从我开始在伺服的JS层上工作以来,我一直在考虑垃圾收集很长一段时间。我已经设计了一个GC库,在GC集成想法上致力于生锈本身,致力于伺服的JS GC集成,并帮助解决了其他GC项目的生锈。

因此,我倾向于经常被拉入GC讨论。我喜欢谈论gcs - 不要让我错了 - 但我经常最终走过相同的东西。懒惰我更愿意能够将人们推荐给一个人可以加快GC设计的总体空间,之后有可能有更多关于必要的权衡的深入讨论。

我将注意到这篇文章中的一些GCS是实验或不明意的。这篇文章的目标是展示这些作为设计的例子,不一定是您可能希望使用的通用箱子,但其中一些也是可用的板条箱。

经常混乱讨论GCS的事情是根据“GC”的一些定义,简单的参考计数是GC。通常,学术界使用的GC的定义广泛地指的是任何类型的自动内存管理。然而,大多数熟悉术语“GC”的程序员通常将其比作“Java,Go,Haskell和C#Do”,这可以明确地称为跟踪垃圾收集。

跟踪垃圾收集是跟踪哪些堆对象直接到达(“根”),数字掉整组可达堆对象(“跟踪”,也,“标记”),然后清理它们(“扫描“)。

除非另有说明1,否则在整个博客文章中我将使用术语“GC”来参考跟踪垃圾收集/收集器。

(如果您已经想要在RUDE中编写GC并阅读这篇文章以获取如何获取的想法,您可以跳过本节。您已经知道为什么有人想要编写生锈的GC)

每当这一主题被提升有人会不可避免地走“我认为生锈的点是避免GCS”或“GCS将破坏生锈”或其他东西。作为一般规则,不给评论部分给予太多重量是好的,但我认为解释为什么有人可能希望在锈病中的GC的语义中解释为什么。

有两个不同的用例。首先,有时您需要使用周期和RC< t&gt管理内存;由于RC-Cycles泄露,因此工作不足。 PetGraph或竞技场通常是这种模式的可接受的解决方案,但并非总是,特别是如果您的数据是超级异构的。这种事情经常在处理并发数据结构时经常挣脱;例如,Crossbeam具有一个基于时期的内存管理系统,虽然不是完整的追踪GC,但与GCS具有很多共同的特征。

对于这种用例,设计自定义GC很少必要,您可以查找像GC 2这样可重复使用的箱子。

第二种案例对我的经验更有趣,因为它不能通过现成的解决方案来解决,往往更频繁地播种:与使用垃圾收集器的编程语言集成(或实现)。伺服需要这样做,以便与SpidermoNkey JS引擎和光泽所需的光泽来执行此操作,以实现其Lua VM的GC。蟒蛇是纯Fort JS运行时,使用GC Crate将其垃圾收集器返回。

有时在与GCD语言集成时,您可以逃脱不需要实现完整的垃圾收集器:JNI这样做;虽然C ++没有本地垃圾收集,但JNI通过简单地“rooting”(我们将涵盖有点意味着什么)任何交叉到C ++边的东西3。这通常很好!

这一点的缺点是每个与GC管理的对象的互动必须通过API呼叫;您无法轻松“在GC中”嵌入“高效生锈/ C ++对象。例如,在浏览器中,大多数DOM类型(例如元素)都是在本机代码中实现的;并且需要能够包含对其他原生GC的类型的引用(应该可以检查节点的子节点而不需要调用javascript引擎)。

所以有时您需要能够与运行时与GC集成;甚至在您正在编写需要一个的运行时,甚至实现自己的GC。在这两种情况下,您通常希望能够能够安全地操纵来自RUST代码的GC'D对象,甚至可以直接在GC堆上放置生锈类型。

一句话:生根。在垃圾收集器中,在堆栈上使用“直接”的对象是“根”,您需要能够识别它们。在这里,当我说“直接”时,我的意思是“不必经过其他GC'D对象访问”,因此将对象放在VEC< t&gt内部。不会使它停止成为一个根,但将其放在其他一些GC'D对象中。

不幸的是,Rust并不真正有“直接在堆栈上”的概念:

struct foo {栏:选项< GC<酒吧>> } //这是一个root让bar = gc :: new(bar :: new()); //这也是一个root让foo = gc :: new(foo :: new()); //栏不应该是根源(但我们可以' t检测到!)foo .bar =一些(bar); //由于它'不在//另一个gc' d对象让v = vec! [foo];

Rust的所有权系统实际上使得更容易具有更少的根源,因为它相对容易地说明了GC'd对象的T& t的of gc'd对象不需要创建一个新的根,让rust的所有权系统对其进行排序,但能够排除区分“直接拥有”和“间接拥有的”是超级棘手的。

这一点的另一方面是垃圾收集真的是全局突变的时刻 - 垃圾收集器通过堆读取,然后删除那里的一些对象。这是脚下被拉出的地毯的一刻。 Rust的整个设计是追求这样的粗糙拉动,非常非常糟糕,因此这可能有点问题。这不如它最初声音那么糟糕,因为在所有地毯拉动之后大多只是清理无法到达的物体,但它会在将事情融合在一起时裁剪几次,特别是在析构函数和终结器中4.生根将很远例如,如果您能够宣布“否GC可能发生”的代码区域,则更容易宣布“不发生”5所以您可以紧密地控制漏斗拉动,并且必须少担心根。

特别值得唤醒析构函数。 GCD类型上的定制析构函数的巨大问题是定制析构函数完全可以在垃圾收集期间将自己藏到长期参考,导致悬挂参考:

struct longlived {摇摆:Refcell<选择< GC< cantkillme>>> } struct cantkillme {//在施工self_ref期间设置为点为自己:Refcell<选择< GC< cantkillme>>> long_lived:gc< foo> } icill for cantkillme {fn drop(& mut self){//附加到long_lived * self .long_lived.dangle .borrow_mut()= some(self .self_ref .borrow().clone().unwrap()) ;让long = gc :: new(longlived :: new()); {令不能= gc :: new(cantkillme :: new()); * cant.self_ref .brold_mut()=一些(cant.clone()); //无法超出范围,Cantkillme :: Drop正在运行//无法连接到long_lived.dangle但仍清除} //悬垂的参考!假设LoveLing = long .dangle .grow().unwrap();

此处最常见的解决方案是禁止使用#[导出(跟踪)]的类型上的析构函数,该类型可以通过使自定义导出生成删除实现,或者它生成导致冲突类型错误的某些内容来完成。

您可以另外提供具有不同语义的最终性状:GC在清理GC对象时调用它,但它可能会多次调用或根本不调用。这种东西在铁锈之外的GCS也是典型的。

在大多数垃圾收集的语言中,有一个运行时控制所有执行,都知道程序中的每个变量,并且能够在它喜欢时执行以运行GC。

Rust有一个最小的运行时,不能做到这一点,尤其是您的图书馆可以挂钩的可插拔方式。对于线程本地GCS,您基本上必须编写它,使得GC操作(像GC字段突变的内容;基本上由您的GC库公开的API子集)是唯一可能触发垃圾收集器的东西。

并发GCS可以在单独的线程上触发GC,但是每当这些线程尝试执行可能由正在运行的垃圾收集器失效的GC操作时,通常需要暂停其他线程。

虽然这可能会限制垃圾收集器本身的灵活性,但这对我们来说,从API设计方面非常适合:垃圾收集阶段只能在代码的某些知名时刻发生,这意味着我们只需要制作对这些边界安全的事情。许多设计我们将介绍从这个观察结果的构建。

在进入GC设计的实际示例之前,我想指出所有这些之间的一些设计,尤其是他们如何进行跟踪:

“跟踪”是遍历GC对象图表的操作,从根源开始,仔细阅读他们的孩子,以及他们的孩子的孩子等。

//使用手工执行,因为您可以获得错误的不安全特性跟踪{fn trace(& mut self,gc_context:& mut gccontext); }#[派生(追踪)] struct foo {vec:vec< GC<酒吧>> ,extra_thing:gc< Baz> ,just_a_string:string}

Trace的自定义导出基本上只需在所有字段上调用trace()。将写入Vec的跟踪实现,以便在所有字段上调用trace(),而字符串的跟踪实现将无关。 gc< t>可能有一个标记在gccontext中的可达性,或类似的迹象。

这是一个漂亮的标准模式,而追踪特征的细节通常会有所不同,但一般思想大致相同。

我不会进入Mark-and Sweep算法在这篇文章中如何工作的实际细节;它们有很多潜在的设计,它们从设计安全GC API的角度来看并不是那么有趣。但是,一般思想是保持最初由root填充的找到的对象的队列,跟踪它们以查找新对象,如果尚未进行跟踪,则会向上划分它们。清理未找到的任何对象。

这些设计之间的另一个共性是GC< t>总是潜在的共享,因此需要严格控制可变性,以满足Rust的所有权不变。这通常通过使用内部变形性来实现,就像如何RC< t>几乎总是与Refcell< t&gt配对;然而,对于突变,然而一些方法(如7osephine)的方法都允许在没有运行时检查的无变驱性。

一些GCS是单线程的,有些GCS是多线程的。单个螺纹螺纹通常具有GC< t>不发送的类型,因此在不同的线程上设置GC类型的多个图形,它们基本上是独立的。垃圾收集仅影响它正在执行的线程,所有其他线程都可以继续阻碍。

多线程GCS将具有发送GC< t>类型。垃圾收集通常,但并非总是阻止任何尝试在该时间内访问由GC管理的数据的线程。在某些语言中,有“停止世界”垃圾收集器,它会阻止由编译器插入的“安全点”的所有线程; Rust没有能力插入此类安全点并在GCS上阻塞线程在库级别完成。

下面的大多数例子都是单线程,但它们的API设计并不难以扩展到假设的多线程GC。

GC Crate是我用Nika Layzell写的一个,主要是作为一个有趣的运动,弄清楚是否可以进行安全的GC API。我以前深入了解设计,但设计的本质是它确实与引用计数类似的东西,以跟踪根,强制所有GC突变都通过特殊的GCCell类型,以便他们可以更新根数。基本上,只要某事成为根或停止是root,就会更新“根数”:

struct foo {栏:gccell<选择< GC<酒吧>>> } //这是一个根(root count = 1)让栏= gc :: new(bar :: new()); //这也是一个根(root count = 1)让foo = gc :: new(foo :: new()); // .borrow_mut()' s raii guard root bar(将其根数设置为0)* foo .bar .broldr_mut()=一些(bar); // foo仍然是一个根,没有呼叫.set()让v = vec! [foo]; //在Dradleucion时,Foo' s根数设置为0

当在堆被认为是根据一些启发式的堆被认为已经合理地进行了合理大的情况时,将发生实际的垃圾收集阶段。

虽然这基本上是“免费”的读数,但这是任何类型写入的参考数量的公平数量,这可能不需要;使用GCS的目标通常是避免参考计数样图案的性能特征。最终这是一种混合方法,即跟踪和参考计数6的混合。

如果您只想要几件事,GC作为通用GC很有用,而无需考虑过多。一般设计可以应用于与另一种语言运行时的专业GC,因为它提供了跟踪根部的明确方式;但它可能不一定具有所需的性能特征。

伺服器是我曾经全职工作的浏览器引擎。如前所述,浏览器引擎通常在本机(即Rust或C ++,而不是JS)代码中实现大量的DOM类型,因此例如节点是纯RUST对象,它包含对其子项的直接引用,因此生锈代码可以执行像树横穿树的东西,而不必在js和生锈之间来回来回。

伺服的模型是一个有点奇怪的模型:根是一种不同的类型,而Lints强制强制执行堆积的堆引用永远不会放在堆栈上:

#[dom_struct] //这是#[派生(Jstraceable)]加上Lints的一些标记Pub struct节点{//父类型,用于继承eventtarget:EventTarget,//在实际代码中,这是一个不同的辅助类型// the Refcell,选项和DOM,但是i' Ve简化了它使用// stdlib类型此exampre_sibling:Refcell<选择< DOM<节点>>> ,next_sibling:Refcell<选择< DOM<节点>>> ,// ...} islicn节点{fn frob_next_sibling(& self){//字段可以作为借用访问,如果Let某个(下一个)= self .growner_sibling .borry.as_ref(){next。 frob(); fn get_next_sibling(& self) - >选择< domroot<节点>> {//,但您需要为它们rootipts roothing roflow // .orot()转动dom< t>进入domroot< t> self .next_sibling .borrow().as_ref().map(| x | x .root())} fn非法(& self){//这行代码会被称为unrooted_must_root //的自定义lein被暗示它有点有效地与生锈的必要的东西有所作为)Let Ohno:DOM<节点> = self .next_sibling .borrow_mut().take(); }}

DOM< t>基本上是一个表现得像& t但没有寿命的智能指针,而domroot< t>有生根的额外行为(以及丢弃的掉落)。自定义Lint插件基本上强制执行DOM< t>和任何DOM结构(用#[dom_struct]标记)永远不会通过Domroot< t&gt来访问#或& t。

我不会推荐这种方法;它可以正常工作,但我们想要移开一段时间,因为它依赖于定制插件的声音。但它值得完整起见。

鉴于伺服现有的GC解决方案取决于插入编译器做额外的静态分析,我们想要更好的东西。因此,Alan设计了Josephine(“JS Affine”),它使用Rust的仿射类型和以更清洁的方式借用以提供安全的GC系统。

Josephine明确地设计用于伺服用例,因此在“隔间”周围有很多整齐的事情,这可能是无关的,除非您特别希望您的GC与JS引擎集成。

我之前提到的是,垃圾收集阶段只能在某些众所周知的代码的某些众所周知的时刻发生的事实实际上可以使GC设计更容易,而Josephine是一个例子。

Josephine有一个“JS上下文”,它将在任何地方传递,基本上代表了GC本身。执行可能触发GC的操作时,您必须将上下文变得可变地借用,而在访问堆对象时,您需要借用上下文。您可以root堆堆删除此要求:

// cx是一个`jscontext`,`节点'是`' a,c,node>`//假设next_sibling和prev_sibling不是用于simplicity //借用`' b `让next_sibling:& ' b节点=节点.next_sibling。明度(cx); println! ("姓名:{:?}",next_sibling .name); //非法,因为cx由next_sibling // node.prev_sibling.borrow_mut(cx).frob(); //从Next_sibling读取以确保它生存在这个long println! (" {:?}",next_sibling .name);让ref mut root = cx .new_root(); //不再需要借用cx,借用cx root for' root,而是让next_sibling:jsManaged< ' root,c,node> =节点.next_sibling .in_root(root); //现在它'很好,没有出色的`cx`节点.prev_sibling .borrow_mut(cx).frob(); //从Next_sibling读取以确保它生存在这个long println! (" {:?}",next_sibling .name);

new_root()创建一个新的根,in_root将js托管类型的生命周期连接到root,而不是jscontext借用,释放jscontext并允许它在将来的可变地借用.brower_mut()调用。

请注意,尽管他们与Refcell ::借用()的相似性,但在这里没有运行时借阅 - 支票费用,而是在做一些终身杂耍以使事情安全。创建根通常具有运行时成本。有时您可能需要使用Refcell< t>出于与RC中使用的相同原因,但主要仅适用于非GCD字段。

#[派生(复制,克隆,调试,EQ,PartiaLeq,Jstraceable,JslifeTime,JSCompartmental)] Pub结构元素< ' a,c> (Pub Jsmanaged<' a,c,manteelement< 39; a,c>);); #[派生(Jstraceable,JslifeTime,JSCompartmental)] Pub struct maintemement< ' a,c> {name:jsstring< ' a,c> ,父:选项<元素< ' a,c>>儿童:vec<元素< ' a,c>> }

其中元素< 39; a>是一种方便的可复制参考,该参考将在其他GC类型中使用,以及本地精髓.A>是它的背衬储存。 C参数与隔间有关,现在可以忽略。

值得指出的是一个简洁的事情是,即使根允许对同一对象持有多个引用,也没有运行时借用检查。 让parent_root = cx .new_root(); 让父=元素。明度(cx).parent .in_root(parent_root); 让ref mut child_root = cx .new_root(); //可能是对`元素的第二个引用如果它是w ......