cora 语言对垃圾回收实现的探索

2024-09-10

前阵子给 cora 实现 GC 的时候博客是挂着的,所以偷懒没写啥。现在补一篇比较长的总结。

在很早很早的时候,我先写了个最简单版本的 200 行以内实现的 GC,是用的 stop-copying 的算法。其实也可以偷懒直接用 Boehm 的库,很多玩具解释器都是这么干的,自己写一个不一定能干得过这种充分优化过的库。但是我还是想自己写一个。

其实这个版本的实现简单倒是简单,但从原理上讲,其实是有正确性问题的。

copying 的垃圾回收有什么问题?

因为我是做成了编译到 C 语言的方式,并强调与 C 的交互,那么 GC 的 root 就会包括 C 的栈。C 栈上的东西,我们是统一当 void* 指针,所以没办法知道它到底是不是一个 cora 的对象。我们从栈上面拿到一个值,它有可能是一个 cora 的对象,但是也有可能是一个比如说大整数,也有可能是什么结构体或者字符串中间的一段内容。所以这种东西就是歧义指针,即可能是 cora 对象,也可能不是。

stop-copying 算法的原理是把 root 可达的对象,从一个空间搬运到另一个空间去,然后清空旧的空间。这个是需要移动对象的。

所以 stop-copying 算法的问题是啥呢? moving 的算法没办法处理歧义指针

moving 的算法没办法处理歧义指针

举一个例子来解释,比如说我们在栈上看到一个 0x6f32adff382f,它是一个歧义指针,有可能是指向一个我们分配的对象的。我们做 GC 要把它 move 到另一个位置,比如说 move 到了 0x6f64adff382f,这个时候我们是需要更引新引用的,也就是将栈上原来的值 0x6f32adff382f,修改为新的值 0x6f64adff382f。

那万一这个歧义指针实际上不是一个指针,是一个大整数呢?我们可能就把原来的某个变量给改掉了。

所以 moving 需要精确的垃圾回收。我们必须准确地知道一个指针是 cora 对象,没有歧义,才能去 move 它。

stop-copying 是一个 moving 的算法,而 mark-sweep 就是非 moving 的算法。

为什么不做精确的垃圾回收

在非精确的垃圾回收里面,歧义指针可能就是一个 C 栈的数据,但是它可能导致对象被误当作是"活跃"的,无法被及时 GC 掉。精确的垃圾回收是有好处的,它可以让垃圾被及时清理掉。

有种方式就是去实现成精确的垃圾回收。做法就是 root 不要依赖 C 的栈,不要使用它。比如 lua 它的 root 只包含虚拟机中的寄存器和栈,而不包含 C 的栈。 我曾经尝试去这么干,但是发现跟 C 交互的时候,对于写代码特别不友好。比如说像这样写代码都是有 bug 的:

Obj a = intern("xxx");
Obj b = cons(a, Nil);
Obj c = cons(makeNumber(42), b);

因为我们执行 c = cons(makeNumber(42), b) 的时候,涉及对象分配了,代码可能要走到 GC 里面去。注意,由于 C 栈不再是 root,于是栈的对象是不再受到 GC 的保护的,也就是说,在执行 GC 的时候可能把之前的 a 和 b 给清理掉! 于是代码就 bug 了。

lua 它是定义了 C 跟 lua 交互的协议,用一个交互栈去交互的。我希望跟 C 交互写代码更友好一些,所以就是故意做成不精确的垃圾回收,将 C 栈也作为 root。所以有没有其它的解决方式呢?我去搜索了一下,还真发现了一个挺有意思的算法,mostly-copying 算法。

mostly copying 算法如何解决了这个问题

mostly-copying 跟 stop-copying 很相似的地方是,概念上它们都是两个半空间,垃圾回收的时候,将一个空间中,活跃的对象移动到另一个空间。然后回收掉旧的空间。

但是不同的是,mostly-copying 会把空间分成更小的块。块上面可以带上编号,比如 0 或者 1。空间切换的操作,可以不用物理地去将对象从一个块,搬到另一个块中,也可以是另一种简单的操作:把块的编号 0 和 1 反转一下!

