February 03, 2016

ejoy2d sprite pack 的空间优化

在 ejoy2d 里,我将 sprite 的结构信息储存在一组叫 sprite pack 的结构中。其中包括动画的 frame 数据,sprite 由若干部分组成,每个部分的变换矩阵,对应贴图的编码和坐标等等。

通常这些数据不会太大,所以我建议一次加载到内存就不再删除。而动态生成的 sprite 对象则直接引用这些数据,不必做引用计数。这些数据之间的交叉引用(可以像搭积木一样用很多部件构成复杂的 sprite )也不需要额外记录。但如果保存了大量的动画信息,或 sprite 是由非常多的小部件构成,数据量也可能非常可观。

在我们的 心动庄园 里,达到了数十 M 内存之多。前几天同事提到这个问题,我便动手做了一点简单的优化,居然省出了几十兆内存。

阅读全文 "ejoy2d sprite pack 的空间优化" »

opengl bug 一则

这是个老问题,但是公司不同的同学先后被坑,所以必须记录一笔。这样可以增加事后被 google 到的概率,千万别来第三次了。

我原本以为 opengl 在 bind VBO 后,如果修改了 VBO 的数据,是不需要重新 bind VBO 对象的。所以早先的 ejoy2d 在这里的处理就做了一点优化,并没有重复 bind ,可以减少一些 API 调用。

大约在 2015 年 6 月左右,由于需要跟进 mac osx 的系统更新,ejoy2d 增加了对 VAO 的支持。我再修改相关实现的时候发现在某些设备上,出现了 bug 。

当时猜想可能是新的手机的驱动做了一些过去没有做的奇怪的优化。我没有太多确认 opengl 是否对次有要求,不知道是否是驱动的 bug ,不过还是做了一些修改。

这个 patch 中,我在更新 VBO 的数据后,设置了脏标记,最后提交时会重新 bind VBO 。

当时并没有刻意做为一个独立 commit 提交,所以被同事疏忽了。

前不久,我们的 心动庄园 在新版锤子手机上出现花屏,同事追查过原因,发现只需要更新 ejoy2d 就解决了。定位了一下原因,就是上面提到的 patch 解决的。

前几天,另一个同事自己用 ejoy2d 开发的小游戏 爆裂方块 在 360 平台审核时被报告有显示 bug ,又查了好久,今天终于定位到是同一问题。

阅读全文 "opengl bug 一则" »

January 26, 2016

资源包的设计

一般游戏会把所需要资源数据打包成一个大文件,游戏运行时可以像访问普通文件一样,访问包内文件。如何打包如何更新资源包,是设计重点。

现在有很多资源包直接使用通用打包(压缩)格式,比如 zip 。也有自行设计的,多半是为了一些特殊需求。比如资源间有引用关系等。如果资源数量过多,通常还会对原始资源文件名做一次 hash 索引,加快包内文件检索效率。像暴雪的 mpq 格式,还有 unity3d 的 asset bundle 格式都是这样的。

一旦资源打包,原始文件名信息就不再需要了。应用程序可以在运行时通过文件名的 hash 值索引到包内文件。(所以第三方的 mpq 解包工具需要提供一份额外的文件名列表)

阅读全文 "资源包的设计" »

January 17, 2016

嵌入式 lua 中的线程库

无论是客户端还是服务器,把 lua 作为嵌入语言使用的时候,都在某种程度上希望把 lua 脚本做多线程使用。也就是你的业务逻辑很可能有多条业务线索,而你希望把它们跑在同一个 lua vm 里。

lua 的 coroutine 可以很好的模拟出线程。事实上,lua 自己也把 coroutine 对象叫做 thread 类型。

最近我在反思 skynet 的 lua 封装时,想到我们的主线程是不可以调用阻塞 api 的限制。即在主干代码中,不可以直接 yield 。我认为可以换一种更好(而且可能更简洁)的封装形式来绕过这个限制,且能简化许多其它部分的代码。

