简单的Memory leak跟踪

前言

C++编码中Memory Leak是一个很讨厌却又挥之不去的话题,最近由于引入了GC,为了验证GC是否确实正常free了内存,于是先提供了一个内存分配的Tracer。

与分配器不同,分配器主要解决的是两个问题:

1、性能,池式分配往往能提供比直接Virtual Allocation快得多的效能。据说这一原则在Vista后无效了,因为微软修改了VA的实现机制,只是听说,没有实际测试过。

2、碎片,避免大量散内存分配冲散了本身连续的内存,导致后面内存因为没有连续区块而分配不出来。

我们的跟踪器Tracer主要是想解决一个问题,就是啥时候分了内存,啥时候删的,程序退出时删除掉没。

 

方案一:DEBUG_NEW方案

基本上,这个主题之前也有很多前辈都写过了,这里也没有超越前辈们的什么方案,只是自己做这个模块时的心得和理解。

这个问题有两个比较成型的方案,一个就是MFC的DEBUG_NEW方案,MAX SDK用的也是这个方案。

其实原理很简单,我们只要能获取到当前语句的文件名和行号,然后new的时候,我们让我们的Tracer记录一下当前的地址,并与文件名和行号绑定,然后,delete的时候,按照地址来去掉这条记录即可。

实现起来如何实现呢?

 

这个问题无非是要解决两个问题,首先,new这个东西,我们需要接管下来,接管下来后才能记录我们自己的信息。

C++的operator new是可以有不同形式的重载的,比如:

 void* operator new (size_tInSize, const char* InLogMsg)

 {

     std::cout << InLogMsg <<std::endl;

     return ::malloc(InSize);

 }  

调用这个重载时,new就要这么调了:

int* p = new ("我正在分配内存") int;

注意,new和operator new不是一回事儿,而提供了特殊形式的operator new后,需要相应地提供类似的operator delete,否则会有Level 1 warning。

对这个问题有兴趣可见本文最后的补充1,它与本文的主题无关,暂时无视。

第二个问题是,我们如何获知当前语句的文件和航好呢?

C++可以用下面的方法来取得当前语句所在的文件和行号:

std::cout << "当前文件为:" <<__FILE__  <<"。当前行号为:" <<  __LINE__<<std::endl;

准备工作齐活儿了,准备开始吧!

 

首先,我们需要提供一个Tracer来记录文件名和行号这些信息:

class TracerFileLn
{
public:
    TracerFileLn& singleton();
private:
    struct _AllocInfo
    {
        const char*filename_;
        size_tfileLn_;
    };
    typedefstd::hash_map<qword, _AllocInfo>alloc_hash;
    alloc_hashallocInfoHash_;
public:
    void traceMalloc(void* InPtr, const char* InFilename, size_t InFileLn)
    {
        _AllocInfosAllocInfo = { InFilename, InFileLn };
        allocInfoHash_.insert( alloc_hash::pair((qword)InPtr, sAllocInfo) );
    }
    void traceFree(void* InPtr)
    {
        auto it = allocInfoHash_.find(InPtr);
        allocInfoHash_.erase(it);
    }
};

所以,我们能不能提供下面这个new的重载呢?

void* operator new(size_tInSize,const char* InFilename,size_t InFileLn)

然后,operator new这么实现:

void* operator new(size_t InSize, const char* InFilename, size_t InFileLn)
{
    void* pPtr = ::malloc(InSize);
    TracerFileLn::singleton().traceMalloc(pPtr, InFilename, InFileLn);
    return pPtr;
}

然后,operator delete需要这么实现:

void operator delete(void* InPtr)
{
    TracerFileLn::singleton().traceFree(InPtr);
}
void operator delete(void* InPtr, const char* InFilename,size_t InFileLn)
{
    TracerFileLn::singleton().traceFree(InPtr);
}

记得 new[] 和 delete[] 也要做相应的实现。

但这样的话,咱们就不能再使用C++的原生new了,而是必须要用新的new。