比如说当前的 FROM 和 TO 空间对应的编号分别是 0 和 1。我们要将全部编号为 0 的块里面,活跃的对象,搬到编号为 1 的块中去,可以选择分配一个新的块,编号为 1 表示 TO 空间,然后物理地把编码为 0 的块中的对象复制过去;也可以选择,把编号为 0 的那个块,直接修改编号为 1,相当于整块中所有对象都保留。等垃圾回收做完一轮结束的时候进行切换(flip),FROM 和 TO 分别对应于编号 1 和 0。

mostly-copying 算法会先对 root 直接可达的块,进行编号修改的方式,概念上从 FROM 移到 TO 空间。再之后,以当前的编号为 1 的块为 root,进行正常的物理 copy GC 的过程。也就是说 C 栈那些对象引用到的对象,其实是非 moving 的,解决了歧义指针的问题。只有间接可达的对象才是真正 copying 的,这类对象才是占大多数,所以算法叫 mostly-copying。代价是这个判断会变得不那么准确,从 root 的一个对象活跃,则对象所在的整个块的所有对象都会被当作"活跃"。

刚看到这个算法后,我两眼发光:这就是我要的滑板鞋啊!迫不及待地去实现。

重构步骤

首先是要把那个旧的 200 行 GC 从递归实现改成非递归的。因为递归版本会一次直接把 GC 全做完。而改造成 mostly-copying 则需要第一步按改编号的方式 mark root,第二步再以这些块为 root 继续 copy 下去。

这里面有一个技巧是用 TO 空间本身当作广度优化遍历的栈。在 TO 空间里面从上往下标记,记录两个位置:一个是已标记到哪儿 curr,另一个是 TO 空间的 end 在哪。标记过程是,对于 curr 位置的对象,将它们加入到队列,也就是拷贝到 end 位置然后 end++。等这一步做完后就可以 curr++ 再去处理下一个对象。随着标记的进行,不断会有新的对象推进 end,而 curr 也会不断向下移动,直到 curr 追上 end,则标记完成。

如果从黑 白 灰三色标记的角度来看,则 FROM 空间的对象全部是白色。从 TO 空间起始,到 curr 位置,这区间的对象全部是黑色。而 curr 到 end 区间则是灰色的。灰色就是自己已标记,但是自己引用到的对象还没标记完毕。而黑色则是自身以及自身引用的对象全部都标记完毕。

从递归改成非递归之后,再从 stop-copying 改到 mostly-copying 就非常容易了。我这两次 PR 的提交就是在做这些重构步骤。

其实递归转非递归实现,还有另外一个考量的点,是我想要实现增量的垃圾回收。因为全量的垃圾回收卡顿时间会比较久,而增量垃圾回收则可以每次只做一小段的 GC,然后切换回用户代码继续执行。要做增量的前提之一就是要做到一半可以停下来,于是必须是非递归的算法,利用队列的数据结构才能够做到中间位置了停下来。

接下来,我就想要开始实现增量的垃圾回收了。

增量垃圾回收和 barrier

当开始涉及到增量垃圾回收的时候,就开始涉及 barrier 了:读屏障或者写屏障。专业术语上,用户程序叫做 mutator,而垃圾回收那边是 collector。

在 stop-the-world 的时候,collector 活动的时候 mutator 是休眠的,所以算法更简单。而如果我们做增量的垃圾回收,它们就会交替运行,于是就会更复杂,就像给飞行中的飞机替换引擎。其实也没那么复杂,只要保证两种规则中任何一种,都可以保证不会发生 mutator 正在使用中的对象被 collector 给咔嚓清理掉了:

  • 黑色的对象,不能直接引用白色的对象
  • 或者是,黑色引用了白色对象,则这个白色对象一定通过某个灰色对象可达

第一种是很强的规则,也很直观,黑色对象是自身以及自身引用的对象都已经标记完毕的对象,也就是不会再有从它继续标记了。所以如果它指向了一个白色对象,这个白色对象其实是可达的,但是不会被标记到,所以会被当作垃圾误给回收。

关于几种 barrier。Dijkstra 和 Steele barrier 都是破坏第一种规则,dijkstra 是如果出现黑色对象指向白色对象,则将该白色对象变成灰色。steele barrier 则是如果出现黑色对象指向了白色对象,则将黑色对象回退到灰色。

第二种规则看起来难理解一些,其实就是,允许了黑色对象引用白色,那么一定要额外有某个灰色对象再来保护这个白色。Yuasa barrier 就是来保证这一种约束不被打破的。

为了实现增量的垃圾回收,在垃圾回收期间一定要加某种 barrier 来保护,无论是选择 dijkstra / steele / yuasa 等等哪种。它们都属于 write barrier 的范畴。

