进程段错误问题查找

      近期接手了一个服务器项目,服务器程序已经上线将近2个月,期间有过几次崩溃,崩溃时没有生成coredump,从服务器进程的监管deamon的反应来看,服务器进程是收到了SIGSEGV导致崩溃。而日志输出上仅仅能查到崩溃位置位于代码中使用的公司的基础代码库中,而这个库已经被证明是正确的。

      由于崩溃问题并不严重,曾经负责调查的人认为是服务器程序收到了异常报文,这个问题也随着这位同事的离职而不了了之。

      在我接手之后,由于需要增加功能,就进行了后续开发,开发结束在内网测试没有任何问题,然后一旦上线,运行1-2个小时必然崩溃,崩溃依旧没有core文件生成,从日志输出来看,曾经的崩溃问题再次浮出水面。

      由于这个崩溃比较严重,导致后续工作无法进行,于是上面下了期限,必须在2周内找出崩溃原因,然后进入后续功能的开发,悲剧的是开发这个代码的同事已经走了,最有可能崩溃的地方这位同事应该是比较清楚的,于是乎我不得不read the fucking code。

      比较奇怪的是如果我新增加的功能单独运行,进程完全不会崩溃。而旧的功能单独运行也看起来很稳定,可一旦同时启用两个功能,必定在两个小时后崩溃。起初我怀疑是我新增加的代码导致了内存写越界,于是我把新增加代码所有涉及内存写操作的代码注掉,然而问题依旧会发生。于是我考虑是否新增加代码在和旧功能交互的时候导致内存写越界,于是我把我所修改代码所有可能影响到的代码都分析了一遍,结果没有任何问题,其间也做过几次假设,把边界条件全部严加判断,结果也没有解决问题。

       随后我的精力集中在,判断是否线程的栈空间不足,进程的同时打开文件数目限制以及进程中使用的非线程安全的系统调用(strerror),以及线程所使用的一个第三方日志库上,最终这些地方的疑问都被排除了。

       时间经过了一周,问题没有丝毫的进展。      

       在一度绝望中,想到了coredump,由于进程崩溃不生成core文件,于是我想到是不是进程的信号处理有问题,于是分析了代码中的信号处理问题。可以说,信号处理写的很乱,在一个多线程的程序,各个线程分别设置自己的屏障字,有些线程还使用sigwait,不同的线程又修改信号处理函数。更要命的是,这个多线程的进程居然在一个地方执行了system("curl "),在某个线程中fork了一个子进程shell,又这个shell里fork子进程去执行curl的一个命令。这个东西在退出的时候会发送SIGCHLD信号,这里是整个代码最阴暗的地方,我在重写了整个进程的信号处理部分后,屏障了SIGCHLD和SIGPIPE,因为我曾经认为这两个信号会打断我在进程中的不可重入的new或malloc操作,导致后续的内存写操作出错,但结果证明并不是这两个信号的问题。

       在这个时候有转机的是在修正了信号处理部分之后,进程可以正常的生成coredump了,服务进程上线后可以生成比较多的coredump文件让我来分析崩溃时代码内部的状况。在此期间痛苦的在GDB中不停的print和X众多变量和对象的内存值,这时我发现在程序崩溃点确实在公司的基础类库中,而只有我的代码调用这个库的接口,但在库中使用的一个指针的所指向的内存空间却经常无效。这时我又对我新增加的代码产生了疑问,我认为崩溃是由于我的某些代码,导致写越界把栈里保存的指针的值给写错了,可仔细检查代码也没有发现问题。由于每次崩溃这个指针指向的值都不同,有时这个指针指向更好像是其所指对象被析构后的遗所,于是我开始怀疑库中所使用的一个系统调用存在BUG,在网上搜索也没有得到什么有用信息。为此我重写类的析构函数,让他写些特定的值,以验证指针所指的对象是自动析构了,然而在另一个地方却被再次使用,不过证明这个假想也不正确。

        由于我的视线一直停留在崩溃点附近,这让我一直找些不可能的原因来验证,不过在验证过程中我渐渐发现,大多情况下,这个指针所指向的内存对象基本上是完整的,只有部分被改写。而这个指针是在栈上,指针所指的空间是在堆上,问题不是这个指针被改写了,而是指针所指向的堆空间被改写了。此时形势渐渐明朗起来,我要找的是程序中写堆上内存可能出错的地方。

        其间想到借助第三方工具来进行查找,于是详细用了valgrind,挂着程序跑,可是由于服务器程序在运行时分配了大量内存,但在退出时并不释放,因此valgrind报了很多内存泄露的错误。然后据我分析,进程在运行过程中并没有泄露内存,只是在退出时没有释放,让操作系统进行自行回收而已。此外valgrind也报了一些其他的使用未初始化内存的提示,但都被一一排除在导致崩溃的问题之外。传说中的valgrind也不能帮助我找到写内存越界的情况。期间也想到用gdb跑程序,可是程序运行要提供保证10几万用户的在线处理,在gdb中根本无法达到这个性能要求,只得放弃。

         这个时候我对整个程序的框架也渐渐清楚了,对coredump的分析也让我程序的运行状态有了比较清楚的了解。这个时候我观察到一个很常见的现象。这个服务器程序兼容了一个旧版本的协议模块,而这个协议模块还兼容一种更为古老的登录协议,这种登录协议会导致代码执行那个丑陋的system()调用,目前使用这种登录协议的用户很少,在每天登录的几百万用户中,只有几百是用这种登录方式。然而每次崩溃的时候,负责处理这种旧协议的线程必然在执行那个system()调用,函数调用栈的顶端一定是sys_wait的内核调用接口。不过我这时还是很放心system()这个函数的,因为man上说它可以处理其内部执行命令的错误状态。由于我很信任那段阴暗的代码,于是转而在整个代码中查找所有的内存写操作。

        我分析了代码中所有snprintf(),所有会写内存的地方。这个时候我被代码中各处的string对象折磨的要死,由于string特有的写时copy能力,这让我不得我仔细分析所有string对象的使用和引用的地方。本来做为服务器程序,在处理协议报文时,以高效为先,可是代码中处理协议报文全是用string对象来实现,各种的resize,data,c_str调用,把string内部的指针在各处传递,要命的是这个const指针在传递后其所指内存内容也会被修改。虽然string类的设计者一再强调不要修改data(),c_str()返回的内存内容,可在代码里,显然不是这么回事。在分析完所有的内存读写操作后,居然并没有找到写越界的地方,这个时候我失去方向了,不知道下一步改往哪里查找原因了。

        这一阶段我重写程序的内存池模块,简化内存池的分配和写操作,当然主要是为了排除内存池模块的写越界。当没有原因再查时,我只得再把目光转到那个SYSTEM调用上,我怀着试试看的态度,去掉那个system的调用而是使用第三方库在线程中直接实现其功能,重新编译、上线,奇迹居然发生了,程序稳定没有再崩溃。

        到此问题终于解决,如果非要让我对崩溃原因做个解释的话,我要说:(1)不要在多线程的程序中执行与fork相关的操作 (2)不要把一个string的内部指针传到另一个进程中去使用。(3)屏蔽掉大多数信号,不要以为忽略就可以,否则它会中断你的某些系统调用,导致出错。

        在这次问题解决的过程中,也学到很多东西,以前也没有这么痛恨string类,当然也更加理解string这个类的行为,对信号处理也更加得心应手。

        需要反思的地方是:当面对一个无从下手的问题时,你要直面其中最困难的部分,因为你不敢面对的部分往往是你最后才去触碰的部分,而它也是最有可能出问题的地方。如果你不能第一时间去面对它,那么在此之前,你只能在其他的非重点的部分徘徊,只到你无路可走,当你在最困难的地方发现了问题所在时,你会发现之前的徘徊都是在浪费时间和精力。所以解决问题要从最困难也是最有可能出问题的地方去检查,而不是避开它去寻找那此不可能的次要原因。

         在此罗列一些可能导致段错误的地方,以及如何避免相应的错误

         出现段错误时,有的很容易调查,但有的很难调查,比如在一个地方把内存写错,需要过一段时间另一个地方读这个内存时,才出错。这种是非常难定位的。因此在编写代码时一定要小心预防。

         1 使用非法的指针,包括使用未经初始化及已经释放的指针(指针使用之前和释放之后置为NULL)

         2 内存读/写越界。包括数组访问越界,或在使用一些写内存的函数时,长度指定不正确或者这些函数本身不能指定长度,典型的函数有strcpy(strncpy),sprintf(snprint)等等。

         3 对于C++对象,请通过相应类的接口来去内存进行操作,禁止通过其返回的指针对内存进行写操作,典型的如string类的data()和c_str()两个接口。

         4 函数不要返回其中局部对象的引用或地址,当函数返回时,函数栈弹出,局部对象的地址将失效,改写或读这些地址都会造成未知的后果。

         5 避免在栈中定义过大的数组,否则可能导致进程的栈空间不足,此时也会出现段错误。

         6 操作系统的相关限制,如:进程可以分配的最大内存,进程可以打开的最大文件描述符个数等,这些需要通过ulimit或setrlimit或sysctl来解除相关的限制。

         7 多线程的程序,涉及到多个线程同时操作一块内存时必须进行互斥,否则内存中的内存将不可预料

         8 使用非线程安全的函数调用

         9 在有信号的环境中,使用不可重入函数调用,而这些函数内部会读或写某片内存区,当信号中断时,内存写操作将被打断,而下次进入时将不避免的出错。

         10 跨进程传递某个地址

        11 某些有特殊要求的系统调用,例如epool_wait,正常情况下使用close关闭一个套接字后,epool会不再返回这个socket上的事件,但是如果你使用dup或dup2操作,将导致epool无法进行移除操作。

         其它还有很多,以后再进行补充。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值