【C/C++】手写内存池(三):大块内存的申请、记录与单独释放

【C/C++】手写内存池(三):大块内存的申请、记录与单独释放 前两篇介绍了内存池的基本结构以及小块内存如何通过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记录可以继续复用 销毁内存池时统一清理剩余大块内存。0voice · GitHub