在不同的 lua vm 间共享 Proto

1,116 阅读5分钟
原文链接: blog.codingnow.com

这些限制有哪些呢?

函数原型包含了三类数据:字节码、常量表、调试信息(包括字节码对应的行号、函数名、局部变量名等等)。这些数据都是只读的,理论上是可以被共享的。

但是函数原型(proto) 也是 lua 的基础类型(但没有暴露到语言层),依然是被 Lua 虚拟机管理的 gcobject ,它需要参于垃圾收集的过程。Lua 在实现时并没有考虑将多个虚拟机共享数据。

如果我们需要共享,第一步就是要改变 proto 类型的生命期管理。不能再由单个 lua 虚拟机的 gc 扫描流程决定是否要释放一个不再被引用的 proto 。

一个完备的方案是对 proto 做一个线程安全的引用计数,但我们也可以简单粗暴的直接在内存中保留所有的 proto 对象,无论是否有人引用它。

保留所有用过的函数在内存中这种做法是广泛存在的,如果你对比看 C 层次的函数,即使 C 函数存在于动态库中,我们也不能轻易卸载动态库,这有让其它模块保留过动态库中函数指针变得无效。另外,由于调试信息的存在,引用计数的方案会对 lua 实现做相当大的改变。

第二步,我们需要考虑常量表。对于常量字符串,往往是不可以被多个 lua 虚拟机共享的。尤其是短字符串,lua 会对短字符串做唯一化 (string interning) 处理,同样的短字符串在同一个 lua 虚拟机中只有一份。不同的 lua 虚拟机中的短字符串一定会被判定为不同的。如果对常量表中的字符串也做共享处理,那么除了需要给 lua 实现增加一种字符串类型(不被 gc 管理的字符串)外,还会降低字符串处理速度(目前 lua 在做短字符串比较时,直接比较对象指针,可以达到 O(1) 的处理速度;而如果常量字符串在不同的虚拟机中的话,比较会变成 O(n) 的复杂度)。

第三步,每个 proto 对象中带有一个 closure cache 。绑定同样 upvalue 的 proto 生成的 closure 可以被复用。但如果 proto 是跨虚拟机的,这个 cache 就很难正常工作了。

第四步,调试信息中也有大量的字符串。考察一下 Lua 实现可以发现,Lua 的 api 仅将这些字符串用内部字符串对象储存参与 gc 管理,但并不会把这些字符串对象传递到别的地方。所有 api 都是返回这些字符串对象的 C string 指针的。


针对这些问题,我们可以开始对 lua 的实现做改造了。

我们可以将 proto 数据结构拆分成可共享和不可共享两部分。不可共享的有常量表和 cache ,其它都可以共享。不可共享部分继承原有的 proto 结构,再用一个指针指向共享部分即可。我们需要在共享的数据结构中保留一个它实际存在于的 lua 虚拟机的指针。只有这个虚拟机才有权利回收它所占的内存。而其它引用它的 lua 虚拟机在 gc 时,可以检查这个指针来决定是否要标记清除它。

lua 提供了一个 api lua_topointer 可以返回一个 lua 函数对象中的原型指针(注:这是 undocument 的)。我们只需要再添加一个 api 把函数原型还原成 closure 即可。

这里引入了一个新 api 叫 lua_clonefunction 它能复制一份函数原型的常量表到当前的 lua 虚拟机中,并创建其它需要的数据结构。

我给 lua 5.2.3 打好了 patch 支持这个特性 。并将它合并到 skynet 的主干上了。

为了更好的利用这个特性,我在 skynet 中,改写了 luaL_loadfilex 。这个 patch 版的文件加载函数是线程安全的。它为每个文件名对应的函数(lua 中加载一个源文件,就生成一个函数)创建一份独立的 lua 虚拟机,并将生成好的函数原型指针记录下来。之后同名文件的加载就不再有文件 IO ,不必再次解析文件,直接用 lua_clonefunction 复制一份出来。

为了 skynet 服务器可以热更新 lua 脚本,还增加了 clear cache 的方法(skynet.cache.clear),可以将 cache 重置。当然,之前加载过的代码其实是没有从内存中清理掉的,这一定程度上会带来一些内存泄露。但考虑到这个 patch 可以给系统节约的内存,不是过于频繁的热更新是可以接受的。


这个 patch 可以带来的好处:

  1. 对于我们的项目(陌陌争霸 ),每个在线用户大约可以少消耗 1M 内存。

  2. 为一个在线用户初始化 lua 虚拟机的时间加快了 4 倍。

  3. 由于字节码占用的内存更为集中,提高了 cpu 内的 cache 利用率。

但是,这个 patch 也增加了热更新的复杂度。需要主动清理 cache ,并考虑历史上的过期版本的代码占据内存不能回收的问题。如果不想在 skynet 中使用这个 patch ,可以在 makefile 中调整 lua 库的链接,指向官版的 lua 即可。