记一次使用Valgrind查找解决内存问题的玄幻旅程

前言

看题目来说这应该是一篇教程式文章,但为了突出“玄幻”二字,我们不讲细节只讲过程,在过程中体会解决问题的方式和方法,以及避免一些我在这个过程中绕的弯路,如果想找工具的详细使用方法可以去参考文章中翻一翻,有几篇文章写的真不错,下面我们开始扯淡啦。

玄幻旅途

本故事并非虚构,如有雷同,纯属命苦~

故事背景

作为本文主人公的我——小Z,是一个后端C/C++搬运工(这不废话吗,不是C系列谁老倒腾指针和内存?),在一个阳(yue)光(hei)明(feng)媚(gao)的下(wan)午(shang)接到一个完善游戏战斗系统的需求,然后便开始了紧张开发、积极调试的每一天,事情比较顺利,一切都在计划之中,不过平凡的日子总是无趣,没有一点点意外总让人感到有点意外。

初入泥潭

好吧,到目前为止一切都很顺利,服务器各个功能模块分批完成,终于完成了最后拼装,启动调试看结果,出现了一点点逻辑问题,这个战斗过程根本停不下来,整个程序一直在递归,最终导致函数调用栈溢出崩溃。不过这都是小问题,简单梳理逻辑后增加必要的出口判断条件,问题很快被解决。

再次启动调试,程序正常运行,符合预期结果。啥?这就完了,幸福来的有点突然啊,整个流程基本符合需求,只是缺少一些细节逻辑需要补充,感觉胜利就在前方了啊!

一片混沌

补充细节的过程中,也需要不断调试来验证结果,咦?怎么连不上服务器了?查看一下进程,果然服务器进程已经不在了,难道是我不小心关掉了,先记一下,解决掉手头上更重要的问题后再来看它。

上次的问题好几天都没有出现了,可能真的是我不小心把服务器进程关掉了,今天还有个小BUG需要修复一下,先搭建好调试环境准备定位一下问题。整个过程比较顺利,没过几分钟BUG就找到了,修复后调试看看结果,Duang!进程挂了,好在这次是在调试状态,能看到是哪里引发的崩溃,查看函数调用栈来看看是谁捣的鬼。

什么玩意,智能指针出作用域时自动析构挂了?这是什么鬼,从上到下看了一遍近百层的函数调用关系,感觉没什么问题啊,真是奇怪。

重新启动进程,开始了疯狂测试,跑了20几次相同的逻辑,没有任何问题啊,那刚刚发生了什么,转过头来继续看刚刚出现崩溃的位置,完全找不到问题。这个问题先放一放,继续补充细节,调试解决发现的BUG,在多次调试之后,Duang!进程又挂了,这次更离谱,在定义lambda表达式的时候崩了,看着函数调用栈依旧一头雾水,看不出是什么问题。

退出调试状态,重启进程,继续跑了10多次相同的逻辑,这次进程真的崩溃了,看来程序真的是有隐藏的BUG。再次重启,继续跑,这次又不崩溃了,这种状况让人有点头大啊。启动调试状态开始测试,跑了几次就崩溃了,原来和调试有关系呀!经过多次测试发现,如果在调试状态下测试几次就会出现崩溃的情况,如果在非调试状态下大概需要跑10多次才会崩。

为了查出问题便开始在调试状态下更加疯狂的测试,这次真的开了眼了,每次崩溃的位置都不太一样,有的在析构函数中,有的设置变量值时,有的在发送函数中,有的在申请内存时,总体来看基本都是围绕着内存出现的问题,但是问题原因未知。

追根溯源

虽然经过大量测试仍不能准确给出问题原因,但是几十次的崩溃结果中还是能看到一些规律的,其中有50%左右出现在第二场战斗释放之前战斗对象的时候,40%出现在玩家重新登录释放之前战斗对象的时候,这两种情况加在一起就占了绝大多数,所以要从这里开始入手,查看释放战斗对象的函数是不是存在问题。

因为程序中很少直接使用简单的指针,基本都会用智能指针来代替,所以在战斗对象析构时会有很多小对象自动析构,花了不少时间来看这些代码,结果一无所获,这就怪了,那么多次崩溃都是在这,居然找不到任何问题。

抽丝剥茧

因为之前测试时需要完成跑完整个战斗流程,严重影响了测试效率,既然感觉释放战斗对象这部分代码有问题,那就单独跑这一段逻辑呗,单独建个分支,改代码!!!另外还发现一个事情,本来在我机器上需要在调试状态下跑好几次才能重现出的问题,在另一台发布机上两三次就能重现,干脆用它来验证结果。

