图解 slab 内存分配算法

图解 slab 内存分配算法

1. 前言

学习内存分配算法,往更深层次底层知识学习,这是一条少有人走的路,但成功一定很酷。

关于slab算法,网上也有很多文章介绍,也不乏有很多讲解的很全面的,但是感觉都还是很难以理解消化,此篇文章继续采用之前的风格,以图解的方式对整个算法原型进行讲解,由浅入升,相信这回事一篇成功之作。

在之前我们已介绍过小内存分配算法和静态内存池分配算法,但是对应都有各自的优劣势,本文我们开始继续图解 slab 内存分配算法。

最原始的 SLAB 算法是 Jeff BonwickSolaris 操作系统而引入的一种高效内核内存分配算法,在 linux 内核内也有所使用。在阅读本文之前可以先阅读前两篇内存分配算法文章:

推荐的原因是,对于slab算法的实现,其实是上述两种算法的一种结合,因此先理解上述两种内存分配算法之后再来学习slab分配算法效果会更好。

本文以 rt-threadslab 算法为基,分析 slab 的实现过程,源码见 RT-Thread/rt-thread/blob/lts-v3.1.x/src/slab.c。需注意的是 rt-thread 内的 slab 算法精简掉了对象的构造和析构过程,只保留了纯粹的缓冲型内存池算法的部分,但这更有利于降低复杂度来学习算法的底层原理。接下来就进入正文吧~

2. Slab 算法详解

在学习 slab 算法之前,先讲解几个概念。

2.1 概念

2.1.1 页

slab 算法将整个内存采用页分割(一块大内存均分为很多个页),由页管理器进行管理。slab算法在内部执行的时候会按页进行,但应用层申请的时候对此无感知。

一个页的大小通常在 4k Byte。

在这里插入图片描述

2.1.2 zone

一个小内存块,每个zone的大小是固定的,在系统初始化的时候会根据整个内存空间大小自动计算合适的zone大小,通常由几个页组成。一个zone下面其实就是一个内存池,包含了一种类型大小的数据分配,在此zone下每次分配出来的内存大小都是一样大的。

通常实际上,一个 zone 的大小在 32k Byte ~ 128k Byte 直接。我们这里假定一个 zone 的大小是 4 个page,如下图所示。

在这里插入图片描述

2.1.3 chunk

chunk 即用户每次申请的内存块。chunk是从一个zone里面分配出去的,每个zone下面包含了若干个同一类大小的chunk。

chunk的大小也不是任意的,chunk的有这么几类:8 Byte16 Byte32 Byte64 Byte128 Byte256 Byte512 Byte1024 Byte2048 Byte。chunk的大小满足2的幂次方,因此用户在申请内存的时候,并不是完全按照用户指定的大小去分配chunk的,实际分配的时候,会根据用户申请的大小向上对齐补齐到一个最近的符合规则的 chunk 大小进行分配(也即向上取2的幂次方)。

在这里插入图片描述

2.2 算法

2.2.1 页分配器算法

slab将整个内存空间按页分割,slab内部进行内存申请释放的时候基本都是基于页操作的。因此理解页分配算法是理解 slab 算法关键的一步。整个页分配算法其实有点类似于小内存分配算法。

我们先来看下单个页的构造数据结构:
在这里插入图片描述

  • struct rt_page_head *next:指针,指向下一个page,用于将各个页链接起来
  • rt_size_t page:页的数量,记录此页与后方page - 1页会组成一个整体挂载链表内,详见后文申请与释放。
  • char dummy[]:定义一个数组,占据此页剩余的内存空间,因此整个页的数据结构对应一个页空间大小。
2.2.1.1 页初始化

在这里插入图片描述

2.2.1.2 页申请

第一次页申请通常如下:

在这里插入图片描述

经过若干次页申请与释放之后的页空间可能如下:

在这里插入图片描述

当出现上述情况,在页申请的时候,会遍历 rt_page_list 的成员,找到某个成员下的 page 指示后续的空闲页满足申请的页的要求时,再对页进行分割,之后进行分配。

2.2.1.3 页释放

页的释放,也即把需要释放的页重新插入上述 rt_page_list 链表内,同时需检查回收的页的前后是否页存在空闲的多个页,存在的话需要整合组成一个大的页,以便于后续分配。因此再插入 rt_page_list 链表时应注意插入的位置不是直接插入链表头,也不是插入链表尾,而是根据回收页的当前地址,插入对应的链表内的位置,且同时检查对应位置的链表前一项成员记录的多个页是否与此页连续,以及对应位置链表的后一项成员的多个页是否与是否的页连续,连续的话就进行整合,删除掉一个链表成员,修改保留的链表成员的page参数。