int * pPtr = new(__FILE__,__LINE__)int;

 

所有使用new的地方都要这么写,太麻烦了?不过这难不倒我们,有宏呢,就跟MFC DEBUG_NEW那样:

#define DEBUG_NEW  new (__FILE__,__LINE__)

#define new DEBUG_NEW

然后再用new 的地方实际上就会被替换为new (__FILE__,__LINE__) 了。

int * pPtr = newint;

继续这么写就可以,只是在这句话之前,必须要确保其前面有#define new DEBUG_NEW的宏声明。

 

信息Trace下来了,程序结束后,只需要看hash里还有哪些AllocInfo,一个个Dump出来就可以查到相应的内存泄露了。

看起来很方便吧大笑?方不方便,后面还会继续展开,敬请期待。

 

 

补充1:

提供新格式的operator new后,为何要提供相应的operator delete呢?
因为C++标准规定,object的构造是可以有异常的,如果构造有异常,那么当前object就应该被回收。如果你对这个object的new使用了自定义格式,那么在构造函数异常时,C++回收object也会使用对应自定义格式的delete,所以相应的operator delete一定要提供,否则这种情况下内存就不会回收了。
 
但如果一切正常,手动调用delete时,调用的是哪个delete呢?
答案是标准的delete:
void operator delete(void* InPtr);
 
另外,new 和 operator new 不是一回事儿?
是的,new是C++关键字,new做的事情是什么呢?
1,调用对应形式的operator new,分配内存。
2,调用placement new,也就是对象的构造函数,构造对象。
也就是new有两步,第一步是operator new,operator new只管分配内存,不管别的。
 

不想Tracer的场合

上篇文章我们大概介绍了一下DEBUG_NEW的原理和实现。

上篇的TracerFileLn,我们用一个hash_map来提供了Trace功能。

这个中间可能会存在一个潜在的陷阱,在进入下章前,我们需要把这个潜在的陷阱给灭了。

 

如果重载C++原始的operator new,也加上Trace会如何?

 

void* operator new(size_t InSize)

{

    void* pPtr = ::malloc(InSize);

    TracerFileLn::singleton().traceMalloc(pPtr, "<Null>", -1);

    return pPtr;

}

 

会stack overflow!

为什么呢?

一旦重载了原生operator new,C++在调用::operator new的时候会优先使用用户提供的版本,结果就是:

hash_map的insert里,分配内存也用到了::operator new,记得C++ 默认allocator的实现吗?allocator::allocate的实现调用的就是::operator new!

而这个operator new被我们重载了,里面又调用了traceMalloc,于是:

new  -> operator new -> traceMalloc -> insert -> operator new -> traceMalloc -> insert -> …………………………

 

所以,为了要解决这个问题,必须实现一个不再调用::operator new版本的allocator。

具体的实现方法就不多说了,请参考侯捷翻的那本STL书,上面写的再清楚不过了。

把operator new系调用改成malloc / free系调用即可。

 

同样的,如果有哪些STL的容器不想进行trace的话,只要用自己这个版本的allocator替代掉即可。

另外就是,如果自己写的类不想进行Trace呢?

也很简单,C++类,如果自己实现了operator new,则new会优先调用类自己提供的operator new,所以,让这个类从下面这个类派生、或者自己实现着几个方法即可:

class UseSystemMallocForNew
{
public:
	void* operator new( size_t Size )
	{
		return ::malloc( Size );
	}
	void operator delete( void* Ptr )
	{
		::free( Ptr );
	}
	void* operator new[]( size_t Size )
	{
		return ::malloc( Size );
	}
	void operator delete[]( void* Ptr )
	{
		::free( Ptr );
	}
};

  

方案2:Dbghelp

dbghelp这个方案比较复杂,速度也比较慢,不过用在Trace的场合也不算太糟糕。

原理是,dbghelp.lib、dbghelp.h提供了一大堆获取当前调用栈信息(ESP、EBP),并通过这些调用栈,配合上相应模块的pdb文件,得出当前的调用模块(dll)、调用函数、调用行和指令。

