Emacs GC 的一些研究

邮件列表中,最近关于gc的讨论也是2016年的讨论了

如果有这个时间,抄过去当然是最好的,让更多人参与这个讨论里来

是否可以把第二步和第三步拆分开,用多线程呢?不用多线程只是怕并发操作了全局变量,锁操作频繁影响性能。
但是每次标记完后,不在 mark_stk 这个数据结构中的对象,就代表它肯定不再被使用,可以安全释放

步骤变为如下
0 定义一个全局线程安全的生产者消费者队列,用来保存所有待处理的 unmark_stk,启动一个线程
1 正常触发垃圾回收,阻止用户输入
2 标记可达对象,标记完成后把当前全局对象里不在 mark_stk 的对象加入到一个 unmark_stk 里,把 unmark_stk 加入到全局队列,然后就返回
3 允许用户输入

新线程的工作
1 检查全局队列里是否有 unmark_stk ,有的话取一个出来
2 遍历 unmark_stk ,把对象释放掉,然后释放掉 unmark_stk 本身

这里涉及线程安全的地方有两个
1 加入和删除 unmark_stk 到全局队列的过程,不过这里是每执行一次垃圾回收才产生一个,性能影响应该不大。而且如果实现了这种方式,完全可以把垃圾回收的时间设长一点。
2 如果释放的速度比较慢,很可能会存在某个不再使用的对象存在于多个 unmark_stk 的情况。这就需要新线程在释放前去全局对象里检查这个对象是否还存在。这样的话,释放和检查对象是否存在都是新线程去执行的,也不存在线程安全问题。

和现在gc方案的比较:
1 生成 mark_stk 之后多了一个生成 unmark_stk 的操作,但是本来按现在gc的方式,在下一步 gc_sweep 的时候仍然需要多次遍历。这种方式相当于把原来第二步和第三步的部分操作拿到主线程来做,但是对于主线程,总的来说是减少了操作时间。

可能存在的问题:
新线程在释放前去全局对象里检查这个对象是否还存在时,如果单纯依靠内存地址来判断,可能会出现这种情况
1 旧对象的地址是123,它被释放了
2 分配了一个新对象,内存地址也是123
3 在全局队列里的 unmark_stk 中仍然存在 123 这个地址,这时去全局对象里遍历的时候会发现存在123,就把新对象给释放了

总感觉得上多线程,宁愿多干点活,只要主线程少干一点就能减少卡顿

1 个赞

除非空出来的是完整的一个内存页(在 Emacs 现在不支持 copy compact 情况下很少见),Emacs 是不会释放内存还给系统,直接就地用来装新的物件了,所以你说的这种方法不实用

回头看了下,主题用了“释放内存”词是容易误解的,实际上是 gc sweep 后把可用地址记在一个 list 里,等需要分配了再从这个 list 里取用。

1 个赞

我里面说得可能不准确。我说的内存释放也是抽象后的概念。
比如之前也看过glibc的malloc和free实现,她其实也不是每次都执行系统调用获取内存,是用自己数据结构把释放的内存管理起来。
但是她抽象之后,我们就不需要关心malloc和free是不是直接获取了新内存,用它的api就行。
emacs也是类似自己实现了堆内存管理。把上面提到的换成调用emacs的内存释放函数,把内存放到emacs的类似free link list的结构里面。
反正抽象来看,只需要调用emacs的内存释放函数就行。 不过确实没我上面说的这么简单,如果是启动新线程来释放内存,这里也涉及到线程安全的问题。

今天也跟了下emacs里面gc_sweep的细节,最终是走到free函数去了。但是emacs貌似会根据某些宏重定义free和malloc函数,还需要再看看她是怎么实现的。

如果是直接调用free函数,上面说的就没问题,是线程安全的。

感觉总的思路得这样 1 使用多线程 2 保证线程安全,且加锁粒度够大,不会影响主线程的性能 3 尽量少干扰现有代码

看这个分析 windows版emacs内存管理分析
其实空闲内存管理完全没必要由gc来做,直接引入mimalloc之类的库,不需要的内存直接mi_free掉,由库统一管理,效率高很多,还减少了gc的工作

不过想了下,直接这样的话好像还不大行,得等把emacs gc的算法详细研究清楚才行,如果gc清理的时候是直接扫描整个堆,就只能现在这样。不过对于走mmap_系列函数,比如buffer相关的,接入mimalloc还是有好处

可达性分析,其实按照 go 或者 java 的实现就足够了,都是实际经过检验的

一个比较突出的问题,Emacs 不自己管理 Lisp 堆用外部库分配,pdump 这样的功能要怎么实现?没有这功能的话编译一个能用的 Emacs 都做不到。

emacs GC就是缺少一个多线程机制,多个线程只读扫描对象非常安全。

一个后台线程慢慢扫描,不管什么算法都可以做到不卡界面。

这几天想了下,直接引入多线程去单独处理gc比较复杂,要改的很多。
但是先用多线程把garbage_collect函数里面的任务分解下应该没问题把。(需要注意下依赖关系,有些貌似必须先执行)
比如下面两部分
1 调用几个 mark_* 函数,这些可以启动不同的线程来做,主线程join
2 在 gc_sweep 里面启动不同的线程去执行 sweep_*函数,主线程join
如果线程启动耗时,弄个线程池也行。
这样的话,最坏的情况下,gc时间也和现在差不多,但是最好的情况是gc时间/线程数

猫大,可以分享一下你是怎么深入研究的过程的吗,我感觉我做这种课题很容易陷入钻牛角尖的陷阱,然后搞好久,效率也不高

就是玩玩, 不要带目标, 不要着急

3 个赞

偏个楼,说一下我的经验:就是做事情时刻保持大局观,抓主要矛盾。比如编程,比较好的方式是先写整体结构的函数,主要的功能,至于具体的实现,只在必须要实现的时候才去考虑这些细节。这么做到原因就像你说的,砖牛角尖,搞了好久,可能最后还发现方向完全错了。所以一定要对做的事情有整体的把握,在没有搞清整体功能之前,不要深入到细节。都是血泪的教训啊 :joy:

归根结底就是 Donald Knuth 的那句话: Premature optimization is the root of all evil.

当然有些时候,我们可能能力达不到一开始就做很好的整体的规划,只能”走一步看一步“,这时就是要从具体的问题出发,去思考抽象和关联,尽量按图索骥去探索整体。

7 个赞

对这个话题还是非常感兴趣,理论上说函数式语言还是适合 copying,不过 emacs lisp 可能没那么函数式,以及很多 object 是 long-live 的。

Java就是copy的啊。

现在已经有人在研究利用 Ravenbrook MPS 重新实现 Emacs GC 了

https://lists.gnu.org/archive/html/emacs-devel/2024-06/msg00177.html

4 个赞

emacs.git - Emacs source repository 在Ubuntu 24.04上可编译正常使用。

不过最新的代码编译报错。之前的都没问题。

G1 似乎是某种 “复合” 实现,还有一些不是 copying 的,反正 Java GC 是挺复杂

这个我是看到了,不过我还没试过他那个版本。感觉应该会比现在的 GC 好吧