C++用valgrind排查内存泄露

前言

C/C++运行高效,不管是操作系统内核还是对性有要求的程序(比如游戏引擎)都要求使用C/C++来编写,其实C/C++强大的一点在于能够使用指针自由地控制内存的使用,适时的申请内存和释放内存,从而做到其他编程语言做不到的高效地运行。但是内存管理是一把双刃剑,用好了削铁如泥,用不好自断一臂。在申请堆上内存使用完之后中如果做不到适时有效的释放,那么就会造成内存泄露,久而久之程序就会将系统内存耗尽,导致系统运行出问题。就如同你每天跑去图书馆借一打书籍而不还,直到图书馆倒闭为止。
C语言中申请内存和释放内存的方法是使用 malloc和free。
C++中能兼容C,所以也能使用malloc和free,面向对象的情况下使用的则是new和delete,能够自动执行构造函数和析构函数。

如何发现内存泄露

内存泄漏一般不会造成程序崩溃,所以比较隐晦,但是发现内存泄露的方法也很简单,就是让程序运行一段时间,然后查看内存先后变化,通过任务管理器(windows)或者top(unix/linux)来监控某个进程的内存变化是比较方便的,有些程序的内存泄露比较小,但是发现它的内存泄露也都是时间问题。这里列出一个内存泄漏的程序的内存变化时间图,可以看出其内存占用总体上是呈递增的

内存泄漏较大的情况下,机器cpu使用率飙升,cpu的wait百分比增加,通过top可以看到swap内存使用量不断增加,kswap进程不时出现在进程列表当中。
linux中可以通过watch -n1 "ps -o vsz -p ",实时看到特定进程的内存使用量不断地增加

valgrind定位内存泄露

Valgrind是一个用于构建动态分析工具的工具框架。Valgrind工具可以自动检测许多内存管理和线程错误,并详细分析程序。您还可以使用Valgrind来构建新工具。valgrind的默认工具就是内存检测,除此之外还有其他的监控功能,这里不做太多讨论。

valgrind的安装

debian/ubuntu派系的linux下安装方法:

itcast@ubuntu:~$ sudo apt install valgrind

redhat/centos下

[itcast@localhost ~]$ sudo yum install valgrind

valgrind的使用

这里是测试的一个C程序例子

#include 
#include 
void func()
{
 //只申请内存而不释放
    void *p=malloc(sizeof(int));
}
int main()
{
    func();
    getchar();
    return 0;
}

编译程序

gcc -o ./a.out ./main.c

使用valgrind命令来执行程序同时输出日志到文件

valgrind --log-file=valReport --leak-check=full --show-reachable=yes --leak-resolution=low ./a.out

–log-file=valReport 是指定生成分析日志文件到当前执行目录中,文件名为valReport
–leak-check=full 显示每个泄露的详细信息
–show-reachable=yes 是否检测控制范围之外的泄漏,比如全局指针、static指针等,显示所有的内存泄露类型
–leak-resolution=low 内存泄漏报告合并等级

最后执行输出的内容如下
报告解读,其中==98725==是指进程号,如果程序使用了多进程的方式来执行,那么就会显示多个进程的内容
第一段是valgrind的基本信息

==97825== Memcheck, a memory error detector
==97825== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==97825== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==97825== Command: ./a.out
==97825== Parent PID: 97615
==97825== 

第二段是对堆内存分配的总结信息,其中提到程序一共申请了3次内存,其中2次释放了,2052字节被分配

==97825== 
==97825== HEAP SUMMARY:
==97825==     in use at exit: 4 bytes in 1 blocks
==97825==   total heap usage: 3 allocs, 2 frees, 2,052 bytes allocated
==97825== 

第三段的内容描述了内存泄露的具体信息,其中有一块内存占用4字节,在调用malloc分配,调用栈中可以看到是func函数最后调用了malloc,所以这一个信息是比较准确的定位了我们泄露的内存是在哪里申请的

