« March 2018 | Main

April 17, 2018

skynet cluster 模块的一点优化

上周末,我对 skynet 的 cluster 模块做了一点优化。

cluster 模式 是 skynet 的一种集群方案,用于将多台机器更为弹性的组成一个集群。我们将每台机器都赋予一个名字,然后就可以在集群间用这个名字向对方推送消息或发起请求。

集群的管理是一项非常复杂的工作,skynet 作为一个轻量化的框架,只实现了最基本的基础设施。cluster 这个基础设施已实现的部分并不复杂。在每个 skynet 进程中,我们启动了一个叫 clusterd 的服务,专门用于集群间的通讯。由 clusterd 再启动了一个 gate 服务,监听其它节点连过来的连接;同时,当前节点如果要对其它节点发送数据,也通过 clusterd 向外连接。业务要使用 cluster 的时候,都是通过 require "cluster" 这个库,然后这个库中的 api 负责和本节点的 clusterd 交换数据。

我们正在开发的一个 mmorpg 项目重度依赖了 cluster ,在过去的多次压力测试中,发现 clusterd 是测试中单位时间内消耗 CPU 最多的服务。它是整个 skynet 节点中的一个性能热点。所以我考虑了对这个 clusterd 做一些优化。

clusterd 在转发内部请求到外部的过程中,由于涉及消息转发,所以有几处地方需要拷贝/序列化消息。这个可以考虑优化为内存指针的传递。有些环节好做,有些环节不好做。我先把好做的部分改掉了。不好做的地方涉及对更底层 socket 模块的改造,为了避免优化改动涉及面太大,暂且放在一边。单单优化实现未必能获得特别的效益。

skynet 框架的目的就是为了充分发挥多核硬件的优势。在同一时间能把工作分摊到更多核心上处理,且不增加总体的工作量,才是最大的优化。从这个角度看,最容易做的优化是把 clusterd 负责对外请求的部分和接收外部请求的部分分离开。降低单个服务的 cpu 占用量就可以提高硬件对同时在线玩家的承载能力,且减少每个玩家消息处理的响应速度。

接收外部请求的部分是比较容易剥离的部分,因为它不需要了解 cluster 网络上各个节点的名字和地址的对应关系,而这个对应表可以是动态的,在 clusterd 中统一维护。接收请求只需要获得外部连接的网络包,解析出请求,转发给当前节点中的对应服务,然后把回应原路返回即可。

之前的版本中已经启用了一个 gate 来接受外部连接,只是过去把所有的请求又转回了 clusterd 。我改动成针对每个连接都启动一个新的 clusteragent 服务,然后让 gate 把数据转给它就行了。至于处理数据包的代码,简单的从 clusterd 中移到了新服务 clusteragent 中。由于每个连接都分开了,原来处理大数据包请求的部分还可以做一些简化。

周一,就这个修改,我们在已有的项目上做了压力测试。从数据看效果比较明显。由于我们的组网从功能上是对等结构,每个节点是等价的,所以在压力测试中 cluster 向外请求和接收请求数量是基本相同的。压测一个小时,发现 cluster 相关服务在处理的消息数量上是基本相同的。即过去被 clusterd 单一服务处理消息被分摊到了多个服务上。clusterd 保留了对外请求的功能,所以大致是原来 50% 的处理量。而剩下的 50% 则被分摊到了多个 clusteragent 上,数量正是节点数量减一。

由于 clusteragent 的相关消息转发环节还做了一些优化处理,减少了消息内存的拷贝,对消息转发的处理速度也比 clusterd 的转发速度快了一倍。经测试,在我们这台虚拟机上,差不多达到了 7 万条每秒。因为每个请求都要转发一个外部网络包,以及把内部的回应包转发出去,也就是可以达到每节点 3.5 万 qps 。

我们压力测试用的硬件 E5-2620 两块,有 24 核心,CPU 主频 2GHz 。这应该是 Intel 26 系列中最低档次的 CPU 。我们采购的比较早,如果是现在采购新的硬件,应该比它性能要好一些。我们的压力测试并没有完全跑满硬件的处理能力(cluster 相关服务的消息队列并未堆积过载)。我想 3.5 万 qps 应该能作为之后项目中, cluster 针对两个节点间互通的处理能力下限参考。这个处理能力和 CPU 单个核心的处理能力有关,和核心数量无关。

