浅谈 Emacs 的性能优化
Emacs
2023-02-24 2322字

最近在 Emacs China 看到网友关于 Rust 来编写 Emacs 底层以提升 Emacs 性能的讨论, 分享一下我自己的一些想法:

多线程模型

多线程的关键是要分开 “图形线程” 和 “非图形线程”, 图形线程只做常量界面绘制, 非图形线程可以一直在后台跑, 不管是短期还是长期跑, 只要不影响图形线程就不会导致卡界面或卡手。

不管什么语言, 都可以很好的实现多线程模型, 这一点我同意 oldosfan 的观点, 多线程的关键是线程代码之间不要同时操作一个对象, 特别是图形对象, 和什么语言实现没有关系, rust 自成一体系生命周期第一不能解决 Emacs 的多线程问题(注意不是多线程,而是 Emacs 的多线程), 第二, Emacs 这种富应用场景的软件其实更适合 GC,而不适合 Rust, 太束手束脚了。

Emacs 性能优化方向

我个人的经验, 分享怎么解决性能问题:

  1. 外部进程多线程程序来支援: 用类似 EAF 或者 lsp-bridge 的技术, 首先把所有耗费性能的代码先分离到外部进程中, 再在外部进程中有其他语言(不一定是 Python, Golang, Rust, TypeScript 等都可以)自身完善的多线程支持来实现 “非图形线程” 模型, 最大程度减轻 “非图形代码” 对 Emacs 的性能拖累, 外部进程计算完结果后, 通过 IPC 的方式把计算结果发送给 Emacs, 由 Emacs 本身来渲染图形。 这样的模型的好处是, 各种语言的多线程都很成熟, 马上就可以编写代码来实现非图形部分的算法, 因为进程隔离, Emacs 本身除了绘制界面啥活都不干, 所以 Emacs 很流畅。 缺点是, 有一些顽固的 Emacser 就是卡死也不用非 lisp 的语言写代码。 我期待有一天大家把 lsp-bridge 类似的 IPC 技术用于桥接到 Common Lisp 或者 Scala 类似的 lisp 风格语言, 会大大减轻 Emacser 的内心洁癖。

  2. 两个 Emacs 实例的方法: 类似第一个方法, 但是是一个 Emacs 用于绘制,第二个 Emacs 只用于执行 Elisp 计算代码(比如做文本搜索, 列表计算等), 第二个 Emacs 计算结果以后, 通过 IPC 发送结果给第一个 Emacs 进行绘制。 这样的好处满足心中的洁癖, 两个进程都在写 Elisp 代码。 缺点是, Elisp 本身没有多线程, 每开一个耗时计算任务都要启动一个新的 Emacs, 而且一定要注意新开的 Emacs 只能做 Elisp 本身的计算或者 subprocess filter 计算, 不能被第一个 Emacs 的配置所污染了。 同时, 如果 IPC 就是追求性能, 其他语言的绝对执行性能都要比 Elisp 好很多。

  3. 平行宇宙改造: Emacs 自身的多线程是 RMS 写 Emacs 的时候根本就没有考虑多线程(包括 vim 也是), 导致几十年累计的代码就像面条一样绕在一起, 没有分 ”图形 API“ 和 ”非图形的语言 API“, 而 Emacs 很多插件本质是在做光标和 Buffer 操作代码, 当多个线程同时操作这些图形 API 时就会导致很多锁冲突, 也就是现在 Emacs 这种类协程的设计没法解决很多 CPU 密集性场景。 解决方法很简单, 就是不要动现在这些已有 API, 因为 API 太多了, 你动一部分根本就解决不了多线程锁的问题, 而且还把现在的 API 弄坏, 已有插件跑不起来, 失去群众的支持。 需要创造新的 API, 这些 API 在设计的时候就要考虑是图形 API 还是非图形 API, 图形 API 限制在主线程运行, 非图形 API 跑多少个线程都无所谓, 只要不要在子线程运行图形 API 就好了, 这样的设计和 Gtk、 Qt 是一致的, C 语言本身就支持多线程。 这些新 API 就像一个平行宇宙一样, 和原来 API 互不干扰, 所有多线程插件用新的 API 去构建。 缺点是, 工作量浩大, 不是一个人可以完成的, 需要巨大的力量投入, 还要说服 Emacs 社区合并新的 API, 非常容易因为力量不够或者不懂多线程的喷子乱喷, 而半途而废。

  4. GC 改造: 在我编写 lsp-bridge 的过程中, 我发现 Emacs 的性能主要是多线程和 GC, 多线程隔离计算上面已经说了很多了, 我们只是专注于 Emacs 主线程的绘制性能来说, 单单只是绘制对象, 比如 buffer text, highlight, overlay, 如果只要是常量绘制, Emacs 的性能还是够的。 现在的问题是 Emacs 的 GC 太差了, 只要创建上千个对象, GC 就会频繁介入, 而且 GC 运行效率太差, 导致对象多了 GC 就会导致主线程卡顿, 这一点不解决, 就算第三点解决了, 还是会导致主线程卡顿。

Emacs GC

关于 GC, 我自己有一些想法, 可能不对, 先抛砖引玉给大家思考:

  1. GC 对象回收的性能要做极大优化, 这一部分代码的性能提升比 native comp 这种投入产出比高很多, 效果也明显很多, 改善以后基本所有插件都会受益

  2. GC 对象看看能否快速 dump 出去, 用外部进程去分析哪些对象是否需要回收, 这样可以把非常耗时的分析工作分离到外部进程中去做, 免得分析一下 GC 对象都会卡死 Emacs

  3. GC 默认的策略代码改一下, 现在都是大内存时代, 不要动不动就那么敏感, GC 可以慢慢回收, 不要求一次回收所有不用对象, 通过第二步的迭代, 依次慢慢回收, 但是保证每次回收 Emacs 主线程消耗不要超过一定时间, 比如 5ms, 超过这个时间就下次再回收

最后

上面是我这几年对 Emacs 性能的实践和一些想法分享给大家, 我个人觉得现阶段最有必要的反而是改造 GC, 效果就会明显。 也希望大家讨论 Emacs 多线程的时候, 多思考多线程的本质, 把思路理清楚再讨论就会相互有启发, 相互可以学习。

我个人认为如果不好好分析现状, 只是期望 Rust 这种语言能够解救 Emacs 的诸多问题是非常不现实的, 因为不考虑 ”经济可行性“ 或 ”Emacs 可持续性发展” 的前提去讨论怎么优化 Emacs 性能, 其实除了浪费时间和无谓的争吵, 并没啥用。