使用Memcheck工具输出程序的内存检查报告后该如何解决?

导读

Valgrind是比Memcheck最广为人知的开发者使用的工具,它是一个检查c/c++程序内存错误的神器,报告结果非常准确。

本文主要分享笔者在使用该神器解决内存问题的过程中积累的一些实践经验,希望能帮助大家快速定位问题,甚至在编码阶段避免这些问题。

Memcheck 可以检查哪些内存错误?

Memcheck 可以检查 c/c++ 程序中的以下常见问题:

内存泄漏,包括进程运行时的泄漏和进程结束前的泄漏。访问不应该访问的内存,即非法读写内存。变量未初始化,即未定义值。堆内存释放不当,例如 double free 或 malloc/new/new[] 与 free/delete/delete[] 不匹配。内存块重叠,例如,当使用 memcpy 函数时,源地址和目标地址重叠。将非法值(可疑值)传递给内存分配函数的大小参数,例如,负值。

其中,问题1的内存泄漏一般都比较容易定位和解决,但是笔者在实际项目开发中遇到了stillreachable错误掩盖了肯定丢失的错误,增加了定位内存泄漏点的难度。问题 2 和 3 属于经常发生的一类内存错误。它们经常导致程序崩溃。此类错误必须认真对待,必须予以解决。问题4、5、6也是典型的内存错误玩游戏应用程序错误 该内存不能为read,可以通过Memcheck快速定位并解决。

对于c/c++开发者来说,如果这些内存隐患不能及时发现并消除,偶尔的crash和难以诊断的coredump将是一场挥之不去的噩梦。而且,这些内存问题自己可能很难定位,尤其是当程序代码庞大,逻辑抽象复杂时,会让人不知所措。至此,Memcheck 就是一个神器,可以帮助我们解决这堆内存问题。

使用 Memcheck 解决问题的原则

使用 Memcheck 工具输出程序的内存检查报告后,我们该如何解决报告中的问题呢?笔者根据长期使用积累的经验总结出以下四个原则。

原则一、内存非法读写错误必须解决

此类错误在检查报告中输出为大小为 x 的无效读/写。此类错误有三种主要情况:

动态分配的内存已被释放,但开发者仍在对这块无效内存进行读写。

比如一个悬空指针,即基类指针指向的子对象已经被释放,但它继续使用基类指针调用它的方法。

动态分配的内存不会被释放,但会发生对该内存的越界访问。

例如,在复制字符串时忘记尾随字符 \0。

比如memcpy(dst, src, len);,src的内存大小是1024B,但是len的值是1025。

越界访问堆栈空间(即堆栈溢出)

比如越界访问一个数组。

其中,场景一出现的频率更高。因此,我们在处理Invalid read/write等内存读写错误时,更有效的解决方案是:首先要考虑的是非法读写的块(内存块)是否因为读写前的一些程序错误。异常处理已发布,并进行了代码审查以验证这种可能性。如果排除了内存释放的可能性,我们再看看是否存在内存越界访问的可能,然后继续验证。

在这个过程中,我们要完整读取 Memcheck 的 Invalid read/write 输出的详细信息。比如非法读写的内存块分配在哪里?它是在哪里发布的?读写在哪里违法?将这些线索结合到具体的项目代码中,有助于我们更有效地解决问题。

忽略此类错误会给自己的程序带来巨大的隐患,最坏的结果是程序崩溃,对服务器来说是致命的。

记得有一次使用c++11的range loop语法遍历和删除map中的元素,Memcheck检查出红黑树节点写入内存错误。当时以为是STL库底层出现错误,程序改动很小,所以忽略了这个错误,不知道底层错误是由上层引起的代码。后来在压力测试中,发现程序经常崩溃,正是因为这个错误。幸好当时服务程序没有上线,否则后果不堪设想。因此,必须解决此类错误。作为服务器端开发人员,不能过于谨慎。

最后,我们来演示一下这种错误,代码如下:

