目录
nb.c: 测试仓库 项目代码+图片
核心图
内存池是什么
内存池(Memory Pool) 是一种动态内存分配与管理技术。 通常情况下,程序员习惯直接使用 new、delete、malloc、free 等API申请分配和释放内存,这样导致的后果是:当程序长时间 运行时,由于所申请内存块的大小不定,频繁使用时会造成大量的内存碎片从而降低程序和操作 系统的性能。内存池则是在真正使用内存之前,先申请分配一大块内存(内存池)留作备用,当程序 员申请内存时,从池中取出一块动态分配,当程序员释放内存时,将释放的内存再放入池内,再 次申请池可以 再取出来使用,并尽量与周边的空闲内存块合并。若内存池不够时,则自动扩大内 存池,从操作系统中申请更大的内存池。
为什么需要内存池
一、申请效率的问题
频繁的进入内核态申请内存是不明智的,申请效率大大降低。
例如:我们上学家里给生活费一样,假设一学期的生活费是6000块。
方式1:开学时6000块直接给你,自己保管,自己分配如何花。
方式2:每次要花钱时,联系父母,父母转钱。
同样是6000块钱,第一种方式的效率肯定更高,因为第二种方式跟父母的沟通交互成本太高了。
对堆申请内存也是同样的道理,
二、内存碎片化
碎片化问题非常头痛,在没有内存池的状态下,假设系统依次分配了16byte、8byte、16byte、4byte,还剩余8byte未分配。这时要分配一个 24byte的空间,操作系统回收了一个上面的两个16byte,总的剩余空间有40byte,但是却不能分 配出一个连续24byte的空间,这就是内存碎片问题。
高并发内存池的优势
这里先告诉大家,malloc其实就是一个内存池,他解决了碎片化和效率的问题,在日常的所有是没有问题的,但是在多线程的情况下,malloc函数是一个多线程不安全函数,我们如果在多线程使用malloc就需要每次使用的时候都需要加锁操作,这是十分影响多线程进程的效率。
高并发内存池使用插件
对象池
先理解以下对象内存池,这属于tcmalloc的一个组件,承担管理对象内存申请的对象,不论什么对象,对象池都向系统申请同页大小的内存块,在对象销毁的时候,利用对应对象池回收,并不归还系统中。
对象池的工作流程是,先查看自由链表_freeList查看是否有空闲的内存块,如果有,头删一块给与申请的对象使用。
向上对齐
因为申请的内存不会固定大小,有可能申请1~42亿字节,难道我们需要开这么多个hash映射位吗?这是不切实际的想法,所以当我们申请内存的时候,需要的字节与实际得到的字节是不一样的,比如申请6个字节大小的内存,会得到一个8字节大小的内存块,虽然会浪费2个字节大小,但这是为了效率不得不浪费的内碎片内存问题,无关痛痒,因为在后续为了向上对齐的情况下,最多浪费10%的内碎片字节,让我们看看任何做到的。
为什么最小内存是8个字节呢?我们的内存池必须要适应32位和64位机器的使用,提高设备适配性,这里就需要我们知道自由链表链接了。
所以为了32位机器和64位机器都适配,我们使用8字节,
自由链表
将每个内存块以单链表的方式链接,不用特别定义小内存块的类型,我们直接采用小块内存的头4~8字节保存下一小内存的地址信息。
那么我们怎么知道我们的高并发内存池在32位还是64位机器跑呢?难道需要写2个高并发内存池吗?这是愚蠢的想法。我们定义最小的小内存块大小必定为8字节大小,
来保证64位机器也可以使用,然后利用函数NextObj:
cpp
static void*&NextObj(void*age)
{
return *((void**)age);//这里也可以用int**,无关痛痒
}
将age强转为2级指针,二级指针解引用就是一个一级指针,那么这一级指针就会跟着机器的不同而大小不同,4字节或者8字节。这样就可以用其来保存下一个小内存的地址信息。
cpp
void *cur=freeList;
while(cur)
{
cur=Next(cur); //遍历自由链表
}
{ //头插内存块
NewNode=归还/申请的内存块;
Next(NewNode)=freeList;
freeList=NewNode;
}
{ //头删内存申请
void* ret = _freelist;
_freelist = _NextObj(ret);
return ret;
}
Span
在同一自由链表的的小块内存虽然大小相同,但是可能并不是在同一连续空间上:
为了方便后续内存合并,多个小块内存被同一个Span结构体管理着,在同span下的小内存是连续空间的。
Span是高并发内存池中最重要的内存管理结构体,他是小块内存的上层级,是内存合并的根据,也是每一层归还内存载体,向系统申请内存并且管理的数据结构。
SpanList
Span对小内存的描述管理,也需要将多个Span管理起来。我们使用带头双向循环链表分方式管理Span结构体。
基数树
该树存放着spanID和span地址的映射关系,为了通过spanID快速可以找到对应的span地址,以方便小块内存快速归还对应Span,合并前后span时查看对应span使用情况,并且使用基数树映射所有spanID到span地址也仅仅花费2MB内存,spanID其实就是内存上的一个地址经过必要的转换得出的结果,我们吧内存的每一页都编号,
SpanID等于0相对于地址0x00000000,
SpanID等于1相对于地址0x00002000,
如何通过小内存地址求对应Span地址呢?将地址右移13位就是ID号了。
假设一个地址0x020e0000,将该地址值向右移动13位
得-->0x020e0000>>13 (也可以用除以一页大小0x020e0000/8/1024) 得结果:4208;
这个通过这个页号ID进入基数树找到对应span地址,当然我们找到得span中得ID号也许和查询号不同,这是正常的,因为基数树中得不同的页号(连续页号)存放的span地址可能是相同。
基数树:
高并发内存池设计
线程先在所拥有的threadCache中寻找所需内存,有着返回,没有向cantrlCache申请一批对应大小的小块内存,有则返回,无则向下PageCache申请对应span块。
ThreadCache层
每一个线程拥有独立的ThreadCache层,为其保护线程在访问内存的安全性,因为每个线程都有独立的ThreadCache的,以至于多个线程如果只在各自的ThreadCache中申请内存时,所有的申请操作都是并行的,基于这条件下,多线程访问内存时,效率会大大提升。
每个thread中是类似hash同的数据结构,数组的每个元素保存着一个自由链表的头结点,按照下标区分每个自由链表中结点大小:
cpp
整体控制在最多10%左右的内碎片浪费
[1,128] 8byte对齐 freelist[0,16)
[128+1,1024] 16byte对齐 freelist[16,72)
[1024+1,8*1024] 128byte对齐 freelist[72,128)
[8*1024+1,64*1024] 1024byte对齐 freelist[128,184)
[64*1024+1,256*1024] 8*1024byte对齐 freelist[184,208)
1~15:以8字节递增
16~71:以16字节递增
72~127:以128字节递增
128~183:以1024字节递增
184~208:以8*1024字节递增
申请的内存与实际返回的内存是不一样的我们以向上取整的方式返回内存:
假设
- 申请7个字节内存->返回8个字节大小内存
- 申请9个字节内存->返回16个字节大小内存
- 申请129个字节内存->返回144个字节大小内存
这不也是一种内存浪费吗?对的这也是内存浪费,这叫做内碎片问题,这是为了效率无法避免的一种浪费,但是这种浪费是极小的,大概才浪费10%左右,计算如下。
除了最前面的8字节递增的小内存区间,浪费的比例有点大:申请9字节浪费7字节,但是到了后面大字节小内存的浪费会最多会稳定浪费10%字节。
申请129字节,得到144字节,浪费15字节,15/144=10%浪费;
申请1025字节,得到1152字节,浪费127字节,127/1152=11%浪费
申请8193字节,得到9,216字节,浪费1023字节,127/8192=11%浪费
以空间换取时间的效率这就是向上取整的意义。
向上取整函数:
cpp
static size_t __RoundUp(size_t size,size_t alignNum)//得知这个数需要增加多少空间
{ //维护规则,向上对齐。
size_t alignSize=0;
if (size % alignNum != 0)
{
alignSize = (size / alignNum + 1) * alignNum;//补齐,size值,向上取
}
else
{
alignSize = size;
}
return alignSize;
}
//上面是我的土狗写法,子函数
//下面是大佬写的写法,子函数
static size_t _RoundUp(size_t size, size_t alignNum)//得知这个数需要增加多少空间
{
return ((size + alignNum - 1) & ~(alignNum - 1));
}
static size_t RoundUp(size_t size)
{
if (size<=128)
{
return _RoundUp(size, 8);
}
else if (size <= 1024)
{
return _RoundUp(size, 16);
}
else if (size <= 8 * 1024)
{
return _RoundUp(size, 128);
}
else if (size <= 64 * 1024)
{
return _RoundUp(size, 1024);
}
else if (size<=256*1024)
{
return _RoundUp(size, 8*1024);
}
else
{
return _RoundUp(size, 1 << PAGE_SHIFT);
}
}
根据字节映射哈希桶函数:
cpp
static inline size_t __Index(size_t bytes, size_t alignNum)
{
size_t index=0;
if (0!=bytes % alignNum)
{
index = bytes / alignNum;
}
else
{
index = bytes / alignNum - 1;
}
}
//我的土狗写法
/
//大佬的牛逼写法
static inline size_t _Index(size_t bytes, size_t align_shift)
{
return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;
}
//计算映射到哪个桶中
static inline size_t Index(size_t bytes)
{
assert(bytes <= MAX_BYTES);
// 每个区间有多少个链
static int group_array[4] = { 16, 56, 56, 56 };
if (bytes <= 128) {
return _Index(bytes, 3);
}
else if (bytes <= 1024) {
return _Index(bytes - 128, 4) + group_array[0];
}
else if (bytes <= 8*1024) {
return _Index(bytes - 1024, 7) + group_array[1] +
group_array[0];
}
else if (bytes <= 64*1024) {
return _Index(bytes - 8192, 10) + group_array[2] +
group_array[1] + group_array[0];
}
else if (bytes <= 256*1024) {
return _Index(bytes - 64 * 1024, 13) + group_array[3] + group_array[2] + group_array[1] + group_array[0];
}
assert(false);
return -1;
}
};
CentralCache层
作为中心层,用来协调分配多线程的多个ThreadCache的小内存转移层,当ThreadCache的某个自由链表收到归还一定量的小块内存,就会触发回收机制,将小内存返还给CentralCache层中,这个机制可能是小块内存到数量到一定,也可以是这个自由链表中小内存字节数量和,到一定数量。
当本层内存无法满足时,执行流继续向下申请一大块内存Span,CentralCache将申请到的大块内存切分为多块小内存链接起来。
所以CentralCache是负责给ThreadCache申请和分配小块内存与向下申请大块内存并且切分的中间层。
centralCache也是一个哈希桶,应用的数组和ThreadCache是一样的,所以依据的哈希映射也是根据向上取整函数,和下标查找函数:
cpp
size_t RoundUp(size_t size);//向上取整
size_t Index(size_t bytes);//bytes位置映射
这个哈希桶中每一个桶都是一个Span的带头循环双链表,同桶中得span切分得小块内存块一样大的。
这个小块内存大小类似于ThreadCache得自由链表。
CentralCache逻辑图
这样得设计有一个好处:就是我们得当多个线程同时访问到这一层得时候,如果并不是对同一桶做切分时,并不用锁,允许并行访问不同得桶,只有在访问同桶时才需要加锁保护线程安全,站在这个条件上,多线程在CentralCache层内内存访问,也可以认为在这层形同无锁状态。
PageCache层
PageCache是基层,是高并发向内存池向系统要内存关键层,ThreadCache和CentralCache层中的内存都是由PageCache层发出内存块。
Page层是高并发内存池与向系统申请内存的关键层,他是高并发的最底层,负责切分大块内存与合并大块内存的场所,他也是一个哈希桶,每个桶也是一个带头双向循环链表,但是他的映射规则与上两层是不一样的,它一共129个位置,第一个位置不用,因为在这层是以span标识的大块内存有几页来标定的。
每个span根据维护的内存页数,挂接到对应的双链表上。
切分
如果在某桶申请K页内存,为空时,会向后找,一旦找到有大于申请K页的span,然后切分span,将多出的部分链接到对应映射桶中,得到的K页span返回给上层。
如果后面所有双链表都没有空闲的span,就取系统申请128页大小的span,然后重复后续动作,切分后链接与返回。
合并
当无法合并就向查看是否允许合并,逻辑一样,每次合并后更新span数据。
因为PageCache层存在合并切分操作,所以必须使用全局锁,当任何执行流在PageCache层运行的时候。
锁在内存池有哪些
对象锁
CentralCache桶锁
PageCache全局锁
多线程性能高的重要点
基数树无需锁,ThreadCache层无需锁,CentralCache层桶锁。