上一篇谈了一些关键技术的实现方案。本篇描述一些遇到的问题。
一
在策划制作完了几个职业后(主要是技能制作),大概去年年底公司内部进行了一次混战测试。30个角色在一个场景进行混战,测试结果从技术上来说非常不理想。首先是客户端和服务器都巨卡无比。服务器CPU一直是满负载状态。而客户端又频繁宕机。
我们关注的主要问题,是服务器CPU满负载问题。最开始,我通过日志初步定位为网络模块问题,因为逻辑线程表现不是那么差。然后考虑到技能过程中的特效、动作都是通过服务器消息驱动,并且本身特效和动作就比一般网游复杂,通过逐一屏蔽这一部分功能,最终确认确为网络模块导致。然后团队决定从两方面努力:重写网络模块,改善性能;改善技能实现机制,将表现类逻辑移到客户端。 至于网络模块,在后来才发现,虽然网络流量过高,但导致网络线程CPU满的原因竟然是网络模块自身的流量限制导致。而技能实现机制的改善,考虑到改动的成本,最终使用了一种RPC机制,让服务器脚本可以调用客户端脚本,并且支持传入复杂参数。然后策划通过一些关键数据在客户端计算出特效、动作之类。
此外,程序将更多的技能属性广播给客户端,一个客户端上保存了周围角色的技能数据,从而可以进行更多的客户端逻辑。这一块具体的修改当然还是策划在做(我们的脚本策划基本就是半个程序员)。后经测试,效果改善显著。
二
在策划制作了一个PVP竞技副本后,服务器在10V10测试过程中又表现出CPU负载较高的情况。这个问题到目前为止依然存在,只不过情况略微不同。
首先是触发器生命周期的问题。触发器自身包含最大触发次数、存留时间等需求,即当触发一定次数,或超过存留时间后,需要由程序自动删除;另一方面,触发器可以是定时器类型,而定时器也决定了触发器的生命周期。这一块代码写的非常糟糕,大概就是管理职责划分不清,导致出现对象自己删除自己,而删除后还在依赖自己做逻辑。
但这样的逻辑,最多就是导致野指针的出现。不过,这种混乱的代码,也更容易导致BUG。例如,在某种情况下触发器得不到自动删除了。但这个BUG并不是直接暴露的,直接暴露的,是CPU满了。我们的怪物AI在脚本中是通过定时器类触发器驱动的,每次AI帧完了后就注册一个触发器,以驱动下一次AI帧。由于这个BUG导致触发器没有被删除,从而导致服务器上触发器的数量急剧增加。但,这也就导致内存增长吧?
另一个巧合的原因在于,在当时的版本中,触发器是保存一个表里的,即定时器类触发器、属性类触发器、移动类触发器等都在一个表里。每次任意触发器事件发生时,例如属性改变,都会遍历这个表,检查其是否触发。
基于以上原因,悲剧就发生了。在这个怪物的AI脚本里,有行代码设置了怪物的属性。这会导致程序遍历该怪物的所有触发器。而这个怪物的触发器数量一直在增长。然后就出现了在很多游戏帧里出现过长的遍历操作,CPU就上去了。
找到这个问题了几乎花了我一天的时间。因为脚本代码不是我写的,触发器的最初版本也不是我写的。通过逐一排除可能的代码,最终竟然发现是一行毫不起眼的属性改变导致。这个问题的查找流程,反映了将大量逻辑放在脚本中的不便之处:查起问题来实在吃力不讨好。
修复了这个BUG后,我又对触发器管理做了简单的优化。将触发器列表改成二级表,将触发器按照类型保存成几个列表。每次触发事件时,找出对应类型的表遍历。
改进
除了修改触发器的维护数据结构外,程序还实现了一套性能统计机制,大概就是统计某个函数在一段时间内的执行时间情况。最初这套机制仅用于程序,但考虑到脚本代码在整个项目中的比例,又决定将其应用到脚本中。
这个统计需要在函数进入退出时做一些事情,C++中可以通过类对象的构建和析构完成,但lua中没有类似机制。最初,我使用了lua的调试库来捕获函数进入/退出事件,但后来又害怕这种方式本身存在效率消耗,就取消了。我们使用lua的方式,不仅仅是全局函数,还包括函数对象。而函数对象是没有名字标示的,这对于日志记录不是什么好事。为了解决这个问题,我只好对部分功能做了封装,并让策划显示填入函数对于的字符串标示。
除此之外,因为触发器是一种重要的敏感资源,我又加入了一个专门的触发器统计模块,分别统计触发器的类型数量、游戏对象拥有的触发器数量等。
END
到目前为止,导致服务器CPU负载过高,一般都是由BUG导致。这些BUG通常会造成一个过长的列表,然后有针对这个列表的遍历操作,从而导致CPU负载过高。更重要的,我们使用了这么多的脚本去开发这个游戏,如何找到一个更有效合理的监测方法,如何让程序框架更稳定,则是接下来更困难而又必须去面对的事情。