记一次查找内存泄露问题的过程
在最近的项目中,新接手了一个模块(以下简称A),在操作后退出,提示存在内存泄露,本文回顾解决这个内存泄露问题的全流程。
问题复现
为了定位内存泄露,对A模块的各个子功能都进行操作,然后退出程序,观察内存泄露报告,通过多次操作,总结出以下现象:
- 有的子功能存在内存泄露,有的不存在。
- 存在内存泄露的功能,每次泄露的内存块个数以及总大小是一样,平均在11KB左右。
- 不同内存泄露的功能,泄露的内存块以及总量是不同的
- 每个泄露的回溯堆栈都是一样的,定位到解析响应数据组件
通过以上现象,推测是解析响应数据时,存在内存泄露。
问题分析
数据解析模块是通用模块,查阅代码得知该模块根据请求Id,将响应数据解析成内部结构 CResponseData
,并保存map中,抛开业务细节,保存和使用的逻辑如下:
// 存储响应数据
map<string, list<CResponseData*>> m_mapResp;
void ParseData(const char* pRespData, int nRespDataLen, CBaseAsk* pAsk)
{
// xxx
CResponseData* pRespData = new CResponseData(pRespData, nRespDataLen);
// 从请求中获得唯一标识
string strKey = pAsk->GetUniqueId();
m_mapResp[strKey].push_back(pRespData);
// xxx
}
CResponseData* GetRespData(const string& strReqKey)
{
CResponseData* pData = nullptr;
auto it = m_mapResp.find(strReqKey);
if (it != m_mapResp.end())
{
pData = it->second.front();
it->second.pop_front();
}
return pData;
}
数据解析模块内部通过 map<string, list<CResponseData*>>
结构保存解析完成的数据,通过 GetRespData
获取数据时,该接口要求外部使用者承担释放,响应数据是有解析模块负责申请。这里,违背了申请与释放一致的原则。
看到这里,起初猜测,有没有可能是两个同样的请求,后一个请求覆盖了前一个请求的数据,看到value
是采用list
保存,支持保存同个请求的多次响应数据,但这里还是存在依赖响应顺序的问题,不过,同个请求响应数据是幂等的,此处不影响。
在该模块的析构函数中,对list
中所有尚未取走的响应数据进行统一释放。在调试过程,发现析构释放流程始终未进入,应该是都被取走了,接下来,大概率就是调用方的问题。
如果每个使用者按照接口约定,使用完后释放,不会出现泄漏。搜索了下调用方,有70多个,还有一个模块基于该接口再次封装,又有50多个调用方使用二次封装的接口。采用笨办法,一个一个的搜索排查。
经过漫长的排查,终于发现内存泄露的代码,大概长这个样子:
bool DealFun()
{
// xxx
CResponseData* pData = GetRespData(strReqKey);
// 业务有效性校验
if (xx)
{
// FreeData(pData);
}
// 很长的业务代码块
// 在结尾处释放
// FreeData(pData);
}
忘记释放的原因:
- 中间有多个退出路径时,遗漏了某些路径的释放
- 中间有很长的业务代码块,超过了一页屏幕,写到最后,忘记释放最初的数据指针。
数据解析模块是通用模块,几乎每个功能组件都依赖它,有的组件还会二次封装,自己内部使用,需要调用者释放的入口又多了。
问题解决
经过搜索排查,逐一补全遗漏释放的地方。
在排查过程中,发现一种较好应对这种场景的写法,核心思想是用do-while
来包裹处理代码,通过break
跳出,在最后处释放。具体实例如下:
bool DealFun()
{
CResponseData* pData = GetRespData(strReqKey);
do
{
// 业务有效性校验
if (xx)
{
break;
}
/// 业务处理1
/// 业务处理2
/// 业务处理3
}while(0);
FreeData(pData);
}
上述写法有以下好处:
- 有效性判断以及业务处理无需考虑释放,退出执行
break
,在外部调用一次释放即可。 - 代码检查时,利用代码折叠,可把
do..while()
语句折叠起来,一眼看到结尾处的释放语句,直接明了,不用翻页查看
小结
此次内存泄露的原因是获取数据的接口要求调用方释放内存,但调用方忘记释放导致的。本文分享了一种较好应对这种场景的代码组织方法,
笔者推荐在响应处理中,将有效性校验以及业务处理封装成函数,减少主流程的代码长度。