« 远程设置防火墙要小心 | 返回首页 | IDE 不是程序员的唯一选择(一) »

重构

随着 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

跟我理解的重构不太一样,我认为重构是小步骤的修改代码,以实现可读性。

但是学习了很多,谢谢大大

不是吧,你都可以写一个脚本代码,然后把这个脚本代码加到vc的项目工程里,然后使用特定的解释器将这个脚本代码转换成c++代码,然后再将这个c++代码编译到最终的exe中去,这些都可以在ide里做,而且也可以在ide里调试这些脚本代码.很多项目都是这么干的.我想之所以有很多人不愿意去做这些小工具可能还是嫌麻烦吧,而且未必通用.

期待你的MAKE教程阿。

@peter

"做软件是个体力工作,大部份的需求都是非常简单的工作。"

这句话放到现实来看没错,但是造成这种结果的原因是,没有人去把这些简单的工作交给机器去做。

所以自动化非常重要,面对多变的需求,自动化很难做。这样就需要更丰富的小工具和一些粘接剂。需求变化了,就重新组装一下。

可惜的是,在 windows 平台上这很匮乏。

比如,一些机械编码。设计一个小语言去生成 C 代码来解决,人的体力劳动就能被减少许多。

设计小语言和实现就变成了一项有创造性的工作。

但是为什么大家选择机械编码呢?因为 windows 下用 vs 环境不能很好的实现“设计并实现一门小语言”这个工作;即使去做,也用诸如采用 C++ 的高级 template 技术来模拟一个出来,这种“高难度、开发周期长、难维护”的方案。当然就不喜闻乐见,易于推广了。

十分期待make教程 如果有写出来
能否发一份给我 or2@zishuo.com

啊,做程序要做到这么复杂的程度吗?

这样的话,要花多少时间才能找到合适的工作人员呢?

我的老板始终是相信,做软件是个体力工作,大部份的需求都是非常简单的工作。

"貌似用 IDE 的同学们都不以为苦呢。"
呵呵,,看看现在VS的样子, 完全是身处两个世界的程序员了, 今后程序员要两极分化了? ms用一个顶尖团队领导数量庞大的小喽啰来对抗所有其他程序员.
商业上讲,哪个阵营在会更成功呢?

期待您的MAKEFILE教程

@Cloud

另外,说到gc的实现,我倒是觉得,“复杂的交叉引用的对象正确回收的手段”很多时候可以通过RAII方法来完成,既可以避免gc,程序员也不用“偷懒”少写一些代码。这方面可以通过邮件我们交流一下.

@Cloud

“但是大部分的内存泄露本身就是因为采用 C++ 而产生的 :) (即,如果用 C 来实现根本不会存在)”
这句话欠妥。。。至少我用了这么久C++,没有发现这个问题。

无意引发C/C++论战,只是就事论事。实际上我的工作中经常C/C++混用,哪个方便有效用哪个。

boost的智能指针在对付这种有固定生命周期的指针之类的东东的时候的确是非常方便的,的确减少了很多代码出错的几率。

@analyst

什么样的问题就有什么样的解决方案。这种可以明确知道生命期的数据。用的上 gc 么?

gc 只是提供了一种对复杂的交叉引用的对象正确回收的手段。在 C/C++ 里应用 gc 的技术,绝非让程序员去“偷懒”少写一些代码,而是在于简化设计和做出正确设计的意义。

资源管理有一个非常确定的简单行为。就跟 OS 管理物理内存一样。随时可以把虚拟内存的数据扔出物理内存,直到应用程序需要(引发缺页中断)的时候再加载回来。资源管理模块内部用到的内存块都有层次分明且唯一的依赖关系,自然不需要动用 gc 了。

btw, gc 依然是我们 engine 构建的核心。尤其用于 3d 对象的管理和 UI 对象的管理。

貌似云风鼓吹了多年的GC并没有发挥作用啊....原来还是手工释放的。

C++ 方便解决的问题,大多数是不需要解决的问题。

C 带来的麻烦通常是体力上的;C++ 带来的麻烦却是脑力上的。

当然,什么事情都不是一概而论的。补充一句避免进入无谓的论战。:)

貌似有些东西C做起来更费劲儿吧~~
上面这个显示时间是哪儿的时间啊?

@lxc

C++ 的确可以通过语言的某种技术对付大部分的内存泄露。

但是大部分的内存泄露本身就是因为采用 C++ 而产生的 :) (即,如果用 C 来实现根本不会存在)

流水账都能写这么长,汗:),写个教程吧,俺也跟着学学~~~

云风大哥是在广州吗?今年校园招聘会出来面试吗?

期待make教程

云风童鞋真是个强悍滴人物啊。。

感觉 对付内存泄漏这些问题还是C++来的实在。

偶也刚刚开始接触make
觉得它自己的manual蛮好的
云风大哥给点实例操作就更好了
^_^

特别佩服您的精力,和对程序的激情

期待你的make教程,

最近得用到一些gdb,总觉的没有vc设断点太麻烦啦。

Post a comment

非这个主题相关的留言请到:留言本