编程开源技术交流,分享技术与知识

网站首页 > 开源技术 正文

一个内存泄漏的故事!(内存泄漏有哪些)

wxchong 2024-07-29 07:50:28 开源技术 41 ℃ 0 评论

作者:lemon

起因

刚发完一个大版本,平平静静的过了几天。

某日,正在会议室中昏昏欲睡的开着会,突然收到一条 proxysvr 进程内存使用率超过 60%的告警推送。瞬间精神了起来,心中涌起了一丝丝的兴奋,有点意思的活来了。

有内存泄漏。

首先,对于 proxysvr 这个服务先介绍一下。

这是一个承接和第三方平台交互通信的服务。服务本身没有任何数据,仅仅是作为类似桥梁代理作用的无状态服务,服务的代码里面接入了一些访问第三方的 sdk,也能作为 http 的客户端去访问第三方的 http 服务,而自己本身也可以作为 http 的服务端接受第三方的 http 请求。

这个告警只有一台实例,但是其它实例应该也都差不多快到预警值了。

会议室出来,回到座位,打开监控一看,果然所有实例的内存使用率都在预警值的上下。

摩拳擦掌,准备开始捉虫。

问题分析

从源码上去找出问题一直是我屡试不爽的方法,因此这次的起手式仍然是从源码上开始。

从源码找问题,并不是说直接就翻开源码从头看到尾,这样和无头苍蝇没啥区别。而是需要先收集到足够的信息,从现象上去推测分析,最终缩小范围,有目的的去分析源码。因此,这里开始我们的第一轮的分析吧。(咦,为啥是第一轮,难道有好多轮?)

Round 1

最直观的信息就是监控(搞了那么多监控总算派上用场了)。

通过监控,我们收集到了几个信息:

  1. 从发了大版本后开始,内存增长飞快,差不多 4 天就达到了预警值
  2. 上个版本看起来也有 memory leak,只是增长比较缓慢,导致一直没有发现,而上上个版本由于时间太多久远,监控数据已经没有了
  3. 只有 proxysvr 有 memory leak,其它服务暂时没有发现
  4. 手 Q 大区的 proxysvr 普遍都达到预警值,而微信大区却离预警值还有一段距离
  5. 虽然活跃和在线相比于上个版本涨了,但是内存泄露的速率相比上个版本内存泄漏的速率明显要快好多倍
  6. 这个版本没有接入新的第三方 sdk,proxysvr 也没有增加太多新的业务代码

综合上诉信息,我们做出了几个范围推测:

  • 底层的 http 基础框架实现(目前只有 proxysvr 使用了)
  • 只有手 Q 才有的新的第三方业务调用

其中 http 基础框架实现分为两部分:

  • 基于 asio 实现的 http 服务端封装HttpServer
  • 基于 libcurl 实现的 http 客户端封装CurlMgr

此时,就要说一下通过源码分析问题的一些原则:

永远都不要优先怀疑成熟的第三方库的实现

许多同学的通病,一旦出现了比较匪夷所思的问题,第一个反应就是这个库是不是有问题。殊不知一个成熟的第三方库所经历的验证和考验要远远多于你自己撸的代码。所以,轻易不要去怀疑成熟的第三方库的实现。注意,我这里说的是库的实现,不是库的使用,使用是属于我们自己的问题。在这基础上也引出了另一条原则:

当出现问题时,最应该怀疑的是自己

基于这些原则,我们准备把目标锁定在手 Q 才有的第三方业务调用上,而此时一个信息的出现,刷新了我们的锁定范围。

有同事发现,微信大区也有 memory leak,只是相对于手 Q 大区没那么快。而这个信息给我们后面的工作也增添了一些变数。

基于这个信息,我们把范围推测进行了更新:

  • 底层的 http 基础框架实现(目前只有 proxysvr 使用了)
  • proxysvr 业务代码中是否出现第三方的 sdk 使用上不正确

确定了最小范围,那就开始了 code review 吧。

这个过程可能是枯燥的,但是对于我来说却是乐在其中。通过已有信息和条件进行分析推测,最终找出问题所在。这颇有点推理探案的味道,也有点类似解数学题的感觉。这一旦解决了问题,得到的成就感和满足感是足以令人愉悦很久的。(好吧,其实可能是想快点找出别人的问题以彰显自己的牛逼或找出自己的问题避免被别人嘲讽)

由于不久前,刚刚发现过底层 http 基础框架服务端实现中有一处问题,会在某个异常情况下造成资源没有回收。所以就先锁定在基于 asio 实现的 http 服务端部分的代码(HttpServer)。

