记录一次线上组件崩溃的解决过程
马上就要离职了,想想工作中有些东西还是需要沉淀下来的,不仅仅要沉淀到心里,因为年纪大了_,很容易忘记,不是有句话么,好记性不如烂笔头。
分析这个bug之前先说点别的。
解决bug的大致思路
我觉的解bug和医生看病是一样的,中医看病讲究望闻问切。软件出了毛病也按这个套路来,但是不需要闻。
- 望。观察表面现象,server端出了问题还是client端?现象是什么?log里记录了什么?
- 问。询问客户最近做了什么操作?哪些是重现问题的必要步骤?
- 切。为软件的code把把脉吧,由表及里,看一下软件的哪些机能出现了问题,小问题还是大问题?表层应用代码有问题?还是底层代码库有问题?只是逻辑的问题?还是性能问题?或者设计架构缺陷?
我们开发的产品运行在windows server 平台上,几个月之前fix过一个线上发现的bug。对于有经验的开发人员来说,需要解决的bug分为两种:能稳定重现的和不能稳定重现的。只要能够稳定重现,从客户提供的种种数据中总能顺藤摸瓜,找到问题根源。在我们的软件中,这些数据包括以下几种:
- Windows Event Log,这也分为两个不同的级别(只说和我们相关的)
- windows logs,这是操作系统级别的log,若遇到组件崩溃,windows logs下面的Application会打印log记录。
- Application and Services Logs,我们的产品自定义的log放在这个目录下面,这些log这是最直接的信息,如插在软件中的一个个索引,报错之后能够很快定位代码位置,找到问题突破口。
- 网络包(wireshark包),这些是实时动态的数据,可以作为review静态代码时的辅助,查看数据交互是否出现了问题。是不是漏发数据或者多发了等等。
- trace log,这是更加详细的log,如果windows log是CT片,那么trace log就是核磁共振片了。这些log的打印会影响性能,因此是有开关的,平时处于关闭状态,如果需要把开关打开后,log就会被打印出来。这些log首先被写入windows Message Queue中,然后使用工具进行显示或重定向到log文件。
- 数据库备份。在实验室里重现问题使用客户的数据库可能会更容易些。
- dump文件。抓取dump文件是一种在遇到代码崩溃时行之有效的获取相关信息的方法,它是出错时内存的一份快照。我们会在下面介绍如何在windows环境下设置注册表,在出现崩溃时自动抓取dump。
根据bug的难易程度,找问题出现的根源有三种手段,可以层层递进或者同时进行:
- 开发人员review静态代码,这可能需要结合前面提到的两种log或者网络包来review。有可能问题的根源很简单,一眼就能看出来。下面的步骤就省了。
- 如果review代码发现不了问题或者问题不好重现,测试可以尝试去重现问题,寻找规律,如果自己的制作的数据库重现不了可以使用客户数据库。问题可以稳定重现后,开发人员可以在remote debug来进一步跟踪代码。但是release环境下的debug因为有编译优化存在,有些内存数据更本显示不出来或者是错误的,代码执行顺序有时候也很怪异。这点需要小心。
- 鉴于debug release环境的难处,开发人员可以自己搭建一个debug版的环境进行重现、调试。但是有一点,还是因为release和debug环境的差异,有时候只有在release环境下才能重现问题,这种情况下这种方法就失效了。
还有一种特殊情况就是出现组件崩溃时,这种情况下我们会抓取到dump文件,把dump文件导入到visual studio中,就能一步步查看call stack来寻找问题的出错点。
好了,前面将我们解决bug时需要的信息以及解决方法做了一个简单的总结,下面就具体说一下博主一次解决组件崩溃的经历。很早之前的事了,dump文件和环境都没有了,主要从以下三点介绍:
- 问题是如何重现的;
- 如何抓取dump文件;
- 问题根源在哪里
问题描述
客户发现有一个server组件一周会出现至少一次崩溃,windows的application log报出类似如下错误:
根据客户的描述,这个server组件大约有100多个socket连接,这些连接每隔几分钟就会有一些断掉然后重连(有可能是网络问题导致的),他们推断这和组件崩溃有一定的关系。
因为问题不好重现,测试团队先行重现客户的这个bug。使用客户数据库,实现脚本模拟客户的socket断开和重连。
前面也说过,bug分为容易重新和不容易重现的,这个bug的第一个难点是如何重现,测试为了增加压力,将连接数增加至300,socket的断开时间间隔逐渐改小等等。
如何抓取dump文件
在重现问题之前,需要配置windows在组件崩溃时自动抓取dump文件,如何配置,很简单,将下面的注册表项导入注册表即可,注意要把DumpComponentName.exe替换为你的需要抓dump的组件。
Windows Registry Editor Version 5.00
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps]
"DumpType"=dword:00000002
"DumpFolder"=hex(2):43,00,3a,00,5c,00,43,00,72,00,61,00,73,00,68,00,44,00,75,\
00,6d,00,70,00,00,00
"DumpCount"=dword:000000ff
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\DumpComponentName.exe]
"DumpType"=dword:00000002
"DumpCount"=dword:000000ff
"DumpFolder"=hex(2):43,00,3a,00,5c,00,43,00,72,00,61,00,73,00,68,00,44,00,75,\
00,6d,00,70,00,5c,00,44,00,75,00,6d,00,70,00,43,00,6f,00,6d,00,70,00,6f,00,\
6e,00,65,00,6e,00,74,00,4e,00,61,00,6d,00,65,00,00,00
问题根源分析
因为dump文件和环境都不在了,不能介绍分析dump文件的过程了。只能分析最后的结果,发现了两个问题:
问题一:有关STL容器的线程安全问题
有关这个问题建议先拜读一下Scott Meyers的effective STL 条款12。
引用其中的话:
在STL容器(和大多数厂商的愿望)里对多线程支持的黄金规则已经由SGI定义,并且在它们的STL网站[21]上
发布。大体上说,你能从实现里确定的最多是下列内容:
- 多个读取者是安全的。多线程可能同时读取一个容器的内容,这将正确地执行。当然,在读取时不能
有任何写入者操作这个容器。 - 对不同容器的多个写入者是安全的。多线程可以同时写不同的容器。
也就是STL容器只能够在两种情况下保证多线程安全:
- 同时读取一个不会发生变化的容器。
- 同时写多个不同的容器。
仅仅这两种情况,看看下面的发现有问题的code,你能看出问题在哪里么?
template<typename T>
ResourceResult getValue(ResourceKey key, T value)
{
ResourceResult result = ResourceFailure;
resourcevalues_.guard();//lock
Resources::iterator resource = resourcevalues_.resources().find(key);
if (resource == resourcevalues_.resources().end()) // not found
{
resourcevalues_.addResource(key, resourceValueEntry);
resourcevalues_.unGuard();//unlock
}
else
{
// item cached
resourcevalues_.unGuard();//unlock
if ((*resource).second.getValue(value))
{
result = ResourceSuccess;
}
else
{
result = ResourceConversionError;
}
}
return result;
}
这是典型的有问题的SLT多线程编程,写这段代码的coder可能是这么认为的:
- 多线程不能同时写一个容器,因此加了lock
- 多线程可以同时读一个容器。
第一条没有问题,但是第二条是有前提的,同时读一个容器需要在容器不发生变化的情况下。如果恰巧在读取容器元素时另外一个线程对此容器进行写操作,读线程的iterator会失效,接下来的行为是未定义的。
如何修改?对,读也需要加锁。
template<typename T>
ResourceResult getValue(ResourceKey key, T value)
{
ResourceResult result = ResourceFailure;
resourcevalues_.guard();//lock
Resources::iterator resource = resourcevalues_.resources().find(key);
if (resource == resourcevalues_.resources().end()) // not found
{
resourcevalues_.addResource(key, resourceValueEntry);
}
else
{
// item cached
if ((*resource).second.getValue(value))
{
result = ResourceSuccess;
}
else
{
result = ResourceConversionError;
}
}
resourcevalues_.unGuard();//unlock
return result;
}
这就没有问题了。
问题二 : 一个线程句柄释放问题
GeneratorThread::~GeneratorThread()
{
if (thread_)
{
stop();
thread_->wait(5000);
delete thread_;
}
delete generator_;
}
上面代码的背景是一个controller thread(简称CT)管理着多个Generator Thread(GT),CT会监控每个GT的状态,如果有的状态不是Active,那么就delete掉它的资源,调用这个析构函数的时候会释放掉GT所拥有的线程句柄(thread_)和线程使用的相关资源(generator_)(socket和应用层协议的相关资源),delete thread_之前需要先给这个thread发送一个stop()信号:
void
GeneratorThread::stop()
{
(void) stopRequest_.set();
}
然后调用thread_->wait(5000)确保线程收到这个stop信号之后,采取delete thead_,为的是防止打断thread有可能正在进行的其他工作。通过抓到的dump文件,发现在这里跳出了exception。我们怀疑5秒的等待时间是不是太短了,于是改成了下面的代码:
GeneratorThread::~GeneratorThread()
{
if (thread_)
{
stop();
thread_->wait(INFINITE);//wait forever
delete thread_;
}
delete generator_;
}
问题依然得不到解决,继续研究,发现thread_包含的线程的真正句柄(thread_对象所属类的成员变量,windows线程函数_beginthreadex的返回值),此时已经变成了无效句柄。问题有眉目了,竟然没有注意到野指针问题,我们在删除一个指针之后,编译器只会释放该指针所指向的内存空间,而不会删除这个指针本身,因此,如果delete两次GeneratorThread,析构函数会进入两次,第二次执行下面这个句子的时候,虽然thread_已经被delete过了,但仍然能够进入
if(thread_)
这时候一个无效句柄调用wait函数,跳出了exception。如何修改就很简单了。
GeneratorThread::~GeneratorThread()
{
if (thread_)
{
stop();
thread_->wait(5000);
delete thread_;
thread_=nullptr;
}
delete generator_;
generator_=nullptr;
}
因此,看似复杂的问题往往是由一些低级错误造成的。反过来,如果写代码的时候如果不小心,一些小错误往往也会酿成大祸。
好了,最后是出补丁,release...
这篇博文结束了。
后续如果时间充足,会有工作中的其他总结,包括但不限于:
- 如何用OOP实现我们的应用层协议——协议状态机
- 设计模式实战——我们的产品中用到的其它设计模式
- google C++ mock测试框架在我们产品中是如何使用的
- 谈谈开发眼中的敏捷——我们的敏捷团队是如何运作的
- 记录其他疑难bug的解决过程
- 。。。
作者:
HarlanC
博客地址:
http://www.cnblogs.com/harlanc/
个人博客:
http://www.harlancn.me/
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出,
原文链接