void foo() {
  char* buffer = (char*)malloc(5);
  strcpy(buffer, "01234");
  cout << "buffer[5]="
       << buffer[5] << endl;
  free(buffer);
}

在 foo 函数中,动态分配了一个 5 字节的内存块,然后将字符串“01234”复制到这块内存中,但是忽略了字符串的结束字符 \0,最终写入了 6 字节的字符串。当内存空间为5字节时,内存写入越界,Memcheck报错Invalid write of size 2。

最后一行代码打印buffer[5]时,发生内存读取越界,即字符数组被越界访问,Memcheck报错Invalid read of size 1。

这里只演示一些非法读写内存的场景。对于其他很多非法读写内存的场景,读者可以自己尝试重现代码。

原则2、变量未初始化错误必须解决

此类错误在检查报告中输出为使用大小为 x 的未初始化值或条件跳转或移动取决于未初始化的值。即程序使用未初始化的变量或从上层未初始化的变量一层一层向下传递的未定义值。

一般来说,这类错误是由定义后未初始化的变量引起的。所以,一定要同时养成变量定义和初始化的良好编程习惯,把这样的错误杀在摇篮里。其次,如果检测报告出现这样的错误,那么千万不要忽视这个错误,一定要及时修复,要及时止损。

作者曾经没有将指针变量初始化为null,导致它变成了野指针,各种指针置空逻辑对其无效,导致程序各种匪夷所思的崩溃。花了很多天终于找到问题所在。所以,不要给自己找麻烦。

如果难以确定此类错误的根本原因,您可以尝试使用 --track-origins yes 跟踪未初始化变量的问题以获取更多信息。虽然这使得 Memcheck 运行速度变慢,但是你获得的额外信息通常可以节省大量时间来确定未初始化值的来源。

最后,我们来演示一下这种错误,代码如下:

void foo(int y) {
  cout << y << endl;
}
int main() {
  int x;
  foo(x);
  return0;
}

在main函数中,定义了一个未初始化的变量x,然后传入了foo函数。这个函数的作用是打印传入的参数。由于变量y的值依赖于x,所以y的值是未定义的,打印变量y相当于间接使用了一个未初始化的变量,Memcheck会报这种类型的错误。

原则3,启用-show-reachable=yes命令行选项

强烈建议在运行 Memcheck 时添加 -show-reachable=yes 命令行选项,它可以帮助我们检查与全局指针和静态静态指针相关的内存泄漏。

强烈建议在进程结束时正确、优雅地释放所有资源,包括关闭定时器和套接字、释放全局或静态对象、回收线程资源等。养成严谨的编程风格。

为什么必须启用可达命令行选项?别着急,在揭晓原因之前,我们先来看看内存泄漏的定义以及Memcheck工具报告的四种内存泄漏。

内存泄漏究竟是如何定义的?

笔者认为内存泄漏有以下两种情况:

在进程结束之前,内存已分配但未正常释放。

也就是在进程结束的前一刻,进程还有一个指向内存块的指针,指针没有丢失,仍然是可达的。

在这种情况下内存泄漏的主要原因是具有进程级生命周期的静态指针或全局指针指向的内存块在进程结束之前没有被释放。

内存已分配,但在进程运行时无法正常释放。

此时,进程不再有指向内存块的指针,指针丢失。这种情况被 c/c++ 开发人员称为真正的“内存泄漏”。在这种情况下内存泄漏的主要原因是:

Memcheck 输出中四种形式的内存泄漏

内存检查报告按丢失字节数的升序显示。我们来认识一下 Memcheck 工具输出的检查报告中的四种内存泄漏:

肯定丢了,指针肯定丢了。

在进程运行或进程结束时,如果一块动态分配的内存还没有被释放,程序再也找不到可以正常访问该内存的指针,就会报这个错误。也就是说,指针已经丢失,但内存还没有被释放。这是一个真正的内存泄漏,需要注意并且需要尽快修复。

间接丢失,指针间接丢失。

使用带有指针成员的类或结构时可能会报告此错误。这类错误不需要直接修复,它们总是以肯定丢失的形式出现,只需修复肯定丢失即可。

