« February 2019 | Main

March 14, 2019

Lua 虚拟机间函数原型共享的改进

在我们的服务器框架 skynet 中,往往会在同一进程启动非常多的 lua 虚拟机,而大多数几乎运行着相同的代码(为不同的用户服务)。

因为 Lua 的代码也是数据,和 Erlang 这种天生设计成多服务的语言不通,Lua 并没有为多虚拟机同时运行做内存上的优化。所以,我在 5 年前给 Lua 制作了一个 patch ,可以把不同虚拟机间的函数原型数据提取出来共享一份,节省了不少内存。

不过,这个方案只能功能函数原型中的字节码以及调试信息,无法共享函数原型用到的常量表。这是因为,字符串常量是一个对象,尤其是短字符串,Lua 在虚拟机内部做了 interning 已加快字符串比较速度,使得它们很难在虚拟机间共享。

导致 Lua 和 Erlang 在处理多虚拟机共享代码这个问题上不同的本质原因,我认为是 Erlang 在语言层面区分了 atom 和 string 。atom 是虚拟机间共享的,维护在一个大的 atom 池中,只增不减。而 Erlang 的代码本身引用都是 atom ,这样就很方便做共享。而 Lua 不分 atom 和 string ,string 是无法承受只增不减策略带来的内存开销的。

为了解决构成 Lua 函数中大量短字符串常量的问题,我后来又给了一个方案 。它模拟了一个类似 Erlang atom 管理的机制,把所有虚拟机间共享的短字符串维护在同一张 hash 表内。为了解决内存无限增长问题,我给这个 hash 表设定了一个阙值上线,让它只在进程启动时期工作。


昨天,同事在分析我们最近的线上服务器时发现,即使使用了代码共享机制,一个刚加载完代码的虚拟机,还是占用了接近 6M 内存。其中,有大约 1.6M 内存是函数原型使用的常量表。如果我们能节省下这块内存,整体可以有很大的收益。(因为我们大约会在一个进程内启动 2000~3000 个这样的虚拟机)

我仔细考虑一下,其实基于过去做的工作,还能做如下的改进:

在共享函数原型的数据结构中,增加常量表项。过去这一项是放在外面的。

常量表中的长字符串其实可以直接用指针引用,而不必拷贝。

当常量表中是短字符串时,尽可能克隆这个字符串,它有可能在全局短字符串表中,这样,指针是唯一的,也可以被直接共享。

当整个常量表都可以被共享时,直接引用共享数据,而不需要额外建立复制表。

不过针对这些改进,还需要修改 gc 的标记和回收阶段的流程。如果函数原型并非共享,就要正常做标记和删除;如果是共享版本,就需要跳过它。

在修改过后,我们非常完美了减少了 1.6M 内存的使用,整个进程总内存开销减少了 4G 左右。


那么为什么不能强制把所有常量表中的短字符串都放到全局表中共享呢?因为在机器罕见的情况下,加载代码之前,虚拟机内已经构建出来了本地的短字符串,并未置入全局表,之后,克隆函数原型阶段,我们就无法使用全局表中的相同字符串。因为那会导致 lua 的 string interning 机制失效。

如果一个函数原型内包含的所有子函数都被共享,那么为何不干脆复用 Lua 的函数原型对象呢?这是因为 Lua 5.3.5 在每个函数原型内还放置了一个闭包指针,用于 Cache 最后创建出来的闭包。这个是和虚拟机相关的。不过在 Lua 5.4 的开发版中,这个 Cache 已经被取消,未来 Lua 5.4 在这方面应该可以做的更好。


有在使用 Skynet 的同学,可以去开发分支上取下代码 ,试一下这次的新改进能否减少你的项目的内存开销。