前言
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:
不想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的使用方面的文档也很多,贴几个参考:
使用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这部分本身理解不是特别透彻,就不乱发表意见了,稍后发出代码,请轻喷~。
参考代码、组织和几个问题的讨论
参考代码
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里记录一个魔数,每次访问时先判断魔数能不能对上,毕竟大部分场合下,内存里的数据正好对上魔数的可能性极低。但这种问题还是防不胜防。当你提供的并非全套解决方案,而是只是一个小模块,且会被其它人代码级而非二进制级引用时,这个问题就可能会变得愈发突出。
多进程优化
所占内存优化
如果把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,相信我,这只是噩梦的开始……你永远无法知道用户会怎么使用你提供的库和接口……所以唯一能做的,只能是让他们无从选择。
跨模块是一个相当让人绞尽脑汁的特性,如果您自己完全可控的项目,建议还是不要在这个路上走得太远。毕竟,适应的才是最好的,您说呢?