对于很多不对等的组集群的方式,很多节点的业务单一,处理着外部大量节点的请求,这次优化的意义可能更大。比如中心的登录及在线状态维护,信箱,等等业务都可以放在集群的一个单独节点上。cluster 的这次优化可以帮助这类节点消除之前 clusterd 是单一服务的热点问题。

April 09, 2018

Lua 5.4 的改进及 Lua 的版本演进

Lua 社区最近的一件大事是 Lua 5.4 的 work1 版本发布了。

这次的首发版本中引入了一个试验性的新特性,用来解决将 nil 放入数组的问题。因为是实验性特性,所以开发组决定默认关闭,必须在编译源代码的时候定义 LUA_NILINTABLE 这个宏才能开启。注意:默认是不开启的,后面的讨论都以这个为基础。

在邮件列表的讨论中,有不少人引入了不必要的激烈情绪,反对这种影响兼容性的改变。 Roberto 同学看起来是生气了,用全大写字母又重新强调了一次。当然虽然也有人不仔细阅读就评论,也比充斥在网络的大部分地方的喷子要强得多。

我觉得阅读整个讨论能加深对 Lua 语言的理解,非常有价值。这里做一点记录。毕竟,深入学习任何东西都回避不了了解其历史。每次 Lua 的版本升级的预发阶段,都会引入一些有趣的东西,大多数又会在正式版本发布前删掉。说明 Lua 的开发团队在语言设计上是及其谨慎的,我们要追寻这些历史痕迹,也只能从这些讨论中发掘了。

对于不太熟悉 Lua 的同学来说,我们先简单介绍一下这个特性的缘由。

Lua 只有一种复杂数据结构就是 table ,table 即可以储存键值对的关联表;又可以作为常规数组使用,即键值为连续正整数的特殊关联表。Roberto 在一次访谈中曾谈及过原因:其灵感来自于VDM(一个主要用于软件规范的形式化方法)。这里,数组作为一种特殊形式,对新手,引起过很多的误解。在官方文档中,对这部分的解释可谓斟词酌句,反复推敲。简单说就是,只有键值从 1 开始连续的元素放在 table 里,才可以作为数组 sequence 使用,否则针对 sequence 的操作行为都是未定义的。

怎么才算连续呢?lua 把 value 为 nil 的键值对视为不存在。所以,{ 1,2,nil,4 } 就不是一个 sequence ,因为 3 号位是不存在的。当你用 # 取这个 table 的 sequence 长度时,到底是 2 还是 4 就是未定义的。

这个问题坑过很多人,也真的带来过现实中的麻烦。因为在其它大多数语言中,数组是一种被单独列出来的数据类型,并不会有这种限制。像 json 这种原本依赖语言,却因为语言的流行而流行起来的数据编码协议,就有可能编码出 { 1,2, null, 4 } 的数组。而解码到 lua 中就带来了很多麻烦:到底怎么表示 json 中的 null 的问题。如果你用一个 userdata json.null 来表示 null ,这个类型的实际意义却不是 nil ;如果你用 nil 的话,会导致这个数据结构不再是数组。

为了这个问题,我就曾经写过一个库 来解决 nil 无法计入 sequence 的问题。

nil 放在 table 中有删除一个 slot 的语义,也影响了 __newindex __index 了行为。nil 和其它数据类型变得不一致,在 tracedoc 这个库中,为了解决这个问题,我写了很多代码来绕开,而这些繁琐的代码也真的出过不少 bug 。

正是这个问题在邮件列表中被反复提及,光是(通过修订手册)不断强调 sequence 的定义是不够的,我想开发团队早就想解决它了。甚至于 Lua 引入 boolean 类型都是很它相关。是的,没多少人用过的 Lua 4.0 是没有 boolean 类型的。Roberto 确认,他们给 Lua 增加 boolean 类型的唯一动机就是想在 table 中明确保存一个相当于 nil 的值。也就是说 false 就是 table 中的 nil ,而 true 只是一个附带产品,如果没有这个需求,true 就是毫无存在意义的。

你想想,如果 Lua 中没有 boolean 这种原生类型,那么你只需要定义 true = {} (也可以是别的任何东西), false = nil 。绝大多数代码都可以正确运行。从 Lua 字节码的设计也能看出,所有针对 bool 运算的的字节码都不是生成一个 boolean 量,而是生成的代码跳转行为。即在字节码层面,并不存在真正的布尔运算:and or not 的短路规则就是这么来的。

增加一个 boolean 类型,就是为了增加 none/null/false 这样一个特殊的值用于插入 table 中占位而已。它在影响代码执行逻辑方面有着和 nil 一致的作为,区别仅在于 false 作为 value 存在于 table 中时,其 key 是存在于 table 中的。

