今天准备给 pangu-spacing 这个插件修复一个bug的, 最后发现大型Markdown文档中导致GC频繁介入的凶手是 pangu-spacing , 禁用以后Markdown文档编辑就很流畅了。
看来Emacs的GC最佳优化方案还是不要产生过多对象啊, 我在想要不要用 Python 外部进程/多线程来实现实时的文档空格分析, 这样可以实现 pangu-spacing 的效果, 同时不会因为GC卡住Emacs。
备注, 后面实现 wraplish 后, Markdown大文件添加空格分析(主要是正则搜索)就不会在短时间产生过多的GC对象啦。
6 个赞
没事就看一下日志, 其实 Emacs 的GC介入频率比我原来想想的要快很多, 最快1秒内会有5次以上的GC调用, 似乎这样反而不卡(我试过强制让GC罢工10秒再干活,发现10秒后一起动就卡住Emacs了)。
我们平常使用Emacs, 其实Emacs无时无刻不再执行GC, 只是大多数的时候, GC很快就执行完了(40毫秒以内, 两个字符间人手速最快也要100毫秒,大部分在250毫秒左右,也就是说大部分GC都在手速的15%左右的时间就完成了)。 我们感受到卡的那一刻是, 那一刻GC起来发现活干不完, 要超过250毫秒以上的时间才能回收GC Object, 而往往造成这个问题的原因就是某个插件在一个函数内创建了过多的对象需要GC扫描了。
3 个赞
目前想到比较好的方案是:
- 每个对象创建的时候都给一个时间戳
- GC每次扫描的时候, 主要扫描那些年轻的对象, 老年对象之所以存活大概率的原因是它们一直都需要存活
- 只有年轻对象都被释放差不多再去扫描老年对象
- 如果发现年轻的对象太多了, 就只扫描一部分, 剩余的对象下次再扫描
这样可以大大减少那些常用插件老年对象的扫描所占用的时间, 应该可以整体提高GC的性能。
1 个赞
感觉重新发明了generational gc和incremental gc
因为只在书上看过的人没自已写过 GC,觉得现成的算法抄一个就能用,实际看了 Emacs 代码才觉得爪麻,这也不能动那也不能动。
4 个赞
现在主要麻烦的是, 我感觉 process_mark_stack 不全部扫描完一遍, gc_sweep 都不能乱清理。
而 process_mark_stack 全部扫描一遍的代价就是现在的GC逻辑保持不动, 因为其他函数相对于 process_mark_stack 的耗时来说都不算啥。
另外难的是, GC还不能太激进去改, 因为改的太多, make 都会有问题, 都make不了, 啥都干不了。
邮件列表中,最近关于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时间/线程数
猫大,可以分享一下你是怎么深入研究的过程的吗,我感觉我做这种课题很容易陷入钻牛角尖的陷阱,然后搞好久,效率也不高