重构
随着 engine 开发进入尾声,最近几个月已经在修一些边角的东西,顺便给其他组的同事做介绍和教学。由于不在一起办公,折腾 VPN 和防火墙也折磨了好几天。
中秋假期前,我重新思考了去年设计好,但一直没精力去实现的东西:资源的多线程预读。
由于资源管理模块当初设计的还是比较仓促,有些需求到了后期才逐步出现。比如,我们需要同时使用不同的资源加载模块,分别从本地文件系统于打包文件中读取。一开始,我认为,开发期不将资源文件打包是可以的。随着开发的进展,数据文件在数量级上增加,感觉将一些不太变化的文件打包还是有必要的。这就必须实现混合加载。
前期考虑到要做数据预读,我们将文件之间的依赖关系放到了文件系统内。而非自己定制的数据包文件系统和本地文件系统间有很大的不同。这使得混合加载实现比较困难。(具体原因是,我们的文件系统内采用的类似 linux ext2 的思路,每个文件有一个 inode 标识,并描述相互关系。但文件却不一定有文件名。这和本地文件系统相异,导致相互依赖关系难以描述。)
第二,虽然前期设计为多线程加载考虑。但由于中间变化很多,例如需要在加载过程中把部分数据上载到显存。最终,实现多线程安全变的很困难。(具体原因是,每种文件类似都有自解释的代码,我们的客户端主体 engine 代码是按单线程设计,实现资源加载的多线程安全必然对以后扩展资源文件类型的程序员做过多限制)
基于以上两点,我觉得花一番气力对整个资源管理模块做一次大的重构。
为了不影响项目组其他人员的工作,估算了工作量后,我决定自己在中秋节做这次重构工作。并在假期结束可以顺利归并到代码基的主干。
事情没有预期的顺利。
中秋假期三天,按我预估的 3000 行代码的工作量,本应该是绰绰有余的。临时有其他活动安排,结果我到第三天下午才开始做这件事。
已经在稳定运行的版本有些过度设计了。为还没有实现的多线程方案预留了许多灵活度。却没有全盘考虑清楚。
为了让文件类型的的自解析过程在单独线程里可以安全运行,不和主逻辑线程冲突。我为这个模块设计了独立的 CRT 子模块,和并没有考虑线程安全的主 CRT 隔离。慢慢的才发现,严格的做到线程安全,需要对程序员做更多限制。如果全部资源文件解析过程都由我或另外一两个程序员进行,这个要求勉强可以达到。但是一旦隐藏问题依然难以发现。要么采取保守方案用加锁保证,要么牺牲一些多线程好处,把一些不可靠的部分保持在主线程内实现。总之,都违背了当初的设计初衷。
另外,最初没有考虑混合加载模块共存。文件内部的 inode 在不同的加载模块间可能引起冲突。(自定义的包格式中,对每个文件都定义了 id ,而本地文件系统则采用了一种自动生成唯一 id 的方式工作)这点必须修改。
我觉得重构分两步走。第一步先解决较容易的混合模块加载的需求。测试通过后。再把新写的代码连同老的所有资源管理模块抛弃,全部重写。
第一步工作很顺利,大约改写了几百行代码就几乎无错的一次通过了。
第二步工作打算放弃所有的相关代码,重新实现。为了这个,我在节前就在纸上准备了几天。勾勒了大致的子模块关系图和接口设计。
很久没有动笔打草稿了。这真是个很难做简单的设计。虽然我还是力图简化思路。或许放弃 ”一个文件必然拥有一个字符串标识的文件名“ 是个错误的决定。不过我还是肯定,这样带来的性能提高能弥补设计复杂度提升的负面影响。
这次把资源管理模块划分成了三个层次,比以前的简单一些。
第一个层次提供用户界面。并管理多个不同加载模块。第二个层次提供资源的 cache ,可以将已经打开过并存在于内存的资源索引不通过加载模块直接返回到上一层。其中,提供两种 cache 方式,以文件树结构 cache 和以 inode 映射的方式 cache 。第三个层次是真正的加载模块,分别做文件名到 id 的映射,获取文件间的依赖关系,读取文件头,分析数据。另外有一个文件类型的解析器管理模块,供具体文件解析过程,把特定文件解析方法注册进去加以管理。
至于多线程预读的问题,资源模块已经可以了解资源数据文件间的关联信息。简单的开一条线程 touch 那些未来可能读到的文件即可。把预读和缓冲的工作交给 OS ,这应该是一个简洁有效的设计。并不需要太多顾虑线程安全的问题。
想归想,实际操作起来颇费了一番功夫。
第一天,手指没停的新写了 1000 来行代码的实现,替换掉旧的近两千行代码。并且修改了十多个接口相关的源文件。大体完成,眼瞅着假期内完成不了了,只好回家睡觉,留下了些小模块。
然后,陆续跟两个主要负责同事干了几天。把各自负责的资源解析模块按新标准改写。大多数地方重新写可以简化代码,干的还算没那么郁闷。只是做的很小心,时断时续。好在我们的模块划分比较清晰,可以独立编译审核。大家都把代码在我个人机器上做合并,由我逐步做渐进式的测试。
到了周四下午晚饭时,大致重构完成。在跑完整的测试 demo 前,我去开了瓶汽水庆祝。lulu 笑谈,如此大规模的重构,通常应该有 5 个 bug 吧。
果然,第一次运行毫无悬念的 core dump 了。
接着又来了几次。在修改掉第 3 个 bug 后,demo 顺利的跑了起来。真是值得庆贺。
不过晚上继续测试,又发现了两个 bug ,正好不多不少是 5 个 。:D
周五,按计划,我们测试资源在运行中全部 unload 并重新加载。这个功能打算用于编辑器中。或者用在游戏更改设置的位置。同时,也可以测试,所有类型的资源是否可以正确的卸载。
demo 又一次毫无悬念的挂了。原本以为是个小问题,几分钟可以搞定。结果却折磨了我 4 个多小时 :( 。最终发现还是一个弱智的错误。
这个调试过程很有代表性,记录如下:
我们发现程序崩溃时,gdb 停在一个莫名其妙的地方,前后毫无问题。倒溯回去,发现有一处结构里的数据内容无法被代码改写。
ps. 关于程序倒溯,并不指利用 gdb 回退堆栈。这时堆栈已经被破坏掉了。因为我们的程序有录象功能(录制所有时间点的任何外部输入),可以保证在录象文件的输入回放下,程序每次运行都有唯一的输入,保证每次精确的以同一执行流程运行。当然,如果发生 core dump ,也是精确的在同一个地方。所以可以做到再次运行,提前设置断点停下来查看,仿佛时间倒流,代码向前回退。
凭经验,我猜测是某处内存访问越界,破坏了正确的结构指针,让结构指针指向了非法地址。
事实并不完全是这样。几经最终,查到了是我们自己实现的内存分配器分配出了一个并不该它管理的地址空间。而这个内存分配器早在 04 年就经过了严格的测试,应该没有问题。这让我相当迷惑。
由于内存分配器在分配内存块时,会给内存头部添加一个小的 cookie 方便管理。一开始怀疑,这个 cookie 被越界修改,导致内存分配器工作不正常。
但是不段的运行回溯(虽然做起来容易,但是非常费时),观察到,并不是某个内存块的头部 cookie 被篡改。而是内存分配器自己的私有数据不太正常。但我还是相信内存分配器的实现无问题,只是这个结果很让人费解。因为我们的内存分配器的私有数据并没有从运行期的堆内分配出来,而是开在数据段里。而我们的代码模块物理上做了隔离。所以除非分配器本身的代码,很难有地方可以通过非法指针方法篡改模块内部的私有数据。
经过了很长时间的运行时数据分析,最终找到了罪魁祸首。居然有一个内存块的 cookie 中,表识长度的字段为 0 。但这在正常的数据中是不可能存在的。(通过接口分配内存,0 字节的请求最终会被扩展成 8 字节以上)而这个 0 ,在内部工作时被换算成一个负数,作为下标改写分配器内部的一个数组,导致了一个不相关数据的篡改。
了解了这一点,反过来审核代码,很容易的找到了元凶。一处不正确的内存指针调用了 free 接口。
接下来的几个小时都是和内存泄露在做斗争。
传统的内存泄露检查我最早是从 MFC 的源码里学来的。就是记录每处内存分配的源代码行位置,以及分配的内存块大小。并在程序结束的时候统计没有释放的内存块。
再这几年的编码实践中,我逐渐淘汰了这种效率不高(指快速发现问题的效率)的方案。
由于我们完全实现了自己的内存分配器。所以可以更为精确的知道每个内存指针的合法性。并更容易监控每个内存块指针。(我们的内存管理器采用一次性向 OS 申请大块内存,并采用三种不同的分配方式向应用层提供服务的方式工作。)所以完全可以在调试期,申请等大的内存地址空间做监控。
鉴于游戏 client 的特殊性,内存申请需求呈周期性的变化(按渲染帧为周期)。便可以周期性的捕获内存申请的异常变化。最终得以快速的定位内存泄露。
一开始我们捕获了 74 处可疑的内存泄露,分三次定位到了两个漏写 free 的代码。最终排除掉了 4 处合理的地方。用了不太长的时间修正了内存泄露问题。
一个稳定的底层架构,还是对定位并排除 bug 有很大好处的。对比看来,当年大话西游 server 端的一个内存泄露问题,前前后后查了大半年。早期 server 不敢开放太多人同时上线的限制,就和那处内存泄露有关。(人数太多,泄露的更快,服务器撑不到一周就会因为虚拟内存耗近而崩溃)
虽然多花了一周时间,比我预期的时间长了许多。但总算了了件心事。engine 基本上不需要再多做改动了。这个规模的代码重构,上次要前推到 06 年初了。算是一重大事件了,写此流水帐纪念之。
btw, 最近有个小朋友写 email 问我关于 makefile 的问题。和广州一些同事讨论问题时也涉及这个方面。我已经三年没有用 IDE 开发,越来越对 make 有爱。最近计划花掉时间写点普及教程,教那些过度依赖 IDE 开发的同学脱离苦海。不知道这么做有没有意义。貌似用 IDE 的同学们都不以为苦呢。
还有,有个朋友写 email 问我些问题,我好不容易回完,发现他的邮件居然没有发信地址。太寒了。最近老碰到这种郁闷的事,比如写这篇 blog ,浏览器中间崩溃了一次,损失了 1000 来字 :( 。
Comments
Posted by: 南郭大侠 | (23) September 23, 2008 09:32 AM
Posted by: ix | (22) September 21, 2008 06:52 PM
Posted by: christanxw | (21) September 21, 2008 04:06 PM
Posted by: Cloud | (20) September 21, 2008 02:46 PM
Posted by: 山越野人 | (19) September 21, 2008 01:00 PM
Posted by: peter | (18) September 21, 2008 10:51 AM
Posted by: zicjin | (17) September 21, 2008 10:32 AM
Posted by: jim | (16) September 21, 2008 09:19 AM
Posted by: lxc | (15) September 21, 2008 01:53 AM
Posted by: lxc | (14) September 21, 2008 01:37 AM
Posted by: Cloud | (13) September 20, 2008 11:50 PM
Posted by: analyst | (12) September 20, 2008 11:38 PM
Posted by: Cloud | (11) September 20, 2008 11:10 PM
Posted by: macro | (10) September 20, 2008 09:42 PM
Posted by: Cloud | (9) September 20, 2008 09:22 PM
Posted by: macro | (8) September 20, 2008 09:14 PM
Posted by: vincent | (7) September 20, 2008 08:45 PM
Posted by: func | (6) September 20, 2008 08:42 PM
Posted by: jtuki | (5) September 20, 2008 08:35 PM
Posted by: lxc | (4) September 20, 2008 07:52 PM
Posted by: star | (3) September 20, 2008 06:55 PM
Posted by: chengj | (2) September 20, 2008 05:26 PM
Posted by: kai | (1) September 20, 2008 05:25 PM