当我们逐渐遗忘了 boolean 引入 lua 的动机,把它真的当成了 boolean 这个独立类型使用,问题并没有得到完全解决。从完整性角度看 lua 这门语言,如果我们需要 nil 和其它类型的值一样放在 table 中的话,加一个 nil 的替代品是不足够的。因为原本的 nil 还是特殊的,对于语言来说还是存在一个值无法放在 table 中。而这次,Lua 的开发团队想彻底解决它。

方法就是增加一对方法,removekey haskey ,用来从 table 中移除一个 key ,以及判断一个 key 是否存在;同时取消 nil 为 value 时会影响 key 的存在性这种附加行为。

如果增加一组关键字来操作 removekey key from table ,对兼容性影响是比较大的。这会导致新版 Lua 下写的代码无法在老版 Lua 下编译;如果是以函数形式实现成 table.remove(table, key) 的话,会让这段代码(无论在新版或老版中都)曾受不必要的性能损失。

最终的折衷方案是增加了一个新的关键字 undef ,用特殊的语法来表达上面的意思。table[key] = undef 就等价于 removekey key from table ,而 if table[key] == undef 就等价于 if not haskey key in table 。这种写法的好处是,即使你在旧版本的 Lua 中也可以完美的编译运行,甚至结果也是正确的:undef 未定义时就是 nil 。但新语法也带来了更多的误解。

因为,undef 不是一个 value ,它不是 javascript 中的 undefined 。这点很多人第一眼看过去很难理解。但想想 javascript 在没有深思熟虑就草率的加入这些特性后的麻烦吧,为了 null 和 undefined 的区别,又进一步的引入了三个等号的操作符,甚至还需要程序员去背一个真值比较表 :) 。那已经是一个不那么好笑的玩笑了,对吗?

怎么理解 undef 不是一个 value 呢?table[key] = undef 只可以在字面这样写,你无法写一个 function foo() return undef end ,然后再写 table[key] = foo() 。因为 undef 无法作为一个 value 返回。如果你真的这么写了,就是一段非法的 lua 代码,无法被编译加载,而不是推迟到运行时出错。table[key] = undef 并不是一个常规的赋值,而是生成了等价于前面说的那个并不存在的新语法 removekey key from table 对应的字节码。

当然从兼容性角度看,undef 并没有完全解决兼容问题。虽然老代码到有这个特性的新版本中,写 table[key] = nil 依旧能工作,行为也大体一致,但是由于元素并没有真的被删除,对 gc 的影响差别还是存在的。nil 不会真的删除 table 中的元素还会进一步影响到 pairs 的行为:在允许 nil 存在于 table 中后,pairs 也是能迭代出 nil 来了……

关于 undef 这个新关键字的引入的利弊,邮件列表中争论的太多,还引出了几个子话题,例如把数组作为原生数据类型分离出来的可能性,这里我就不一一展开了,有兴趣的同学可以自己去邮件列表慢慢看。


这里,我想谈谈我对 Lua 语言进化过程的理解,和我猜测的这次新特性引入的可能动机。

首先 Lua 的大版本更新,是完全不在乎兼容性影响的。比如 Lua 3 到 Lua 4 ,以及 Lua 4 到 Lua 5 ,都是天翻地覆的变化。而 Python 这类语言不同,Lua 首先是作为嵌入式语言发明的,大部分项目都是和 C/C++ 混合使用,极度依赖宿主环境提供的能力。一个成熟的项目,通常没有升级 Lua 大版本的需要,实际上也没有多少大项目真的去升级它。我知道的唯一特例是我自己参与的大话西游II ,真的是在运营过程中从 Lua 4 迁移到了 Lua 5 ,当初也是颇费了一番功夫的。小版本升级其实也不多见,魔兽世界从 5.0 升级到 5.1 已经是很难得了,都没有继续向 5.2/5.3 迁移,不保持最新版本,并不太影响项目本身。而你也不大可能从大话西游的代码中抽一段用到魔兽世界的 mod 中,它们都是和宿主环境强相关的,而两个项目用的 Lua 版本不一致并不是主要障碍。这就是 Lua 的生态现状。

而 Lua 的小版本升级,则会更多的考虑一些向前兼容性,但又不完全拘泥于此。但在增加新特性,修改老特性的行为方面,开发组是及其克制的。变更总是向着完善语言,减少特例,消除不完备方面进行。

