« July 2015 | Main | September 2015 »

August 25, 2015

我们的新游戏《心动庄园》上线啦

《心动庄园》已经开发超过了一年半,其实去年这个时候我们已经在内测了,折腾了很久,改来改去的。

之所以项目折腾超过了预期时间这么久都没有被公司砍掉,纯粹是因为开发者对这款游戏是真爱。所有开发人员,甚至包括程序,都花了大量时间玩它,并喜欢它。你可以想想,反复改了一年多还留有爱,那不是真爱是什么?所以即使开发预算超标,公司的管理层也不舍得砍掉它。

这是款农场经营游戏,一开始立项是因为我们公司有很多人喜欢玩 hayday 。头三个月,我们也真的是在认真的复制 hayday 。之后,就开始了漫长的修改期。如果你对一款游戏有了深入的了解,你就会去想游戏背后的内在逻辑,是什么导致游戏粘住用户的。当把这些剥离出来,你就可以设计属于自己的东西了。

改着改着,这就成了一款我们自己的游戏,虽然新手阶段依然有 hayday 的影子。但不用刻意回避它,毕竟很多基础玩法是被无数用户证明过有趣的,而整个游戏的骨干是经过设计人员思考的,并不是简单的复制。我们在长期的测试过程中,不可避免的一些原创的玩法也陆续被国内一些已经上线的同类游戏借鉴过去,但我们并不在意。抄个表象容易,那些系统的结构却很难复制。

另一方面,没玩过 hayday 的玩家依然很多,只要给喜欢这类游戏的玩家带来一个逻辑完整的游戏就好了,不必太在意表层的相似。

说了这么多,如果有兴趣试一下的同学,可以在这里下载。

游戏官网 http://xd.ejoy.com/

这款游戏继续和陌陌合作,但这次,不必安装陌陌客户端,也不用注册陌陌号就可以游戏了。所以只是想看一下的同学不必有心理负担。Andriod 版和 iOS 版均有,下载安装后直接可进入游戏,没有什么多余的注册环节。

这是我们使用 ejoy2d 制作客户端的第三款产品,使用 skynet 做服务器的第五款产品。如果仅仅想看看 ejoy2d 的表现力,也可以下载看看。


btw, 到现在,是在陌陌游戏上线的第一个 24 小时,同时在线的人数全天一直保持在注册总用户数的 1/8 稳定上升。玩家对我们的游戏的认可可以说是预料之外,而在情理之中。毕竟用心去做,总能被玩家体会到。

August 20, 2015

共享 lua vm 间的小字符串

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 的临时内存增长也会慢一些。

August 19, 2015

希望 Lua 可以增加一个新特性 userdata slice

Lua 是一门嵌入式语言,和 host 的联动非常重要。Lua 使用 userdata 来保存 host 里的数据,userdata 非常强大,可以有 metatable 还可以关联一个 uservalue ,可以封装一切 C/C++ 对象,非常强大。但有的时候却稍显不足,似乎缺了点什么,导致一些简单的需求要用很繁琐的方式解决。

有个想法想过很久,今天动了念头用英文写了一遍投递到 lua 邮件列表里去了。

那就是,如果我们可以给 userdata 的值关联一个整数,而不是把 uservalue 关联到 userdata 的对象里那样,可以简化很多事情。

看这样一个例子:

如果我们有一个数据结构:

struct foo {
  struct foo1 foo1;
  struct foo2 *foo2;
};

如何保存到 lua 里被 lua 引用呢?通常我们使用一个 userdata 封装它:

struct foo * f = lua_newuserdata(L, sizeof(*f));

但是,如果我们想进一步引用这个结构中的一个子结构怎么办?即,如果我们在 lua 里有了 userdata foo, 希望可以通过 foo.foo1 引用其子结构 foo1, 用 foo.foo2 引用 foo2 怎么办?

当然,我们先要给 foo 配置一个 metatable 并实现 index 元方法。这样在语法上就可以用 foo.foo1 和 foo.foo2 了。

