C/C++ young library 设计与实现 — 内存池

来源:互联网 发布:linux系统下安装软件 编辑:程序博客网 时间:2024/04/29 04:47
    本篇介绍程序库中的内存池算法。
    内存池函数的声明文件为: young/youngc/yc_memory.h
    内存池函数的实现文件为: young/youngc/yc_memory.c


    **3.1**
    首先来看一下内存池算法用到的一些类型和常量。

    下面的类型和常量定义在头文件 yc_definition.h 内;
    硬件字节类型 :ylib_byte_t;

    下面的结构和常量声明于实现文件 yc_memory.c 内;
    内存池属性:
    DEFAULT_PAGE_SIZE:     内存页可供分配的字节数;
    MEMORY_POOL_LISTS:     内存池包含的内存链数;
    MEMORY_POOL_BUFFER:    内存池每个链的缓存大小;
    MEMORY_POOL_MIN_BYTES: 内存池管理的最小字节数;
    MEMORY_POOL_MAX_BYTES: 内存池管理的最大字节数。

    内存块类型:
    typedef  struct memory_block
    {
        struct memory_block*  next;
    } memblk_t;

    内存页类型:
    typedef struct memory_page
    {
        struct memory_page*  next;  /* 下一个内存页 */
        size_t               count; /* 未分配的内存块数 */
        memblk_t*            free;  /* 未分配的内存块链表 */
    } mempage_t;

    内存链类型:
    typedef struct memory_list
    {
        size_t      page_size;                  /* 内存页内可供分配的字节数 */
        size_t      useable;                    /* 未满的可供分配的内存页数 */
        mempage_t*  buffer[MEMORY_POOL_BUFFER]; /* 页面缓存 */
        mempage_t*  first;                      /* 第一个内存页 */
    } memlist_t;


    **3.2**
    内存池原理图:
    索引:0      1     2     3           ......         MEMORY_POOL_LISTS - 1
    --------------------------------------------------------------------------
    | page_size |     |     |     |        ......        |                     |
    --------------------------------------------------------------------------
    |  useable  |
    -------------
    |   buffer  |
    -------------
    |   first   |
    -------------
        |
        |
        |    ---------
        ->|  next  |--
            |--------|   |
            |  count |   |
            |--------|   |
            |  free  |   |
            |--------|   |
            | memory |   |
            ---------   |
                        |
    -----------
    |
    |    ---------
    ->|  next  | --> NULL
        |--------|
        |  count |
        |--------|
        |  free  |
        |--------|
        | memory |
        ---------

    实际上,内存池的全称应该是小内存块内存池,内存池管理的内存块的上限是
MEMORY_POOL_MAX_BYTES 个字节,超过这个数值的申请要求会被转调用至 MEMALLOC 函数,
正因为此,所以在释放内存的时候必须显示地指定要释放的内存块的大小,这样才能确保
正确地释放。
    内存池是一个 memlist_t 指针类型的数组,数组的大小为 MEMORY_POOL_LISTS 数组内
的每个指针元素指向一条内存链,每个内存链由大小不同的内存页以单向链表的形式组成。
为了方便管理,凡是向内存池申请小于等于 MEMORY_POOL_MAX_BYTES 个字节的内存块都会
被自动调整为 MEMORY_POOL_ALIGN 的倍数,而每个内存链则管理着不同大小的内存块,可
用该公式计算:内存链管理的内存块大小 = MEMORY_POOL_MIN + 索引 * MEMORY_POOL_ALIGN。
    由于每个内存链管理的内存块都很小,为了减少向系统申请内存的次数和内存碎片,内
存链的每个节点并不是内存块,而是一种比内存块大的多的被称为内存页的数据结构。内存
页由四部分组成:
    1、next 是指向下一个内存页的指针;
    2、count 是内存页中可供分配的内存块数;
    3、free 是指向可分配内存块链表的指针;
    4、内存页中用以分配内存块的缓存区,缓存大小由内存链的 page_size 成员决定。


    **3.2**
    分配功能的实现。

    内存功能功能由函数 pool_alloc 实现,其声明如下:
    void* pool_alloc( size_t bytes );
    函数的参数是申请的动态内存字节数,返回值为一个指向动态内存首地址的空指针,
如果分配失败,返回的值为 NULL。
    如前所述,内存池处理的内存块大小是有上限的,当申请的字节数大于阈值的时候,则
直接 MEMALLOC 函数,只有当申请的字节数小于等于阈值的时候才会使用内存池进行分配,
这个阈值就是前面提到的 MEMORY_POOL_MAX_BYTES。
    为了便于分配,内存池不一定会刚好分配用户申请的那么大的内存,而是会对申请的