asio 再加上 http 协议的处理,这代码酸爽程度可想而知。好在这块代码之前有过简单的了解,因此这次 review 只要注意一些编码细节即可。这一 review,好家伙,还真发现一些问题。

void HttpSession::AsyncSendReal() {
    // send_queue_ 是 list<string> 成员变量
    // 这里auto msg缺少了引用导致msg只是一个局部变量
    auto msg = send_queue_.front();
    // HttpSession是shared_ptr管理的对象,但是下面的lambda里面调用的成员都是用了this
    auto self = shared_from_this();
    asio::async_write(socket_, asio::buffer(msg.c_str(), msg.length()),
                      [this, self](const asio::error_code& err, std::size_t write_len) {
                          if (err) {
                              Close();
                          } else {
                              // 发送完成,将消息从队列删除
                              send_queue_.pop_front();
                              if (!send_queue_.empty()) {
                                  AsyncSendReal();
                              } else {
                                  Close();
                              }
                          }
                      });
}

这段代码,竟然一直跑得好好的,没有 coredump。

果然,未定义是一种超乎认知的东西。

但,这并不会造成内存泄漏。

继续进行一番 review,最终也没有找出HttpServer会造成 memory leak 的地方。

结论:http 服务端的代码是正常的

在下这个结论之前,又要提出我们的第三个原则了:

所有的结论都需要有现实的数据和表现作为完整的支撑

所以,我们统计了现网关于 http 对象创建和释放的日志,以及对应协议请求次数等数据,都是正常的。基本上已经可以佐证我们的这个结论了。

Round 2

既然HttpServer没有问题,那下一步就是CurlMgr了(一个基于 libcurl+epoll 的封装实现)

为什么我们一开始的时候先 review 的是 HttpServer 而不是 CurlMgr 呢? 因为这个 CurlMgr 是从某个上线多年项目拿过来的,在外网经过了验证,而 HttpServer 是我们做当前项目才实现的,应用的地方也只有 proxysvr 并且跑了不到一年。根据前面的原则一和原则二,做了这样的优先选择。

CurlMgr 本身会在启动的时候创建好指定数量 CurlTrans 对象(之后就不会再创建新的了),每个 CurlTrans 维护一个 http 请求,并且在请求结束后放回空闲链重复使用。

设计上很简单,复杂的就在于 libcurl 的 api 和 epoll 的配合使用上。

快速过了一下,没有出现类似 HttpServer 的那些问题,接下来就是 API 的调用上了。

我们不怀疑 libcurl 的实现,但是却要警惕对于 API 的调用。

调用第三方库,往往会遇到内存管理的问题。对于一些 API 的输出指针,都得明确管理权在哪边,是否需要调用方来释放。

基于这个认知,快速扫了一遍调用到的 libcurl 的 API,马上有一个 API 引起了我的关注。

void CurlMultiMgr::MakeReqheaders(struct curl_slist** curl_headers, const std::map<std::string, std::string>& headers) {
    for (auto itr = headers.begin(); itr != headers.end(); ++itr) {
        std::string header_str = itr->first + ": " + itr->second;
        // *curl_headers传入的时候是nullptr,但是这里返回的是一个非nullptr的指针
        // 谁来释放?
        *curl_headers = curl_slist_append(*curl_headers, header_str.c_str());
    }
}

curl_slist_append返回的链表指针谁来管理内存呢?翻开文档:

curl_slist_append appends a string to a linked list of strings. The existing list should be passed as the first argument and the new list is returned from this function. Pass in NULL in the list argument to create a new list. The specified string has been appended when this function returns. curl_slist_append copies the string. The list should be freed again (after usage) with curl_slist_free_all.

很明显,这里的内存是需要调用者来释放的,但是整个 CurlMgr 中并没有地方去释放,因此,这个地方就是内存泄漏的地方了。

既然这里是内存泄漏的地方,而 CurlMgr 的实现从引入项目到现在都没有人修改过。为什么偏偏会在这个版本发布后泄漏得特别快呢。

进一步分析推测,调用 MakeReqheaders 是为了构造 http header,而大部分的第三方 http 服务并不需要构造 http header,唯独两个最近几个版本引入的第三方服务请求是需要构造 http header 的:

  • 信鸽推送服务
  • 精细化服务