如果释放的页前后都没有空闲链表成员指示的页与释放的页均不连续,则将释放的一个or多个页添加到链表内,操作如下:

在这里插入图片描述

如果释放的页只有前是空闲的,操作如下:

在这里插入图片描述

下面直接上图,下述是释放的页刚好前后页都是空闲的情况:

在这里插入图片描述

在内存初始化的时候,首先计算整个能存能分割的页的个数 npages,之后进行页释放,即完成了内存页的分割。

2.2.2 页管理器

🆗 上述完成了页分配实现,对于这么多页的使用信息,在 slab 中还有一个页管理器记录了各页的使用个情况,每个页在页管理器中都对应有一个结构体记录相关使用信息,其结构体如下:

struct memusage
{
    rt_uint32_t type: 2 ;       /* page type */
    rt_uint32_t size: 30;       /* pages allocated or offset from zone */
};
  • type:页类型,共支持三种页类型

    • PAGE_TYPE_FREE 指示此页为空闲页
    • PAGE_TYPE_SMALL 指示此页为小页,被分配给 zone 使用的页都是此类型。
    • PAGE_TYPE_LARGE 指示此页为大页,当用户申请内存的空间非常大,超过了一个阈值 zone_limit 大小时,不再通过 zone 分配,而是直接按页进行分配,对应的页类型便是 PAGE_TYPE_LARGE
  • size:不同的页类型 typesize 的含义亦不一样。

    • 当页类型为 PAGE_TYPE_SMALL 时,size 存放的内容为当前页在对应 zone 内的偏移页。
    • 当页类型为 PAGE_TYPE_LARGE 时,size 存放的内容为大块内存空间包含的页的个数。

    在完成内存页的分割之后,根据页的个数 npages,每个页分配一个 struct memusage 进行管理,因此页管理器需要占用 npages * sizeof(struct memusage) 大小的内存空间,此空间我们从内存中分配,因此需要进行页申请,页管理器实际会占用 ALIGN(npages * sizeof(struct memusage), RT_MM_PAGE_SIZE) / RT_MM_PAGE_SIZE 个页。【ALIGN(a, b) 是a按照b的大小向上取值对齐】

    由于页管理器是在内存页初始化后第一次申请的页,因此页管理器占用的内存一定是在整个内存的头部的几个页空间内。

2.2.3 zone 管理

当用户申请内存的空间非常大,直接通过页管理器分配,但是当用户申请内存比较小的时候,就会从 zone 内去分配了。前面我们介绍zone的时候已经介绍过了,每个zone的大小是一样的,通常由几个页组成,因此zone的申请也就是先向也分配器申请对应大小的页,之后用于zone下的内存分配。每个zone能提供某一种类型大小的内存分配。那么zone又是如何管理的呢?

我们继续假定一个 zone 由4个页 page 组成(实际不止四个),zone的头部存储了整个zone的管理结构体信息。通过此结构体控制整个zone空间下的chunk分配与释放。

在这里插入图片描述

zone的管理头数据结构如下:

typedef struct slab_zone
{
    rt_int32_t  z_magic;        /* magic number for sanity check */
    rt_int32_t  z_nfree;        /* total free chunks / ualloc space in zone */
    rt_int32_t  z_nmax;         /* maximum free chunks */

    struct slab_zone *z_next;   /* zoneary[] link if z_nfree non-zero */
    rt_uint8_t  *z_baseptr;     /* pointer to start of chunk array */

    rt_int32_t  z_uindex;       /* current initial allocation index */
    rt_int32_t  z_chunksize;    /* chunk size for validation */

    rt_int32_t  z_zoneindex;    /* zone index */
    slab_chunk  *z_freechunk;   /* free chunk list */
} slab_zone;
  • z_magic:填充固定值 0x51ab51ab,一般用来校验此为一个 zone 的管理头
  • z_nfree:记录总共的空闲 chunk 个数
  • z_nmax:记录此 zone 下所有 chunk 总数
  • z_next:指针,用来连接下一个 zone
  • z_baseptr:此 zone 内的第一个 chunk 的起始地址
  • z_uindex:一个索引号,大有意思,后面细说
  • z_chunksize:记录此 zone 下的 chunk 的空间大小
  • z_zoneindex:用来记录此 zone 的在 zone_array[] 下的索引,什么是 zone_array[] ,后面细说
  • z_freechunk:一个列表,此 zone 下的 chunk 回收的时候,均将其挂载到此列表下。

对于每个zone内的内容管理采用上述结构体进行管理记录,此外,slab算法内还有一个static slab_zone *zone_array[NZONES] 指针数组,此数组采用静态分配,每个数组成员是一个 struct slab_zone 类型的指针,所有的 zone 都通过 struct slab_zone *z_next 挂载在这些数组内的指针列表下面。那么每次申请完一个zone之后,又该挂载在 zone_array 哪个数组成员下面呢?