值进行一些调整。内存池不会分配小于 MEMORY_POOL_MIN_BYTES 的内存块,算法会将分配
的字节数调整至刚好不小于申请的字节数的 MEMORY_POOL_ALIGN 的倍数,默认管理的最小
内存块和对齐的字节数均为sizeof(void*),在32位的机器上其值为4。举几个例子:假设用
户申请的是14个字节,由于14不是4的倍数,算法自动将申请的字节数调整为刚好大于14的4
的倍数,也就是16。
    在完成了字节对齐后,就可以确定由哪个内存链来进行分配,确定内存链后,先进入
该链的页面缓存,在页面缓存中寻找是否有可供使用的内存页。链的页面缓存是长度等于
MEMORY_POOL_BUFFER 的一个 mempage_t* 数组,对数组进行遍历,寻找第一个不等于 NULL
的值,这个值就是需要的内存页指针。需要注意的是,被分配的内存页是连在 mempage_t
下面的,所以在查找内存块时,需要对指针进行偏移操作,跳过 sizeof(mempage_t) 个字
节。
    在找到了有空闲内存块的内存页后,将该内存页 free 指向的第一个空闲内存块取出,
作为返回的值,随后调整 free 指向第二个内存块。
    但是缓存并不总是会有内存页的,当缓存为空时,就需要对链表进行遍历,以找到可用
的内存页。对链表的遍历并不是找到第一个可分配的内存页后就停止,这样做会让后面的分
配在不久之后又必须要遍历链表,来一趟不容易,不如来了以后多做点事,免得以后老是要
跑来爬楼梯。所以内存池的链表遍历实际上是一个整理操作,它会在遍历的过程中动态调整
链表的排列,把找到的可供分配的内存页逐一上调到链表的首位,这样遍历完成后,所有的
可分配内存页都被上调至了链表的前部,在调整的同时用找到的未满内存页重新填充页面缓
存。这里需要注意的是遍历的页面数,其实并不需要将链表这个遍历,在遍历的同时,用一
个无符号整数 count 记录遍历过的未满内存页,当 count == useable 的时候,表示后面已
经没有可供分配的内存页了,此时就可退出循环。
    以上的行为均在 useable 大于 0 的时候发生,而当 useable 等于 0 的时候,既不需
要遍历页面缓存,也不需要遍历链表,直接调用底层动态内存分配函数 MEMALLOC 分配一个
内存页,将分配的内存页挂在内存链的首位,同时放入页面缓存,再将可用内存分割成一个
个小内存块并串联起来,最后返回串联起来的内存块链表中的第一个内存块即可。需要注意
的一点是内存页内可供分配的字节数是由链表的 page_size 成员来指定的。page_size 初
始时等于 0,当进行内存页申请的时候,如果 page_size = 0,则计算该内存链下每个内存
页需占用的字节数。为了确保不浪费内存,page_size 的计算结果必须要能被当前内存链管
理的内存块大小整除,如果 DEFAULT_PAGE_SIZE % BLOCK_SIZE(index) = 0,则令 page_size
等于 DEFAULT_PAGE_SIZE,否则令 page_size等于 BLOCK_SIZE(index) 的整数倍。
    可用如下代码测试内存池内所有内存链的属性:
    for( i = 1; i <= 256; ++i )
        pool_alloc(i);
    pool_print();


    **3.3**
    回收功能的实现。

    回收实际上分为两个操作,一个是回收,一个是释放。
    回收操作由函数 pool_dealloc 实现,其声明如下:
    void pool_dealloc( void* ptr, size_t bytes );
    函数的第一个参数是指向要回收的内存块的指针,第二个参数是要回收的内存块的大小。
    进入函数后,先判断指针是否为空,接着判断内存块的大小,如果大于内存池所能管理
的最大内存块,则直接调用 MEMFREE 将之释放;如果小于等于则有可能是由内存池分配出去
的。注意,只是有可能而已!
    根据内存块的大小,先计算出内存块理论上所属的内存链,然后对该内存链进行遍历,
在遍历内存页的过程中,判断内存块的地址是否落在内存页的首地址和末地址之间,如果是
的话,则表示找到了内存块所属的内存页,如果没有,则继续遍历,如果遍历至链尾依然没
有找到,则表示该内存块不是由内存池分配的,直接调用 MEMFREE 将之释放。
    在确定了内存块所属的内存页后,依然不能马上执行回收操作,还必须确定内存块的地
址是否正确。由于分配时,是以 BLOCK_SIZE(index) 来进行对齐的,所以回收时的地址也必
须能满足这个对齐要求,亦即满足条件:(块地址 - 页首地址) % BLOCK_SIZE(index) == 0,
验证无误后,方能执行回收操作。
    回收的操作很简单,只需要将内存块回收至空闲块链表即可。随后将内存页调整至链首,