但是 foo.foo1 和 foo.foo2 导致是什么?在目前的 lua 中,必须也返回一个 userdata 。那么我们就得在 foo.foo1 时,生成一个新的 userdata 作为 proxy 来访问 foo 对象的一部分。而且一旦访问过 foo.foo1 还得 cache 住这个新生成的 userdata ,保证下次再引用时还能返回同一个 userdata 。

为了保证 foo 和 foo.foo1 的生命期绑定,也就是在 foo.foo1 还在用的时候 foo 不被回收掉,往往我们还需要在 foo.foo1 的 uservalue 中保存一份 foo 的引用。这样才可以让 gc 能正确工作。


如果我们可以给 userdata 的值(而不是 object)关联一个整数就不一样了。例如,我们用 0 表示 foo 本身,1 表示 foo.foo1 ,2 表示 foo.foo2 。当 foo 被创建出来的时候,关联值默认是 0 ,也就是指代 foo 本身。

当我们运行到 foo.foo1 时,在 index 元方法中可以返回同一个 foo userdata ,但关联上新值 1 。注意,这个 1 是跟着返回值走的,并不在 userdata object 上,所以并不影响之前的 foo 对象。

这样,同一个 foo userdata 就有了对 C 对象的多种表达,在 lua 中也可以正常的做比较,做 table 的键。

为了实现这个新特性,并不需要修改 lua 原有的定义,也不需要增加新类型。只需要把以前类型为 LUA_TUSERDATA 的值在实现上变成两个整数组。第一个整数是一个 userdata 的 handle ,而所有真正的 userdata object 指针都保存在 lua vm 的 global state 里即可。第二个整数是关联其上的 slice 值,表示这个值应用 userdata object 的哪一个切片。

这一对值可以随 lua 的赋值质量做值拷贝,而不像之前的 userdata 只是做引用拷贝,这样就可以作到对同一个 C 对象的不同表达。


我们只需要增加两个新的 C API 就够了:

int lua_getuserslice(lua_State *L, int index);
void lua_setuserslice(lua_State *L, int index, int slice);

lua_getuservalue 的用法也基本一致。

有了这个特性,封装 GUI 对象或 3d engine 中的对象要容易的多。

当我们在 lua 里写 OBJECT 和 OBJECT.X 或 OBJECT.X.Y 时,它们可以都指向同一个 userdata object ,只是 slice 不同。这样 OBJECT.X 获得 OBJECT 下的一个子节点也就轻量的多了。

August 12, 2015

一个内存泄露 bug

起因是 skynet 的一个 Issue ,同时,这两天我们正在开发的一个项目也反应貌似有内存泄露。

我觉得两件事同时发生不太正常,就决定好好查一下。

其实在 skynet 里查内存泄露要比一般的项目容易的多。因为 skynet 天生就分成了很多小模块,叫作服务。模块申请的内存是独立的,内聚性很高。模块的生命期比整个进程短的多,模块的规模也不会太大,可以独立分析。一般说来,如果有内存申请没有归还,应该是 C 模块里的 bug 。而 skynet 会用到的 C 模块也很少,一旦有这样的问题,很快就能定位。

skynet 实现了一个内存管理的钩子,用于按服务分开统计内存的开销。代码在 skynetsrc 目录下的 mallochook.c 文件里。

我建议同事做这样的修改:

在每次内存分配释放时,按当前服务的 handle 做文件名记录 log 。这里有考虑一点点多线程并发的问题,但发生的概率很少,如果只是做临时的调试,可以暂时不理。因为同一个服务的分配行为不会并发,只有很少的情况释放在另一个线程中。如果发生了,会导致写 log 遇到一点错乱。

比较麻烦的是,在 hook 里第一次写 log 打开 log 文件有可能由 crt 触发 malloc 导致死循环。如果要避免这种情况,需要加一个 tls 标记,在进入 hook 后就设置上标记,避免被重入。

加上十多行代码后,内存管理的 log 就可以正常工作了。


一开始我认为 lua 本身的内存管理是没有问题的,所以可以将 malloc hook 里为 lua 定制的 skynet_lalloc 绕过 log (直接调用 je_malloc 即可)。这样就可以只记录 C 模块中的内存管理调用,数据量要少的多,比较容易排除问题。

