继续说那些奇葩的Bug。
靠不住的系统组件:Vista和Speech Recogition
我们游戏使用了语音识别,使用了DirectX里面的XAudio来采集声音。Windows Vista里面有一个语音识别的组件,启动那个程序,然后玩我们的游戏,玩了一段时间,因为没有使用麦克风,那个程序会自动关闭使用麦克风,然后我们的游戏就Crash了。又是一个跨进程的奇葩Bug。
从现场来看,Crash在使用XAudio的库代码里面。看不出什么线索,最后发现在日志里面,Crash前一会儿,XAudio的dll被Unload掉了。Vista那个语音组件很强悍,也会跨进程追杀大法,在自己进程把我的进程dll给Unload掉了。
虽然遇事先要怀疑自己的代码有问题,但这个情况太匪夷所思,所以我试图推卸责任,我跑了几个其他DirectX程序和Demo,但凡用到XAudio组件的,在Vista Speech Recognition这一大招前无一幸免,全部Crash。
问题不在我们这里,但是作为一个职业杀虫人,我有着“只解沙场为国死,何须bug裹尸还”的觉悟,决定还是要想办法搞定它。
我猜这个组件退出的时候,广播了点什么消息,让系统所有进程去卸载这个Dll。我找了一个微软提供的库Detours Express,专做Hook API的勾当。它会反汇编API的入口代码,然后动态替换入口,插入jmp指令去执行我们自己的函数。先看了文档,搞懂这个东西怎么用,然后猜测是一个OS内部的消息导致Unload DLL,我们就拦截了RegisterWindowMessage。一通翻箱倒柜,啥有意义的东西都没发现。
再用spy++来拦截消息,发现有一个MSUIM.msg.rpcsendreceive的消息面目狰狞,很可疑。就在收到这个消息以后,Dll就被Unload了。于是我们用Detours截住这个消息,直接返回。
以为搞定了,但结果还是不行,有几条别的消息也会触发Unload Dll,我一一用Detours拦截返回。拦截的消息越来越多,看来不是个解决办法。于是我怀疑我们没有拦截到正确的消息,怀疑OS启动的时候便注册了一堆内部用的消息,而不是在运行进程的时候才注册。那些消息里肯定有我想要的,我便异想天开地Hook了User32.dll,拦截下所有消息,在安全模式下把修改过的user32.dll覆盖原来的文件,然后满怀希望的重启动Vista的时候。结果不出所料的蓝屏了...User32.dll是凡人能随便碰的吗?这不是一个User friendly的库,应该改名叫God32.dll。
这个bug搞了1周,最后才想到最基本的道理,说不定只是Dll内部引用计数被清掉了,所以系统就卸掉Dll了。当Vista语音组件Unload XAudio的时候,系统去看看XAudio库有没有什么别人引用,结果发现居然没有,那就顺手清理了。就像地上有100块钱,你东张西望,发现没有人宣布拥有那张钱,没有一点点犹豫,你随手就把它放进了裤兜,进行了回收。嗯,就是这样的,我喜欢比喻这种修辞手法。
理论上创建XAudio设备的时候没有做什么额外的加Dll引用计数的事情,怀疑是MS的bug,创建D3D设备、DInput设备的时候都不需要做什么特别的事情的,但他们的引用计数都没有问题。
于是我恶搞了一下,在初始化XAudio完成以后,手动做了一次LoadLibrary,把XAudio2_1.dll强行Load一遍,相当于自行增加了引用计数。于是再无Crash。
从此我们的游戏,在Vista 32上过上了幸福的生活。
可得结论:Vista靠不住
Takeaway: 不知道该说什么了,这个bug太奇葩了。
性能的迷思
临近最终版本发布的时候,测试人员发现一个问题,当在Vista上连续打游戏过7关以后,游戏帧数一下子变成原来一半了。大家觉得很诡异,都不相信,之前整天玩游戏都没问题的,于是责令测试人员再重现几次,否则拖出去刨坑埋了。我也没当回事,继续调试别的bug。结果他们测了几遍都是这种情况,看来我必须花点力气看看了。
先编译了一个Profile版本,有大量的Profile代码,可以用我们自己做的工具看Profile结果。跑游戏,一个小时后顺利重现之,按下快捷键,截下一段性能分析数据,打开工具就开始分析。显示结果是在某个线程的声音处理函数里面特别慢,占了20多ms,找来做声音的程序员,让他也去帮忙看。自己继续研究,发现那个声音函数简单,估计最多1-2ms了,绝对不会有问题的。
于是开始怀疑多线程的问题,之前有过类似bug,在加载关卡的最后一小段时间,音乐和语音会很卡,音频程序员查不出原因。后来我发现声音线程根本分配不出内存,声音线程的内存分配器和主线程共享,通过Critical Section保护着。声音线程进不了Critical Section,因为主线程那时忙于加载,正在疯狂地分配内存,导致声音线程被无法分配所需资源。我又不能调低主线程的优先级,否则加载速度会受到很大影响。最后解决方案是为声音线程单独开了一个内存分配器,不和主线程抢了。会不会是这个分配器有问题呢?我验证了很久,把那个声音线程的内存分配器换成绝无性能问题的版本,还是有问题,估计不是内存分配的问题。
也许是那个很慢的声音处理函数里面有些资源被别的线程占了,游戏账号转让导致在那里傻等,最后耽误了系统所有线程的同步,使帧数下降。有了想法我就开始验证,我在那里的每个多线程同步操作里都加上了Profile代码,继续花了一个小时重现bug,并检查Profile结果,发现那个线程就是慢,没什么特别原因,每个Critical Section都很快就过去了,也就是说,没有哪一个部分特别慢,是整个函数被均匀地拖慢了。
忙了几天,死去活来,找不到线索。领导决定游戏还是照样发布,我继续看这个问题,准备在Patch里面修正这个bug。
在一次次重现中,又简化了重现方法,我发现只需要顺序进入那几关,不需要把每一关都打过去的,所以我用作弊码直接完成每一关,重现一次 bug的时间缩短到10分钟以内了。
看来自己的工具是搞不定这个问题了,请出Intel Vtune来收拾它。公司比较抠门,不肯买Vtune,只好申请了评估版,一个月试用期,应该够搞定这个问题了。用Vtune Profile了几次,每次拖慢的函数都不一定,而且里面真没什么特别的地方。
又郁闷了两天,某天看着Vtune里面红红绿绿的代表线程忙碌状态的条条,突然发现有一个线程条全是密密麻麻的红条,其他线程的红蓝条相对稀疏一点。开始猜想,是不是有某个线程太忙了,导致抢了所有的时间片,让其他线程都没机会拿到时间片。有了理论依据,大胆求证一番,重现一次bug,帧数很低了以后,断下游戏,把系统里面的n个线程用二分法,Freeze一半,再运行,看帧数,然后恢复一半线程再跑,来回几次,终于发现当某一个线程恢复以后帧数就很低了,其他线程开关都没关系。关掉那个线程游戏马上全速欢快跑开了。再恢复运行那个线程,又是老样子。
找到了嫌疑目标后,我全力追查这个线程是如何创建的。先在所有创建线程的地方加上日志,输出线程 id,重现bug后找出那个问题线程,然后对照线程 id的日志,试图找出这个线程创建的地方。结果很杯具,那个线程根本就不是我们自己程序创建的。也就是说,OS偷偷帮我们创了一个线程。这个就比较难查了,线程创建的时候我又没法设断点,也不知道系统内部用什么函数去创建线程,无法用Detour去Hook API。
转机出现在Intel的Thread Profiler,照例又是一个月评估版。Thread Profiler可以显示出创建这个Thread的Callstack,虽然不是特别准确,不过已经是很有用的信息了。我发现那个线程创建的时候Callstack里面有Winhttp之类的函数。
继续转移战场,看Msdn上介绍的Winhttp系列函数,然后搜索整个项目里面所有用到Winhttp系列函数的地方。应该是我们调用Winhttp的时候方法不正确吧,我猜。好在项目里面用的Winhttp系列函数也不多,每个地方读一遍代码,似乎都没问题。继续想,会不会是中间件的问题,我们用了一个其他分公司开发的网络组件,那个组件没有包含在项目里面,只是弄了个lib过来。我连忙找到那个组件的源码,一搜Winhttp又一大堆,一个个看,也都貌似正确。
既然是连续玩n关才出问题,可能和什么资源泄露有关吧?我恍然大悟,注意看Winhttp 句柄的生命周期,发现那个中间件,在Xbox360版本上正常释放了句柄,可是Win32上就没有...没什么好说的,WinHttpCloseHandle伺候,问题迎刃而解。修正这个bug耗时2周,一路杀到声音、内存管理、网络模块,中间还顺手修复了无数其他bug,最后终于将其正法,改动只是一行而已。
可得结论:中间件靠不住。