这样做一来可以方便下次回收,二来可以方便当页面缓存使用完后,对链表的快速遍历。
    回收操作只是把回收的内存块重新放进内存池,并不会把空闲的内存页释放给系统。假
如应用程序向内存池申请了大量的内存而后由内存池回收,此时内存池的使用率接近 0%,而
其他的应用程序却因为大量内存被内存池占用而无法正常运行,此时需要将内存池占用的大
量空闲内存释放给系统以供其他应用程序使用。很显然,回收操作并不能胜任,这个工作必
须由释放操作来完成。
    释放操作由函数 pool_free 实现,其声明如下:
    void pool_free( void* ptr, size_t bytes );
    函数的第一个参数是指向要回收的内存块的指针,第二个参数是要回收的内存块的大小。
    释放操作分为两步,第一步直接调用 pool_dealloc 完成内存块的回收,第二步遍历整
个链表,在遍历的过程中将空闲的内存页释放给系统。注意,第一个参数是可以为 NULL 的,
此时,将跳过第一步,直接执行第二步。
    为什么在释放操作里是对整个链表的遍历呢?因为我预期大部分的时候执行的都会是归
还操作,只有当系统内存紧张的时候才需要执行释放操作,此时释放操作执行一次即可满足
要求,接下来就又可以使用回收操作了。譬如,可以调用 get_pool_dealloc_count 函数了
解执行了回收的次数,当达到某个阈值的时候就可以调用 pool_free 释放一些空闲的内存页。


    **3.4**
    并发控制。

    在多线程环境下,内存池的操作将会因线程之间的切换而崩溃!为此,内存池在实现的
时候提供了并行加锁和解锁操作。为了移植,加锁和解锁的实现交由用户来实现,内存池只
负责调度。
    设置加锁函数:void set_pool_lock( void (*lock)(size_t index) )。
    设置解锁函数:void set_pool_unlock( void (*unlock)(size_t index) )。
    两个函数的参数都是一个声明原型如 void f(size_t) 的函数指针。这里解释一下传递
进来的加锁和解锁函数为什么需要一个 size_t index 参数。这个 index 参数就是内存链的
索引值,仔细观察一下就会发现,实际上每个内存链是相互独立的,内存池最多可以允许
MEMORY_POOL_LISTS 个在不同的链表内的线程同时操作。通过 index 这个参数,实现加锁和
解锁功能的用户就可以对不同的链表分别进行加锁和解锁。例如在 Windows 系统下,可以先
调用 get_pool_lists_count 函数以获取内存链的总数,然后创建一个互斥量数组,数组的大
小就等于内存链的总数,在实现的加锁和解锁函数里可以根据 index 参数决定是对哪个互斥
量进行操作。
    如果用户没有设置这两个并发加锁、解锁函数,但是在 yc_configuration.h 中定义了
__MACRO_C_YOUNG_LIBRARY_OPERATING_SYSTEM_SUPPORT_POSIX_THREAD__ 宏,则程序库将自
动启用 POSIX 多线程互斥控制。如果以上两者用户均设置了,则程序库会优先调用用户传递
进来的并发控制函数。


    **3.5**
    模板化。

    在 C++ 中使用内存池可以借助模板来进行一下包装。STL 中的内存分配器已经为我们
提供了一个样本。
    模板化的内存池源码在 young/youngcpp/ycpp_memory.hpp 中。下面只列出三个主要的
成员函数。
    pointer allocate( size_type n )
    {
        return (pointer)( youngc::pool_alloc( sizeof(T) * n ) );
    }

    void deallocate( pointer ptr, size_type n )
    {
        youngc::pool_dealloc( ptr, sizeof(T) * n );
    }

    ~pool_allocator()
    {
        youngc::pool_free( NULL, sizeof(T) );
    }


    **3.6**
    移植。

    内存池的实现大部分都是不依赖于任何系统的标准 C 代码,需要修改的只是调用动态
内存分配和释放的两个系统函数,为了便于移植,将它们定义为了两个宏,在一些嵌入式系
统中可能需要重新定义:
    #define  MEMALLOC( bytes)  malloc( bytes )
    #define  MEMFREE( ptr )    free( ptr )
    另外一个可能会带来移植问题的函数是 pool_print,有些嵌入式系统的 C 编译器不支
持标准输入和输出,如果移植到这些平台的时候需要把这个函数注释掉。
    若要修改内存池的属性,可修改 3.1 中介绍的几个属性枚举值。
    在不需要并发编程对函数指针支持的又不好的硬件平台上,把 pool_lock 和 pool_unlock
函数指针以及 set_pool_lock 和 set_pool_unlock 函数注释掉即可。
原创粉丝点击