但是加上 log 后,分析 log 似乎找不到泄露。我只好转而怀疑通过 lua 的分配器分配的内存有没有释放的。

怀疑点有两个,一是通过 lua_getallocf 取到分配器调用的。这个只在 lpeg 里发现过,应该没有问题;另外就是我自己对 lua 的修改了。

为了提高 skynet 下多 lua vm 的内存利用率,我曾经给 lua 打过一个 patch ,可以让不同 lua vm 共享相同的函数原型 。第一次加载的 lua 字节码是不会释放的,但第 2 次加载相同的代码,会复用之前的拷贝。

我仔细把 patch review 了一遍,没有发现问题。所以还是得靠新的 log 分析。

接下来把 log 重新写了一下,改成在 lua 定制分配器里记录。这个要容易的多,正好前几天刚做过 。还真发现了有一些长度为 88 字节的内存块没有释放。

lua 分配器在分配新内存时,会传入内存的用途(供什么类型的数据使用),这是一个重要的线索。这个泄露的 88 字节的类型为 0 (无记录),但它的上一条分配记录的类型是 9 也就是 LUA_TPROTO 。这说明很可能真的是函数原型中有数据没有释放。

而 88 字节恰好是我的 patch 中增加的 ShareProto 这个结构的长度。


最终 bug 的修复很简单:见这个 commit 。就是漏了一行 free 。

真是撞了鬼了。

因为我自己的硬盘上的本地代码明明一直是有这行的 :( 甚至我都不记得有过漏写这行代码。(这个 patch 是根据更早的 lua 5.2 版上的 patch 改过来升级到 lua 5.3 的。老版本是正确的。) git 的 diff 和 status 都没有察觉到我本地代码和仓库里有什么不同。这也是为什么这个内存泄露在我自己的机器上从来都没有发生过。

所以最后我重新 clone 了一份仓库,提交了这行遗漏的代码。但很难找到线索为何在操作 git 仓库的时候出现这个问题。或许是几个月前某次 push -f 强制推送导致的吧。


8 月 21 日补充:

前两天清理本地仓库的时候发现,应该不是 git 的问题。是我的本地分支太多太杂,停在了一个和远程不一样的分支上,以至于漏掉的这行代码没有同步到远程仓库。

August 05, 2015

去掉 skynet 中 cluster rpc 的消息长度限制

之前写过一篇 为什么 skynet 提供的包协议只用 2 个字节表示包长度 里面提到, 如果有体积很大的消息传递需求,那么应该在上一层去处理。

从另一方面来说,我们应该正视长消息的处理,而不应该将其和普通(较短)消息的处理混为一谈,在底层抹平之间的区别。

最近,需求就来了。

我们的一个新项目希望在 cluster 间通讯的时候,可以支持较大的消息。原本提出需求的同学想自己修改 skynet 的 cluster 模块,修改底层协议的包头长度的。我即使阻止了他,并自己动手做了修改。

简单说,就是在上一层的 cluster 协议上,增加了长消息的标识。由于之前就有一个字节做标识,其实并没有对协议做太大的修改。

当消息(无论是请求还是回应)太长时,就分开打包,并在标识字节上表明这只是一个完整消息的一部分。然后在接收方合并即可。

一开始我并不打算修改 cluster 依赖的 socket channel 模块 ,让分开的每个小包都有一个独立的 session 。做了一半以后,发现对 socket channel 做一些增强(不应该之前的接口)会更好一些。而且可以很容易利用上之前做的 socket 低优先级队列这个特性。

新的修改我暂时提交到一个叫 multipart 的 skynet 独立分支上,欢迎感兴趣的同学 review 。

同时,我把 skynet 之前的两个含义不明确的内部宏重新定义了,去掉了之前对内部消息长度的 16M 限制。


经过一些简单测试,目前可以达到设计目标:在 cluster 内部,rpc 的请求和回应消息都不再受之前的 64K 限制。而且,一旦消息过长(目前是以 32k 为界),长消息的传输会被分成多个小于 32K 的消息投递,并且其它短消息允许穿插在其间。也就是说,如果你用 cluster 向外投递一个几十 M 的巨大消息的话,不会因为消息过大而阻塞了通讯通道。