下面介绍一下我的新想法,它不仅可以用于 skynet 也应该能推广到一切 lua 的嵌入式应用(由你自己来编写 host 代码的应用,比如客户端应用):

阅读全文 "嵌入式 lua 中的线程库" »

January 06, 2016

基于引用计数的对象生命期管理

最近在尝试重新写 skynet 2.0 时,把过去偶尔用到的一个对象生命期管理的手法归纳成一个固定模式。

先来看看目前的做法:旧文 对象到数字 ID 的映射

其中,对象在获取其引用传入处理函数中处理时,将对象的引用加一,处理完毕再减一。这就是常见的基于引用计数的对象生命期管理。

常规的做法(包括 C++ 的智能指针)是这样的:对象创建时,引用为 1 (或 0)。每次要传给另一个处地方处理,或保留待以后处理时,就将其引用增加;不再使用时,引用递减。当引用减为 0 (或负数)时,把对象引用的资源回收。

由于此时对象不再被任何东西引用,这个回收销毁过程就可视为安全且及时的。不支持 GC 的语言及用这些语言做出来的框架都用这个方式来管理对象。


这个手法的问题在于,对象的销毁时机不可控。尤其在并发环境下,很容易引发问题。问题很多情况是从性能角度考虑的优化造成的。

加减引用本身是个很小的开销,但所有的引用传递都去加减引用的话,再小的开销也会被累积。这就是为什么大多数支持 GC 的语言采用的是标记扫描的 GC 算法,而不是每次在对象引用传递时都加减引用。

大部分情况下,你能清楚的分辨那些情况需要做引用增减,哪些情况下是不必的。在不需要做引用增减的地方去掉智能指针直接用原始指针就是常见的优化。真正需要的地方都发生在模块边界上,模块内部则不需要做这个处理。但是在 C/C++ 中,你却很难严格界定哪些是边界。只要你不在每个地方都严格的做引用增减,错误就很难杜绝。


使用 id 来取代智能指针的意义在于,对于需要长期持有的对象引用,都用 id 从一个全局 hash 表中索引,避免了人为的错误。(相当于强制从索引到真正对象持有的转换)

id 到对象指针的转换可以无效,而每次转换都意味着对象的直接使用者强制做一个额外的检查。传递 id 是不需要做检查的,也没有增减引用的开销。这样,一个对象被多次引用的情况就只出现在对象同时出现在多个处理流程中,这在并发环境下非常常见。这也是引用计数发挥作用的领域。

而把对象放在一个集合中这种场景,就不再放智能指针了。


长话短说,这个流程是这样的:

将同类对象放在一张 hash 表中,用 id 去索引它们。

所有需要持有对象的位置都持有 id 而不是对象本身。

需要真正操作持有对象的地方,从 hash 表中用 id 索引到真正的对象指针,同时将指针加一,避免对象被销毁,使用完毕后,再将对象引用减一。

前一个步骤有可能再 id 索引对象指针时失败,这是因为对象已经被明确销毁导致的。操作者必须考虑这种情况并做出相应处理。


看,这里销毁对象的行为是明确的。设计系统的人总能明确知道,我要销毁这个对象了。 而不是,如果有人还在使用这个对象,我就不要销毁它。在销毁对象时,同时有人正在使用对象的情况不是没有,并发环境下也几乎不能避免。(无法在销毁那一刻通知所有正在操作对象的使用者,操作本身多半也是不可打断的)但这种情况通常都是短暂的,因为长期引用一个对象都一定是用 id 。

了解了现实后,“当对象的引用为零时就销毁它” 这个机制是不是有点怪怪的了?

明明是:我认为这个对象已经不需要了,应该即使销毁,但销毁不应该破坏当下正在使用它的业务流程。


这次,我使用了另一个稍微有些不同的模式。

每个对象除了在全局 hash 表中保留一个引用计数外,还附加了一个销毁标记。这个标记只在要销毁时设置一次,且不可翻转回来。

