目录
1、问题描述
2、使用Process Explorer初步找到CPU占用高的原因
3、使用Clumsy工具在公司内网环境复现了问题
4、根据Process Explorer中的函数调用堆栈,分析源码,最终找出了问题
5、总结
在排查项目客户的视频图像闪烁问题时,无意中发现一个掩藏很深的高CPU占用的bug,本文将详细讲述使用网络环境模拟工具clumsy以及Process Explorer定位该CPU高占用问题的全过程。
对某客户的项目即将收尾,目前处于客户试用阶段,如果没什么大问题,一切进展顺利的话,客户就准备进行产品采购了。结果在客户的一台笔记本电脑上出现了严重的视频图像闪烁问题,客户要求必须解决这个问题后才会进行产品采购。
这个视频闪烁是个很棘手的问题,目前使用的视频编解码库对不同厂商及不同类型的USB摄像头,会有小概率的不兼容问题,正好整个项目中碰到,于是开始了漫长的问题排查过程。
某天使用向日葵远程到客户的笔记本上,发现系统的CPU占用很高,系统产生了明显的卡顿。查看系统的资源管理器,向日葵软件占了15%左右的CPU,客户还启动了其他的一些软件,这些软件也占了30%左右,我们的软件居然也占到了30%左右!不应该啊,我们软件登陆后什么业务都没做,居然也能占到30%,这肯定是有问题的!
于是在客户的机器上下载了Process Explorer,使用该工具看一下我们软件进程的各线程的CPU占用情况,看看到底是哪个模块在没做什么操作时就占用了30%左右的CPU。
Process Explorer是我们排查Windows软件问题时使用频率很高的一个工具,主要使用到以下几个功能:
1)可以查看目标进程加载的dll库信息,包括库的路径、库的版本等信息。还可以查看通过LoadLibrary动态启动的库有没有启动起来。
2)可以查看目标进程的线程信息,包括各线程的CPU占用率、线程实时的函数调用堆栈等信息。
3)可以查看目标进程使用GPU硬件的情况(CPU上集成了GPU模块)。很多软件会使用GPU,比如进行视频编解码,可以使用GPU实现硬编硬解(使用CPU的计算能力进行的编解码叫做软编软解),从而有效地减少对CPU占用。
将Process Explorer启动起来后,在进程列表中找到我们的软件进程:
双击我们的软件进程,弹出进程的详细信息窗口,然后切换到Threads标签页下:
从图中我们可以看出,是线程id为12292的那个线程有问题,居然占了25%左右的CPU!
双击该线程,查看到该线程当前的函数调用堆栈如下:
函数调用堆栈是与libwebsockets开源库的接口调用有关,我们底层之前有对libwebsockets开源库做过简单的封装,难道是我们封装的有问题?
我们软件的A业务模块会通过libwebsockts和平台的A服务器进行通信,平台的A服务器是负责A业务的相关事务处理的。但当前我们软件什么都没做,还没有发起A业务的操作,为啥底层的模块会频繁的调用到libwebsockets呢?
我们软件在登陆时,会和平台的A服务器建立链接去向A服务器注册,并且这个链接是长连接。如果软件底层与A服务器链接断开后,底层会发起自动重连。难道是底层当前登陆不上A服务器,底层在不断的定时重连,而定时重连的代码有问题,导致了高CPU占用?
于是通过查看打印找到A服务器的地址为172.16.72.235,在客户的笔记本上去ping这个服务器地址,结果服务器地址ping不通:
所以我们基本可以估计出,就是因为服务器连不上,底层在不断的重连该服务器导致高CPU占用的。
上面大概猜测出是底层在连接不上A服务器的情况下,一直在定时地去重连A服务器导致的高CPU占用问题。但根据Process Explorer中查看到的函数调用堆栈:
对照着源代码,我们并没有找到出问题的点。
我们不能一直远程客户的电脑,客户还有很多自己的事情要做,于是我们尝试在对我们公司环境中复现这个问题(我们公司内部搭建了多套用于测试的平台环境,有内网环境的,也有公网环境的)。那如何在公司内部复现呢?其实很简单,使用网络工具将与A服务器交互的数据都拦截掉,让客户端软件连不上A服务器就能触发底层的自动重连,应该就能将问题复现出来。
于是想到一个很好用的、轻量级的网络环境模拟工具clumsy,直接用该工具将客户端发给A服务器的数据包全部拦截掉。
Clumsy是一种弱网络环境模拟工具,可以直接将网络数据拦截掉,也可以设置对目标地址的丢包率,模拟较差的网络环境,它也是我们日常工作中用的比较多的一种软件工具。
打开clumsy后,默认的过滤条件是:outbound and ip.DstAddr >= 127.0.0.1 and ip.DstAddr <= 127.255.255.255,如下:
我们只需要设置目标地址为平台中A服务器的地址139.224.XXX.XXX(该平台是我们公司内部大搭建的测试平台)即可,即ip.DstAddr == 139.224.XXX.XXX:
然后勾选Drop选项,并将Inbound和Outbound都勾选上,将丢包率设置为100%,这样软件终端发给A服务器数据包都丢弃掉了,这样A服务器就连不上了。注意,可能需要以管理员权限运行该工具(特别是在win10系统中)。
A服务器连不上,底层就会不断去定时重连,然后问题就复现了,在我们的工作机器上也是占用了较高的CPU,所以更加能确定是重连的代码导致的高CPU占用了。
其实这个高CPU占用的bug是有很大的隐蔽性的,在能连上A服务器时没有这个问题,只有连不上A服务器时触发重连,才会出现。
于是找来负责维护底层模块的同事,一起来看看他们的代码到底为啥为导致高CPU占用。因为我是负责软件异常排查的,所以会经常协助底层模块的同事去排查各种软件异常问题,比如协议模块、网络模块,音视频编解码模块、组件模块等。
对照这Process Explorer中显示的函数调用堆栈,找到了源代码的位置,但是详细看了一下这些代码并没有发现明显的破绽!为啥能连上代码执行的没问题,而连不上时这段代码会有问题呢?负责底层模块的同事很忙,开玩笑地说,这个问题既然是服务器连不上,那就让客户查为啥会出现服务器连不上的问题,这个问题先放着吧。这哪成啊!这明显是个很大的隐患,不管客户的环境能否连的上,都必须要解决!
于是我将他们的代码拿过来,仔细研究了一下,看看到底是怎么回事。重连A服务器的代码放置在一个线程中处理的,相关代码如下:(问题就出在调用lws_service的那句代码上!)
static void* WSSocketProc( void* pParam )
{
s_ptContext = CreateContext();
if ( NULL == s_ptContext )
{
MLOG::MLogErr( ML_WEBSOCKET, "[%s] Create Context Failed!!!", __func__ );
return NULL;
}
if ( FALSE ==OspSemBCreate( &s_hWsiCloseSem ) )
{
MLOG::MLogErr( ML_WEBSOCKET, "[%s] s_hWsiCloseSem Inited Failed!!!", __func__ );
return NULL;
}
SemGive( g_hWSInitSem );
while ( TRUE )
{
CheckSvrConnect();
SemTake( s_hWsiCloseSem );
std::vector::iterator itWsi = s_vecToBeClosedWsi.begin();
for ( ; s_vecToBeClosedWsi.end() != itWsi; ++itWsi )
{
SemTake( g_hSessionIDSem );
std::map::iterator itSessionID = g_mapSessionID.find( *itWsi );
if ( g_mapSessionID.end() != itSessionID )
{
bClientForceClose = TRUE;
lws_close_free_wsi( (lws *)( *itWsi ) , LWS_CLOSE_STATUS_NOSTATUS );
}
SemGive( g_hSessionIDSem );
}
s_vecToBeClosedWsi.clear();
SemGive( s_hWsiCloseSem );
// 问题就出在这句代码上,在没有websockets连接时,该接口没有起到sleep的作用
lws_service( s_ptContext, LWS_SERVICE_TIMEOUT );
if ( s_bExitSocketProc )
{
MLOG::MLogHint( ML_WEBSOCKET, "[%s] SocketProc Thread Exit!!!", __func__ );
OspSemDelete( s_hWsiCloseSem );
s_vecToBeClosedWsi.clear();
break;
}
}
lws_context_destroy( s_ptContext );
return NULL;
}
按讲,在使用一个线程去处理事务时必须要加一个Sleep,不能让线程一直在运行,否则线程一直在占用CPU时间片,一直占用着CPU就会导致高CPU占用,类似于死循环。对于程序员来说,这是个常识!
代码中确实好像也有Sleep,通过调用libwebsockets的接口lws_service时传入了一个超时的参数,估计是通过该函数实现的Sleep的功能。
能否不调用这个lws_service,直接调用Sleep接口呢?答案是不可以,go到lws_service接口实现处,查看lws_service接口的注释:
/**
* lws_service() - Service any pending websocket activity
* @context: Websocket context
* @timeout_ms: Timeout for poll; 0 means return immediately if nothing needed
* service otherwise block and service immediately, returning
* after the timeout if nothing needed service.
*
* This function deals with any pending websocket traffic, for three
* kinds of event. It handles these events on both server and client
* types of connection the same.
*
* 1) Accept new connections to our context's server
*
* 2) Call the receive callback for incoming frame data received by
* server or client connections.
*
* You need to call this service function periodically to all the above
* functions to happen; if your application is single-threaded you can
* just call it in your main event loop.
*
* Alternatively you can fork a new process that asynchronously handles
* calling this service in a loop. In that case you are happy if this
* call blocks your thread until it needs to take care of something and
* would call it with a large nonzero timeout. Your loop then takes no
* CPU while there is nothing happening.
*
* If you are calling it in a single-threaded app, you don't want it to
* wait around blocking other things in your loop from happening, so you
* would call it with a timeout_ms of 0, so it returns immediately if
* nothing is pending, or as soon as it services whatever was pending.
*/
LWS_VISIBLE int
lws_service(struct lws_context *context, int timeout_ms)
{
return lws_plat_service(context, timeout_ms);
}
上述注释的翻译如下:
This function deals with any pending websocket traffic, for three kinds of event. It handles these events on both server and client types of connection the same.
1) Accept new connections to our context's server
2) Call the receive callback for incoming frame data received by server or client connections.
此函数处理任何需要处理的(悬而未决的)websocket 流量,适用于三种事件。它以相同的方式处理服务器和客户端连接类型上的这些事件。
1) 接受到我们上下文服务器的新连接
2) 调用回调函数,将服务器或客户端连接接收到的数据,回调出去。
You need to call this service function periodically to all the above functions to happen; if your application is single-threaded you can just call it in your main event loop.
你需要周期性地调用这个服务函数来使上述所有函数发生; 如果您的应用程序是单线程的,您可以在主事件循环中调用它。
从上面的注释可以看出,要保证libwebsockets库中能正常的收发数据,必须要调用lws_service接口。
那为什么连上服务器时上述代码运行有问题,而连不上服务器时就会有问题呢?估计是连不上服务器时,libwebsockets中就没有有效的websockets连接,当调用到lws_service接口时该接口立即返回了,即该接口没有起到Sleep的作用!应该就是这个原因引起的。
最后的解决办法是,在线程函数中添加一个Sleep:(在调用lws_service的下方加上一句Sleep)
lws_service( s_ptContext, LWS_SERVICE_TIMEOUT );
Sleep( LWS_SERVICE_TIMEOUT ); // 需要人为地添加sleep,以保证当前线程有睡眠时间
不管在什么情况都要执行一定时间的Sleep。修改代码后,将库编译出来,覆盖到软件的目录中,重新运行软件后就不再有问题了。
作为软件开发人员很有必要去掌握一些常用工具的使用,通过这些工具来辅助排查我们软件产品在运行中遇到的各种问题,可以有效地提高排查问题的效率。
在本例中,正是通过查看Process Explorer工具的函数调用堆栈找到了出问题的代码块,通过Clumsy工具在公司环境中复现了问题。正是依靠这些工具,我们才逐步的定位和解决问题的。