技术开发 频道

内存泄漏检测程序的算法优化

  【IT168 文档】内存泄漏检测动态链接库(ResLeak),是白箱测试部门为了检查达梦服务器是否存在内存泄漏的情况而专门开发的。通过在达梦服务器代码中使用该动态链接库,可以很方便的检查出存在内存泄漏的情况,并能保存发生内存泄漏的堆栈信息,方便开发人员定位存在问题的代码。

  1、 工作原理

  内存泄漏检测动态链接库的工作原理,基于达梦服务器统一的内存管理架构:

  达梦服务器的内存管理系统有利于控制使用的内存总量,及加快内存释放和申请的速度。系统对不同的内存需求采用不同的方式,如系统缓冲区,由于在系统运行时大小不会改变,所以就直接利用OS的malloc/free调用,对大量的动态申请/释放, 则采用标准的“Buddy System”模式来管理内存池。对于前一种方式,统一调用函数

void* os_malloc(ulint n_size)

  从操作系统中分配指定大小的内存块,调用函数

void* os_free(void* p)

  释放指定首地址的内存块;对于后一种方式,系统首先通过前一种方式申请一块固定大小的内存池,单独对此共享内存池进行管理,达梦服务器退出时再调用os_free释放掉整个内存池,其他模块可以直接从该共享内存池上分配内存,统一调用函数

void* mem_malloc(ulint size_in_bytes)

  从共享内存池分配指定大小的内存块,调用函数

void mem_free(void* p)

  释放指定首地址的内存块,注意,这里释放内存并不是把该内存块返回给操作系统,而是返回给达梦的内存管理系统。

  由于达梦服务器的所有内存分配和释放的操作都统一在上面这四个函数中,这样就可以简单的修改这四个函数,在分配一块内存成功之后,保存其首地址、大小、堆栈跟踪等信息,在释放某一内存块时,删除其相关的保存信息;最后,在达梦服务器正常退出时,如果存在内存泄漏的情况,则把没有正常释放的内存块相关的保存信息打印出来,便于开发人员进行代码分析。

  2、 调用接口和数据结构

  内存泄漏检测动态链接库提供的头文件resleak.h定义了所申请内存块的相关信息结构:

typedef struct leak_struct leak_t;
struct leak_struct{
void
*   p;          //要跟踪的指针
char*   callstack[MAX_LEVEL];   //堆栈跟踪信息,MAX_LEVEL宏定义为10
int    level;         //堆栈深度
char        time[128];        //内存块申请时间
unsigned
int       size;                   //内存块申请大小
unsigned
int       thrdid;                 //当前线程ID
int                type;                   //申请方式,区分os_malloc和mem_malloc
int                nChanged;               //linux下用于堆栈跟踪信息转换的标志
struct leak_struct
* next;
};

  该数据结构定义的变量用于保存达梦服务器申请内存时的相关信息,并用一个单向链表串联起来。该单向链表的起始长度为1,在调用初始化接口leak_init函数时创建了一个头结点,当达梦服务器申请新的内存时,则创建新的信息结点,插入到该链表中,而在达梦服务器释放内存时,则遍历该链表,寻找到对应的信息结点后,将其从该链表中删除。

  头文件resleak.h中还申明了动态链接库的调用接口,如下:

  1) 初始化函数接口:

leak_t*
leak_init();

  接口功能:创建信息结点链表的头结点,和多线程互斥变量,并返回头结点地址。

  2) 退出清理函数接口

void
leak_deinit(leak_t
* head);

  接口功能:释放链表中所有信息结点和互斥变量

  3) 添加信息结点

BOOL
add_leak_point(leak_t
* head, void* p,unsigned int nSize,int nType);

  接口功能:申请内存成功后,向信息结点链表中添加该内存块相关的信息结点。

  4) 删除信息结点

BOOL
release_leak_point(leak_t
* head, void* p,int nType);

  接口功能:释放内存成功后,在信息结点链表中删除该内存块相关的信息结点。

  5) 保存未释放信息结点的堆栈信息到文件中

