目录
项目介绍
该项目是原google开源的项目tcmalloc,线程缓存的malloc,实现了高效的多线程内存管理,用于替换系统内存分配相关函数如malloc,free等,这里是将核心部分简化出来模拟实现出mini版本的,目的是为学习它。
实现高并发内存池之前,我们先开看看mallco:
我们都知道使用malloc函数回从堆区给我们开辟一块空间,而事实上malloc并不是直接向堆区申请内存的,malloc实际上就是一个内存池,malloc会向操作系统申请一大块空间,之后谁要用就分配给一小块,用完之后就需要重新申请。
我们在内存开辟的时候基本都是用malloc,但是malloc并不是在所有方面都很高效,一个东西有所长,必然有所短,虽然malloc通用,但是在其他方面性能就不是很高了,我们先以一个定长的内存池来认识一下。
定长内存池就是解决固定内存大小的内存的管理,这样性能就会很高,且不考虑内存碎片化,当然这是内存很理想的情况才会用到定长内存池,这里主要实现为了认识一下:
定长内存池MemoryPool
const size_t MAXSIZE = 128 * 1024;
template<class T>
class MemoryPool
{
public:
T* New()
{
T* memory = nullptr;
//优先看还回来的空间是否够用
if (free_list != nullptr)
{
//头删
void* next = *(void**)free_list;//先取第一个内存块的头,同时也是下一块内存的首地址
memory =(T*) free_list;//把第一块内存给我用
free_list = next;//重新指向链表 头删
return memory;
}
else
{
// 不够用回来分配
//剩余不够就扩容
if (_remainbite < sizeof(T))
{
//开空间
_memory = (char*)malloc(MAXSIZE);
_remainbite = MAXSIZE;
if (_memory == nullptr)
{
throw std::bad_alloc();
}
}
//分配空间
memory = (T*)_memory;
size_t Size = sizeof(T) > sizeof(void*) ? sizeof(T) : sizeof(void*);//分配的空间至少能存一个指针的大小用来存放下一个空间
_remainbite -= Size;
//往后偏移
_memory += Size;
//显式调用构造初始化 :string,vector
new(memory)T;
return memory;
}
}
void Delete(T *memory)
{
//显式调用析构清理
memory->~T();
//头插
*(void**)memory = free_list;//先取这块内存的头
free_list= memory;//指向整块内存的头
}
private:
char* _memory = nullptr;//指向的一大块空间
size_t _remainbite ;//剩余的内存
void* free_list=nullptr;//内存释放后还回来以链表形式链接,先把还回来的内存挂起来
};
//*(int *)memry是前四个 *(void **)是前void *个,即指针的大小
该定长的内存池,大小是固定的,先开辟一大块空间,之后没需要一点就分配一点,这里需要强调的是我们每次分配空间至少能存下一个指针的大小(*(void**)),用来之后回收的时候重复利用,回收内存块并不是直接释放,而是顺序的先挂接着,看是否需要重新使用。通过这种方式就高效的动态管理内存。
这里也不能释放,因为每一块你不知道什么时候用完,只要进程正常,就会还给os了,不会造成内存泄露。
这里申请内存也是直接可以调用系统的接口SystemAlloc,按页申请内存。
高并发内存池的设计
因为malloc本身的效率其实也不低了,因此在我们更加的优化malloc就需要从三个方面,做出改善:
1.性能问题
2.多线程竞争问题
3.内存碎片问题
高并发内存池主要分了三个部分:
1.线程缓存:每一个线程拥有自身的线程缓存,用于小于256kb的内存发分配,线程从这里申请内存不需要加锁,每一个线程独享一个线程缓存。
2.中心缓存:所有线程共享的一片空间,线程缓存向中心缓存按照需要分配的对象,再合适的时候回收,这里的中心缓存就是线程的共享资源,因此需要加锁。
3.页缓存,是中心缓存上的一层缓存,存储单位为页,当中心缓存的资源没有时,此时页缓存就向中心缓存创建多个page,再进行切割成定长大小内存块,再分配给中心缓存,当连续的几个页都被回收时,在组合成更大的页,减少内存碎片的问题。
因此我们一步一步来,先来看
线程缓存ThreadCache:
线程缓存的设计思路是延申了定长内存池的设计,选用了自由链表的设计,将定长的内存块挂接再一起,需要的时候提供。但是内存块的大小是由许多大小的,可能是8字节,可能是8k,每一个大小对应一条自由链表,那要从1bite->256k,那也太多了,为了减少负担,我们均分这些大小,例如以,2,4,6,8,10...这样递增会减少至少一半的开销,但是同时也应出了一个小问题,就是内存碎片化,一般我们将连续空间被拆分,由于归还的效率不一样,就会产生内存碎片,这样的碎片,称为外碎片 ,而对于我们这样的小空间,就是内碎片。
因此需要控制内碎片的消耗,去极大的利用空间了:
利用MappingSize来管理内存映射:
内存我们如何映射呢,之前我们说过,不可能创建256kb个自由链表,每一条对应其大小,而是按段分,因此我们使用了如下的对齐规则:
我们将256kb成了5个区间,每一个区间的内存对应一个对齐数,即划分该内存为自由链表时的内存块的大小。
以第一个一个区间为例,每一个内存块为8字节,之后的自由链表在128之前,都是以8字节递增的快的大小的链表
之后就是线程之间互相各自有各自一个cache对象,相互他们是看不见的,但我们学过线程,我们知道线程其实相互之间是透明的,那么怎么样线程之间的cache对象是独立的。
Linux下的gcc提供了tls。
我们用的windows vs下提供了tls.两个tls是系统各自实现的。
TLS(线程的局部存储),使得在该方式下存储的变量在线陈内是全局访问的,但是其他线程是无法访问到的,以这种方式就保证了多线程下数据安全,也不需要用锁。
TLS可以使用动态的,也可以使用静态的。我们可以定义一个全局的该TLS指针,之后每个线程都会有独立的一份
_declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
用该指针去获取线程缓存对象,在调用的内存申请的接口。
总结下来:
中心缓存 centralCache
中心缓存的设计与线程拥有相似之处,主要结构也是哈希桶,因为要给线程缓存返回自由链表对象,因此中心缓存的内存映射规则是一样的。
CentralCache也是按照一定大小映射哈希桶,这里的映射关系与线程自己申请时内存映射一致,区别是他的哈希桶里放的SpanList的链表结构,我们之前放的是自由链表,不过,这里的SpanList的内存比较大,因此还会在切分成一块块小的内存对象,然后挂在SpanList上。其次这里存在多线程竞争问题,需要加锁。
但是这里虽然要加锁,是在桶上加锁,只有同时向一个桶申请空间才加锁,不同桶的申请是不需要加锁的,保持高效性。
Span链表是一个管理以页为大小的内存块,每一个Span下面挂着内存块,利用usecount来进行计数,如果一个span不够用,就需继续申请新的Span,每一个span链表都映射着不同的大小内存。
具体这里的span怎么取切大块内存,这里适合threadCache一样,为了更加管理好大块内存,我们会用Span对象进行管理。
每一个 Span节点中有一条条自由链表,因为ThreadCache需要208种自由链表,因此对应的208条双向循环链表的节点的自由链表就与之对应,如果多线程同时申请一个双链表中获取自由链表,那么就需要加锁,互斥访问,且自由链表不够时就插入节点。
且在线程获取内存时,按理来说,每次给一个对应size的内存块的对象就可以了,比如(8字节),我就给一个块大小为8对象的头节点中的自由链表的一个对象,但是为了提高效率,对于这种小内存,不想ThreadCache一直频繁的向CenterCache申请,于是想要给多点,但是thread不能申请一次就给很多个,没必要。于是在自由链表中增加一个计数的max_size,第一次申请我就给一个,再问我申请,我就给两个,,,,,,申请的次数越多,我就会给的更多,且不会太多(512封顶),大大提高了效率(不会频繁的申请)。
页缓存PageCache
页缓存提供以页为单位的内存块给中心缓存,之后中心缓存,将该页通过映射的方式划分为一块块小内存。页缓存的结构也是一个哈希桶,每个桶里放的是一个Span链表,但是页缓存的映射更加简单,第几页就映射的是第几个Span链表。总共的链表的个数,也就是桶的个数是128个。
从第页号为1开始,也就是第一条链表,这里的节点保存的都是1页大小的空间,与之类似,页号为2,就是第二条链表,节点保存的大小是2页,以此类推,直到页号为128,及总共128条链表。
如果centercache需要申请2k的span,此时中心缓存内存并不会直接去找2k的大小的页的位置去要,而是先往下走,找一个比2k更大的Span,直到此时都没有,还没申请,pagecache会向系统申请128页的大小的Span,之后切分2k的大小的给中心,剩下的全部内存挂载到内存当中。至于为什么一次申请这么大的,这是因为当大内存被切分使用完还回来的时候,我们在将一片片小的页给拼接到原本挂载的大页中,减少了内存碎片,且在分配时功能更加高效了。
当中心缓存的usecoun为0的时候,说明内存都还回来了,此时中心缓存把Span对象还给页缓存,如果是小的页,就等更多的页,合并在一起,减少内存碎片的问题。
加锁与解锁
首先,多线程运行获取内存,第一步先去ThreadCache中获取内存,由于ThreadCache专门利用了TLS的线程局部存储,使的每一个线程都有独一份的指针,如果有内存,值为每一个进程提供,不存在竞争。
但是如果没有内存,就需要去中心缓存获取内存,此时多线程可能会同时访问同一个双向循环链表,此时就需要在双向循环链表的节点分配空间时加锁。
而分配空间,首先获取链表的头节点,在获取节点存储的自由链表,返回一段自由链表给threadcache,若此时链表为空就需要向页缓存申请空间。
但是此时在遍历找到对应链表时,我们就可以解开访问循环链表的锁,因为此时当前进程已经拿到了头节点,如果链表为空,解锁也不影响,因为都拿不到,此时多线程都竞争的让pagecache给中心缓存空间,因此在向pagecache申请内存完之后(获取一个pagelist的头节点),内存切分完后需要加锁,因此申请完内存,返回头节点后,再将访问链表节点的锁加上。
最后centralcache拿到自由链表时解锁
拿到了内存,此时会调用函数切分内存,再返回需要的内存给链表,此时切分内存时,会将需要的给链表,剩余的挂接回页缓存中,不允许其他线程访问,因此需要加锁。
内存申请
了解完上述三大内存的工作方式,我们现在来总结一下内存申请的流程:
以申请一个8字节的内存块为例:
1.线程首先根据自身大小找到线程缓存对应的自由链表,第一次申请,链表内为空,需要向中心缓存申请内存。
2.来到中心缓存,根据自身大小找到对应链表,之后去遍历中心缓存的双向循环链表,看哪一个头节点不为空,第一次申请,整个链表为空,此时需要去获取一个节点(内存),于是向页缓存申请内存。
3.来到页缓存,根据需要的大小判断给第几页,8字节我们这里就给第一页且给一页,但是此时页缓存并没有内存,整个也哈希表也为空,因此需要向系统申请内存,第一次申请会直接申请一页128页大小(一页8k)的页,并将该页切分成第1页与第127页,之后第一页的一页会被切分成1024块大小的内存并组合成自由链表,挂接在一个中心缓存的对应的链表的头节点的自由链表上。
4.中心缓存有了内存,此时对应链表的头节点的自由链表就提供一块内存,(线程缓存申请中心缓存是一个慢增长过程,第一次给一个,第二次给两个...)
5.线程缓存拿到缓存后提供给线程使用。
内存释放
1.内存释放,还是先从threadcache处开始,线程使用完内存块后,拿到内存块此时插回对应的自由链表当中,当链表过长时(或者内存块都已经返回时)(判定条件为自由链表自身的长度大于等于申请是累加的maxsize),此时我们会将该段自由链表切下还给centralcache,返回的是指针指向的空间。
2.来到centralcache,进行回收,首先我们先要找到是哪一个span对应的自由链表,通过链表头自身的地址经过位运算就可以找到对应的page_id,因为之前申请内存时就对每个链表的节点映射,此时就可以找到,之后就对节点的自由链表进行处理:
之后就是从start到null,每遍历一次usecount减1,减到0时(比如在获取7次max_size=size),就说明整条链表为空,此时将该链表返回给pagecahce---
如何找到哪一个span,这就需要我们再从pagecache获取内存后,进行切割时,需要做标记,每一个节点对应要有一个自己的地址偏移量,比如申请8字节的话,总块数为1024块,抽象成一个循环链表,再用页号映射节点,也就是八个节点,一个节点下的自由链表为128块。
分成1页与127页:
具体如下:
1.当初如何切分的页,就怎样去合并页,在切分页的时候,如第一页即8k就是申请8字节所切分的页,这一页我们我们会有也好加偏移量映射每个链表的节点存在Unorderred_map中,到时候找节点在哪一页直接调用方法find即可,因此这一页也就是第一页,只有一个节点,对应8字节的双向循环链表的头节点(page_id+num,span)。
我们将自身需要的页进行页号+偏移量与节点的映射,抽象出链表(如上图),
同时也将剩余的一张页进行首尾的映射,因此归还回来头节点对应找出是哪一个链表的,当链表中节点usecount为零时就归还给Pagecache,返回给Pagecache对应span头节点,之后删除该链表的节点。
3.归还给Pagecache时,我们还需要将归还的节点找到,通过page_id与num找到切分时映射的双向循环链表的页,但此时只是一个节点,根据节点保存的pageid与num页号,加上之前切分页也被映射到unordered_map,因此就能找到切分时的上下(修改pageid,与num)挂载在对应的页表上,最后合并为一整页(即原本切分的页),最后释放delete这一页(一般就是128页)。
内存大于256kb处理
以上的三层内存管理也只是更好管理小于256k的内存块的申请,那么大于256kb的内存块,我们可以怎样去处理呢?
有两种情况:
a:如果内存是在32*8k到128*8k,此时该范围的大小的内存块,可以去直接找PageCache去申请对应一页的页。
b:内存块的大小大于128*8k,此时该范围大的内存pagecache最后一页也满足不了,只能让系统开辟给。
因此在申请内存时,先判断是否大于256k,然后看失去PageCache还是CentralCache,再根据页号,看是去PageCache中获取,还是直接向系统获取一个span。
代码优化
1.使用定长内存池替换程序中的new,让他人在使用tc_malloc直接替换malloc,所以tc_malloc里不能有任何malloc的东西,所以这里的在PageCache构造节点时不能用new,刚好使用我们的定长内存池替换new。
2.在双向循环链表的节点中增加成员objectsize,在pagecache且份内存块时,不仅仅要初始化span的页号,地址偏移量,freelist,同时在将我们需要的size赋给objectsize。记录需要的size,就可以使我们在释放内存时,不需要提供大小的参数,直接调用节点的成员objectsize。
3.使用智能指针的锁,在我们去找对应节点是可以使用c++提供的lock,生命周期结束自动解锁。
测试
这里用malloc与我们的ConcurrentAlloc进行对比测试,传入的参数分别为 ntimes(申请释放一轮使用的时间),rounds(轮次),nwroks(线程数).
此时我们代码基本实现,可是效率貌似并不如malloc,此时需要去进行优化分析。
性能优化
根据我们测试,性能的主要消耗在加锁,其次时无序容器所提供的方法find上,因此我们还可以优化这里的无需容器的查找,根据tc_malloc,其改善的方法使用了基数树进行优化。
使用基数树代替unordered_map,提高查找效率:
这里还有三层的设计比一,两层的更为复杂,这里我们用一层或二层的都可以。
最终测试:
可以看到我,我们的申请与释放势必malloc快不少的.