==97825== 4 bytes in 1 blocks are definitely lost in loss record 1 of 1
==97825==    at 0x4C2DB8F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==97825==    by 0x4006C7: func (in /home/itcast/workspace/memleak/a.out)
==97825==    by 0x4007E8: main (in /home/itcast/workspace/memleak/a.out)

最后这一段是总结,4字节为一块的内存泄露

==97825== 
==97825== LEAK SUMMARY:
==97825==    definitely lost: 4 bytes in 1 blocks
==97825==    indirectly lost: 0 bytes in 0 blocks
==97825==      possibly lost: 0 bytes in 0 blocks
==97825==    still reachable: 0 bytes in 0 blocks
==97825==         suppressed: 0 bytes in 0 blocks
==97825== 
==97825== For counts of detected and suppressed errors, rerun with: -v
==97825== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)

mallinfo手动打印内存信息定位

通过valgrind的log可以基本定位到内存泄漏的位置,在valgrind的log中可以清楚地看到,new和delete或者malloc和free不能一一对应:
第二种方式是通过打log的方式来进行观察,在每次调用完可疑的接口之后都可以调用mallinfo函数来打印当前进程所占用的内存数量,如果通过log文件发现当前进程的内存使用量在不停地增加,则可以认为可疑的接口是造成内存泄漏的元凶。通过这个方式可以不断地缩小怀疑的范围,直到最终定位到泄漏的代码。
对以上程序进行必要调整

#include 
#include 
#include 
void func()
{
    printf("func\n");
    void *p=malloc(sizeof(int));
 //free(p);
}
void displayMallInfo()
{
    struct mallinfo info  = mallinfo();
    printf("===========================\n");
    printf("arena:%d\n",info.arena);
    printf("ordblks:%d\n",info.ordblks);
    printf("smblks:%d\n",info.smblks);
    printf("hblks:%d\n",info.hblks);
    printf("hblkhd:%d\n",info.hblkhd);
    printf("usmblks:%d\n",info.usmblks);
    printf("uordblks:%d\n",info.uordblks);
    printf("fordblks:%d\n",info.fordblks);
    printf("keepcost:%d\n",info.keepcost);
    printf("===========================\n");
}
int main()
{
    displayMallInfo();
    func();
    displayMallInfo();
    getchar();
    return 0;
}

重新编译运行程序可以看到如下信息:

itcast@ubuntu:~/workspace/memleak$ ./a.out 
===========================
arena:0
ordblks:1
smblks:0
hblks:0
hblkhd:0
usmblks:0
uordblks:0
fordblks:0
keepcost:0
===========================
func
===========================
arena:135168
ordblks:1
smblks:0
hblks:0
hblkhd:0
usmblks:0
uordblks:1072
fordblks:134096
keepcost:134096
===========================

使用这种方法来跟综某个函数的内存分配需要对linux的虚拟内存分配的原理非常熟悉,malloc在linux中的实现也都是调用linux的系统接口brk、sbrk、mmap等来实现内存申请
释义:
arena 在malloc中使用sbrk分配内存的总大小(单位字节)
ordblks 普通(即非fastbin)空闲块的数量。
smblks fastbin free块的数量(参见mallopt(3))。(该字典未被使用)
hblks 当前使用mmap(2)分配的块数。(见的讨论M_MMAP_THRESHOLD在mallopt(3) 。)
hblkhd 当前使用mmap(2)分配的块中的字节数
usmblks 分配空间的“高水位线” - 即最大值(该字段未被使用并且总为0),分配的空间量。这个领域是,仅在非线程环境中维护。
fsmblks fastbin空闲块中的总字节数。(该字典未被使用)
uordblks 使用中分配使用的总字节数。
fordblks 空闲块中的总字节数。

监控系统内存相关调用

一般我们的运营环境中都没有安装valgrind,所以这种情况无法用valgrind来跟踪,所以这种情况一般只能用打log的方式来跟踪,如果无法打印log,可以用另外一个比较简单的方式来进行:
直接strace发生内存泄漏的进程。