说干就干,从原来的逻辑中,剥离出创建、释放战斗对象的代码,每次测试重复创建和释放过程几百次,这样就应该很容易就能重现问题了,修改完本地先测试,结果跑了十几次也没出现,部署到发布机上测试多次也没出现问题,和预想的完全不一样,实验失败,这个结果基本说明我的方向错误,并不是这段释放战斗对象的逻辑代码问题,又得重新寻找线索了。

大海捞针

上面的验证虽说失败了,但也给我提了醒,既然释放战斗对象的逻辑代码没问题,但是绝大多数奔溃还发生在这里,那肯定是别人把它影响了,结合之前看到的内存问题,应该是有其他的逻辑写错了内存数据,导致释放战斗对象的内存时出现了问题。

这个崩溃在主分支是没有出现过的,在我开发完这个新需求之后才出现了这个问题,那么需要查新加了哪些代码,但是这个版本单单是新的文件就增加了几十个,要想从中找到一个内存问题犹如大海捞针一样。

祭出法宝

在大量代码中直接寻找内存问题,非寻常人所能企及,这时可以考虑借助第三方力量——比如检测工具,根据以往经验,我用的最多的内存检测工具是 ValgrindAddressSanitizer,起初 Valgrind 用的比较多的,后来认识了 AddressSanitizer 之后发现使用 Valgrind 后程序运行太慢了,而使用 AddressSanitizer 虽然需要重新编译一次,但是基本不影响原有程序的运行速度,所以渐渐偏向了 ASAN

但是,这次我先用了 Valgrind,还原代码,重新编译,调整参数后启动服务器程序,果然是半天没反应,测试多次之后居然没崩溃,查看了它的检测报告也没发现什么问题,决定换 ASAN 试试,因为每次用 Valgrind 启动和运行真的太慢了。

修改Makefile重新编译,使用 AddressSanitizer 来进行检测,这次更奇怪,添加了 ASAN 选项的程序编译后,貌似代码逻辑感觉到了它(ASAN)的存在,程序运行逻辑直接变了,原来能完整跑完的战斗逻辑,总是跑到一半因为条件不满足停下了,不过有几次跑到了最后,也出现了崩溃的情况,但是从检测报告中未查到问题的原因,仅仅找到一处内存泄漏问题,修改完崩溃问题依旧存在。

屏蔽无关

既然上面的工具没能提供帮助,那么还得依靠我硬啃代码了,还是先来分析之前各种崩溃结果,发现每次析构对象前都给客户端发了消息,而这些消息使用了 protobufoneof 结构,这个结构之前没用过,会不会因为使用不当,把内存写坏了。

这次我没有直接去看代码的细节,而是采用了屏蔽的方式,将一些不影响战斗逻辑的消息数据精简,不断注释代码,不断发布测试,结果依旧崩溃,最后仅剩一处同步技能的协议,其中也用了 oneof 结构,这时我更加感觉它有问题,但是它不能被注释掉,需要通过它发消息给客户端,然后客户端请求放技能才能将战斗进行下去,测试暂时卡在这了。

移形换位

必须想一种办法把这仅剩的一条消息同步去掉,如果不给客户端的同步消息,客户端就不能通知服务器放技能,那只好服务器自己把这些事都做了,修改服务器代码,采用延迟触发的方法,来驱动整个战斗进程能进行下去,最终把仅剩的那一条消息屏蔽掉了,同时把所有的try-catch也屏蔽了。

打包部署发布服,启动测试,问题依旧存在,唉,我麻了!

再请法宝

因为 ASAN 这个工具我一直在观察着输出的报告,并没有发现什么值得注意的问题,所以我打算换为 Valgrind,因为它们两个有点冲突,所以得把Makefile还原回去,重新编译再使用 Valgrind 来测试。

启动程序,依旧卡的像时间静止了一样,启动客户端开始了常规的疯狂测试,Duang!进程挂了,赶紧打开 Valgrind 的输出报告看看,亲人呐,我在里面找到了 Invalid write 的字样。

赶紧去查看这段报告对应的代码问题,其中包含了 std::sort 函数的使用,但是自定义的排序函数不满足严格弱排序规则,感觉这逻辑确实有问题,把它先注释掉来试一下。

风平浪静

注释掉 std::sort 之后,在本地机器测试半小时未发生崩溃,重新编译打包发布,几十次测试之后也没有发生崩溃的情况,一切又恢复了平静。

若有所思

如果在第一次使用工具时,我给予Valgrind 多一点点宽容就好了。

其实事后看来好像没有多磨曲折,但是真实情况却是,前面的步骤交叉进行,经常会出现反复的情况,前前后后调试了近3天。

为什么如此执着?因为如果类似的问题不再早期发现时解决,后面要想再解决所付出的成本会更大,所以早发现早解决。

参考文章


==>> 反爬链接,请勿点击,原地爆炸,概不负责!<<==

靠想象打开未来一扇扇大门,靠理性选择其中正确的一扇~

2021-12-5 01:03:26

Albert Shi wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客