BOOL
write_leak_to_file(leak_t
* head, char* filename);

  接口功能:遍历信息结点链表,打印当前未释放的每一个内存块的堆栈跟踪信息。

  3、 优化原因

  内存泄漏检测动态链接库(ResLeak)的设计目标之一:不仅可以在Windows环境下使用,也可以在Linux环境下使用,而且对达梦服务器的性能影响很低。在Windows环境下,通过调用Windows的库函数,可以方便快速的获取堆栈跟踪信息,达梦服务器调用该库带来的性能影响可以忽略不计。在Linux环境下,达梦服务器申请内存成功后,在内存泄漏检测动态链接库的add_leak_point接口中通过调用backtrace函数和backtrace_symbols函数来获取堆栈跟踪信息,获取的每一层堆栈信息格式如下所示:

  ./应用程序名 [函数地址]

  具体的例子如下:

./dmServer [0x804869c]

  也就是说获取的只是堆栈跟踪函数的内存地址,并不是我们想要的具体函数名称和源代码定位行数。要想获取这些信息,还必须执行终端命令来进行转换:

addr2line -f 0x804869c -e dmServer > filename1

  该命令把指定应用程序对应内存地址的具体函数信息保存到filename1文件中,保存信息的格式如下例所示:

os_malloc
/root/src/pub/err.c:69

  filename1文件中的第一行保存的是具体的函数名称,第二行是源代码的定位行数。因此,在Linux环境下获取堆栈跟踪信息的步骤应该包括以下几步:

  1) 调用backtrace函数和backtrace_symbols函数获取堆栈地址信息;

  2) 从每一层的堆栈地址信息中解析出函数的内存地址;

  3) 调用system函数执行addr2line命令,把内存地址转换成具体的函数信息,保存到临时文件中;

  4) 读取临时文件,从中解析出函数名称和源代码定位信息,保存到信息结点中。

  具体的代码如下:

void* trace[ MAX_LEVEL ];
char** messages = NULL;
int trace_size = 0;
int i=0;
trace_size
= backtrace( trace, MAX_LEVEL );
messages
= backtrace_symbols( trace, trace_size );  //获取堆栈地址信息
for( i = 0; i < trace_size && i< MAX_LEVEL; i++ )
{
 p_leak1
->callstack [i] = (void *)malloc(strlen(messages[i])+1);
 sprintf(p_leak1
->callstack [i],"%s",messages[i]); //保存中间结果-函数地址信息
 getfunc(p_leak1
->callstack [i]);// 执行getfunc子函数转换成具体函数信息
}
 p_leak1
->level = i;

  采用此逻辑实现内存泄漏检测动态链接库(ResLeak)后,在Linux环境下进行调用测试,发现达梦服务器在启动阶段由于频繁分配内存导致大量调用add_leak_point接口来向信息结点链表中插入新的结点,启动速度极慢。在Windows环境下正常启动只需要一分钟左右,而在Linux环境下,却需要一小时甚至更多时间,程序性能极其低下。因此,必须想办法对程序算法进行优化处理,否则内存泄漏检测动态链接库就无法在Linux下正常使用。

  4、 优化方法

  在对程序进行优化前,首先要了解该程序的性能瓶颈所在之处。通过分析,发现获取堆栈跟踪信息的第3步——调用system函数执行addr2line命令的时间开销最大。但是,这一步是把函数内存地址转换成函数具体信息的必由之路,目前在Linux环境下还没有其他替代方案。既然无法避免,那么如何提高这一步的效率也就成了优化程序性能的关键。

  优化方式一:并行处理

  通过分析,我们发现,在第1步中调用backtrace函数和backtrace_symbols函数可以一次获取多层堆栈地址信息,而每一层堆栈地址信息的转换处理可以互不干扰。因此,可以考虑用多线程方式来同时处理每一层堆栈信息,需要注意的一点是,每个线程都需要使用单独的文件来保存转换后的信息。于是修改代码如下:

pthread_t pid[MAX_LEVEL]; //定义多线程对象
for( i = 0; i < trace_size && i< MAX_LEVEL; i++ )
{
 p_leak1
->callstack [i] = (void *)malloc(strlen(messages[i])+1);
 sprintf(p_leak1
->callstack [i],"%s",messages[i]);
//并发执行getfunc子函数来获取具体函数信息
 
if(pthread_create(&pid[i],NULL,getfunc,(void *)&(p_leak1->callstack[i]))!=0)
 
break;
 }