例如:Lua 5.0 到 5.1 最大的变化是对可变参数的处理上,从利用一个叫 arg 的 table 来传递可变参数,转到了现在的 ... 。我认为这是在完善语言,可变参数是一个原本就具备的语言特性,需要对应的语法机制来对应。如果用 arg 这样一个 table 绕一下,还会增加 gc 的负担。

另外 Lua 5.1 增强了模块化管理,建立起一套相对完善(同时也更复杂)的模块机制。这里可以看出 Lua 在模块化方面做出的努力,试图摆脱一个脚本语言的印象,而变成一门通用语言。把原本需要每个使用的项目自己建立的模块机制统一起来。

Lua 5.1 到 Lua 5.2 的变化则是做减法。环境这个概念被去除了,取而代之的是 _ENV 这个特殊的 upvalue 以语法糖的形式存在。随着环境概念的消失,原本全局变量的概念其实也去掉了。在 5.2 的 beta 期,其实还有更多的有趣的东西,比如 in ... do 的语法,后来都被克制的取消,并没有实际引入。经过反复推敲,只引入一个新的 _ENV 就能解决大部分问题。

5.1 引入的复杂模块化机制也随着环境的取消而被完全删除,这套机制被看作是过度设计。

从完备性上考虑,5.2 增加了一组 C API ,重新实现了协程,终于去掉了 coroutine 在大部分官方库(尤其是 pcall/metamethod)中无法 yield 的限制(减少了使用特例)。coroutine 变得更加通用,而实现上依旧保持了标准 C 的实现。如果做个对比的话,基于 5.1 语法的 luajit 分支也解决了这个问题,但并不是通过标准 C 实现的,而是为每个 coroutine 都生成了独立的 C stackframe (同时也受限于 C stackframe 的空间限制)。而 golang 或是 C++ 的 corontine 库都有类似的问题,必须基于独立的 C 层面的 stackframe;这导致在轻量化角度看 coroutine ,没有别的语言实现能做的比 Lua 更好。

同时,Lua 5.2 增加了一个实验性的新特性:分代 gc ,它在 5.3 中又被移除。我们可以看到,Lua 开发团队在 gc 性能方面是一直在努力的。

另外,还有一些语言层面的小增强,只有你碰到实际需求才能真正理解。比如 ephemeron tables ,我认为它增强了生命期管理方面的完备性。

在官方库方面,Lua 在这个版本增加了对数字的位操作能力,bit32 这个基础库被引入。我视其为 Lua 向通用语言方面的一个努力:原本 Lua 是不打算在处理二进制数据方面做太多工作的。如果你需要,必须自己封装对应的处理函数。而有了官方的位处理函数,终于可以用纯 Lua 写那些二进制数据处理的业务,又不失通用性了(可以跨项目使用)。

btw, 正是 bit32 这个库,引发了 luajit 和 lua 的社区分裂。因为 luajit 早一步规范了位操作,(从性能考虑)添加了整数类型(从 number 中区隔出来),而 Lua 的官方开发团队在使用 luajit 的实现(支持 64bit 整型)还是自己实现(只支持 32bit) 方面产生了争议。在争论过程中引起了 Luajit 作者 Mike 的不快,他不在打算让 luajit 跟进 lua 的官方版本的发展。作为 jit 的实现,把性能放到一个更优先的位置无可厚非,削减语言的特殊化定义也会让一些针对性的优化无法完成(比如 Lua 5.2 取消了全局变量,就无法再针对全局变量这个特定语义进行优化)。

Lua 5.2 的位操作只支持 32bit 整数也有其道理,因为 Lua 到 5.2 为止,都并不真正支持整数类型,而仅有一个 number 。它默认是用 double 实现的,从数学上看,你也可以认为整数只是实数的一个子集。但位操作,模操作,都是仅仅针对整型的操作,没有整形就很难让这些操作完备。所以,有了 Lua 5.3 。

Lua 5.2 到 5.3 ,最大的改变是增加了 number 的子类型:整数的内部表达。它的最大好处是有了原生的 64bit 整数的支持。这些年,主流硬件和操作系统逐步从 32bit 过度到 64bit 架构。如果继续用 double 来表达整型,就只能支持到 52bit 的精度,这显然是不完备的。Lua 5.3 的解决方法是把 number 分开实现为实数和整数两个表达形式。这种在一个类型的实现中分成多个子类型的做法,其实是从 Lua 5.2.1 这个小版本更替中开始的,在 5.2.0 到 5.2.1 的变更中,字符串被分裂为长字符串和短字符串两类