有两种增量更新的算法,一种叫 incremental-update 另一种叫做 snapshot-at-the-begining (SATB)。

先说 incremental-update。我们在垃圾回收的过程中,由于 mutator 还会活动,于是会有新的对象分配出来,这些新分配出来的对象,已需要被 GC。然后在处理增量的过程中,又会有新的增量生成... 所以最终始终会需要一个 stop-the-world 的过程,将 mutator 完全停止,从而不再有增量,再重新标记一个 root 和 re-mark,做完最后的 GC。Go 语言最早实现的时候就是这么干的,用的 dijkstra barrier 和 increment-update 的方式。

snapshot-at-the-begining 则是另一种,它会在 GC 之前取到当前 root 的快照,然后去 mark 下去。最终的 mark 的结果是 GC 之前那个时刻的一个 snapshot,在 GC 过程中生成的新的对象不会被处理,它们会留到下一轮的 GC。然后就是 GC 过程中生新的新的对象,统一会被当作是黑色。这种方式的好处是,不需要一个 stop-the-world 后 remark 的过程,做完了就完了。对应的 barrier 则是使用 yuasa barrier。

Go 语言的 GC 后来做了个优化,完全地去掉了 stop-the-work 的卡顿。它是非常有创意性地将两种 barrier 结合到了一起,会同时加上 dijkstra barrier 和 yuasa barrier。从而,开始的时候可以增量的开始,结束的时候不用 stop-the-world 了再 remark。开始时不需要停整个进程后 mark root,具体执行某个 goroutine 的 GC 的时候,还是会停这个 goroutine 的。它这里的说法是,Go 的程序可能有许许多多个 goroutine,都算是 root。如果只是用 yuasa barrier 和 SATB,那么它在 GC 开始前的 mark root 阶段会需要处理全部的 goroutine,这个卡顿就会比较久。

在我的 cora 语言里面,不会涉及得这么复杂,我可以直接使用 snapshot-at-the-begining + yuasa barrier 的方案。

然后等到我动手去实现增量的时候,我就发现了一个大坑! mostly-copying 不好实现成增量的垃圾回收

为什么 mostly-copying 不好实现成增量的垃圾回收

查了一些文献之后,我注意到,相关的算法做成增量全都是使用的 read barrier。

read barrier 的开销比 write barrier 要大很多,因为正常程序读操作要比写操作频繁得多。所以我很怀疑 read barrier 的性能会对垃圾回收最终的影响,把 copying GC 带来的好处都抹消掉。

为什么会需要 read barrier 而不是 write barrier 呢?还是 moving 的问题。我们的 write barrier 只是阻止还在使用在的对象不被误当作垃圾删除掉,但是它并能不阻止对象被 move 后,mutator 读到 move 之前的值。

举一个例子。现在有一个链表 l = cons(a, cons(b, cons(c, cons(d, Nil))))。在我们开始做 mostly copying 的时候,首个 cons 在从栈上引用的,markRoot 过程它会被标记为灰色。然后 a 是安全的。剩下的 b->c->d 还未标记。现在我们垃圾回收做完了 markRoot 之后,又回到了 mutator 这边,用户代码执行了 l = cdr(cdr(l)),于是 l 移动到了 cons(c, cons(d, Nil)) 这个位置,l 现在是白色的。接着继续进行增量的垃圾回收,算法会从顺着灰色可达的对象标记,也就是 b->c->d 从白色标记成灰色。也就是 cons(c, cons(d, Nil)) 被 move 了。白色变成灰色,是从旧的 FROM 空间,拷贝新的 TO 空间。当再次回到 mutator 的时候,l 引用的东西已经不对了。它应该引用新的 TO 空间的对象,然而它实际引用的是旧的 FROM 空间的对象。

也就是说,mostly-copyping 白色到灰色的过程,就是移动对象的过程。如果 mutator 之前从 root 引用了一个白色对象,mostly-copying 做增量回收标记成灰色移动了这个白色对象,那么再回到 mutator 的时候就出错。

read barrier 如何能处理 moving 的问题呢? 在对象被从 FROM copy 到 TO 的过程中,在 FROM 空间的旧对象中记录一个 forward 指针,让它指向新的 TO 空间的对象。然后 read barrier 在读取对象的字段的时候,会先检查 forwarding 是否为空,如果不为空则触发 barrier,它会跟随 forwarding 去找到新的对象,读取新的对象。比如说这段代码是 cdr 函数加上 read barrier:

