0%

C 内存泄露定位

内存泄露是我认为在C或C++项目上最难处理的一个bug,由于没有明显的现象,可能要到某一天,服务器内存耗尽后,申请不到新的内存,程序报错才能发现程序有内存泄露的现象。如果没有足够的监控或者维测的统计,很难发现内存泄露的原因。

什么是内存泄露

首先并不是说程序运行中有没释放的内存就是内存泄露。因为程序本身逻辑的运行,就需要一些内存用于记录信息。程序的运行,或多或少地会从堆中申请内存,但是一定保证,这个内存,还可以释放,还回去给操作系统。假如一个服务器程序为一个客户端提供服务时,需要申请一定的内存,这是程序本身的逻辑决定的。但当这个客户端完成服务,断开连接后,刚刚申请的那些内存一定要保证全部释放。简单的说,就是申请的内存,要能还回去。

为什么出现内存泄露

C或C++中,动态申请的内存,例如mallocnew申请处理的内存,但是没有释放,也没有记录下指向申请的那片内存的指针,最后无法释放这片内存。举个简单的例子

1
2
3
4
void GetMemory()
{
char *p = (char *)malloc(sizeof(char) * 100);
}

上面的函数从堆中申请了100个字节的内存,p是指向这片内存的指针。但是函数没有把p当成返回值提供给调用函数。GetMemory函数执行完后,p的值就不得而知了,既然指向内存的指针没了。那么想释放内存就无从下手了。只有程序没有结束,申请的这片内存就没法归还给操作系统。内存泄露一次两次不会有很大的影响,但是如果跑的是服务器的程序,日积月累,肯定会把内存耗尽,最终无法正确提供服务。

定位思路

明白为什么会内存泄露后,肯定首先瞄准的目标是申请内存的地方。你先要知道是哪里申请的内存。什么函数,函数里面的哪一行,申请的多大的内存。对于大的项目,申请内存的地方到处都是,直接走查代码就跟大海捞针一样,不科学。

首先我们需要做一个内存监控模块。简单来说就是一个hash表的结构。每次申请内存生成一个hash结点,插入到hash表中去。释放这片的内存的时候,从hash表中移除。如果程序运行过程中,这个hash表变得越来越大,你就要怀疑程序是不是有内存泄露的现象。

怎么设计这个hash表呢,最简单的方式就是把申请内存的地址addr作为hash表的key,每次申请内存的地址可以作为唯一标识这块内存的keyvalue的话看你想记录多少信息了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#define GetMem(size)   MyGetMem(size, __FUNCTION__, __LINE__)
#采用宏可以隐式地把函数名,行号等信息传递进申请内存的函数,方便记录

typedef sturct
{
void *addr
}Key;

typedef sturct
{
char funcName[64];
int line;
int size;
}Value;

struct HashNode
{
Key k;
Value v
}

你只需要实现这个hash表的接口,例如插入,删除,查找等接口。就可以实现一个简单的内存统计模块了。

其实不需要一定是hash表的数据结构,只要是方便查找的结构就可以了。可以底层用红黑树,平衡树什么跳跃表等等来实现。只要是一种查找速度快的数据结构即可。C++中可以直接用map这种数据结构。C语言就只能直接写了,苦逼啊。。。

这个内存监控的模块只能帮你发现泄露的内存是哪里申请的,具体为什么会泄露也只能自己看代码,开调试工具调试了。

似乎也有一些内存泄露定位的工具valgrind,但是由于我工作上的业务代码是跑在特定的嵌入式平台上的,根本跑不了这些东西,所有东西都要自己手写去实现,太痛苦了,真的羡慕那些开发的程序跑在X86上面程序员,有太多方便的工具了。

代码改进

其实代码编写的时候就有一些方法可以更好地管理申请的内存。方便理清逻辑,避免因为代码逻辑错误导致内存泄露。如果是服务端的程序。业务逻辑应该是比较单一的,需要面对的仅仅是高并发,但是每次并发的请求处理逻辑变化不大。所以内存使用是可以根据用户数来预估的。

首先是内存的管理,使用内存池的方式,并对内存做分类。可以采取类型和大小的两种分类。

这里举个例子:

根据使用内存的用途来分类。

  1. 报文存储内存
  2. 模块消息交互内存
  3. 实例区域内存

根据大小分类

  1. 64字节的内存1000个
  2. 128字节的内存500个
  3. 1024字节的内存2000个
  4. ……

发现的一些内存泄露的bug

以下代码均是伪码

  1. 申请的内存都要插入某个释放的队列保存起来的,但是插入释放队列是有条件。满足一定条件才能插入队列。但是原来的代码中对不满足插入队列的内存没有释放,导致内存泄露

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    void fun(queue)
    {
    buffer = GetMem() //申请内存
    if ()
    {
    insert(queue, buffer)
    }
    else
    {
    //这里缺少了free(buffer),因为没有放入释放队列,就要马上释放
    }
    }

    //业务逻辑处理完后要释放内存
    void freeQueue(queue)
    {
    //释放队列中所有结点内存
    }
  1. 多进程,使用共享内存通信。有两个进程会调用同一个函数申请内存,并把地址记录到共享内存的一个地方。可能导致相互覆盖。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    struct {
    Buffer *buffer
    } TaskData


    //入参是指向共享内存的指针
    void GetBuffer(TaskData *taskData)
    {
    if (taskData->buffer != NULL)
    {
    taskData->buffer = GetMem()
    }
    }


    进程A:
    GetBuffer(taskData)

    进程B:
    GetBuffer(taskData)

    两个进程都跑到line 9的时候,发现buffer是NULL,那就都会申请内存,最后导致后一个申请的地址覆盖了前一个申请的地址。申请的地址没记录下来,肯定没法释放了。

  2. 用一个数组来记录申请的内存。数组下标没有正确更新,导致新申请的内存覆盖了旧的内存。原意是申请到内存,记录在数组中,用memNum作为下标,然后memNum自增。但是会有异常流程导致内存记录到数组里面去了,但是memNum没增加,那下次进入这个函数,申请的内存就会覆盖掉前面的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    struct
    {
    Buffer sendMem[100]
    int memNum = 0;
    } SendPara

    void fun(SendPara sendPara)
    {
    buffer = GetMem()
    sendPara.sendMem[memNum] = buffer
    if (xxx) //遇到异常
    {
    return
    }
    memNum++
    }

总结

比较简单的情况,申请内存和释放内存都在一个函数里面。这样内存泄露肯定是因为函数没跑到释放return返回了。

复杂的情况。申请的内存需要经过多个函数处理,最后才释放。这个时候一般要关注,申请的内存是不是都记录在某个结构体,某个队列,某个数组里面。有没有相互覆盖的情况。我找的bug里大多是这种情况。核心的地方就是在于申请的内存有没有记录下来,在释放的时候还能不能找到那个地址。

至于那种根本就没写释放的。。。那就不知道代码评审是怎么过的了。。。