详谈内存管理技术内存池

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了详谈内存管理技术内存池相关的知识,希望对你有一定的参考价值。

  嗯,这篇讲可用的多线程内存池。

 

零、上期彩蛋:不要重载全局new

  或许,是一次很不愉快的经历,所以在会有这么一个“认识”。反正,大概就是:即使你足够聪明,也不要自作聪明;在这就是不要重载全局new,无论你有着怎样的目的和智商。因为:

class XXX{
public:
    XXX* createInstance();
};

  这是一个不对称的接口:只告诉了我们如何创建一个【堆】对象,但是释放呢??!  很无奈,只能假设其使用全局默认的delete来删除(除此之外,没有其他选择);这时,我为了某些目的,重载了全局delete(或许是为了监视、为了优化、为了...):

void operator delete(void* addr)
{
    ...
    //一些事情发生了
    ...
    std::free(addr);
}

  这是一种很自然的做法;但是,但是会崩的,在未来或现在的某日;其名为:堆错误。也就是崩在了堆上,原因也很简单:代码中有谁并不是使用std::malloc来分配内存的——比如说前面的那个【XXX】,我们谁也不知道它是分配在那个堆上面的:是默认的系统堆,还是VS-debug中的调试堆(此为坑点)。

  当然,我们可以足够小心;比如仔细考察每个对象的分配方式,对于非我们自己new出来的,给予特别的关怀。或者,也可以这样:

void operator delete(void* addr)
{
    ...
    //一些事情又发生了
    ...
    ::operator delete(add);
}

  我们最后使用了原来的全局释放方式;嗯,这是一种安全的方式。当然,这样的话,你可以自定义的部分,只有一种:监视。你不能够通过自定义的内存分配方式(比如将要讲的内存池),来优化。当然,如果没有那个【XXX】来搅局就好了;但,作为【全局】的操作,一旦修改了,你必须给予极其健壮的支持和保证。

  最后,因为是全局而隐式调用,你并不能够完全地控制,该操作什么时候是一定会被调用,什么时候却没有被调用(当你的代码作为lib/dll库被调用,而new重载没有被导出);假如,有同样一个聪明的人也重载了,那么当需要混用代码时(如lib/dll),你会觉得整个世界都不好了.......

  当然,这是个人见解;如果你执意用,建议使用宏的方式,去调用非全局版本的等价物。

 

一、什么是内存池?

  嗯,就是下面一坨代码:

struct BlockNode{
    BlockNode* next;
};

//创建一堆BlockNode
BlockNode* allocate(size_t index, size_t count);

//对外的分配内存接口
void* alloc(size_t size)
{
    size_t index = (size - 1)/8 + 1;
    BlockNode* data = freeList[index];
    if(data){
        freeList[index] = data->next;
        return data;
    }
    else{
        freeList[index] = allocate(index, /*一个合理的大数*/);
        return alloc(size);
    }
}

//对外的释放内存接口
void dealloc(void* addr, size_t size)
{
    //与alloc相反的操作(我懒)
}

  以上就是内存池本身的所有细节;至于allocate和dealloc,不难想象出来。

 

二、什么是多线程内存池?

  直白的翻译就是:支持多线程安全操作的内存池。当然,我们不能够通过加锁的方式来获得安全;否则,我们只会做的更糟(会比系统的慢....可能)。

  所以,这里我们需要用到下一篇将要讲到的技术之一:TLS(线程本地存储)。是的,我们将在每一个线程里创建这么一个内存池;这样,便不需要锁就能够自然地获得内存分配时的线程安全。那么,释放时呢?