Obj cdr(Obj x) {
  struct scmCons *h = ptr(x);
  if (h->forwarding) != NULL) {   // read barrier
	h = h->forwarding;
  }
  return h->cdr;
}

回到 mark sweep 了

mark-sweep 不会移动对象,因此只需要加上 write barrier 就可以处理 incremental。

mark 的复杂度是 O(n) 的,其中 n 是跟活跃对象相关。而 sweep 是复杂度是 O(空间大小)。比如说 1G 的空间在 mark 完毕之后,去 sweep 一遍,会需要遍历整个 1G 的空间,将没有被标记的对象回收到 freelist 中,同时清除掉标记对象的标记。

有一个很重要的优化是干掉 sweep 阶段,让整个 GC 算法只跟活跃对象数相关。为此 cora 里面引入了对象分配版本的概念。比如说刚开始分配出去的对象,它的版本是 0。然后执行 mark 的时候,会把活跃的对象的版本 +1。等 mark 结束后,全部活跃的对象版本都为 1,而成为垃圾的对象版本则仍然为 0。 这个时候 cora 不会刻意去执行一遍 sweep,而是等一轮结束后,让 GC 的版本 +1。到下一轮的时候,所有新分配出去的对象版本 1 了。如果类推,下下轮的时候 GC 版本到 2,分配的对象版本也是 2... 当一个对象的版本小于当前的 GC 版本的时候,我们知道它是垃圾,可以回收重用。

对象的分配就是从 GC 完的块里面,通过 first fit 方式去寻找到一个足够大的,版本号当于当前 GC 版本号的块,从里面分配。通过这种方式,我们把 sweep 操作给省掉了。

举个例子,在一轮 GC 之后的一块内存块:

(ver: 0, size: 24)  (ver: 1, size 32)  (ver: 0, size 24) (ver: 0, size 48) ...

我们要分配一个 36 字节的对象,需要先查看第一个,发现它 ver 是 0,是可以用于分配的块,然后它的 size 不够,24 小于 36 了。于是继续向前寻找,找到了 (ver:1, size 32)。这个对象还在使用中,需要跳过它。于是我们到了 (ver:0, size 24)。这块大小不够,不过它后面的那块也是 ver:0 我们可以将两块合并成一块,(ver: 0, size 72),然后从中分配出 36 字节的对象,最终新的对象布局:

(ver: 0, size: 24)  (ver: 1, size 32)  (ver: 1, size 72)   // 合并处理,现在大小够了
(ver: 0, size: 24)  (ver: 1, size 32)  (ver: 1, size 36) (ver: 0, size 36) ... // 然后再分配

比较类似于 Immix 的方式,只不过没有切分 block 和 line 弄成 region based 的概念,但基本上还是一个 bump allocator。

bump allocator 做法的好处是分配的对象符合局部性,对硬件的缓存友好。而 free list 则不一定相邻的分配操作,分配出去的内存也是物理上比较相邻的,缓存不友好。通过 mark sweep 之后,活跃对象会导致整个大块里面形成许多的洞。做 bump allocator 的时候需要去 first fit 跳过这些洞,这个操作有一点费。 有一个优化的措施是仍然保留 free list:

  • 先从 free list 分配;
  • 如果 free list 为空,则回到 bump allocator 方式分配;
  • 如果 bump allocator 的分配过程中,遇到当前的块大小不够,而后一个对象又是活跃的,则将该对象回收到 free list

还是上面的那个例子,通过 free list 回收之后它的内存布局就是:

freelist -> (ver: 0, size: 24)
(ver: 1, size 32)  (ver: 1, size 36) (ver: 0, size 36) ... // 然后再分配

如果有后面某一次申请 24 字节的内存,就会优化把 freelist 中的那个消费掉。

在 Go 语言里面,也有着类似的消除 sweep 阶段的优化。跟这边实现的主要差异是 Go 的分配模式是完全使用 freelist,而 cora 里面则以 bump allocator 为主。Go 的 freelist 的方式会对消除碎片更友好一点点,而 cora 这边则是缓存友好性更高一点。