5.3 之前的版本,如果你想使用 64bit 整数的话,比较简单的方法是在 64bit 平台下利用 lightuserdata 也就是 C 指针。通过给 lightuserdata 加上元表,也能实现大部分需要的功能。在 skynet 的早期,我采用了这个方案 。不过它的缺点是和其它第三方库不兼容,对于特定项目倒是合适的。在 skynet 采用 lua 5.2 的时期,所有 skynet 官方库都约定了用 lightuserdata 指代 int64 。

从 5.3 开始,由于有了整型的支持,逻辑位操作、移位操作、取模等都有了原生的支持。通过对整型的支持,把文本串视为整数序列,支持 utf8 字符串也变成了官方支持。另外,还整合了二进制数据块的打包解包模块 lpack 。这些都可以看成是围绕整型支持的周边完善。而 C API 方面更是加强了整数在 C 和 Lua 间的交互能力。

最后,我们来看看最新的 Lua 5.4 。首先要说明的是,目前 5.4 还处于 work1 阶段,任何改动都可能在正式版中去掉,也有可能加入新的特性。这里只讨论到目前已知的变化。

从官方介绍看,除了默认关闭的 nil 放在 table 中的特性,最主要的改动是重新实现了 5.2 中出现过的分代 GC ,但这次即有可能去掉过去的分步式 GC 成为默认配置。另外小的改动,例如允许 userdata 绑定多个 uservalue 可视为对过去 5.2 取消环境的完善(减少了 userdata 的固有内存尺寸)。

随带提一句,lua 5.4 中官方库的 random 采用了自己编写的新算法而不是采用系统的 CRT 。虽然不起眼,但是对于游戏这类应用来说是个好消息。游戏环境非常依赖一个高质量的随机数发生器,而这点又极容易被开发者忽略。对随机数算法有兴趣的话,可以参考这里 。在邮件列表中也曾对此有所讨论。

我想进一步展开说的是,在 5.4 work1 发布前,github 的仓库中曾经反应出 5.4 版 Lua 考虑过把可变参数 ... 重新用 5.0 中的临时 table 的形式实现,但保留 ... 的语法,另外引入 _ARG 这个语法糖。据作者说,还原成过去放弃的做法是因为 gc 部分重新编写后,临时 table 对性能的影响下降了,这样写还可以简化 gc 的实现。通过引入新的字节码,{ ... } 这个操作生成临时 table 要快的多。而 ... 用 table 的形式表达,可以解决过去 ... 不完备的问题:你无法把 ... 封在闭包里。这个限制和过去 coroutine 无法穿越 C 边界的限制很像,所以去掉这类限制的动机也很好理解。

最终,这项改动并没有反应到 5.4 的发布版本中。从 github 的历史看,特性被回退掉了。我猜测是最后又考虑到了一些东西,这里重新体现了 Lua 设计委员会的克制。虽然没有明说,但我认为动机之一很可能是 nil 做 table 的值的问题。当 ... 中包含 nil ,那么打包出来的临时 table 就不是个 sequence ,这多少有点问题没解决干净的味道。不过实现上相关的改进还是进行了,从语言层面的变更看,现在的 debug 接口能查询到更多的关于参数传递和返回值数量的信息,参数传递方面明确定义了传递语义,而不是过去默认实现的拷贝行为。优化参数传递,意味着 Lua 向函数式编程方面偏移了一些。

和过去几次 Lua 版本升级性能下降不同 ,这次有人对 Lua 5.4 做了一些简单的性能测试(俗称跑分),在 Lua 实现的矩阵乘法方面,居然成绩比过去的版本提高了 100% 。对于其它一些跑分测试,也有 20%~40% 的提高。我猜测这得益于对 vm 内核的小调整(可选采用 C99 的新特性 computed goto),以及扩展了一些针对整型的的字节码(例如针对 for 循环的处理可以让跑分成绩更好),针对无返回值和单个返回值的独立字节码的设计。单从性能角度看,把 Lua 5.3 升级到 Lua 5.4 是非常值得的。

Lua 5.4 最重大的改进: gc 模块,有同学测试过的确提升很大。单从跑分成绩看,一些简单的测试中,完全相同的写法,lua 5.4 中制造“垃圾”的速度要慢的多。新的 gc 器在跑完一个循环的耗时上也有显著的提升(20% 以上)。我对具体跑分成绩兴趣不大,有兴趣的同学可以自己翻一下最近的 lua 邮件列表中的相关讨论。