static slab_zone *zone_array[NZONES] 的每个数组成员分别对应着拥有对应大小 chunkzone。因此申请完 zone 之后,会根据此 zonechunk 大小挂载在对应的 zone_array 下面
在这里插入图片描述

重点再强调一遍: 并不是每一个 zone_array 成员下都挂有zone,只有有对应的zone申请之后,才会被挂上来。其中zone 管理数据结构头struct slab_zone 内的z_zoneindex 指示的便是此 zone 挂载在 zone_array 的哪一个成员下面了。

2.2.3.1 zone 的申请与释放

zone 的申请:

也即向页分配器申请分配固定页的个数,同时修改页管理器对应页的类型以及大小,完成一个zone页的申请。之后初始化 struct slab_zone 管理结构体,信息存储在此zone的头部,剩余空间则均分为若干个 chunk。

zone的释放:

在用户调用 free 操作触发一次释放的时候才会触发 zone 的释放检查,当判断到struct slab_zone 内的 z_nfree == z_nmax 时,认为整个zone下的所有chunk均已空闲。

此外还需判断此 zone 是否为 zone_array[z_zoneindex] 下挂载的第一个 zone 是否就是当前zone,如果是,则不对此zone释放,保留着,只有当 zone_array[z_zoneindex] 下挂载了多个zone,且此 zone 不是zone_array[z_zoneindex] 下挂载的第一个zone时才对此 zone 进行释放。这样做的目的是保证之前有程序内有使用过此大小chunk的数据结构,则至少保留一个 zone 用于后续分配,减少不必要的页分配,这也就是 zone 的缓冲型特性。

如果上述条件均符合释放条件,z_nfree == z_nmaxzone_array[z_zoneindex] != this_zonez_next != NULL,则可以对此 zone 进行释放,第一步则是将此 zone 从 zone_array[z_zoneindex] 内移除,之后将其挂载到 static slab_zone *zone_free; 全局的空闲 zone 链表上去,到这里还没有释放哦!因为每个zone的大小都是一样的,因此下次如果需要申请新的zone即可直接从此 zone_free 上直接获取一个空闲的zone,又可以省去一次申请zone时的页分配操作!只有当zone_free列表下挂载的空闲zone数量超过阈值 ZONE_RELEASE_THRESH 2 时才真正释放此zone,从 zone_free 列表下移除一个zone,之后调用页释放接口释放对应的页。

2.2.3.2 chunk 的申请与释放

讲完了zone的申请与释放,我们再来看看chunk的申请与释放,同时想要知道 z_uindex 是什么含义,我们就需要来看下zone下的chunk分配了!

chunk 的申请:

zone 初始化之后其值为0,整个zone 下所有 chunk 未曾被分配一次。此时我们可以理解为整个 zone 下有一个数组 chunk_buffer,其起始地址为 z_baseptr,数组总共有 z_nmax 个成员,每个成员的大小为 z_chunksize 字节。

那么此时如果需要从此 zone 下分配一个 chunk,也就是提取各数组成员了,即 chunk_buffer[z_uindex],分配完一个之后把 z_uindex + 1,指向下一个还未分配出去过的成员。

通过这样的方式,此 zone 下的所有 chunk 的第一次分配,都可以根据 z_uindex 快速找到对应的 chunk,时间复杂度为 O1!而当判断到 z_uindex == z_nmax 的时候,说明整个 zone 下的所有 chunk 都分配过一次了,此时还需要 chunk 就得从 z_freechunk 列表下提取回收回来的 chunk 了。

chunk 的释放:

而对应分配出去的 chunk,当用户调用 free 函数进行内存释放的时候,通过其所在地址按页大小对齐,先找到此chunk所在的页,同时通过查询页管理器内关于此页的信息记录,确认此页类型为 PAGE_TYPE_SMALL 小页,则对应页地址头部存储的就是 zone 管理结构体的信息,这样便可找到此chunk对应的zone管理结构体,之后将此 chunk 挂载到 z_freechunk 列表下即可。同时注意修改 z_nfree值加一,更改空闲 chunk 个数。

2.2.4 malloc 和 free

🆗 上面已经把 页、zone、chunk的申请与释放都基本分析完了,那么接下来我们再来完整的梳理下,用户调用 malloc 和 free 时是怎样的一个过程吧!

2.2.4.1 大内存申请与释放

申请内存大小大于等于 zone_limit 值时,直接通过页分配器实现内存分配。zone_limit = zone_size / 4,最大为 16k(参数可调)。