整体来看,mark sweep 这个算法对于碎片就不太友好。有些算法会使用 mark compact 来优化碎片,但是实现复杂度上面就又上了一个档次。现阶段 cora 暂时还没想好怎么优化碎片问题。随着长时间的使用,如果碎片越来越多,则分配的时候需要跳过的空洞越来越多,影响分配效率。可以缓解的方式是,另开一个备用分配块,当分配时连续遇到 N 次空洞则不再继续尝试,而是从备用块中分配。另外一种方式就是故意浪费更多的内存,来让空洞的比例降低。比如让分配池的内存是实际对象占用的内存的 2 倍大小。

两层的内存管理

之前我有一个观点,内存分配器和垃圾回收是两件事情,最好不要强耦合。会出现这种耦合的一个原因是,垃圾回收需要知道一个对象是不是从托管内存中分配出去的。只有定制的分配器才更容易实现这一点。否则比如说用 malloc 来分配,没有什么规则来判断。一个简易的做法是,把托管内存分配出去的所有对象维护一个 map 之类的,但是这种形式额外的内存消耗就太高了。另一种稍好一点的做法是,由 malloc 来分配大块内存,自己手动管理这些大块内存。于是只需要判断对象是否在这些大块内存中。维护的复杂度会略低一些。比如 for 循环所有大块的起始结束地址,判断对象指针是否在这个范围内。这样算法还是一个 O(N) 的。

有没有办法变成 O(1) 呢? 可以,但是比较依赖于对齐。比如说我们块的地址是按 4K 或者 8K 这种形式对齐过的,则块内的指针地址可以直接通过取余运算来得到对应的块的地址。所以现在有两个要求了,一个是大块对象管理,另一个是地址对齐。 这样 malloc 就没有什么优势了,还不如自己用 mmap 来申请大块内存以及维护。

于是 cora 中的内存管理变成了两层,上面一层是 mmap 来分配大的块,维护这些大的块。而下层则是从这些块里面分配 cora 的对象,实现对象的垃圾回收。目前暂时就这样了,还没有引入多线程,于是还没有复杂到像 Go 那样更多层次:中心化的块 - thread 缓存的块 - 对象分配。复杂度是一个很容易失控的东西,我希望能简单都尽量简单。现阶段对于引入多线程还没做打算。

如何测试

实现垃圾回收复杂的不是算法本身怎么样实现,而是如何测试和 debug。这块真的是天坑,写过都能体会。

oil shell 里面提到它认为非常有用的方式是开启 ASAN。我试了一下发现 ASAN 只能对 malloc 的内存生效,所以对于这边 mmap 两层的管理的内存分配就不管用了。

我自己主要的 debug 方式就是 gdb 的 watch。结合在代码里面多加 assert。垃圾回收不好调的原因是,出错后 panic 的位置,跟最初引入问题的位置不匹配。那么如果我们在 gdb 里面运行一遍,panic 之后就可以看到最终的环境和栈,然后就可以拿到 panic 相关的对象。这时可以 watch 该内存,再执行一遍。 所有对这块内存的改动都会暂停,于是就可以分析出最初引入 bug 的位置。

目前我会执行 (load "test/run-all-test.cora") 这个脚本来运行这里面的全部测试。还有另外一个测试就是 bootstrap 过程。cora 编译成 C 是用 cora 语言写成的,如果用当前版本的 cora,把这个脚本编译出来,然后再生成一遍 cora 的可执行文件,新的可执行文件再去加载编译器脚本,整个过程不出错,GC 的实现基本上就没问题。

当然,这些都是通过很痛苦的调试中得到的经验。这些经验也可以分享一下。

第一条是,先从最简单的实现开始。我最初实现的那个版本,200 行代码不到,一气呵成,都没有调试就直接跑通了。

可以一方面是踩狗屎撞大运,另一方面就是代码足够简单以至于不容易出错。然后我就直接给自己上难度了,去尝试实现 treadmill GC,结果调试过程让我怀疑人生,最终也没调通过。

后面吸取教训就是从易到难,回退到之前 200 行的版本重构,从 stop-copying 开始,到递归写法到非递归写法,从 stop-copying 到 mostly-copying。虽然又切换到 mark-sweep 了,还做了增量实现,还从 malloc 改到了 mmap,但是中间每一小步改动都不是特别大。 就是从最简单的开始,一步一步改过来。如果直接从某个复杂的算法实现,其结果可能就是调试花了比这种方式高许多倍的时间,甚至仍然搞不定。

第二条是,漏掉部分 root 是最可能导致出错的。