通过监控统计( 没错,监控、监控,还得是监控,详尽的监控对于服务器线上稳定运行的重要性可想而知 ),我们可以发现信鸽推送的请求数量很有限,而精细化服务的调用次数则是和活跃用户数量已经具体运营活动的调用成一定的正比关系。手 Q 区和微信区的用户数量差异也对应了泄漏的速率不一样。新版本后活跃用户的增长和运营活动调用次数的增加似乎也叠加影响了内存泄漏的加快。

我们快速修改了一下本地压力测试的用例,进行了本地压测,确实印证了存在内存泄漏。

于是,发挥了拉群侠的被动技能,快速拉起整个发布流程的相关同事,迅速修改验证并发布了现网。

至此,似乎问题已经解决了。

但,我们仍然没有一种完全释然的畅快感,颇有点拉屎没有拉干净的感觉。

Round 3

原则三有个关键的词

所有的结论都需要有现实的数据和表现作为完整的支撑

注意,是需要完整的。而我们数据和表现并没有很完整的验证最终的结论。

  • 泄漏的速度和实际请求量进行粗略估算后,并没有完全对上,泄漏的内存比估算的多
  • 活跃用户的增长比例也没有和泄漏的速度增加比例完全对上
  • 微信区的泄漏速率并没有手 Q 区高,两个大区虽然用户数量不一致,但是每个 proxysvr 的实例接受的请求数是均匀的,理论上应该速率相差不大,而微信区却是慢很多。

当我们发布出去后,持续观察了一天,发现了不一样的现象。

微信区内存泄漏没有了,但是手 Q 区只是稍微减缓了一点,内存仍然以一个较快速度增长。

至此,我们重新可以得出了新的一个推断:

CurlMgr 的问题是造成微信区 memory leak 的原因 但,还存在一个只有手 Q 才会触发的速度更快的 memory leak。

由于当前版本并没有新增第三方的调用,所以范围很快锁定在之前的只有手 Q 才有的一个上报业务上。( 对业务的熟悉程度非常重要,正如山路上跑得最快的车手永远都是对赛道最熟悉的车手

重新翻开对应的业务代码和历史提交记录,果然很快就发现了问题:

template <typename ARRARY>
static std::string BuildAchievementData(const std::string& data_name, ARRARY&& arr) {
    auto descriptor =
        google::protobuf::DescriptorPool::generated_pool()->FindMessageTypeByName("ua.protocol." + data_name);
    COND_RET_GID_ELOG(!descriptor, "", 0, "not find data type:name=%s", data_name.c_str());
    auto prototype = google::protobuf::MessageFactory::generated_factory()->GetPrototype(descriptor);
    // 创建一个堆上的对象,没有释放
    auto instance = prototype->New();
    auto reflecter = instance->GetReflection();
    COND_RET_GID_ELOG(descriptor->field_count() != arr.size(), "", 0,
                      "field_count not equal arrary size:name=%s size=%u", data_name.c_str(), arr.size());
    // ......
}

每次上报手 Q 平台的时候,都会调用这个构建上报数据 string 的函数。其中在堆上 New 出来的一个对象后续忘记释放了。

而这个只有 proxysvr 才使用的函数却是写在了公共库代码中的,导致前期在查看提交记录的时候忽略了。

最终,原因确定是 new 了对象忘记 delete。

没错,就是这么简单。

最后,就是找出证据来完整的支撑我们的结论了:

  • 手 Q 上报的这个代码在这个版本进行过重构
  • 上报的次数粗算的内存和泄漏基本符合

再次修改发布后,内存曲线总算都变成了水平了。

结语

根据信息一步一步分析和 code review,整个问题的发现和解决也并没有花费太多的时间。只是叠加了两个不同的 memory leak 导致在一开始的时候没有第一时间发现泄露快的那个。

最终的原因也还是离不开 new 了忘记 delete 这种低级错误。

其实大部分的内存泄露无外乎几种:

  • new 了之后没有 delete(包括不限于各种形式的 new)
  • 大量使用 shared_ptr 的地方导致了环形引用
  • 外部 API 调用产生的对象没有正确释放
  • 全局容器对象塞进去元素没有及时删除,越积越多

查找的时候也可以优先定位这些地方,十有八九都能找到问题。而如何降低在后期问题定位的难度,这还得有赖于在早期编码上。

良好的接口定义,合理的结构设计,严谨的编码细节,无一不是在减轻后期维护和重构的负担。

这里对整个事件的记录,也并不是为了说明这个问题难度,而是想通过一次完整的复盘,清楚了解一下在遇到类似情况的时候,如何思考和按部就班的分析,抽丝剥茧找出问题。希望对一些同学能有一些启发和帮助。

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表