垃圾回收和Rc

在上面字符串定义小节中,我们使用了Rc来定义Lua中的字符串类型,这就涉及到了一个重要话题:垃圾回收(Garbage Collection,即GC)。垃圾回收是一个非常通用且深入的话题,这里只介绍跟我们解释器实现相关的部分。

GC vs RC

Lua语言是一门自动管理内存的语言,通过垃圾回收来自动释放不在使用的内存。垃圾回收主要有两种实现途径:标记-清除(mark-and-sweep)和引用计数(reference counting,即RC)。有时候RC并不被认为是GC,所以狭义的GC特指前者,即标记-清除的方案。本节下面提到GC都是其狭义的含义。

相比而言,RC有两个缺点:

  • 无法判断循环引用,进而导致内存泄漏。这点是很致命的。其实Rust中的Rc也有这个问题。Rust对此的策略是:由程序员来避免循环引用。

  • 性能相比GC较差。这点倒不是绝对的,但貌似是主流观点。主要原因在于每次clone或drop操作,都需要更新引用计数器,进而影响CPU缓存。

基于以上原因,主流语言都不会采用RC方案,而是采用GC方案,包括Lua的官方实现版本。但是我们在本章字符串的定义中仍然选择了用Rc,也就是采用RC方案,这是因为GC的两个缺点:

  • 实现复杂。虽然实现一个简单的GC方案可能比较简单,但是如果要追求性能就非常难。很多语言(比如Python、Go、Lua)的GC也都是在多个版本持续改进。很难一步到位。

  • 用Rust实现更复杂。本来Rust语言的最大特色就是自动内存管理。而GC方案这种手动内存管理的功能就跟Rust的这一特性相违背,会使得更加复杂。网络上有很多关于用Rust实现GC的讨论和项目(比如1234等),明显已经超出Rust初学者的能力范围。

相比而言,如果采用RC方案,只要使用Rust中的Rc即可,并不需要额外的内存管理。也就是说,完全可以避免垃圾回收的部分。

对于上述的RC方案的两个缺点的对策:一是循环引用,也就只能交由Lua程序员来避免循环引用了,不过我们在常见的情况中,比如一个表设置自身为元表,可以特殊处理以避免内存泄漏。二是性能,只能放弃这方面的追求。

采用RC方案来实现垃圾回收,是一个艰难的决定。因为这个项目一开始的目标就是完全遵守Lua手册,完全兼容官方实现版本。而采用RC方案后,就不能处理循环引用的场景,也就破坏了这个目标。怎奈能力有限,暂时只能如此,偷懒采用了RC方案。不过以后也可能会尝试GC方案。替换GC方案对我们这个解释器的其他部分影响很小。感兴趣的读者可以先自行尝试。

Rust中的Rc

好了,现在让我们离开垃圾回收这个话题,单纯讨论一下Rust中的Rc

在很多介绍Rust的文章中都提到要尽量避免使用Rc,因为Rust语言特有的所有权机制不仅提供了编译期的自动内存管理,而且还会优化程序设计。其他支持指针的语言(比如C、C++)可以用指针随便指向,每个Object都可能被很多其他Object指向,整个程序就很容易形成混乱的Object Sea。而Rust的所有权机制强制要求Rust程序员在设计程序时,每个Object只能有唯一的所有者,整个程序形成一个清晰的Object Tree。大部分场景下,后者(Object Tree)显然是更优良的设计。然而Rc打破了这个规范,整个程序又变成了混乱的Object Sea,所以要尽量避免使用Rc

我非常认可这个观点。我在实现这个Lua解释器项目的过程中,为了遵循Rust的所有权机制,必须要调整之前C语言的设计思路,而调整后的结果往往确实更加清晰。

从某个角度而言,Lua解释器这个项目可以分为两部分:

  • 解释器本身,主要是词法分析和语法分析部分;
  • 要解释执行的Lua代码,包括值、栈、字节码对应的预置执行流程等,也就是虚拟机部分。

对于前者,我们完全遵循Object Tree的设计要求,力求程序架构清晰。而对于后者,由于我们并不能限制Lua程序员编写的Lua代码(比如Lua代码可以很方便的实现图的数据结构,而这是明显不符合Object Tree的),所以对于这部分我们就不去追求Object Tree。哪怕采用GC来实现垃圾回收,必然会涉及大量unsafe代码,那相比Rc更加违背Rust的设计意图了。