从我这些 debug 的经验上看,最容易出现的一类 bug 就是漏掉了某些 root。从简单实现开始迭代,可以保证 root 的追踪在前一个版本中就是对的,那么到后一个版本只需要特别注意引入的改动。

除了 root 之外,下一个容易出错的点是在实现增量那里,当 mutator 和 collector 交错运行时。如果我们在前一个版本非增时的时候正确性可以保证,那么关注的重点就在 barrier 那边。相比于一开始就实现增量算法,关注的点要小很多。

第三条是,在 safe point 里触发 GC 会比在 alloc 时触发安全。

cora 中实现尾递归用到了 trampoline。在回到 trampoline 的时候,所有 cora 对象都在自己的虚拟机堆栈上,这是一个很好的 safe point。而 alloc 时刻则不同,它可能从 C 的栈进来,中间涉及到更多 C 栈上的内容作为 root。前一条也说了,很多 bug 的触发都是漏掉 root 相关的。 所以经验上看,就是 safe point 的时候去触发 GC 会规避一些 bug。

未来的方向

region based? 每次 mark sweep 到精确的对象,粒度更细,代价也就更高。如果按 region 来,只要有一个对象被 mark 到,对象所在的整个 region 就被 mark,后面不需要重复 mark。粒度变粗后 mark 的代价会变小。像比如 Immix 就是 region based,然后优化 region 大小的选择,变成论文中 line。不过感觉 Immix 也没有比当前的实现先进太多,唯一可取的地方可能是 evaculating 那块,这样就可以应对碎片问题。但是关于 evaculating 怎么样实现成增量还是没想清楚。

分代以及G1。分代的原理都是新生代和老生代的行为不一样,新生代对象生命一般都很短暂,比如其中 80% 的对象可能很快就成为垃圾了。所以扫描新生代是很划算的,如果算法是 O(n) 只跟活跃对象数有关,那么扫描新生代只需要扫 20% 的对象就可以释放出 80% 的空间。而老生代的对象存活得更久,更倾向于常驻内存,扫描老生代区域有可能扫了 80% 的对象只能释放出 20% 的空间。于是策略上,就可以倾向于扫很多次新生代,较少去扫老生代。G1 的意思是 garbage first,它没有分代的概念了,反而是把分代发挥到极致: 通过 region 中活跃和不活跃对象的比例来排序,有越多垃圾的 region 越是优先处理。花了最小的代价,释放最多的空间。分代引入的复杂度较高,比如需要 remember set 记录不同代之间的引用关系。还有不同代使用不同的策略,不同策略就是不同的算法实现,这都等于复杂度高了好多倍的。暂时我不想考虑过于复杂的东西,尽管产品级的实现都做得非常复杂。

关于 compact。semi-space copy 不会产生碎片问题,但是 copy 移动对象比 mark 复制对象操作要重一些。当活跃对象数量比较少的时候,copy 算法占优势。而活跃对象数较多则是 mark-sweep 占优势。mark sweep 会有碎片问题,碎片越来越多会影响分配的效率。这时就可以考虑 mark-compact。本质上就是不同的场景,应用不同的策略,始终选择最优的算法。代价呢? 复杂度! 多套算法都必须实现一遍。我想要 compact 的效果但是不想要这个复杂度,现在还没想好,等想好了再看。

现在 cora 实现了增量,没有实现分代也没有实现并行。分代和并行都是增加复杂度为代价的,我还是更重视卡顿时间短,所以增量是最重要的。分代理论上是可以减少工作量的,而增量和并行都不减少工作量。假设我们有好几十 G 的内存需要去做 GC 的时候,增量的方式做垃圾回收的时间会被拖得非常长。 在 Go 语言里面它是实现了并行,但是我也看到了还是会有很多场景还是不够。所以我感觉在方向上,算法一定是只需要处理部分的数据,而不是全量数据。目前这块都会涉及到 remember set 来记录不同区域间的跨引用,复杂度少不了。火车算法是其中一个比较有意思的,这里提一下但是也不想展开了。

先这样吧。反正目前 it works。像 lua 之类的它也只是一个增量的 GC,据说分代的实现还不太稳定。cora 在定位上面并不要比 lua 复杂度高。

垃圾回收GCcoramostly-copyingmark-sweep

HNS.to is a highly insecure way of browsing Handshake domains and should only be used for demo or educational purposes. Click to see preferable resolutions methods