接上一篇文章:
central_cache设计:
上一篇文章 我们完成了 thread_cache的设计: 总体来说就是 thread_cache 里面没有内存申请时,会向下一层 申请一些内存:
central_cache也是⼀个哈希桶结构,他的哈希桶的映射关系跟thread_cache是⼀样的。不同的是他的 每个哈希桶位置挂是SpanList链表结构,不过每个映射桶下⾯的span中的⼤内存块被按映射关系切成 了⼀个个⼩内存块对象挂在span的⾃由链表中。
申请内存:
1. 当thread_cache中没有内存时,就会批量向central_cache申请⼀些内存对象,这⾥的批量获取对象 的数量使⽤了类似⽹络tcp协议拥塞控制的慢开始算法;central_cache也有⼀个哈希映射的 spanlist,spanlist中挂着span,从span中取出对象给thread_cache,这个过程是需要加锁的,不 过这⾥使⽤的是⼀个桶锁,尽可能提⾼效率。(就是每一个桶,有一个锁)
2. central_cache映射的spanlist中所有span的都没有内存以后,则需要向page_cache申请⼀个新的 span对象,拿到span以后将span管理的内存按⼤⼩切好作为⾃由链表链接到⼀起。然后从span中取对象给thread_cache。
3. central_cache的中挂的span中use_count记录分配了多少个对象出去,分配⼀个对象给thread_ cache,就++use_count。 (这一点先不用了解 给释放的时候 回收内存 使用的)
现在开始正式介绍一下:
这里的大小划分还是 和 上一层thread_cache里面一样 因为thread_cache 直接申请的就是固定大小的,所以这里延续这一个风格。
不同的就是 这个里面的桶不再是直接放自由链表 而是放的是一个 结构体 struct span
struct Span
{
void *_initial_address; //一方面用来计算起始地址 一方面用来标识作用
size_t _page_id; // 起始片id 其实id从1开始 就是第一片 第二片 第三片 ……
size_t _page_number;
struct Span *_next;
struct Span *_prev;
void *_free_list; // 切成小块内存的自由链表 头结点
size_t _useCount=0;
};
这里先解释一下 为啥 多封装一个结构体 而不是直接像第一层 直接放自由链表 主要是为下一层服务的 因为多线程你向操作系统申请内存 是要加锁的 所以每次申请内存 我们多向操作系统申请一大块 然后我们管理 这也就是 多线程环境下 比malloc优秀的原因。
一次性申请一大块 又要分208个不同大小的固定内存块 直接来管理是不方便 ,这里就引入struct Span 来进行管理 作为中间层 链接上下层 所以为什么设计成三层都是有道理的。
这个是描述 从操作系统申请一大块内存的首地址 ,一方面后面用他来计算 切分之后 各个内存的首地址的 , 一方面 是用来 区分哪些Span是属于同一个大内存块的(因为属于同一个大内存 这个地址肯定一样 ) 方便后面释放还给系统。
这个就是 用来 切分大内存块的
这里要引入一个名词 就是 页 也是为了更好的管理大块内存的
不用过去深究 你就记住:我们从操作系统申请一大块内存(就理解为:malloc一个很大的内存 , 实际用的不是malloc 是 mmap 你就理解为malloc就行 )
把这一大块内存 切分成若干小份 (这里的一小份 我们就叫做页)
最后把 连续的若干页 组成一个Span
这就是三者的关系,这么讲就很明了
红色是申请的一大块内存
绿色是 大块切成若干小块的一块 (因为申请的大块是我们自己控制的 所以可以保证是页的整数倍)
黄色是 若干个连续的页组成的 结构体 Span
所以这个page_id就是 上面的绿色框页 从左到右 12345……这样编号下去,_page_number这个表示的是一个span里面有几个页
为什么这么处理呢?
其实就是 有了这三个参数 我可以知道任意一个Span对象里分的内存的首地址 _initial_address+(_page_id*-1)*(一页的大小)
这个就很好理解了 因为我这里挂的是 双向带头循环的 所有得有前后指针
这个就表示的是 span所管理的那一部分的内存的起始地址
useCount表示 这部分内存被使用了几个(后面用来回收内存的时候使用 后面详细展说)
因为我们最终 还是要把Span所管理的内存 切成固定大小的内存块 然后用自由链表把这些连起来
有了struct span之后 我们类似thread_cache一样 在封装一个类来管理他
class Central_cache_Span_list
{
public:
Central_cache_Span_list()
{
_head = new struct Span;
_head->_next = _head;
_head->_prev = _head;
}
void Push(struct Span *tem)
{
_head->_next->_prev = tem;
tem->_next = _head->_next;
_head->_next = tem;
tem->_prev = _head;
}
struct Span *Get_head()
{
return _head;
}
~Central_cache_Span_list()
{
delete _head;
}
private:
struct Span *_head; // 双向带头循环的链表
public:
std::mutex _mutex; // 桶锁
size_t _flow_control = 1;
};
因为是双向带头循环结构的链表 所以构造函数要new一个对象 析构函数要释放new的对象
然后这里从下层申请到一个Span的话 得头插到我们对应位置的链表里面 封装一个接口
给每一个桶加一个锁 就是我们前文提到的桶锁
_flow_control 就是一个控制流量大小的 (后面在介绍)
然后就是central_cache的代码了: (不用全看懂 大致看一下有哪些东西 我下面会逐一介绍的)
class Central_cache // 饿汉模式 比较适合场景 因为最开始 这个内存池里面是啥都没有的 所以这里的每一层都会走一遍
{
public:
static Central_cache &Get_instance()
{
return Central_cache_obj;
}
void *Central_cache_Allocate(size_t index, size_t size)
{
// 每次不可能thread_cache 要一个给一个 这里他要 我就一次性多给几个 涉及一个 慢开始算法
_central_cache[index]._mutex.lock();
int n = std::min(central_cache_flow_control(size), _central_cache[index]._flow_control);
if (n == _central_cache[index]._flow_control)
_central_cache[index]._flow_control *= 2;
struct Span *head = _central_cache[index].Get_head();
while (head->_next != _central_cache[index].Get_head() ) //注意这里判断是不是回到了头部
{
head = head->_next;
void *left;
void *right;
if (head->_free_list)
{
left = head->_free_list;
right = left;
int i=1;
while ((*(void **)right) != nullptr && --n)
{
right = *(void **)right;
i++;
}
head->_free_list = *(void **)right;
*(void **)right = nullptr;
head->_useCount+=i;
_central_cache[index]._mutex.unlock();
return left;
}
}
_central_cache[index]._mutex.unlock();
// 走到这里就是需要去 page_cache 里面去申请内存
Span *tem = Page_cache::Get_instance().Page_cache_Allocate(central_cache_page_flow_control(n * size));
// 对Span进行切小
void *start = (void *)((tem->_page_id - 1) * Page_Size + (size_t)(tem->_initial_address));
tem->_free_list = start;
void *end = (void *)((tem->_page_number * Page_Size / size) * size + (size_t)start); // 这里会有内存碎片 为了下面的while不越界访问 这里要把内存碎片消除 求边界点
while ((void *)((size_t)start + size) < end) //这里移出去的碎片 不用管 反正它依旧在这里 后面合并成大的也 直接就还给
{
(*(void **)start) = (void *)((size_t)start + size);
start = (void *)((size_t)start + size);
}
(*(void **)start) = nullptr;
_central_cache[index]._mutex.lock();
_central_cache[index].Push(tem);
return Central_cache_Allocate(index, size, n);
}
void *Central_cache_Allocate(size_t index, size_t size, int n)
{
struct Span *head = _central_cache[index].Get_head();
while (head->_next != head)
{
head = head->_next;
void *left;
void *right;
if (head->_free_list)
{
left = head->_free_list;
right = left;
int i=1;
while ((*(void **)right) != nullptr && --n)
{
right = *(void **)right;
i++;
}
head->_free_list = *(void **)right;
*(void **)right = nullptr;
head->_useCount+=i;
_central_cache[index]._mutex.unlock();
return left;
}
}
_central_cache[index]._mutex.unlock();
return nullptr;
}
// void Central_cache_Deallocate()
// {
// }
private:
Central_cache()
{
}
Central_cache(const Central_cache &) = delete;
Central_cache &operator=(const Central_cache &) = delete;
private:
Central_cache_Span_list _central_cache[Hashi_Bucket_Size];
static Central_cache Central_cache_obj;
};
Central_cache Central_cache::Central_cache_obj;
上一篇文章我们提到了 每一个线程都有一个thread_cache 所以这里不用加锁
但是他们共用一个central_cache 也 共用一个 page_cache
所以这里设计 我们采用单例模式 而且要加一个锁 为什么这里加桶锁呢? 因为每个桶里面的Span管理着不同大小的固定内存 所以之间互不影响,只有在 两个线程都要申请相同大小的内存块时,才会有竞争问题,所有这里每个桶一个锁,而不是整体一个大锁,是为了提高效率。
单例模式 我们采用 饿汉模式 为啥不用懒汉模式 就是因为反正每一层都会走一遍的,因为刚开始申请内存的时候,三层里面都是空的 ,所以采用恶汉模式,程序一运行,就把三层创建好,而不是等要申请内存的时候才来创建。
单例模式:
这么设计是为了 只有一个对象的生成 防止拷贝
用这个类 创建一个 静态成员变量 然后再类外定义它。因为静态变量,在重新一开始运行就实例化了。
然后再用一个静态成员函数来获取这个类的实例
(恶汉模式不懂的 gpt一下 代码稍微看一下 就懂 我这里就不详细展开说了)
对于我们现阶段 只关注申请内存这一通道 所以就这一个函数
下面我们就详细展开说说 这个函数:
先看传进来的两个参数是什么: thread_cache 里面
index是 映射出来的下标 size是对齐之后的大小 其实这两个就是一个意思 一一对应的嘛 只不过程序他不知道 我们得传进来
我们刚刚又说了 central_cache 和 thread_cache 他们的桶设置的大小 是一样的 所以index是通用的,所以这里直接拿进来用,其实只传一个size也可以,反正我们有一个函数可以来算index,但是为了效率,我们把index传进来 ,因为高并发场景,会有大量的申请释放,每次都重复计算,也是一种开销。
下面开始介绍这个函数的设计:
这里最开始 就涉及到一个 慢开始算法,因为我们不可能他要一个我们就给一个,因为这样效率太低,这一层我们是有锁的,如果每一次thread_cache 里面没了 每次拿一个 ,不如他要 我就多给几个让他下次可以无锁的直接在thread_cache里面自己拿,效率不就上来了 。
一次多给几个,我们以二倍的增长来给他 比如说 第一次申请 我们就给1个 再来申请 我们就给 2个 再来申请就给 4个 这样下去,但是不能无限制增长 2的10次方都1024了 所以得设置一个上限
就是min函数里面的 central_cache_flow_control()
size_t central_cache_flow_control(size_t size) // size为需要去申请的大小 为每次去central_cache申请内存设置一个上限
{
if (size == 0)
return 0;
int n = MAX_Thread_memory / size;
if (n < 2)
n = 2;
if (n >= 512)
n = 512;
return n;
}
MAX_Thread_memory 就是我们上篇文章里面提到的256kb
这个算法就是 size越大我保证最小 你每次申请有两个 ,越小 我不让你一次性申请超过512个
相当于就是 你申请的内存块越大 多给你几个 , 申请的越小少给你几个 。 (方法不唯一,可以暴力一点,直接设置申请的这个个数,最小一个,最大设置一个上限就行)
这里就是获得 我管理的 那个 span链表的头节点! 因为index已经让我们知道该从哪个span里面获取链表了。
双向带头循环的链表,我们就开始遍历一下
如果里面有我们就 拿到他
因为这个是存放自由链表的地方 就看他是不是为空就知道 这个span里面还有没有 内存对象拿
如果有 就进来拿:
这个条件就是 你拿n个 或者 就是你把里面全拿走了(不够的情况) 这两种情况嘛
任意哪种情况都无所谓嘛 反正我只要进来了 就至少拿了一个了(所以i初始化为1) 直接给thread_cache 传回去,反正也完成这次他的需求了,在申请 再让thread_cache 找我来拿就行。
用来统计 这个Span对象里面被拿走了多少个
访问内存块的前几个字节 拿到下一个内存块的地址
前文提到过 就不细说了
在解释一下为啥--n 不是要n个么
不够的情况同理!!!
循环退出来 right前几个字节 要么为空 走到结尾了 要么指向下一个内存块的地址 所以把他传回给span 然后再把它置为空 ,因为要把链表的最后一个内存块 指向空 来标识结尾
然后解锁之后返回。
再往后走 就说明 现在的central_cache也有没有我们需要的内存 ,所以先解锁,再往下一层page_cache 里面去申请。
这个函数不用管 反正现在你知道 他会给我传回一个Span对象(里面有若干个页的大块内存)
拿到这个Span了 下来我们就要对Span进行切片了 因为他不是我们要的 那种固定大小的小内存块(不是这种形式 ,而是 我们上面提到的 一大块内存)
首先我们先求 这个span的起始地址,这个上面提过 span里面的内容就支持我们计算了
以为是地址所以计算前先强转为 整数类型 计算玩 在转回指针类型
const static size_t Page_Size_Shift = 13;
const static size_t Page_Size = 1 << Page_Size_Shift; // 一页的大小为8kb
这里要给出每一个页的大小了 我们这里设置的是8kb 也就是2的13次方 (这里和内存管理那里的页框没啥关系哈) 就是随便设置的 你也可以自己设置这个页的大小 就是一个中间变量的作用
为啥这里定义了一个13 因为后面还要用它 并且计算机里面 位移运算要比 乘除 稍微快 所以这里设置了个13
言归正传:
这一步是极容易埋坑的哈, 我们申请的span是含有若干个页的大小哈 无非就是8kb的整数倍
但是我们的size 从 8 16 24 一直到 256kb的哈 是无法保证 每一个span都能整除的哈
所以这样处理一下就是 保证了 我们的start 加 size的整数倍 可以等于 end方便我们切分内存 组织成自由链表。
有同学会问 内存碎片咋办 其实没关系 因为这里浪费的内存肯定小于size大小 而且我们不是真的把内存拿走了 ,他还是在这个位置 ,最后释放的时候 ,只要给了 这块大内存的首地址,依旧是全部归还给操作系统的,不存在内存浪费的问题
while ((void *)((size_t)start + size) != end) //这里移出去的碎片 不用管 反正它依旧在这里 后面合并成大的也 直接就还给OS了
{
(*(void **)start) = (void *)((size_t)start + size);
start = (void *)((size_t)start + size);
}
(*(void **)start) = nullptr;
然后就是 切内存了 每次+size 这样切下去 这里用!=是一样的话 我们反正上面保证了 end的合理性 如果没有上面的处理end那一步,这里大概率死循环或者 越界访问!!!包出问题的
这一步不能少 ,我们要始终保证自由链表的最后一个块指向的是空,这样才能标识结尾。
然后就是上锁,把我们切好的span给push进我们的桶 然后我写了个函数重载,因为现在我们有内存了,可以给thread_cache 分配了
这个函数是包成功的 因为我们是push之后掉的这个函数,而且是有锁的情况进来的
这里就是直接复制上面的一样的逻辑
这里纯属多余 不可能走到这里。不过是为了 消警告 因为返回值在函数里面
基本上走到这里,我们的central_cache 就全部结束了
这三层都是大佬的设计,尤其是 span 页 大块内存
管理的是多么的巧妙, 引入页来更方便的让我们知道任意一个span的地址范围 从而方便我们进行切分。