一、项目介绍
当前项⽬是实现⼀个⾼并发的内存池,他的原型是google的⼀个开源项⽬tcmalloc,tcmalloc全称 Thread-Caching Malloc,即线程缓存的malloc,实现了⾼效的多线程内存管理,⽤于替代系统的内 存分配相关的函数(malloc、free)。
这个项⽬会⽤到C/C++、数据结构(链表、哈希桶)、操作系统内存管理、单例模式、多线程、互斥锁 等等⽅⾯的知识。
1、池化技术
所谓池化技术,就是程序先向系统申请过量的资源,然后自己管理,当程序中需要申请内存时,不是直接向操作系统申请,而是直接从内存池中获取,释放内存时也不是将内存返回给操作系统,而是返回内存池中。
因为每次申请该资源都有较大的开销,这样提前申请好了,使用时就会非常快捷,能够大大提高程序运行效率。
在计算机中有很多使用这种池技术的地方,例如线程池、连接池等。
以服务器上的线程池为例,它的主要思想是:先启动若⼲数量的线程,让它们处于睡眠状态,当接收到 客⼾端的请求时,唤醒池中某个睡眠的线程,让它来处理客⼾端的请求,当处理完这个请求,线程⼜进⼊睡眠状态。
2、主要解决的问题
内存碎⽚分为外碎⽚和内碎⽚。外部碎⽚是⼀些空闲的连续内存区域太⼩,这些内存空间不连续,以⾄于合计的内存⾜够,但是不能满⾜⼀些的内存分配申请需求。内部碎⽚是由于⼀些对⻬的需求,导致分配出去的空间中⼀些内存⽆法被利⽤。
3、malloc
- C++中动态申请内存都是通过malloc去申请的,但实际上我们并不是直接去堆中获取内存的,而malloc就是一个内存池。
- malloc() 相当于向系统 “批发” 了一块较大的内存空间,然后“零售” 给程序使用,当全部使用完或者程序有大量内存需求时,再根据需求向操作系统申请内存。
二、定长内存池设计
1、开辟内存
- 使用malloc开辟一大块内存,让_memory指针指向这个大块内存
- _memory 设置为char* 类型,是为了方便切割时_memory向后移动多少字节数。
2、申请内存
- 将_memory强转为对应类型,然后赋值给对方,_memory指针向后移动对应字节数即可。
- 如果有存在已经切割好的小块内存,则优先使用小块内存。
3、释放内存
- 用类型链表的结构来进行存储。
- 用当前小块内存的头4字节存储下一个小块内存的地址,最后用_freeList指针指向第一个小块内存的地址(并不是将内存释放给操作系统)
- 所以开辟内存时,开辟的内存大小必须大于或等于一个指针类型的大小。
4、向堆申请页为单位的大块内存
- windows下:VirtualAlloc
- brk和mmapLinux下:brk和mmap
5、代码实现
#ifdef _WIN32 //如果32位则包含头文件 #include<windows.h> #else // #endif //定长内存池 //template<size_t N> //class ObjectPool //{}; // 直接去堆上按页申请空间 inline static void* SystemAlloc(size_t kpage) { #ifdef _WIN32 // 参数: 分配的区域的起始地址,区域的大小(字节),分配的类型,内存保护 void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); // kpage << 13 以页为单位进行内存分配 #else // linux下brk mmap等 #endif if (ptr == nullptr) throw std::bad_alloc(); return ptr; } template<class T> class ObjectPool { public: T* New() { T* obj = nullptr; //优先利用还回来的内存 if (_freeList) { void* next = *((void**)_freeList); //转为void**类型,变为下一个结点的指针 obj = (T*)_freeList; _freeList = next; } else { //剩余内存不够一个大小时重新开大块空间 if (_remainBytes < sizeof(T)) { _remainBytes = 128 * 1024; //_memory = (char*)malloc(_remainBytes); _memory = (char*)SystemAlloc(_remainBytes >> 13); //_remainBytes >> 13 单位从字节数转换为页数 if (_memory == nullptr) { throw std::bad_alloc(); } } obj = (T*)_memory; //根据编译器确定指针类型大小 size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T); _memory += objSize; //后移一个指针的大小指向实际的内存块 _remainBytes -= objSize; //实际内存大小 } //定位new,显示调用T的构造函数初始化 new(obj)T; return obj; } void Delete(T* obj) { //显示调用析构函数 obj->~T(); if (_freeList == nullptr) { _freeList = obj; //*(int*)obj=nullptr; *(void**)obj = nullptr; } else { //头插 *(void**)obj = _freeList; _freeList = obj; } } private: char* _memory = nullptr;//指向大块内存的指针 size_t _remainBytes = 0;//剩余字节数 void* _freeList = nullptr;//还回来链表指针 };
三、高并发内存池整体设计
1、thread cache(线程缓存)
- 每个线程独享一个thread cache,用于小于256k的内存分配情况,线程从这里申请内存不需要加锁(因为其他线程无法访问当前线程的 thread cache,没有竞争关系),这也就是这个并发线程池⾼效的地⽅。
2、central cache(中心缓存)
- 所有线程共享一个central cache,thread cache 是按需从central cache中获取对象,central cache在合适的时候会收回thread cache中的对象,避免一个线程占用太多资源。
- central cache 是所有线程共享的,所以存在竞争关系,需要加锁;这里使用的锁是桶锁,并且因为只有thread cache没有内存时才会申请内存,所以这里竞争不会太激烈。
3、page cache(页缓存)
- 页缓存存储的内存是以页为单位进行存储的及分配的。central cache没有内存时,则会从page cache中申请一定数量的page,并切割成定长大小的小内存块。
- 当一个span的几个跨度页的对象都回收后,page cache会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。
四、thread cache 设计思路
1、线程申请内存
- 当内存申请size<=256KB,先获取到线程本地存储的thread cache对象,计算size映射的哈希桶自由链表下标i。
- 如果自由链表_freeLists[i]中有对象,则直接Pop⼀个内存对象返回。
- 如果_freeLists[i]中没有对象时,则批量从central cache中获取⼀定数量的对象,插⼊到自由链表并返回⼀个对象。
- 线程申请内存时,会有不同规格的内存申请(4字节、5字节等),根据范围划定不同的自由链表,设计多个自由链表管理不同规格的内存小块。实质就相当于使用多个定长内存池的自由链表。
2、线程对齐规则
- 每个内存小块采用向上对齐原则(可能会出现多申请内存的情况,这就是内碎片)
例如:
- 需要9字节,则开辟一个大小为2个8字节的空间的节点
- 需要100字节,则开辟一个大小为13个8字节的空间的节点。
- 整体控制在最多10%左右的内碎片浪费
- 总计设计208个桶
- freelist[0,16)个桶,采用8byte对齐,内存大小[1,128]
- freelist[16,72)个桶,采用16byte对齐,内存大小[128+1,1024]
- freelist[72,128)个桶,采用128byte对齐,内存大小[1024+1,8*1024]
- freelist[128,184)个桶,采用1024byte对齐,内存大小[8*1024+1,64*1024]
- freelist[184,208)个桶,采用8*1024byte对齐,内存大小[64*1024+1,256*1024]
- 注意:_freeLists是一个数组,每个元素都是自由链表类型(即存储自由链表的头结点)
3、线程释放内存
- 释放内存后:当释放内存⼩于256k时将内存释放回thread cache,计算size映射⾃由链表桶位置i,将对象Push 到_freeLists[i]。(每一个切分好的小块内存就是一个节点)
- 具体方法是:用切分好的小块内存的前4字节或8字节来存储下一个小块内存的地址。
- 插入节点时,采用头插的方式。
- 当链表的⻓度过⻓,则回收⼀部分内存对象到central cache。
4、TLS--thread local storage
- TLS:thread local storage 线程本地存储(linux和Windows下有各自的TLS)
- TLS是一种变量的存储方式,这个变量在它所在的线程内是全局可访问的,但是不能被其他线程访问,这样就保证了线程的独立性。
静态TLS使用方法:
- _declspec(thread) DWORD data=0;
- 声明了一个 _declspec(thread) 类型的变量,会为每一个线程创建一个单独的拷贝。
原理:
- 在x86 CPU上,将为每次引用的静态TLS变量生成3个辅助机器指令
- 如果在进程中创建子线程,那么系统将会捕获它并且自动分配一另一个内存块,以便存放新线程的静态TLS变量。
// TLS thread local storage static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr; //_declspec(thread)声明线程局部存储,每个线程都有自己独立的 pTLSThreadCache 变量的副本 // static void* ConcurrentAlloc(size_t size) { //通过tls,每个线程无锁获得自己专属的threadCache if (pTLSThreadCache == nullptr) { pTLSThreadCache = new ThreadCache; } cout << std::this_thread::get_id() << ":" << pTLSThreadCache << endl; return pTLSThreadCache->Allocate(size); }
五、central cache 设计思路
1、central cache 结构
- central cache也是一个哈希桶结构,每个哈希桶位置挂载的是SpanList自由链表结构。
- Span管理的是以页为单位的大块内存(一页为8kb(32位系统下))
- 每个Span中的大内存根据映射关系被切成了一个个的小块内存对象,然后挂载在Span上。
- 因为中心缓存是所有线程共享的,只需要定义一个对象,所以这里需要将 central cache 设计为单例模式(这里采用饿汉模式的设计方法)
注意:
- span是双向链表,而span下挂载的小块内存对象是单链表。
- 中心缓存需要加桶锁。
- _spanLists 是一个数组,数组中每个元素都是一个span自由链表的_head头指针。
- 每个span又是一个单向自由链表。
2、内存申请
- 当thread cache中没有内存时,就会批量向central cache申请一些内存对象,从对应的span中取出小块内存对象给thread cache,这个过程是需要加锁的(加桶锁)
- 这里批量获取对象的数量使用了类似网络tcp协议拥塞控制的慢开始算法。
- 如果所有的span都为空了(即central cache使用完了),则将空的span链在一起,向page cache申请一个span对象,span对象中是一些以页为单位的内存,需要切成对应的小块内存对象,并链接起来,挂到span中。
- central cache中每一个span都有一个use_count,分配一个对象给thread cache,就加加。
3、内存释放
- 当thread_cache将内存释放回central cache中的时,释放回来就减减use_count。
- 当use_count减到0时则表示所有对象都回到了span,则将span释放回page cache,page cache中会对前后相邻的空闲页进行合并。
六、page cache设计思路
- page cache中也是哈希桶结构,但是每个节点存储的都是span
- 因为页缓存是所有线程共享的,只需要定义一个对象,所以这里将 page cache 设计为单例模式(这里采用饿汉模式的设计方法)
- 注意:page cache需要加整体锁(因为是所有线程共享的)
1、申请内存
- 当central cache向page cache申请内存时,page cache先检查对应位置有没有span,如果没有则 向更⼤⻚寻找⼀个span,如果找到则分裂成两个。⽐如:申请的是4⻚page,4⻚page后⾯没有挂 span,则向后⾯寻找更⼤的span,假设在10⻚page位置找到⼀个span,则将10⻚page span分裂 为⼀个4⻚page span和⼀个6⻚page span。
- 如果找到_spanList[128]都没有合适的span,则向系统使⽤mmap、brk或者是VirtualAlloc等⽅式申请128⻚page span挂在⾃由链表中,再重复1中的过程。
- 需要注意的是central cache和page cache 的核⼼结构都是spanlist的哈希桶,但是他们是有本质区 别的,central cache中哈希桶,是按跟thread cache⼀样的⼤⼩对⻬关系映射的,他的spanlist中挂的span中的内存都被按映射关系切好链接成⼩块内存的⾃由链表。⽽page cache 中的spanlist则是按下标桶号映射的,也就是说第i号桶中挂的span都是i⻚内存。
2、释放内存
如果central cache释放回⼀个span,则依次寻找span的前后page id的没有在使⽤的空闲span, 看是否可以合并,如果合并继续向前寻找。这样就可以将切小的内存合并收缩成⼤的span,减少内 存碎⽚。
七、申请过程
1、类和对象定义
FreeList对象
ThreadCache对象
ObjectPool对象
CentralCache对象
Span对象
SpanList对象
PageCache对象
2、每个线程获得专属ThreadCache对象
申请6b大小的内存空间
如果小于256k,线程获得自己的ThreadCache对象,利用ObjectPool定长内存池New出一个ThreadCache对象tcPool,然后Allocate分配内存。
new也是封装了malloc来分配内存,使用ObjectPool来替换new,彻底不用malloc。
New对象时,构造_freeList为空,申请128k大小的内存,obj指向分配的内存(一个ThreadCache),_memory指向分配后的内存块。
直接在堆上申请空间,返回ptr。
3、Allocate计算对齐大小和所属哈希桶
分配到pTLSThreadCache线程后再去Allocate(6b)
RoundUp计算对齐数,6b大小则对齐到8b字节
Index计算在ThreadCache哪个桶中,6b大小在第0个桶中
4、慢反馈算法获得要申请对象的数量
项目刚开始_freeList[index]为空,去CentralCache申请内存。_freeLists[index]的maxsize为空,通过慢反馈获得要申请小对象的数量,如果batchNum为maxsize,则申请小对象数量为1。
慢反馈调节,MAX_BYTES为256k,申请字节越大,返回越少
5、从CentralCache中获得对象
定义start和end,通过FetchRangeObj从CentralCache对应哈希桶中获得对象,如果不够则有多少获得多少,数量为actualNum。大于1的话将多余的对象插入_freeList[index]中。
返回内存起始地址start,一次内存申请完成。
计算size(8)所在的哈希桶,给对应_spanLists[index]加锁,然后获取span对象
依次判断桶中的各个span有无小对象,如果有则返回头结点。
6、从PageCache中获取对象
如果CentralCache对应哈希桶中没有小对象,则去PageCache申请一个span大的空间,将span标记为已使用。
慢反馈NumMovePage,size为8b时,通过NumMoveSize慢反馈获得num为512,npage为4096,右移13位为0,申请1页。
NewSpan获取1页的span,先判断PageCache的桶中有没有span,如果有则_spanLists[k]头删一个kspan并返回。
如果PageCache的桶中有没有span,检查后面的桶的span,申请一个1页的span,如果有一个10页的span,则切分为一个1页的kSpan和一个9页的nSpan,返回kSpan。
如果PageCache中没有Span,则去堆上申请一个128页的内存,放在bigSpan中,将bigSpan头插到第128个_spanLists中,然后再执行一遍NewSpan返回需要的span。
7、切分申请好的span
PageCache申请一个span大的空间后将span进行切分,计算起始地址和结束地址
size为切分小对象的大小,tail的nextObj等于start,依次切分,计算这个span能分多少个小对象i。
再将span头插到CentralCache的_spanLists[index] 桶中,返回span。
8、从span中取batchNum个对象
从_spanLists[index]中获取了span,start为_freeList头,将end递增到batchNum个对象,之后的内存接到_freeList中,返回实际获得的对象数量actualNum。
八、释放过程
1、获得对象到span的映射
释放获取的p1内存(8b)
获得从对象到span的映射,得到释放对象的字节大小(8b),该线程调用Deallocate释放。
将对象地址强转为PAGE_ID(size_t)类型再右移13位得到对象的id,通过id找到PageCache中分出去对应的span,并返回该span。
2、将对象插入自由链表桶
获取Index桶号,将对象Push到对应_freeLists[index]中,释放完成。判断_freeLists[index]大于申请时候的值MaxSize,说明申请的内存都还回来了,则统一释放给CentralCache。
当链表过长时释放链表到CentralCache对应_spanLists[index]中。先弹出所有的小对象存到start和end中,再通过start和大小释放给CentralCache。
3、释放对象到CentralCache中
通过size(8b)获得Index所在的桶,将_spanLists[index]加锁便于插入对象,当start还有对象时,获取下一个对象NextObj,获取start指向的对象的span映射,将start头插到span的_freeList链表中,span分出去的小对象计数_useCount--,start后移到下一个小对象,然后继续头插。直到将所有小对象插入后解开桶锁。
如果_useCount为0说明所有小对象都回来了,将span从该桶中摘除,指针置空。然后PageCache加锁,将该span还给PageCache。
4、释放对象到PageCache
先获取span的页号,-1获得前一个页的页号prevId,通过prevId得到前一个span,如果前一个span没有使用,则与当前的span进行合并,将 span的起始页调整为前一个页段的起始页,并更新页数。然后,从_spanLists中删除prevSpan,在_spanPool中调用delete删除prevSpan。
然后获得下一个span的_pageId,检查下一个span是否没有使用,通过nextId返回对应的Span,合并span的_n页数,将nextSpan在_spanLists中摘除,通过_spanPool调用delete释放nextSpan。
PageCache中的_spanLists中插入还回来的span,标记为未使用状态,建立_pageId和span的映射。
九、大块内存的申请释放
1、申请过程
申请257k大的空间
如果申请的size大于MAX_BYTES(256k),计算对齐大小alignSize为264k,kpage为33页。然后PageCache加锁,直接从PageCache获取对应的内存,返回内存地址。
申请33个页,如果对应的桶中有span,则弹出并返回span,如果不够则查看后面的桶,有则进行切分。否则去堆中申请一个128页的span放在_spanLists[128]中。
如果申请的页大于128,则直接向堆申请,建立_pageId和span的映射,返回该span。
2、释放过程
释放257k的空间
先通过该指针获取所在的span,得到该内存的大小,如果size大于MAX_BYTES(256k),释放到PageCache中。
先对前后页进行合并,再插入到对应的_spanLists中,重新建立映射。
如果释放的内存大于128页,直接还给堆。