p_leak1
->level = i;
for( i = 0; ilevel; i++ )
pthread_join(pid
[i],NULL); //等待每一层的转换处理线程结束

  第3步中调用system函数执行addr2line命令来保存具体函数信息到文件中,每个线程使用的文件都不能冲突,因此可以最多用10个文件来同时进行转换操作。

  通过并行处理,理论上最高可以提高程序10倍的性能。修改后进行调用测试,发现程序启动速度得到有效的提高,但是仍然需要20~30分钟的时间,其性能依然达不到我们的需求。

  优化方式二:延后处理

  通过分析,进一步发现,达梦服务器申请内存时插入的很多信息结点,在释放的时候又会删除掉,这些结点都进行了转换操作。而之所以要进行堆栈地址信息的转换,仅仅只是为了保存未释放信息结点的详细堆栈信息到文件中,对于那些保存之前已经删除的信息结点,进行的转换过程都是多余的操作。因此,可以在申请内存时调用的add_leak_point接口中把转换过程的2~4步省略掉,只在达梦服务器调用write_leak_to_file函数接口保存当前未释放信息结点的堆栈信息到文件中时,才对信息结点中的中间结果进行转换,每个信息结点转后之后都将nChanged标识置为1,避免下一次保存时重复操作,这样就可以尽量过滤掉那些中途删掉的信息结点的转换开销。

  修改程序,把转换过程的2~4步从内存泄漏检测动态链接库的add_leak_point函数接口转移到write_leak_to_file函数接口中,修改后代码如下:

if(!p_leak1->nChanged)
{
  pthread_t pid
[MAX_LEVEL];
  
for(i=0;ilevel;i++)
    {
      
if(pthread_create(&pid[i],NULL,getfunc,(void *)&(p_leak1->callstack[i]))!=0)
        
break;
      
else
        p_leak1
->nChanged = 1; //设置已转换标志
    }
    
for( i = 0; ilevel; i++ )
      pthread_join(pid
[i],NULL);
}

  通过这次修改,在达梦服务器没有调用保存函数接口之前,所有的转换过程都省略掉,有效提高了达梦服务器的启动速度和执行速度,基本做到与Windows环境下的性能相当。唯一美中不足的地方在于:在达梦服务器运行期间,手工输入命令调用write_leak_to_file函数接口保存信息结点到文件时,第一次调用过程中转换的时间要花费10分钟左右,后续的调用时间虽然明显减少,但仍然不能满足我们使用要求。

  优化方式三:以空间换取时间

  最后,仔细分析一下从函数内存地址转换到函数名称和源代码定位行数的过程,我们可以发现,虽然达梦服务器在执行的过程中分配内存的次数可能非常巨大——这也意味着前面的程序进行堆栈地址信息转换的次数也非常巨大,但是,达梦服务器的函数总个数是非常有限的,也即函数内存地址的总数量是非常有限的。那么,我们完全可以在内存中建立一个函数内存地址与具体函数信息的映射链表,这样就不需要每次都调用效率低下的system函数来执行addr2line转换命令,而是直接在映射链表中寻找已有结果,只有在映射链表中不存在对应的函数内存地址记录时,才进行此操作,并把结果保存到映射链表中,处理速度将得到明显提高。实现的流程如下:

  1、达梦服务器调用write_leak_to_file函数接口来保存信息,遍历每一个信息结点;

  2、如果当前结点的堆栈地址信息已经转换过,则转到第5步;

  3、如果当前结点的堆栈地址信息没有经过转换,则解析每一层的函数内存地址;

  4、如果函数内存地址在映射链表中不存在,则调用system函数来执行addr2line转换,并把转换结果保存到映射链表中,避免下次重复转换;如果映射链表中存在对应地址,则直接取相关的函数名称和源代码定位行数;

  5、把转换后的信息结点保存到文件中。

  修改后重新进行测试,发现达梦服务器第一次调用write_leak_to_file函数接口时花费时间少于2秒,在前面的基础上又提高了近百倍,已经能够很好的满足我们的使用要求。可见,通过增加映射链表的管理功能,虽然占用了更多的内存,也增加了程序的复杂性,但是对于性能的提升还是很有帮助的,这就是典型的以空间开销换取时间开销的例子。

  总结

  通过上面三种优化方式,我们一步一步把程序的性能提高到了理想的程度。这给我们的教训是:仔细分析小问题有时可以带来巨大的实际好处。寻找和分析程序的性能瓶颈,以及想方设法有效避免或减少它带来的性能影响,完全可以把一个效率极其低下的程序的性能提升到理想的程度。

0
相关文章