现在的流程就变成了,想销毁对象时,设置 hash 表中关联的销毁标记。之后,检查引用计数。只有当引用计数为 0 时,再启动销毁流程。

任何人想使用一个对象,都需要通过 hash 表从 id 索引到对象指针,同时增加引用计数,使用完毕后减少引用。

但,一旦销毁标记设置后,所有从 id 索引到对象指针的请求都会失败。也就是不再有人可以增加对象的引用,引用计数只会单调递减。保证对象在可遇见的时间内可被销毁。

另外,对象的创建和销毁都是低频率操作。尤其是销毁时机在资源充裕的环境下并不那么重要。所以,所有的对象创建和销毁都在同一线程中完成,看起来就是一个合理的约束了。 尤其在 actor 模式下, actor 对象的管理天生就应该这么干。

有了单线程创建销毁对象这个约束,好多实现都可以大大简化。

那个维护对象 id 到指针的全局 hash 表就可以用一个简单的读写锁来实现了。索引操作即对 hash 表的查询操作可遇见是最常见的,加读锁即可。创建及销毁对象时的增删元素才需要对 hash 表上写锁。而因为增删元素是在同一线程中完成的,写锁完全不会并发,对系统来说是非常友好的。

对于只有唯一一个写入者的情况,还存在一个小技巧:可以在增删元素前,复制一份 hash 表,在副本上慢慢做处理。只在最后一个步骤才用写锁把新副本交换过来。由于写操作不会并发,实现起来非常容易。

skynet 消息队列的新设计(接上文)

接前一篇文 ,谈谈 skynet 消息队列的一些新想法。

之前谈到,每个服务的消息接收队列可以是定长的,且不必太长。因为正常运行中,每个服务都应该尽量消化掉需要处理的消息,否则会预示着某种上层设计的问题。

但是,在接收队列满的时候直接丢掉消息显然是不合理的。那意味着必须有更健全的错误传播机制,让发送失败方可以出错而中断业务。允许发送消息出错可能使上层结构设计更难。

让发送方阻塞在 skynet 中显然也不是个好方案。因为 skynet 的服务是允许阻塞时重入执行另一条新 session 的,这是和 erlang 最大的不同。这可以让单个 lua vm 的性价比更高,可以在要需要的时候,做共享状态,而不必全部业务都通过相对低效的消息通讯来完成;但其负面代价是重入会引发一些隐讳的 bug 。很多已有的 skynet 项目都依赖 send 消息不阻塞这点来保证逻辑正确,不能轻易修改。

我的解决方案是给每个服务再做一组发送队列。最接收方忙的时候,把待发消息放在自己这里的发送队列中。这样就可以由框架来确保消息都能正确的依次发送(这里不保证目的地不同的消息的先后次序,但保证目的地相同的消息次序)。

阅读全文 "skynet 消息队列的新设计(接上文)" »

January 04, 2016

skynet 消息分发及服务调度的新设计

这个月 skynet 的 1.0 就会 release 最终版了,除了维护这个稳定版本。我考虑可以对一些不太满意的地方尝试做大刀阔斧的改变(当然不放在目前的稳定版本中)。

我对 skynet 解决的核心问题:多服务任务调度以及内部消息传播这块不是很满意,觉得如果换个方式实现可能会好一些。下面先把想法记下来。

目前,每个服务都有一个唯一的消息队列,且在内存足够的前提下,会无限增长。也就是说,向一个服务发消息是没有失败的可能的。多数情况下,单个服务的消息队列不会太长,在生产消费模型中,也不允许太长。太长意味着消费速度远远低于生产速度,情况多半会恶化。在历史上发生过多起事故,都是和服务过载 有关。

虽然 skynet 提供了 mqlen 这种方法供使用者查询当前服务的消息队列长度,以做出应变,但治标不治本。我想做一个大的设计改动来重新考虑这一块。

阅读全文 "skynet 消息分发及服务调度的新设计" »

Misc

Categories

Archives

Recent Comments