具体的原理就不再废话了,网上dbghelp的使用方面的文档也很多,贴几个参考:

HOWTO: Dump Call Stack

一个调试器的实现(五)调试符号

使用DbgHelp获取函数调用堆栈之inline assembly(内联汇编)法

 

谈一下实现的原理:

还是截获new,但不需要提供new的特殊版本了,劫持全局new即可。

每次new之后,使用dbghelp功能来获取当前Call Stack,并剔除掉从new到Tracer的这几级Stack(就把最上面几个Stack扔掉就行,具体扔掉几个,根据实现不同而不同,我的实现从new开始Call了三层,所以扔掉3层即可,具体的可以大家自己来)。

建立一个hash,key仍然是分配的内存地址,value这里,有个优化的方案。因为CallStack比较大,如果要每次都存当前的CallStack,Tracer最后占的内存就太多了。但注意一点,new虽然在一个程序中能调用成百万上千万次,但new所在的地方,所可能出现的Call Stack的数量却是有限的,可能撑死也就千、万这个数量级。所以,Call Stack一旦获取后,我们可以先将Call Stack,缓存到一个Vector里,并算出CRC放到另一个map里。然后,hash的value只要保存当前Call Stack ID即可。

下次获取Callstack后,先算CRC到map中查一查,如果已经有相应的Call Stack了,就用相应的Call Stack即可,否则再生成新的,这样空间就得到优化了。

其他的跟之前那个Tracer一样,delete时,从Tracer表中去掉相应地址的记录。程序结束后,看hash是否清空,没清空就dump即可。

dump时还需要使用dbghelp功能。

 

因为这种实现需要劫持C++原始的void* operator new(size_t InSize),所以,需要像上一章那样说的,所有的hash、vector都需要用我们自己提供的、取消了Tracer功能的allocator。traceMalloc的实现的过程中如果有其他对operator new和new的调用,也需要一并消灭掉。

dbghelp这部分本身理解不是特别透彻,就不乱发表意见了,稍后发出代码,请轻喷~。

 

参考代码、组织和几个问题的讨论

参考代码

摘录了相关的代码,在小生的CSDN资源站里,0分下载,链接如下。
转载请使用本资源连接。
 

Tracer的变种

Tracer稍加变化,就可以记录更丰富的信息。例如,首先不用hash了,直接使用一个list来记录,free时不再从hash里删除了,list只会越变越大,然后记录例如分配时间、销毁时间、分配大小、线程等等等等信息。这样子就可以将整个应用程序的内存处理给监控下来。
U3就使用了Dbghelp trace来记录当前应用程序的所有分配,一段时间内的,甚至是整个应用程序生命期的。这样做可以提供更多关于内存分配的信息,知道哪些时候、分配内存的调用过于集中,哪些时候,销毁内存的调用过于集中,还是分配和销毁都是平稳执行和发展的。
但是每次增加新的信息,都会让Tracer变得更慢。
 

File Line Tracer 和 Dbghelp Tracer 各自的优劣

首先,从性能上,File Line Tracer 所需的信息均来自编译期,运行时除了程序栈和hash之外不存在新的调用开销,而Dbghelp的信息则来自于运行时,开销自然比File Ln Tracer大得多。

然后,File Line Tracer需要define new,这会引入一些小麻烦,Dbghelp tracer则不需要这个东西。define new后面继续展开。

再然后,File Line Tracer只知道”分配内存的当前语句所在的文件和行号“,但DbgHelp还可以给出”分配内存的当前Call stack,更利于快速定位到错误的分配“。

 

File Line Tracer 的 define new 和因此带来的问题

define new最大的问题在于需要确保在new之前调用#define new DEBUG_NEW宏。

头文件包含关系比较乱的时候,这一点就比较难受。如果有预编译头,相对好办点,只要在预编译头的第一行加入这句话即可。但没有预编译头的时候,这就需要用户自己维护其正确性了。