可能丢失,指针可能丢失。

当进程结束时,如果一块动态分配的内存没有被释放,并且程序中的指针不能访问到这块内存的起始地址,但是可以访问到这块内存的一些数据,那么指针指向的内存块可能会丢失。也就是说,如果原来指向内存起始地址的指针重新指向内存中间的一个地址(即非起始地址),就会报这个错误。

在大多数情况下,应该尽快将其视为肯定丢失,除非这是您的意图,并且您可以将已经指向内存的非起始地址的指针重新指向该内存的起始地址经过一些操作并释放它。

仍然可以访问,您仍然可以获得指针和访问内存。

指针不会丢失,内存不会被释放。如果程序正常结束,那么这种错误一般不会导致程序崩溃,一般可以忽略。

此类指针基本上是静态指针或全局指针,因此这些仍然可访问的内存块通常只分配一次,并且具有进程级生命周期,如官方 valgrind 手册中所述:

这些块通常是一次性分配,在进程的整个生命周期中都会保留对这些块的引用。

综上所述,对于这四种不同形式的内存泄漏,我们应该按照肯定丢失、可能丢失、仍然可达的顺序来解决。

仍然可以达到内存泄漏吗?

实际上,这种场景下的泄漏可能不能称为严格意义上的内存泄漏,因为在运行的过程中并没有发生泄漏。

虽然在进程结束之前内存并没有被释放是真的,但是指向这块内存的指针是可达的,操作系统会得到这些指针并帮助我们释放内存。

但是,请注意,仍然可达可能会掩盖真正丢失的内存泄漏,这就是作者强烈建议启用可达命令行选项的原因。

笔者曾经遇到过一个非常隐秘的内存泄露问题:有一次,在线服务的物理内存使用量达到了 2G。一开始以为是底层的jemalloc没有把内存还给操作系统造成的,Memcheck也没有报出一个肯定丢失的错误。所以它不被认为是内存泄漏。一周后再次查看,发现内存使用量已经超过10G。这一次,毫无疑问肯定是内存泄漏,但 Memcheck 仍然无法检测到任何泄漏。最后,万不得已打开了reachable选项,让Memcheck报告所有仍然可达的信息,对这些可疑信息一一检查,最终定位到内存泄漏点:原来是拉流缓存中的数据包用户停止拉流后未释放。后来又回顾了解决内存泄漏的过程,发现对仍然可达的信息的位置一一查看是低效的。此外,为什么没有将内存泄漏报告为肯定丢失?这是个问题。最后在进程退出时主动释放数据缓存结构的上层全局指针。如此一来,本次的内存检查报告不仅准确定位了内存泄漏,而且没有仍然可达的错误。

因此,笔者强烈建议养成在进程结束前优雅释放静态/全局指针的良好编程习惯,做好资源清理工作,在使用Memcheck时启用reachable参数,千方百计杜绝仍然可达错误,这样不仅丢失的错误肯定会暴露出来,而且检查报告看起来会更干净。

原则四,慎重考虑!确保 Memcheck 测试程序的每个逻辑分支

在运行 Memcheck 之前,我们需要仔细考虑并列出所有重要的测试场景,以确保最大限度地使用 Memcheck。例如,以下测试场景很重要:

是否在弱网场景下测试过?

实验室环境始终是理想的。可能 Memcheck 无法测试弱网环境下的逻辑漏洞。所以,只有在丢包、延迟、乱序的弱网环境下使用Memcheck才能真正暴露问题。

流程结束前的资源清理和释放逻辑是否经过测试?

也就是说,你的程序是否有能力捕捉和处理信号?例如,如果捕获并处理了一个 SIGINT 或 SIGTERM 信号,那么在执行 ctrl + c 时,Memcheck 可以在进程结束之前检查信号处理程序的处理逻辑。

如果程序在退出逻辑中没有释放一些资源(内存、sockets、定时器、io事件等),那么Memcheck会检查这些错误,也许还是reachable错误,如上所述,这个错误提示解决。

