lua 中 40 字节以下的字符串会被内部化到一张表中,这张表挂在 global state 结构下。对于短字符串,相同的串在同一虚拟机上只会存在一份。
在 skynet 中,有大量的 lua vm ,它们很可能加载同一份 lua 代码。所以我之前改造过一次 lua 虚拟机,[让它们可以共享 Proto] 。这样可以加快多个虚拟机初始化的速度,并减少一些内存占用。
但是,共享 Proto 仅仅只完成了一半的工作。因为一段 lua 代码有一很大一部分包含了很多字符串常量。而这些常量是无法通过共享 Proto 完成的。之前的方案是在 clone function 的时候复制一份字符串常量。
或许,我们还可以做的更进一步。只需要让所有的 lua vm 共享一张短字符串表。
首先要考虑的是并发冲突的问题。
如果我们使用开散列 hash 表,预先分配好大量的队列(比如 128K 个),那么就可以只针对 slot 加读写锁。鉴于大量短字符串都是在加载代码的时候构建的,同样的代码不需要构建两次。所以这个读写锁的写频率会非常的低,性能应该不会有什么问题。
比较麻烦的如何回收那些不再使用的对象。
由于共用同一份 TString 对象,所以不能简单的为每个字符串记数(每个引用它的 vm 加一)。我最初的想法是永远不再释放那些短字符串,因为看起来总量并不多。
但在公司里讨论以后,很多人还是很担心。如果程序要运行数个星期,很难保证不会产生大量的垃圾短字符串。大多数是运行时拼接的临时字符串。
我想了一个折中的方案。默认情况下,新构造的 lua vm 是不回收任何短字符串的。但是一旦要求它开始收集,它会在 gc mark 的过程,给短字符串做一个标记表示我正在使用。我在一个全局变量里设置了一个版本号,这个版本号将作为标记设入 TString 对象。
一旦一个短字符串被标记过长期保留,它就不能被改为需要回收(打上版本标记)。
所有在共享 Proto 过程中产生的短字符串(他们的总数是可以被预估的,有限的),都将打上永不回收的标记。而 skynet 运行时,通过 lua 服务模块启动的 lua vm 在运作时,将随 gc mark 流程给短字符串打上版本标记。
一旦我们需要对短字符串池做清理。我们可以先递增一下全局版本号,然后通知 skynet 的 launcher 服务,通知所有活动的 lua 服务做一次完整的 gc 。这可以保证在记录的全局版本号之后,所有活动的 lua vm 都 mark 过它们正在使用的短字符串。
最后,就可以安全的把那些版本号更小的短字符串清理掉。
有兴趣的同学可以在 github 上取下 sstring 分支看一下这个 patch 是否能给你的项目带来好处:
可能的好处包括,减少每个 agent 的内存使用,以及加快 agent 的启动速度。
ps. 记得这个 patch 修改了 lua 的实现,所以更新代码后,需要先运行一次 make cleanall 保证 lua 库能重新编译链接。
8 月 21 日续:
昨天在公司内部做了一点讨论,最后得到一个新的思路。虽然粗暴,但会简单很多。
其实我们未必需要对公有的字符串池做回收,但为了防止被攻击(系统从外部接到大量输入导致公有池暴增),我们可以设一个开关。在我们认为已经没有必要为短字符串做共享后,关掉共享机制,让 lua 原有的机制工作即可。
由于大量字符串是开机的短时间内产生的,只需要在系统稳定后就可以关闭这个共享机制。从实现上来说,比较简单的方法是为共享池设置一个上限,当池达到这个限度后,自动关闭即可。
新的实现我放在了一个独立的分支 shrtbl 上,工作流程是这样的:
每次试图创建新的短字符串,首先检查字符串是否在当前 lua vm 里已经存在,若存在则返回。
其次,检查这个字符串是否存在于全局共享池中,若存在则返回。
第三步,检查全局池的限制是否到达,如果没有达到,则在全局池中加入这个新字符串,并返回。另外对于复制共享函数时产生的短字符串常量,不受容量限制(它们是在代码文件加载过程中产生的,数量可控)。
最后,如果以上步骤均进行完,把短字符串当前 lua vm 的池中。
注意:这里第一步和第二步不能调换次序。因为有可能在全局池关闭的时候,创建了一个全局池中不存在字符串,储存在当前 vm 中。而随后,全局池又增加了相同的字符串。当两处都存在同样的字符串时,不能以全局的那份为准(会和本地 vm 的池冲突)。
我在我们自己的项目上做了简单的测试,通常能够在多个 lua vm 间共享大约 100~500K 的几千个字符串。
另外,这个机制能缓解 gc 的压力。因为一旦字符串被放到全局池里被共享,即使是临时字符串,也不会进入当前的 lua vm ,所以当前 vm 也不必回收它们。这样,单个 lua vm 的临时内存增长也会慢一些。