//发生于线程A
void* addr = alloc(23);
...
...
//这里面发生很多很多的事情
...
...
dealloc(addr, 23);//这是哪里?是线程A吗?

  如果,你的代码支持多线程;那么,内存释放的时候,其绝对不会一定在原来分配时的线程!当然,我们可以将每段内存打上标记,来指明其出生在那个线程。嗯这是一个不错的失败的尝试;首先,其接下来的复杂度就会将你打垮(在不同的线程释放时,如何回到分配线程),其次,如果分配线程死了呢???(我相信聪明如斯的你,总会有办法的....)

  所以,我们需要一个合理且有效的模型:线程间内存池交换内存的模型——使用一个全局的共享内存池,然后各个线程内部的内存池,向其发起分配和释放的请求。这样,我们也就不在担心上面的问题了;我们可以通过这个全局池,来完成跨线程间的内存操作。

  当然,全局池需要加锁,这点毋庸置疑。为了减少加锁的消耗,我们可以缩短线程内部池的访问频率,比如:内部池的分配/释放频率与全局池的访问频率,比例在:10000:1,或更高。这样,通过均摊,最后加锁的消耗,几乎完全没有了(即使消耗1ms,现在均摊后也只有0.1us)。

  所以,现在的挑战就是:如何维持这个均摊比例?(因为,在畸形的分配中,会变成1:1甚至更低)

 

三、我们需要性能!

  没有更好的性能,那我们还造毛??所以,这里,最大的目的是保持住,我们预定的均摊比例。或者说,控制住线程内部池向全局池的访问频率。

  对于向全局池的分配申请策略;我们可以使用一个足够大的申请值:比如100000,或者10MB。

BlockNode* ThreadMemoryPool::allocate(size_t index, size_t size)
{
    //我们直接申请一个够大的
    BlockNode* result = globalPool.allocate(100000*8*(index + 1));
    return result;
}

  那么,在什么时候释放呢?比如,现在线程A申请了10MB的内存,那么在怎样的情况下才释放?释放什么?释放多少?这一直以来是一个盲区(对于我来说,数年来都没完全解决)。当然,聪明的你或许,马上就有了各种的方案。

  我们,为什么要释放???因为,其他线程没有内存可用了;而你,线程A正持有着100TB的内存。

 

四、我们需要均衡...

  我们不能够容忍,任何一个线程持有超过10MB的可用内存!!!所以,有了如下的方案:

void ThreadMemoryPool::dealloc(void* addr, size_t size)
{
    .....
    //我们就不要在意释放过程了
    ...
    if(listSize[index] > 1024*1024*10){
        globalPool.deallocte(freeList[index], listsize[index]);
    }
}

  在线程内部池的释放操作时,检测当前池是否有超过10MB的内存;如果有,那么我们就堕掉它!这时,便会有一个畸形的分配情形:

//线程A向全局池申请10MB
threadMemoryPool.allocate(index, 10MB);
//并内部消耗一个单位(当前持有10MB - 一个单位)
threadMemoryPool.alloc(...);
...
//线程A内部释放一个单位(当前持有10MB)
threadMemoryPool.dealloc(...);
...
//线程A内部释放一个单位(当前持有10MB + 一个单位 > 10MB)
threadMemoryPool.dealloc(...);

  总共进行了3次分配/释放操作,便向全局池返回了所申请的内存。这时均摊比例为3:2(内部操作3次,全局操作2次:申请+释放),其次全局池本身的任何操作都是消耗巨大的(比如那10MB内存是从何而来的,从系统),那么这个实际的比例会变成1:100甚至更低。

  当然,我们可以错开分配和释放的全局操作阈值,比如:分配1MB、释放10MB。这样,我们就有了10-1=9MB的余地,不会发生上面的情形。(当然,反过来绝对不行:分配10MB、释放1MB,可以自己想象。)

 

五、我们还需要什么?

  如果分配值和释放阈值不相等,那么,我们就有可能永远也没有机会回收小于释放阈值,但大于等于分配值的那部分内存。在最常见的情况下:线程A的所有分配释放操作,都在本地进行。

//线程A
for(i = 0 : 10000){
    data[i] = threadMemoryPool.alloc(size);
}
....
...
//线程A
for(i = 0 : 10000){
    threadMemoryPool.dealloc(data[i], size);
}
//没了

  在这之后在没有任何操作,那么,直到线程死亡;我们都不可能回收这段可用的内存!

  所以,我们需要分配值,足够的合理;也需要释放阈值足够的小,且能够维持均摊比例。当然,我们可以办到!我们只需要完全隔离当前内部池的持有的【分配】内存值,和【已释放】内存值。