否则,万一有.h里new,.cpp里delete这样的情况(或者相反),而define new又发生在这个.h之后,就极易发生误判的情况,某个new明明被删除了,但是没有记录下来,于是误报了内存泄露,或者某次删除发现删的不是相应的new……

头文件顺序真是C++永远的痛,伤不起啊伤不起……

 

Tracer优化

Cookie优化

无论哪个tracer,都增加了一些代码的开销。

如果内存分配器也是自己写的,这里就方便一点,内存分配的时候可以多在前分出一些小Cookie,在这些小Cookie里面记录所需的信息。然后需要这些信息的时候,只需要向前寻址若干字节,获取出Cookie,这样的性能是最高的,但会引入两个问题:

一,全套内存分配要自己做,dlmalloc、tlmalloc等成熟的第三方分配用不了了,这也是为什么我的例子代码里使用了独立的Tracer的原因。

二,要确认当前访问的内存是由本分配器分配出来的,一旦new与delete不配对,这种问题就会如雨后春笋般出现。一个不太好但是大部分情况下适用的方案是Cookie里记录一个魔数,每次访问时先判断魔数能不能对上,毕竟大部分场合下,内存里的数据正好对上魔数的可能性极低。但这种问题还是防不胜防。当你提供的并非全套解决方案,而是只是一个小模块,且会被其它人代码级而非二进制级引用时,这个问题就可能会变得愈发突出。

 

多进程优化

如果想避免hash的开销,还有一个办法就是用另外一个线程,将每次Trace的信息发送到其它进程去处理。由于消息可以入队,而处理可以在程序空闲时和退出时再处理,所以对程序运行时的开销影响就会减少。具体的方法,可以是写文件,可以是用TCP发到其它服务器,可以是写入共享内存,那就完全取决于您自己的意愿和实测结果了。
 

所占内存优化

如果把Trace按”变种“章节所说的,改成全截获,永不销毁,那么接下来要面临的问题就是,一旦分配多起来,这内存占用就呼呼地往上涨了。

这时也有方案,就是把Tracer的改成不在本进程处理,而是将消息通过TCP连接发给其它应用程序去截获和处理。但这样的话,对DbgHelper来说,发送的信息就必须得包括Call stack的全文信息。否则另一个应用程序如果得到的只是Stack标记,要反解出来就要麻烦很多很多。好在发送线程可以做在另一个线程立,而每个Call stack全文信息获取一次以后可以缓存下来,Call stack全文信息的数量相对于分配数总归是少的,不是吗?

 

Tracer的跨模块调用

Tracer跨模块后,就会变成比较头疼的问题。

如果所有的模块全都是您自己维护的,那么您倒是可以保证您的Tracer公平地在每个模块里使用,不会出现问题。

但是如果您制作的是一个可发布的、相对精炼且功能相对单一的可分发程序包时,Tracer就要万分小心了。

首先,用户未必希望开启此模块的Tracer功能,这就需要提供专门的MEMLEAK DEBUG版本。

然后,如果用户使用了Tracer,就要确保由Tracer new出来的内存,也要由Tracer监督其delete,这里就需要守住”本模块new本模块删“的原则,但是原则嘛,自然是说着容易做着难了。你中枪了木有?

再然后,如果用户不想使用Tracer,或者不想让用户使用Tracer,就需要在分发版本的include文件中排除掉Tracer,而.h里相应使用的就需要用宏屏蔽。这个应该大家都是这么做的,权当废话好了。

 

最后,如果用户想用你的Tracer,相信我,这只是噩梦的开始……吐舌头你永远无法知道用户会怎么使用你提供的库和接口……所以唯一能做的,只能是让他们无从选择。

 

跨模块是一个相当让人绞尽脑汁的特性,如果您自己完全可控的项目,建议还是不要在这个路上走得太远。毕竟,适应的才是最好的,您说呢?

posted @ 2012-09-13 16:44  天堂里的死神  Views(1978)  Comments(4Edit  收藏  举报