strace -p 

通过strace的内容可以看到,发生大量内存泄漏的进程都会有不少申请系统内存的系统调用,如下:

itcast@ubuntu:~/workspace/memleak$ sudo strace -p 97495
[sudo] password for itcast: 
strace: Process 97495 attached
restart_syscall(<... resuming interrupted nanosleep ...>) = 0
mmap(NULL, 1052672, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fa933374000
nanosleep({1, 0}, 0x7fff275b1160)       = 0
mmap(NULL, 1052672, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fa933273000
nanosleep({1, 0}, 0x7fff275b1160)       = 0
mmap(NULL, 1052672, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fa933172000
nanosleep({1, 0}, 0x7fff275b1160)       = 0
mmap(NULL, 1052672, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fa933071000
nanosleep({1, 0}, 0x7fff275b1160)       = 0
mmap(NULL, 1052672, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fa932f70000
nanosleep({1, 0}, 0x7fff275b1160)       = 0
mmap(NULL, 1052672, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fa932e6f000
nanosleep({1, 0}, 0x7fff275b1160)       = 0
mmap(NULL, 1052672, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fa932d6e000
nanosleep({1, 0}, 0x7fff275b1160)       = 0
mmap(NULL, 1052672, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fa932c6d000
nanosleep({1, 0}, 0x7fff275b1160)       = 0
mmap(NULL, 1052672, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fa932b6c000
nanosleep({1, 0}, {0, 297924314})       = ? ERESTART_RESTARTBLOCK (Interrupted by signal)
--- SIGINT {si_signo=SIGINT, si_code=SI_KERNEL} ---
+++ killed by SIGINT +++

可以看到每次程序调用malloc的时候,系统的api其实是调用了mmap来申请虚拟内存。
这里根据系统调用brk前后几个系统调用来大概确定下申请内存在代码中的大概位置,然后再结合代码进行排查,才可以确定泄露的地方。

重写malloc实现自定义跟踪

如果我们能够在自己程序的每个malloc和free的调用中添加一些自定义的代码,配合一些跟踪记录的实现手段,那么也可以自行实现内存的泄露跟踪,以下是一个例子

#define free(p) {\
    printf("#%s:%d:%s():free(0x%lx)\n", __FILE__, __LINE__,\
            __func__, (unsigned long)p); \
    free(p);\
}
#define malloc(size) ({\
    void *ptr=malloc(size);\
    printf("#%s:%d:%s():malloc(0x%lx)\n", __FILE__, __LINE__,\
            __FUNCTION__, (unsigned long)ptr); \
    ptr;\
})

编译程序后执行可以看到如下的日志输出

...
#main.c:19:func():malloc(0x1b61420)
#main.c:20:func():free(0x1b61420)
...

如果每次malloc的过程中能够将这些地址信息使用诸如链表之类的容器来进行记录,每次free都从中去除记录,最终程序执行结束也可以打印没有被释放的内存。但是这种方法的限制是只能对自己写的代码生效,如果调用了第三方库,而第三方库中使用malloc和free,那么这种方法无法跟踪第三方库的内存泄露问题。
C中可以使用定义宏的方式将malloc和free重新定义,C++中也可以重载new和delete操作符实现相同的功能,这里不做太多讨论。

总结

以上几种方式都比较适合内存泄漏比较快,每次泄漏比较多情况,如果内存泄漏每次只泄一点点,这个是非常难跟踪到的,只能依靠工具valgrind来协助或者依靠代码的阅读来进行排查。
另外的,STL造成的内存泄漏也是一个比较容易忽略的问题,也是最难排查的,之前我们就发生过使用string.append()的方式来保存日志信息,不停地append,然后在最后一步写到本地磁盘,并释放掉申请的内存,但是由于本地磁盘的相关目录没有创建,造成写入失败,导致内存也没有释放,从而引起了内存泄漏,服务器宕机。

你可能感兴趣的:(工具,C++,Linux,内存泄漏,操作系统,linux)