首页 > 代码库 > 详谈内存管理技术(二)、内存池
详谈内存管理技术(二)、内存池
嗯,这篇讲可用的多线程内存池。
零、上期彩蛋:不要重载全局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 =http://www.mamicode.com/ 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:使用内存池后,一旦发生内存越界;其后果将是灾难性的,对于调试。
详谈内存管理技术(二)、内存池