流程运行时中的一些异常处理逻辑是否经过适当测试?

比如对于流媒体服务,是否测试过推拉流停止、推拉流失败、回源失败等相关逻辑。

Memcheck 四指针丢失情况的代码演示肯定丢失仍可达代码演示

首先,我们展示了绝对损失和仍然可达的情况。

void test01() {
  char* p = newchar[1024];
}
void test02() {
  staticchar* p = newchar[1024];
}
int main() {
  test01();
  test02();
  return0;
}

在 test01 中,新数组被分配给局部指针变量 p。test01测试结束后,局部变量p丢失,内存没有释放,导致内存泄漏。Memcheck 将报告一个肯定丢失的错误。

在 test02 中,新数组被分配给具有进程级生命周期的静态指针变量 p。test02测试结束后,直到main函数返回,仍然可以获取到静态指针p,但是在进程结束前内存并没有释放。Memcheck 会报一个仍然可达的错误。

间接丢失代码演示

接下来,说明间接损失的情况。

class Object {
public:
  Object() { _p = newchar[1024]; }
  ~Object() { if(_p) delete _p; }
private:
  char* _p = nullptr;
};
void test03() {
  Object* obj = new Object();
};
int main() {
  test03();
  return0;
}

在test03中,我们新创建了一个Object类型的本地对象指针obj,它的成员_p指向一个动态分配的数组。经过test03的测试,局部变量obj丢失,内存没有释放,其内部成员_p指针也间接丢失,没有释放。Memcheck 将报告肯定丢失和间接丢失的错误。

可能丢失代码演示

下面演示了可能丢失的内容。

void test04() {
  char* data = newchar[1024];
  staticchar* p = data + 1;
}
int main() {
  test04();
  return0;
}

在test04中,我们新建一个数组并返回给局部变量data,然后声明静态指针p,指向数组第二个元素的地址。test04测试结束后,直到main函数返回,静态指针p仍然可用,但是p已经不再指向数组的起始地址。Memcheck 认为指向该内存的指针可能已丢失,并报告可能丢失的错误。

接下来,我们添加一行代码 p = data; 到 test04 函数。

void test04() {
  char* data = newchar[1024];
  staticchar* p = data + 1;
  p = data;
}

此时,静态指针 p 重新指向数组的开头,因此 Memcheck 将不再报告可能丢失的错误。但是Memcheck会报stillreachable的错误,因为静态指针指向的数组空间还没有被释放,在测试过程结束之前还是可以得到结果的,只要加一行delete[]数据或者delete[ ] p 来解决它。

最后,我们添加另一行代码 p = nullptr; 到 test04 函数。

void test04() {
  char* data = newchar[1024];
  staticchar* p = data + 1;
  p = data;
  p = nullptr;
}

现在,Memcheck 输出什么?答案是输出一个绝对丢失的错误。因为 p 是一个空指针,不指向任何分配的内存块,也不指向数组的非起始地址,所以仍然不会出现可到达和可能丢失的错误。

此时只有本地指针数据指向数组的首地址,但是我们在test04函数测试结束前并没有释放这块内存,所以在test04结束后确认本地指针数据丢失测试,程序有内存泄漏。

仍然可以到达的掩码肯定丢失了代码演示

最后,让我们演示一下由于未释放全局或静态指针而仍然可访问掩盖了肯定丢失的错误的情况。

下面的代码模拟了上面提到的隐藏在线服务内存泄漏。简单描述一下代码逻辑:首先,有一个RtcStreamMgr类型的全局指针,这个类的内部成员是一个流名到包缓冲队列的映射。接下来,构造一个流名称为 666、数据包缓冲区队列大小为 1 的键值对,并将其插入到映射中。最后我们来模拟一下删除map中流名称为666的元素时忘记删除对应的包缓冲队列的场景。

