• 简单的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,相信我,这只是噩梦的开始……吐舌头你永远无法知道用户会怎么使用你提供的库和接口……所以唯一能做的,只能是让他们无从选择。

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

  • 相关阅读:
    java rmi 入门实例
    flex“深拷贝”
    Cygwin 下部署Hadoop
    Hadoop学习原地
    Scribe+HDFS日志收集系统安装方法
    使用HDFS来进行线上应用的文件存储
    转:C++初始化成员列表
    转:为什么数据库选B-tree或B+tree而不是二叉树作为索引结构
    B树、B+树、B*树三者的对比详解
    转载:构造函数不能声明为虚函数,而构造函数可以。为什么?
  • 原文地址:https://www.cnblogs.com/noslopforever/p/2683615.html
Copyright © 2020-2023  润新知