前两篇介绍了内存池的基本结构,以及小块内存如何通过 last 指针顺序分配。
小块内存可以直接从内存池节点中取得,但如果一次申请的空间很大,继续从固定大小的节点中分配就不太合适。因此,内存池通常会将申请分为两种情况:
小块内存:直接从内存池节点中顺序分配
大块内存:单独调用 malloc 或 posix_memalign 申请
本文重点介绍大块内存为什么需要单独处理,以及内存池如何记录和释放这些大块内存。
一、为什么要区分小块内存和大块内存
内存池创建时,会设置一个小块内存的最大申请值:
#define MP_PAGE_SIZE 4096
#define MP_MAX_ALLOC_FROM_POOL (MP_PAGE_SIZE - 1)
内存池结构中使用 max 保存该值:
struct mp_pool_s {
size_t max; // 小块内存的最大申请值
struct mp_node_s *current; // 当前优先分配的节点
struct mp_large_s *large; // 大块内存记录链表
struct mp_node_s head[0]; // 第一个小块内存节点
};
创建内存池时:
pool->max = size < MP_MAX_ALLOC_FROM_POOL
? size
: MP_MAX_ALLOC_FROM_POOL;
假设创建了一个大小为 4096 字节的内存池,那么:
pool->max = 4095
调用 mp_alloc() 时,会根据申请大小选择不同的分配方式:
void *mp_alloc(struct mp_pool_s *pool, size_t size)
{
if (pool == NULL || size == 0) {
return NULL;
}
if (size <= pool->max) {
// 小块内存:从已有内存节点中分配
return mp_alloc_small(pool, size);
}
// 大块内存:单独调用malloc申请
return mp_alloc_large(pool, size);
}
之所以不让大块内存直接占用小块内存节点,主要有以下几个原因:
1. 大块申请可能一次占满整个节点;
2. 会造成节点中剩余空间难以继续利用;
3. 频繁的大块申请会导致节点链表快速增长;
4. 大块内存通常需要支持单独释放;
5. malloc更适合处理较大的独立内存申请。
例如,一个内存节点只有 4096 字节,如果申请 8192 字节,显然无法直接从当前节点中分配。
即使申请 3000 字节可以勉强放下,也可能导致剩余空间过小,后续很难继续利用。
因此,可以将内存申请分成两条路径:
mp_alloc(pool, 128)
↓
128 <= pool->max
↓
从小块内存节点中分配
mp_alloc(pool, 8192)
↓
8192 > pool->max
↓
调用malloc单独申请
二、大块内存链表如何记录申请结果
大块内存由 malloc() 单独申请,但内存池销毁时必须知道这些地址,否则无法统一释放。
因此,需要定义一个大块内存记录结构:
struct mp_large_s {
struct mp_large_s *next; // 指向下一条大块内存记录
void *alloc; // 保存实际申请到的内存地址
};
需要注意,mp_large_s 本身不是那块大内存,它只是一个管理记录。
假设申请了三块大内存:
void *p1 = mp_alloc(pool, 8192);
void *p2 = mp_alloc(pool, 10000);
void *p3 = mp_alloc(pool, 16384);
内存之间的关系可以表示为:
pool->large
|
v
+-------------+ +-------------+ +-------------+
| alloc = p3 | --> | alloc = p2 | --> | alloc = p1 |
| next | | next | | next = NULL |
+-------------+ +-------------+ +-------------+
| | |
v v v
16384字节 10000字节 8192字节
其中:
large节点:记录大块内存的信息
alloc指针:指向真正由malloc申请的空间
大块内存记录采用头插法:
large->next = pool->large;
pool->large = large;
每次新增记录都会成为链表头节点。
这样做不需要遍历链表尾部,插入操作的时间复杂度为:
O(1)
另外,大块内存记录结构本身比较小,可以直接从小块内存池中申请:
large = mp_alloc(
pool,
sizeof(struct mp_large_s)
);
因为 sizeof(struct mp_large_s) 通常远小于 pool->max,所以这次调用会进入小块内存分配流程,不会再次进入大块分配,因而不会形成无限递归。
三、大块内存的完整申请过程
大块内存申请函数如下:
/**
* 单独申请一块大内存,并将地址记录到large链表中
*/
static void *mp_alloc_large(
struct mp_pool_s *pool,
size_t size)
{
if (pool == NULL || size == 0) {
return NULL;
}
// 第一步:使用malloc申请真正的大块内存
void *p = malloc(size);
if (p == NULL) {
return NULL;
}
/*
* 第二步:尝试复用之前已经被释放的管理节点。
*
* 大块内存被单独释放后,
* 对应记录节点的alloc会被设置为NULL。
*/
size_t count = 0;
struct mp_large_s *large;
for (large = pool->large;
large != NULL;
large = large->next) {
if (large->alloc == NULL) {
// 找到空闲记录,直接保存新地址
large->alloc = p;
return p;
}
/*
* 不需要遍历特别长的链表。
* 检查有限数量的记录后仍未找到,
* 就直接创建新的管理节点。
*/
if (count++ > 3) {
break;
}
}
/*
* 第三步:没有可复用的记录节点,
* 从小块内存池中申请一个mp_large_s。
*/
large = mp_alloc(
pool,
sizeof(struct mp_large_s)
);
if (large == NULL) {
// 管理节点创建失败,避免大块内存泄漏
free(p);
return NULL;
}
// 保存实际申请到的大块内存地址
large->alloc = p;
// 使用头插法加入大块内存链表
large->next = pool->large;
pool->large = large;
return p;
}
整个过程可以概括为:
申请大小超过pool->max
↓
调用malloc申请大块内存
↓
检查large链表中是否存在空闲记录
↓
存在:复用原来的记录节点
↓
不存在:从小块内存池申请新的记录节点
↓
将记录插入large链表头部
↓
返回大块内存地址
这里有一个非常重要的错误处理:
large = mp_alloc(pool, sizeof(struct mp_large_s));
if (large == NULL) {
free(p);
return NULL;
}
如果大块内存已经通过 malloc() 申请成功,但管理节点申请失败,必须立即释放大块内存。
否则,函数返回后就没有任何指针能够管理这块空间,会产生内存泄漏。
正确的资源创建顺序是:
申请大块内存
↓
创建管理节点
↓
管理节点创建失败
↓
释放已经申请的大块内存
四、大块内存如何单独释放和对齐申请
小块内存采用顺序分配,通常不会逐个释放;大块内存由 malloc() 单独申请,因此可以支持单独释放。
释放函数如下:
/**
* 释放由当前内存池申请的大块内存
*
* 注意:该函数只负责释放large链表中的大块内存,
* 不能单独释放小块内存。
*/
void mp_free(struct mp_pool_s *pool, void *p)
{
if (pool == NULL || p == NULL) {
return;
}
struct mp_large_s *large;
// 遍历大块内存记录链表
for (large = pool->large;
large != NULL;
large = large->next) {
// 找到对应的大块内存地址
if (p == large->alloc) {
free(large->alloc);
/*
* 管理节点本身属于小块内存池,
* 不进行单独释放。
*
* 将alloc设置为NULL,
* 后续申请大块内存时可以复用该记录。
*/
large->alloc = NULL;
return;
}
}
}
假设链表原来为:
pool->large
|
v
large3 → large2 → large1
| | |
p3 p2 p1
调用:
mp_free(pool, p2);
之后:
pool->large
|
v
large3 → large2 → large1
| | |
p3 NULL p1
这里不会删除 large2 节点,而是将:
large2->alloc = NULL;
下一次申请大块内存时,可以直接复用这条记录:
申请新的大块内存p4
↓
发现large2->alloc == NULL
↓
large2->alloc = p4
这样可以避免频繁申请新的管理节点。
对齐的大块内存申请
有些场景不仅要求申请一块大内存,还要求地址满足特定的对齐值,例如:
- 32 字节对齐;
- 64 字节对齐;
- 页对齐;
- SIMD 指令使用的内存;
- 某些硬件缓冲区。
可以提供 mp_memalign():
/**
* 申请一块满足指定对齐值的大内存
*/
void *mp_memalign(
struct mp_pool_s *pool,
size_t size,
size_t alignment)
{
if (pool == NULL ||
size == 0 ||
alignment == 0) {
return NULL;
}
void *p = NULL;
// 申请满足alignment要求的内存
int ret = posix_memalign(
&p,
alignment,
size
);
if (ret != 0) {
return NULL;
}
// 创建一条大块内存管理记录
struct mp_large_s *large =
mp_alloc(
pool,
sizeof(struct mp_large_s)
);
if (large == NULL) {
free(p);
return NULL;
}
// 保存对齐内存地址
large->alloc = p;
// 插入large链表头部
large->next = pool->large;
pool->large = large;
return p;
}
使用方式如下:
// 申请8192字节,并保证起始地址按64字节对齐
void *buffer = mp_memalign(pool, 8192, 64);
if (buffer == NULL) {
printf("对齐内存申请失败\n");
}
这块内存同样会被记录在 large 链表中,因此可以使用:
mp_free(pool, buffer);
进行单独释放。
五、完整使用示例与注意事项
下面给出一个大块内存申请和释放的简单示例:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
// 创建4096字节的小块内存池
struct mp_pool_s *pool =
mp_create_pool(4096);
if (pool == NULL) {
printf("内存池创建失败\n");
return -1;
}
/*
* 128字节小于pool->max,
* 从小块内存节点中分配。
*/
void *small = mp_alloc(pool, 128);
/*
* 8192字节大于pool->max,
* 通过malloc单独申请,
* 并记录到large链表。
*/
void *large1 = mp_alloc(pool, 8192);
/*
* 申请一块64字节对齐的大内存。
*/
void *large2 =
mp_memalign(pool, 16384, 64);
printf("small = %p\n", small);
printf("large1 = %p\n", large1);
printf("large2 = %p\n", large2);
/*
* 大块内存支持单独释放。
*/
mp_free(pool, large1);
mp_free(pool, large2);
/*
* small属于小块内存,
* 不能通过mp_free单独释放。
*
* 最终销毁或重置内存池时统一处理。
*/
mp_destory_pool(pool);
return 0;
}
大块内存的完整管理流程为:
调用mp_alloc申请内存
↓
判断size是否超过pool->max
↓
超过:调用malloc单独申请
↓
创建或复用large管理节点
↓
将地址记录到large链表
↓
返回给用户使用
↓
调用mp_free单独释放
↓
将管理节点的alloc设置为NULL
↓
后续大块申请可以复用该记录
使用时需要注意以下几点:
1. mp_free主要用于释放大块内存;
2. 小块内存不能单独归还,只能统一重置或销毁;
3. 不能使用mp_free释放不属于当前内存池的地址;
4. 同一个大块地址不能重复释放;
5. 管理节点申请失败时,必须释放已经申请的大块内存;
6. 内存池销毁时,还要遍历large链表释放未手动释放的大块内存。
另外,在创建第一个小块内存节点时,需要确保链表指针被正确初始化:
pool->head->next = NULL;
否则后续遍历节点链表时,可能访问未初始化的随机地址。
大块内存管理的核心可以总结为:
小块内存从池内顺序分配;
大块内存通过malloc单独申请;
large链表记录所有大块地址;
大块内存可以通过mp_free单独释放;
空闲的large记录可以继续复用;
销毁内存池时统一清理剩余大块内存。