class RtcPacket {
public:
  RtcPacket(int seq, int len)
   : _seq(seq), _len(len) {}
  ~RtcPacket() {}
private:
  int _seq;
  int _len;
};
class RtcStreamMgr {
public:
  std::map>*>
    rtc_packet_map;
};
auto g_stream_mgr = new RtcStreamMgr();
void test05() {
  // 构造缓存数据包的map
  std::shared_ptr
    packet(new RtcPacket(1, 1024));
  autolist = newstd::list<
    std::shared_ptr>();
  list->push_back(packet);
  g_stream_mgr->rtc_packet_map["666"] = list;
  // 删除map元素,但未删除该元素对应的动态内存
  auto it = g_stream_mgr->rtc_packet_map.find("666");
  g_stream_mgr->rtc_packet_map.erase(it);
}
int main() {
  test05();
  return0
}

首先,在map元素被删除的时候并没有释放相应的动态内存,这显然会造成内存泄漏。其次,全局对象g_stream_mgr也是动态分配的内存,但是因为它的生命周期是进程级的,所以很多开发者不会在进程退出前主动释放它,即使我们原则上应该释放它。但是,问题出现了:

这使得大多数开发者认为他们的程序没有真正的内存泄漏问题,所以他们不会仔细阅读 long-length reacable 错误报告,他们将无法解决内存泄漏问题。

这让开发者可以一目了然地定位内存泄漏问题,轻松解决。

所以这就是上面提到的问题:在某些情况下,仍然可以到达的错误会掩盖肯定丢失的错误,从而使解决内存泄漏变得更加困难。

但是,这个被掩盖问题的作者只在开发机器上工作(CentOS,gcc 4.8.4,glibc 2.12,valgrind 3.11.@ ><@是在0)上转载的,在写这篇文章的时候准备再转载一次(因为某些原因,之前转载的开发机被回收了,只能在其他机器上转载),但是反正无法复制,现在也没用了。

不过,这也是一个好消息,这意味着无论是主动释放全局指针还是静态指针玩游戏应用程序错误 该内存不能为read,都可以精确定位真正的内存泄漏问题。

最后,完整的内存泄漏演示代码[1]已经提交到我的github,大家可以自行下载验证。

编译和使用 Valgrind

最后说说如何使用valgrind,很简单。首先通过 wget 命令下载 valgrind。

wget

然后执行 ./configure && make && make install 完成编译安装。最后,要运行 valgrind,只需执行以下命令。

valgrind --tool=memcheck --leak-check=full --show-reachable=yes --log-file=path_of_log path_of_bin

也可以不指定 --took=memcheck,因为 Memcheck 是默认工具。

运行valgrind可能不是一帆风顺,可能会出现如下错误:

valgrind:“不可能”发生了:LibVEX 调用了 failure_exit()。

发生这种情况时,在运行时添加命令行选项 --vex-guest-max-insns=2 将解决问题。

还可能出现以下错误:

valgrind:无法为平台“amd64-linux”启动工具“memcheck”:没有这样的文件或目录

发生这种情况时,我们需要在重新编译和安装 valgrind 之前执行 autogen.sh 脚本。

另外,还有几点需要注意:

在使用之前,需要确保你的可执行文件已经编译,使用生成调试信息的命令行参数-g,否则检查报告不会输出问题代码的具体行号。根据 Valgrind 的官方文档,它可以将可执行文件的速度降低 20 到 30 倍。因此,一般情况下,Valgrind 不能应用于压力测试场景。结束Memcheck检查的方式一般是发送一个SIGINT信号,即ctrl + c。不要发送 SIGKILL 信号结束进程,否则不会生成检查报告。

关于 Memcheck 输出信息和相关命令行的更详细和权威的介绍,以及 Memcheck 的检测原理,可以阅读 valgrind-memcheck 官方手册 [2]。

最后希望你写的程序能输出下图一样的Memcheck检测报告:没有泄漏,没有错误。

完善的memcheck检测报告

至此,本文结束,感谢您的阅读。

参考

[1]

valgrind_memcheck.cpp:

[2]

Memcheck:内存错误检测器:

© 版权声明
THE END
喜欢就支持一下吧
点赞0
分享
评论 抢沙发

请登录后发表评论