void ThreadMemoryPool::dealloc(void* addr, size_t size)
{
    .....
    //我们依旧不要在意释放过程
    ...
    deadSize[index] += index*8;//使用了一个额外的死亡内存值
    if(deadSize[index] > 1024*1024*10){
        globalPool.deallocate(freeList[index], listsize[index]);
    }
}

  如此简单,却困扰了我如此之久.....这时,我们就可以随意地操作分配值和释放阈值;以维持一个我们所认为的合理的均摊比例。

 

六、我们还需要什么??

  可能有注意到了,我们维持了一种假象:我们的内存池可以回收。

  1、我们不能够向系统释放我们可能不会再用到的内存。(这对没有使用我们的内存池的部分代码和系统本身而言,就是内存泄漏!)

  2、可能大家有注意到了这个【index】,每个内部池,我们维护了数个不同大小节点链。而这些不同大小的链之间,我们是没有办法重复使用的。(这是内存池内部的泄露)

  是的,我们可能正在制造最大规模的内存泄漏;还是我们以一种不可避免的方式造成的(我们要用性能更高的内存分配)。从理论上来说,这是不可避免的;我们唯一能够做到的是,尽量避免上面的情况,演化成最糟糕的局面。

  所以,我们可能需要更加精细的模型了;我们要改造【全局池】!!使其能够支持一定程度上的:内存整理。

  所以,我们需要做一下两个改进:

  1、我们需要保存每一块从系统分配得到的大内存的地址(以可以释放)。

  2、我们需要一种算法,能够整理我们所有不用的内存;让其恢复到从系统分配时的状态(完整的大块内存)。

  这时,我们便可以完成之前的2个目标:向系统释放、节点链间的复用(通过释放给系统,而后再次从系统获取)。在我个人的内存池中是实现了类似的功能,所以,我相信,做到这点并不困难。

 

七、我们还需要什么???

  重要的事情说3遍......

  回到最开始的问题:为什么我们需要内存池?我们需要性能。所以,还有那些地方,值得我们关注;以获得更高的性能?

  有,还有很多!之前的向全局池的释放部分,有一个关键的细节,没有提到:我们释放的内存存在哪里?又怎么复用?有两种方案:

  1、如同线程内部池一样,维护一个链,将释放的部分,加入到链中(需要O(n));在分配时,从链中获取(同样需要O(n))。

  2、将该节点链整个打包,作为一个单独的链保存;在向内部池分配时,直接返回该链。(只有O(1))

  直观地,我们会选择第二种方案(即使,最初我们可能只会想到第一种)。但是,一旦使用第二种,我们将不能够控制每次线程所申请的内存大小:我们只能够返回一个可用的节点链,而并不能够保证是否是其所期望的大小。(我们最多只能够尽可能保证返回大于其期望值;而不能保证和期望值一致;否则会破坏O(1)的复杂度)

  嗯,我们丧失了一小部分控制均摊比例的能力。但,只要我们足够小心安排释放阈值,是不会发生什么畸形的情形。

  其次,分配时,我们可以再小心一些:不是直接申请1MB(可能只会用到很小一部分),而是按照某种增长策略来申请(比如:100、200、400....)。在能够维持均摊比例的前提下,我们可以做很多想做的事情。

  总结一下:我们需要足够大的分配值和释放阈值,以维持合理的均摊比例;而,我们又想要保留足够小的内存,以避免任何可能的内存泄漏。  嗯,这正是矛盾之处,也是我们所追求的。而剩下的,就只有一个:权衡。

 

PS:使用内存池后,一旦发生内存越界;其后果将是灾难性的,对于调试。

以上是关于详谈内存管理技术内存池的主要内容,如果未能解决你的问题,请参考以下文章

C++内存管理--详谈

实现高并发内存池

14.VisualVM使用详解15.VisualVM堆查看器使用的内存不足19.class文件--文件结构--魔数20.文件结构--常量池21.文件结构访问标志(2个字节)22.类加载机制概(代码片段

转内存管理内幕mallco及free函数实现--简易内存分配器内存池GC技术

C++内存管理-内存池3

Bitmap优化详谈