最近在学习内存池,于是仿照nginx的样子手写一个内存池,学习过程记录一下自己的理解。
完整代码放在github
一 内存池介绍
首先我们需要介绍一下为什么需要用到内存池。
- 内存碎片
一个是为了解决内存碎片的问题。这里不详细展开,可以看下面的文章。
内存碎片是什么
我们用malloc等函数频繁申请内存,我们的内存区域会产生很多碎片,这里我画了个图。在学习操作系统的过程中学过类似的,在分区式存储管理的系统中。
我们分配内存可以是按照顺序查找,假如我们是安装上面的方式先分配三个区域。这时候中间的128b的空间被回收了,就留下了一个内存的缝隙,下一次如果申请的内存大小还是1kb,这个缝隙不能被利用,所以我们在分配内存的过程中,内存可能存在很多这样的缝隙,这样是对内存的极大浪费。
- 避免频繁的申请释放
我们用malloc频繁的申请内存,需要不断在用户态和内核态进行切换,这样开销很大,我们本身也要避免这种开销。
二 如何管理内存
首先内存的管理有两个问题
1.分配大小是不确定的
2.什么时候分配是不确定的
这里针对不同的应用场景我们要采用不同的内存池分配方式
假设我们的应用场景是服务器连接
我们有很多个客户端去连接我们的服务器。对于每一个连接,我们要考虑这次传输过程传输的数据的缓冲区如何管理,我们可能连接只是发一句话,也可能是传输很大的文件,那我们如何针对传输的内容不同分配不同的内存呢。
解决方案
我们可以为每一个连接分配一个内存池,如果传输的是大文件,我们就分配一个大块内存,如果是很小的字符串,我们把这些小字符串全部放到一个内存区域。
三 实现内存池
我们首先知道对于大块内存和小块内存我们用不同的分配方式。
1.大块内存
我们用一个类似链表的结构,如果用户申请的空间大于4k我们就分配一个4k的块。
2.小块内存
我们也用一个类似链表的结构存储,但是对于每一个块,我们用两个指针来区分已分配区域和待分配区域。
具体的,我们用head指针来指向内存区域的头,用last指向下一个可以分配的的起点,end指向内存区域的末端。
然后我们就可以进行代码的编写,首先是三个结构体
typedef struct mp_large_s { //大块内存
struct mp_large_s *next;
void *alloc;
}mp_large_s;
typedef struct mp_node_s { //小块内存
unsigned char *last; //指向可用内存的开头
unsigned char *end; //指向可用内存的结尾
struct mp_node_s *next;
// size_t failed;
}mp_node_s;
typedef struct mp_pool_s {
size_t max;
struct mp_node_s *current; //指向当前使用的小内存块
struct mp_large_s *large; //指向大块内存链表的开头
struct mp_node_s head[0];
}mp_pool_s;
前面两个结构体也很好理解。最后一个mp_pool_s的结构我们需要结合下面的初始化内存池的函数来讲解。
我们的内存池需要下面6个接口
用来进行初始化销毁和分配释放
struct mp_pool_s *mp_create_pool(size_t size);
void mp_destory_pool(struct mp_pool_s *pool);
void *mp_alloc(struct mp_pool_s *pool, size_t size);
void *mp_nalloc(struct mp_pool_s *pool, size_t size);
void *mp_calloc(struct mp_pool_s *pool, size_t size);
void mp_free(struct mp_pool_s *pool, void *p);
- mp_create_pool
//创建一个内存池 size是内存池的小块用户空间的大小
struct mp_pool_s *mp_create_pool(size_t size){
struct mp_pool_s *p; //内存池的指针
//给内存池结构体分配空间
//传入的是二级指针,因为我们要修改指针的内容
//MP_ALIGNMENT是对齐的地址大小,代表以32的倍数对齐
int ret =posix_memalign((void**)&p,MP_ALIGNMENT,size + sizeof(struct mp_pool_s) + sizeof(struct mp_node_s));
if(ret){
return NULL;
}
//MP_MAX_ALLOC_FROM_POOL=4095 4096就分配大块
//size 小于4095 max 就等于size 否则就等于4095
p->max=(size<MP_MAX_ALLOC_FROM_POOL)?size:MP_MAX_ALLOC_FROM_POOL;
//这一部分结合图理解 p的current指针指向当前正在用的小块内存区域
p->current=p->head;
//大块内存区域还没有数据
p->large=NULL;
//转化为char 类型指针是为了让指针的加法以1为偏移量
//last指向了用户空间的 开头位置
p->head->last=(unsigned char *)p+sizeof(struct mp_pool_s)+sizeof(mp_node_s);
//end指向了用户空间的末尾
p->head->end=p->head->last+size;
return p;
}
我们来讲解一下这个函数。
我们先创建一个内存池的指针p,然后我们用posix_memalign分配对齐的内存,为什么要使用这个函数,因为malloc 4k这么大的空间有时候会分配不了。
posix_memalign
第一个参数是一个二级指针,因为我们需要修改一级指针的内容。
第二个参数是对齐值,必须是2的n次方,这里我们传入自己定义的常量
MP_ALIGNMENT,值为32
第三个参数就是分配的size大小。这里为什么等于
size + sizeof(struct mp_pool_s) + sizeof(struct mp_node_s)
这个size是作为函数参数传入的,我们传入的是4k。这个4k我们是作为用户的小块内存空间的。我们要确保用户空间一定有4k。为什么呢,我们先讲如何区分分配大块内存还是小块内存。
如何区分大块内存和小块内存
大块内存
size>=4096
小块内存
size<4096
所以如果用户需要的内存是4095 属于小块内存,但是我们程序最初申请的空间留给用户的没有4k,就会发生错误。
我们看一下这个结构体,然后我画了个示意图。
typedef struct mp_pool_s {
size_t max;
struct mp_node_s *current; //指向当前使用的小内存块
struct mp_large_s *large; //指向大块内存链表的开头
struct mp_node_s head[0];
}mp_pool_s;
假设我们只分配size大小的空间。实际上分配给用户的空间就是
4k-sizeof(struct mp_pool_s) - sizeof(struct mp_node_s)
所以我们要分配这么大的空间变成下面这样
此时head是一个柔性数组,指向的位置就是mp_node_t的开头。
然后我们看下head里面的指针
typedef struct mp_node_s { //小块内存
unsigned char *last; //指向可用内存的开头
unsigned char *end; //指向可用内存的结尾
struct mp_node_s *next;
// size_t failed;
}mp_node_s;
因为空间已经分配好了,所以接下来的代码都是用来初始化指针
我们让head->last指向用户区域的开头位置,end指向了末尾
//转化为char 类型指针是为了让指针的加法以1为偏移量
//last指向了用户空间的 开头位置
p->head->last=(unsigned char *)p+sizeof(struct mp_pool_s)+sizeof(mp_node_s);
//end指向了用户空间的末尾
p->head->end=p->head->last+size;
就像前面那个图
2. mp_alloc
初始化完了我们来看分配内存的函数
void *mp_alloc(struct mp_pool_s *pool, size_t size) {
unsigned char *m;
struct mp_node_s *p;
if (size <= pool->max) { //4095 分配小块内存
p = pool->current;
do {
//函数将 p->last 指针对齐到 MP_ALIGNMENT 的倍数
m = mp_align_ptr(p->last, MP_ALIGNMENT);
//并检查剩余空间是否足够容纳 size 大小的数据。
//如果足够,就将 p->last 更新为新的内存块的起始地址,并返回该地址作为分配的内存空间。
if ((size_t)(p->end - m) >= size) {
p->last = m + size;
return m;
}
p = p->next;
} while (p);
//如果遍历完所有节点仍然没有找到足够的空间,那么会调用 mp_alloc_block 函数来分配一个新的内存块,并返回分配的内存空间。
return mp_alloc_block(pool, size);
}
//分配大块内存
return mp_alloc_large(pool, size);
}
核心的逻辑很简单,先判断一下分配内存的大小,决定是否分配大块内存。
如果是小块内存,我们先遍历小块内存的链表结构,找到一个可以分配对齐内存池的空间。
如果找到我们就直接将空间地址m返回。如果没有,我们就用mp_alloc_block函数创建一个新的小块空间,然后分配内存。
那我们接着看着里面函数的具体实现。
mp_align_ptr
是一个宏定义
#define mp_align_ptr(p, alignment) (void *)((((size_t)p)+(alignment-1)) & ~(alignment-1))
具体的步骤如下:
-
将指针 p 转换为 size_t 类型,以便进行位运算。
-
将 alignment 减去 1,得到一个掩码,用于将指针向上对齐到 alignment 的倍数。
-
将指针 p 加上掩码,实现向上对齐。
-
使用按位与运算符 & 将对齐后的指针与掩码进行按位与操作,以确保最终的指针值是 alignment 的倍数。
-
最后,将对齐后的指针转换回 void* 类型,并返回对齐后的指针。
实现的效果就是将指针对齐到alignment指定的数值,这里我们是32,那么最后指针会是32字节的倍数。这里根据实际需求可以调整。
mp_alloc_large
这里执行分配大块内存的操作
static void *mp_alloc_large(struct mp_pool_s *pool, size_t size) {
//分配 size 字节大小的内存块,并将返回的指针存储在变量 p 中
void *p = malloc(size);
if (p == NULL) return NULL;
//函数遍历内存池 pool 中的大块内存链表 pool->large
//直到找到一个未分配的大块内存或者遍历了超过 3 个大块内存。查找太多影响效率
//如果找到了未分配的大块内存,将其 alloc 字段设置为 p,即将分配的内存块指针存储在其中,并返回该指针。
size_t n = 0;
struct mp_large_s *large;
for (large = pool->large; large; large = large->next) {
if (large->alloc == NULL) {
large->alloc = p;
return p;
}
if (n ++ > 3) break;
}
//在这段代码中,调用 mp_alloc 函数分配一个 sizeof(struct mp_large_s) 大小的内存块,用于存储新的大块内存结构。
large = mp_alloc(pool, sizeof(struct mp_large_s));
//如果内存分配失败,会释放之前分配的内存块 p,然后返回 NULL,表示分配失败。
if (large == NULL) {
free(p);
return NULL;
}
//头插法
large->alloc = p;
large->next = pool->large;
pool->large = large;
return p;
}
这个函数本身没什么难度,就是在链表中找到未分配的区域或者创建一个新的大块内存。
申请完空间后我们将这部分内存的管理结构存入链表中,头插法
mp_alloc_block
static void *mp_alloc_block(struct mp_pool_s *pool, size_t size) {
//定义一个指针m 它指向被分配内存的开头
unsigned char *m;
//拿到头节点的控制块 也是小块指针链表的入口位置
struct mp_node_s*h =pool->head;
//计算要分配内存 应该等于 4k +sizeof(mp_node_s)
size_t psize= (size_t)(h->end-(unsigned char *)h);
//这里分配内存
int ret =posix_memalign((void**)&m,MP_ALIGNMENT,psize);
if (ret) return NULL;
//创建新的节点
struct mp_node_s*p,*new_node,*current;
//这里先将指针 m 转换为 struct mp_node_s* 类型
//这样保证用户空间为4k
new_node=(struct mp_node_s*)m;
//指向用户空间的结尾
new_node->end=m+psize;
//next指针置为null
new_node->next=NULL;
new_node->failed=0;
//这里给用户分配内存
//将m移动到用户空间的开头位置
m+=sizeof(struct mp_node_s);
//对m进行指针的对齐
m = mp_align_ptr(m, MP_ALIGNMENT);
//更新新节点的last
new_node->last=m+size;
//拿到当前的current
current = pool->current;
//这里将刚刚创建的内存块插入链表中 尾插法
for (p = current; p->next; p = p->next) {
//在遍历过程中,如果节点 p 的失败计数器 failed 大于 4,则将当前节点 current 更新为 p 的下一个节点 p->next。
//这个计数器会使得current的值前移
//因为current之前的节点也可能存在没有用完的内存
if (p->failed++ > 4) {
current = p->next;
}
}
p->next = new_node;
//current非空就把内存池的current更新
pool->current = current ? current : new_node;
return m;
}
3 .mp_destory_pool
在这里进行资源回收,代码很简单,free掉我们分配的内存就行了
void mp_destory_pool(struct mp_pool_s *pool){
struct mp_node_s *h, *n;
struct mp_large_s *l;
for (l = pool->large; l; l = l->next) {
if (l->alloc) {
free(l->alloc);
}
}
h = pool->head->next;
while (h) {
n = h->next;
free(h);
h = n;
}
free(pool);
}