[C/C++后端开发学习]15 简单内存池实现

文章目录

  • 为什么需要内存池
  • 内存池的设计策略对比
  • 内存池分配方法设计
    • 内存池结构
    • 空洞的利用
    • 内存释放
    • 数据结构设计
    • 内存池操作接口实现
      • 初始化内存池
      • 申请内存
      • 释放内存
      • 用户主动清理内存池
      • 销毁内存池
      • block的自动清理
      • 内存池使用情况统计
    • 测试程序
  • 补充: 内存泄漏的排查

为什么需要内存池

简而言之,反复地进行malloc和free不利于内存管理,同时容易产生内存碎片。复杂的代码中还容易出现内存泄漏问题。内存池则提前分配好大块内存作为备用,然后根据用户需求提供现成的小内存块给程序使用。相对来说使用内存池的效率更高一些,也不那么容易产生内存碎片,同时因为对内存块的集中管理,也可以很好地避免内存泄漏问题,即使出现了泄漏也很容易排查。

内存池的设计策略对比

  • 由一个大的整块分散成多个小块,回收时再整合成大块(如伙伴系统)。一般以页为单位分配,回收时内存块地址必须连续才能整合起来。
  • 提前划分好多个小块,随时回收(如slab分配器)。
  • 多个小块不随时回收,在需要回收且满足回收条件时一起全部回收。相对来说更简单实用一些,适用于特定的业务场景。

内存池分配方法设计

内存池结构

内存池以block为单位向系统申请内存,我们不妨把用户向内存池申请的小块内存叫作piece。这里以block大小为4k的内存池为例进行说明。

多个block如何组织起来呢?通过链表来将各个block串在一起不失为一种简单的好办法。同时,当一个block不够分配时,就新建一个block插入链表来使用。为了管理每个block,我们给每个block分配一个描述符。

那么描述符保存到哪里呢,单独申请内存来存吗?显然没必要。block的描述符就位于每个block内存区域的开头。因此,内存池向系统申请的实际内存大小要考虑这些描述符占用的空间,比如一个4k的block实际大小是4k+sizeof(MP_BLOCK)。

实际上内存池分配给用户的仅仅只是一个指针,指向所分配piece区域的首地址,当用户需要释放内存时也只是传入这个指针。为了快速知道这个piece属于哪个block以方便对block的使用情况进行统计,可以给每个piece增加一个简单的描述符,保存一个指向所属block的指针。这个描述符不宜存储其他内容,否则对于比较小的piece其描述符可能比它自身的空间还大,就显得不划算了。

当用户申请内存时,我们只管从内存池的哪个位置划出下一个piece的空间给用户使用,而不管之前申请的piece各自的状态。因为当我们回收内存时,会直接将整个block回收,所以特定piece是否还在使用就无所谓了,只需要看整个block的状态。

那么对于大于4k的大内存块如何处理呢?对于这种大块我们不妨称其为bucket,我们对其做单独处理,根据其大小要求向系统申请独立的内存块给它,并且将bucket描述符也保存到block。同时将所有的bucket用一个单独的链表串接起来方便管理,也方便与普通piece进行区分。

于是最终内存池的结构如下图所示:
[C/C++后端开发学习]15 简单内存池实现_第1张图片

空洞的利用

每个block的末尾难免会出现没有被使用的空洞,因此每次用户申请内存块时,内存池可以先遍历一下每个block节点,查看一下block剩下的空洞是否能够满足用户的要求,如果可以则这些空洞能够被使用。但是当block的链表很长时,这样的每次遍历不够高效。因此可以统计在某个block节点中申请内存失败的次数,如果失败次数达到某个值,则下一次就不再查看该block了,直接从其后面的block节点开始查看。

内存释放

当用户指定某个piece的首地址进行释放时,其实我们暂时什么也不做,这个piece依然在那里,等到特定时机再由内存池统一清理其所在block。当然,如果用户指定释放的地址是一个bucket,那么自然要把bucket的空间释放掉归还给系统。这使得我们的内存池在管理上非常简单而高效。

只有当整个block都处于释放状态时,才进行该block的清理工作,也就是将整个block空间清零(不必归还给系统)。所谓清零,其实就是改变某些标志使block的状态复原,并不需要真的把内存区域全写为0,这也使得管理工作非常的高效。

那么,怎么判断所有内存块都处于释放状态呢?可以由用户主动调用特定接口来使整个内存池复位,也可以通过给每个block增加一个引用计数来实现。一个block中每被申请一个piece引用计数就加1(bucket的描述符也算一个piece);相反的每次释放时引用计数就减1。当一个block的引用计数减到0时,就清理这个block,使之可以用于下一次内存申请。

数据结构设计

定义block的描述符如下:

typedef unsigned char* ADDR;

struct _MP_BLOCK {
   
    struct _MP_BLOCK* next;
    ADDR start_of_rest;     // 当前 block剩余空间的起始地址
    ADDR end_of_block;      // 当前 block的最后一个地址加1
    int failed_time;        // 这个 block被申请内存时出现失败的次数
    int ref_counter;        // 引用计数
};
typedef struct _MP_BLOCK MP_BLOCK;

piece描述符如下:

struct _PIECE {
   
    MP_BLOCK* block;         // 所处的 block
    unsigned char data[0];	 // 仅作为内存起始地址,不占用空间
};
typedef struct _PIECE PIECE;

bucket也单独分配一个描述符,并且在申请bucket时将其描述符保存在一个piece中。数据结构的定义如下:

struct _MP_BUCKET {
            // 超过 BLOCK_SIZE 内存用 MP_BUCKET 描述
   struct _MP_BUCKET* next;
   int still_in_use;        // 这个 bucket当前有没有被释放掉,如果被释放了可以尝试复用
   ADDR start_of_bucket;
};
typedef struct _MP_BUCKET MP_BUCKET;

内存池操作接口实现

初始化内存池

#define MP_PAGE_SIZE (4 * 1024) // 页大小4k,不要让用户设置的 block大小超过这个值
#define MP_MIN_BLK_SZIE 128     // 一个块也不能太小了
#define MP_MEM_ALIGN 32
#define MP_MAX_BLOCK_FAIL_TIME 4

static inline void init_a_new_block(MP_POOL* pool, MP_BLOCK* newblock)
{
   
    newblock->next = NULL;
    newblock->ref_counter = 0;
    newblock->failed_time = 0;
    newblock->start_of_rest = (ADDR)newblock + sizeof(MP_BLOCK);
    newblock->end_of_block = newblock->start_of_rest + pool->block_size;
}
MP_POOL* mp_create_pool(size_t size, int auto_clear)
{
   
    if(size < MP_MIN_BLK_SZIE) return NULL;

    MP_POOL* pool;
    size_t block_size = size < MP_PAGE_SIZE ? size : MP_PAGE_SIZE;
    size_t real_size = sizeof(MP_POOL) + sizeof(MP_BLOCK) + block_size;
    int ret = posix_memalign((void**)&pool, MP_MEM_ALIGN, real_size);
    if(ret)
    {
   
        log("[%d]posix_memalign error[%d].\n", __LINE__, errno);
        return NULL;
    }
    pool->block_size = block_size;
    pool->auto_clear = auto_clear;
    pool->first_bucket = NULL;
    pool->current_block = pool->first_block;  

你可能感兴趣的:(C/C++后端开发学习笔记,linux,内核,c++)