大内存申请流程如下:

  1. malloc 传入需要申请的内存大小 size
  2. 将 size 按页大小向上对齐
  3. 调用页分配器申请对应页大小
  4. 修改页管理器内对应页的信息,所有对应页的页类型均配置为 PAGE_TYPE_LARGE,所有对应页的页大小字段填入分配的页个数
  5. 返回申请的页中第一页的首地址

大内存释放流程如下:

  1. free 传入需要释放的内存的首地址 ptr
  2. 根据此地址按页向下对齐,获取到对应页首地址(正常情况下页首地址应该与free传入地址是一致的)
  3. 根据页首地址,查询页管理器内对应页的类型,比对确认是否为PAGE_TYPE_LARGE,同时通过页大小字段获取此内存占用的页个数
  4. 调用页释放接口,释放对应页
2.2.4.2 小内存申请释放

申请内存大小小于 zone_limit 时,通过 zone 分配。zone_limit = zone_size / 4,最大为 16k(参数可调)。

小内存申请流程如下:

  1. malloc 传入需要申请的内存大小 size。
  2. 根据 zone 支持的 chunk 的 size 大小,调整 size 大小,向上对齐到chunk的size。
    • 如 malloc 传入 6,由于chunk支持的size不包含6,因此需要向上对齐,对齐到 8。chunk的size类型见 [2.1.3 chunk](#2.1.3 chunk)
  3. 根据调整后的 size 查询对应的 zone_array[] 成员列表内是否有有效 zone。
    1. 若此 size 对应的zone_array[]成员列表内无有效zone
      1. 则按照 [2.2.3.1 zone 的申请与释放](#2.2.3.1 zone 的申请与释放) 走zone分配流程,从页分配器申请一个新zone。
      2. 并格式化此zone下的chunk为size大小,将此zone下的第一个chunk分配给此次申请的内存。
      3. 将此zone挂载到 zone_array[] 对应成员的列表内。
    2. 若此 size 对应的zone_array[]成员列表内有有效zone
      1. 则按照[2.2.3.2 chunk 的申请与释放](#2.2.3.2 chunk 的申请与释放) 走chunk分配流程,从zone内分配出一个空闲的 chunk。
      2. 同时通过 z_nfree 判断此 zone 分配完此空闲 chunk 之后是否还存在空闲 chunk,若此chunk是此zone的最后一个空闲chunk,则分配完此chunk之后需要将此 zone 从对应的 zone_array[] 成员列表下移除。

小内存释放流程如下:

  1. free 传入需要释放的内存的地址 ptr。此值应为一个chunk的首地址。
  2. 根据此地址按页向下对齐,获取到对应页首地址。
  3. 根据页首地址,查询页管理器内对应页的类型,比对确认是否为PAGE_TYPE_SMALL,同时通过页大小字段获取此页位于对应zone的页偏移值。
  4. 通过页偏移值找到对应zone的起始页的页首地址。zone的管理结构体便存放在起始页的头部。由此可以找到对应的 zone 管理结构体struct slab_zone
  5. 将此 chunk 挂载到 zone 的 z_freechunk 空闲chunk 列表下完成 chunk 的回收。
  6. 同时判断此 zone 的 z_nfree 的值。
    1. 如果 z_nfree 为 0,则表面此 zone 在上次分配 chunk 的时候已经被移除对应的 zone_array[] 成员列表了,现在回收了一个 chunk ,那么再将其挂回对应的 zone_array[] 成员列表,下次 malloc 的时候才能利用到此 zone。
    2. 如果 z_nfree 等于 z_nmax,且对应的 zone_array[] 成员列表下不止一个 zone,且当前 zone 不是对应的 zone_array[] 成员列表下第一个zone,则按照 [2.2.3.1 zone 的申请与释放](#2.2.3.1 zone 的申请与释放) 走zone释放流程
  7. 此外修改 z_nfree 值加一,变更空闲 chunk 的数量。

3. 总结

以上便是整个 slab 算法的详细讲解了,最后如果你有时间,推荐阅读下相关源码 RT-Thread/rt-thread/blob/lts-v3.1.x/src/slab.c ,相信有了此篇文章的指导,阅读源码时你将得心应手!虽然此篇文章是基于 rt-thread 内核中的 slab 算法,与原始的 slab 有所精简的,但是其核心思想、思路基本是一致,因此本文对于其他扩展的 slab 算法的学习亦能发挥出指导性作用。


创作不易,转载请注明出处!

关注、点赞+收藏,可快速查收博主有关分享!


如果你觉得我文章写的不错,欢迎点赞、关注+收藏,谢谢~,以下我的一些推荐:


相关推荐:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

爱出名的狗腿子

你的鼓励就是我最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值