linux内存管理4

既然有了伙伴系统,为什么还需要 Slab ?

伙伴系统会将它所属物理内存区 zone 里的空闲内存划分成不同尺寸的物理内存块,这里的尺寸必须是 2 的次幂,物理内存块可以是由 1 个 page 组成,也可以是 2 个 page,4 个 page … 1024 个 page 组成。

内核将这些相同尺寸的内存块用一个内核数据结构 struct free_area 中的双向链表 free_list 串联组织起来。

struct free_area {
 struct list_head free_list[MIGRATE_TYPES];
 unsigned long  nr_free;
};

而这些由 free_list 串联起来的相同尺寸的内存块又会近一步根据物理内存页 page 的迁移类型 MIGRATE_TYPES 进行归类,比如:MIGRATE_UNMOVABLE (不可移动的页面类型),MIGRATE_MOVABLE (可以移动的内存页类型),MIGRATE_RECLAIMABLE (不能移动,但是可以直接回收的页面类型)等等。

这样一来,具有相同迁移类型,相同尺寸的内存块就被组织在了同一个 free_list 中,最终伙伴系统完整的数据结构如下图所示:

free_area 中组织的全部是相同尺寸的内存块,不同尺寸的内存块被不同的 free_area 管理。在 free_area 的内部又会近一步按照物理内存页面的迁移类型 MIGRATE_TYPES,将相同迁移类型的物理内存页组织在同一个 free_list 中。

随后在物理内存分配的过程中,内核会基于这个完整的伙伴系统数据结构,进行不同尺寸的物理内存块的分配与释放,而分配与释放的单位依然是 2 的整数幂个物理内存页 page。
我们从以上介绍的伙伴系统核心数据结构,以及伙伴系统内存分配原理的相关内容来看,伙伴系统管理物理内存的最小单位是物理内存页 page。也就是说,当我们向伙伴系统申请内存时,至少要申请一个物理内存页。

而从内核实际运行过程中来看,无论是从内核态还是从用户态的角度来说,对于内存的需求量往往是以字节为单位,通常是几十字节到几百字节不等,远远小于一个页面的大小。如果我们仅仅为了这几十字节的内存需求,而专门为其分配一整个内存页面,这无疑是对宝贵内存资源的一种巨大浪费。

于是在内核中,这种专门针对小内存的分配需求就应运而生了,而本文的主题—— slab 内存池就是专门应对小内存频繁的分配和释放的场景的。

slab 首先会向伙伴系统一次性申请一个或者多个物理内存页面,正是这些物理内存页组成了 slab 内存池。

随后 slab 内存池会将这些连续的物理内存页面划分成多个大小相同的小内存块出来,同一种 slab 内存池下,划分出来的小内存块尺寸是一样的。内核会针对不同尺寸的小内存分配需求,预先创建出多个 slab 内存池出来。

这种小内存在内核中的使用场景非常之多,比如,内核中那些经常使用,需要频繁申请释放的一些核心数据结构对象:task_struct 对象,mm_struct 对象,struct page 对象,struct file 对象,socket 对象等。

而创建这些内核核心数据结构对象以及为这些核心对象分配内存,销毁这些内核对象以及释放相关的内存是需要性能开销的。

既然 slab 专门是用于小内存块分配与回收的,那么内核很自然的就会想到,分别为每一个需要被内核频繁创建和释放的核心对象创建一个专属的 slab 对象池,这些内核对象专属的 slab 对象池会根据其所管理的具体内核对象所占用内存的大小 size,将一个或者多个完整的物理内存页按照这个 size 划分出多个大小相同的小内存块出来,每个小内存块用于存储预先创建好的内核对象。

这样一来,当内核需要频繁分配和释放内核对象时,就可以直接从相应的 slab 对象池中申请和释放内核对象,避免了链路比较长的内存分配与释放过程,极大地提升了性能。这是一种池化思想的应用。

slab 对象池在内核中的应用场景

当我们使用 fork() 系统调用创建进程的时候,内核需要使用 task_struct 专属的 slab 对象池分配 task_struct 对象。

static struct task_struct *dup_task_struct(struct task_struct *orig, int node)
{
          ........... 
    struct task_struct *tsk;
    // 从 task_struct 对象专属的 slab 对象池中申请 task_struct 对象
    tsk = alloc_task_struct_node(node);
          ...........   
}

为进程创建虚拟内存空间的时候,内核需要使用 mm_struct 专属的 slab 对象池分配 mm_struct 对象。

static struct mm_struct *dup_mm(struct task_struct *tsk,
                struct mm_struct *oldmm)
{
          ..........       
    struct mm_struct *mm;
    // 从 mm_struct 对象专属的 slab 对象池中申请 mm_struct 对象
    mm = allocate_mm();
          ..........
}

当我们向页高速缓存 page cache 查找对应的文件缓存页时,内核需要使用 struct page 专属的 slab 对象池分配 struct page 对象。

struct page *pagecache_get_page(struct address_space *mapping, pgoff_t offset,
 int fgp_flags, gfp_t gfp_mask)
{
 struct page *page;

repeat:
  // 在 radix_tree(page cache)中根据缓存页 offset 查找缓存页
 page = find_get_entry(mapping, offset);
 // 缓存页不存在的话,跳转到 no_page 处理逻辑
 if (!page)
  goto no_page;

   .......省略.......
no_page:

  // 从 page 对象专属的 slab 对象池中申请 page 对象
  page = __page_cache_alloc(gfp_mask);
  // 将新分配的内存页加入到页高速缓存 page cache 中
  err = add_to_page_cache_lru(page, mapping, offset, gfp_mask);

              .......省略.......
 }

 return page;
}

当我们使用 open 系统调用打开一个文件时,内核需要使用 struct file专属的 slab 对象池分配 struct file 对象。

struct file *do_filp_open(int dfd, struct filename *pathname,
        const struct open_flags *op)
{

    struct file *filp;
    // 分配 struct file 内核对象
    filp = path_openat(&nd, op, flags | LOOKUP_RCU);
                ..........
    return filp;
}

static struct file *path_openat(struct nameidata *nd,
            const struct open_flags *op, unsigned flags)
{
    struct file *file;
    // 从 struct file 对象专属的 slab 对象池中申请 struct file 对象
    file = alloc_empty_file(op->open_flag, current_cred());
                 ..........
}

当服务端网络应用程序使用 accpet 系统调用接收客户端的连接时,内核需要使用 struct socket 专属的 slab 对象池为新进来的客户端连接分配 socket 对象。

SYSCALL_DEFINE4(accept4, int, fd, struct sockaddr __user *, upeer_sockaddr,
        int __user *, upeer_addrlen, int, flags)
{
    struct socket *sock, *newsock;
    // 查找正在 listen 状态的监听 socket
    sock = sockfd_lookup_light(fd, &err, &fput_needed);
    // 为新进来的客户端连接申请 socket 对象以及与其关联的 inode 对象
    // 从 struct socket 对象专属的 slab 对象池中申请 struct socket 对象 
    newsock = sock_alloc();

    ............. 利用监听 socket 初始化 newsocket ..........
}

当然了被 slab 对象池所管理的内核核心对象不只是笔者上面为大家列举的这五个,事实上,凡是需要被内核频繁使用的内核对象都需要被 slab 对象池所管理。

slab, slub, slob

在开始正式介绍 slab 对象池之前,笔者觉得有必要先向大家简单交代一下 Linux 系统中关于 slab 对象池的三种实现:slab,slub,slob。

其中 slab 的实现,最早是由 Sun 公司的 Jeff Bonwick 大神在 Solaris 2.4 系统中设计并实现的,由于 Jeff Bonwick 大神公开了 slab 的实现方法,因此被 Linux 所借鉴并于 1996 年在 Linux 2.0 版本中引入了 slab,用于 Linux 内核早期的小内存分配场景。

由于 slab 的实现非常复杂,slab 中拥有多种存储对象的队列,队列管理开销比较大,slab 元数据比较臃肿,对 NUMA 架构的支持臃肿繁杂(slab 引入时内核还没支持 NUMA),这样导致 slab 内部为了维护这些自身元数据管理结构就得花费大量的内存空间,这在配置有超大容量内存的服务器上,内存的浪费是非常可观的。

针对以上 slab 的不足,内核大神 Christoph Lameter 在 2.6.22 版本(2007 年发布)中引入了新的 slub 实现。slub 简化了 slab 一些复杂的设计,同时保留了 slab 的基本思想,摒弃了 slab 众多管理队列的概念,并针对多处理器,NUMA 架构进行优化,放弃了效果不太明显的 slab 着色机制。slub 与 slab 相比,提高了性能,吞吐量,并降低了内存的浪费。成为现在内核中常用的 slab 实现。

而 slob 的实现是在内核 2.6.16 版本(2006 年发布)引入的,它是专门为嵌入式小型机器小内存的场景设计的,所以实现上很精简,能在小型机器上提供很不错的性能。

而内核中关于内存池(小内存分配器)的相关 API 接口函数均是以 slab 命名的,但是我们可以通过配置的方式来平滑切换以上三种 slab 的实现。本文我们主要讨论被大规模运用在服务器 Linux 操作系统中的 slub 对象池的实现,所以本文下面的内容,如无特殊说明,笔者提到的 slab 均是指 slub 实现。

从一个简单的内存页开始聊 slab

从前边小节的内容中,我们知道内核会把那些频繁使用的核心对象统一放在 slab 对象池中管理,每一个核心对象对应一个专属的 slab 对象池,以便提升核心对象的分配,访问,释放相关操作的性能。

如上图所示,slab 对象池在内存管理系统中的架构层次是基于伙伴系统之上构建的,slab 对象池会一次性向伙伴系统申请一个或者多个完整的物理内存页,在这些完整的内存页内在逐步划分出一小块一小块的内存块出来,而这些小内存块的尺寸就是 slab 对象池所管理的内核核心对象占用的内存大小。

下面笔者就带大家从一个最简单的物理内存页 page 开始,我们一步一步的推演 slab 的整个架构设计与实现。

如果让我们自己设计一个对象池,首先最直观最简单的办法就是先向伙伴系统申请一个内存页,然后按照需要被池化对象的尺寸 object size,把内存页划分为一个一个的内存块,每个内存块尺寸就是 object size。

事实上,slab 对象池可以根据情况向伙伴系统一次性申请多个内存页,这里只是为了方便大家理解,我们先以一个内存页为例,为大家说明 slab 中对象的内存布局。

但是在一个工业级的对象池设计中,我们不能这么简单粗暴的搞,因为对象的 object size 可以是任意的,并不是内存对齐的,CPU 访问一块没有进行对齐的内存比访问对齐的内存速度要慢一倍。

因为 CPU 向内存读取数据的单位是根据 word size 来的,在 64 位处理器中 word size = 8 字节,所以 CPU 向内存读写数据的单位为 8 字节。CPU 只能一次性向内存访问按照 word size ( 8 字节) 对齐的内存地址,如果 CPU 访问一个未进行 word size 对齐的内存地址,就会经历两次访存操作。

比如,我们现在需要访问 0x0007 - 0x0014 这样一段没有对 word size 进行对齐的内存,CPU只能先从 0x0000 - 0x0007 读取 8 个字节出来先放入结果寄存器中并左移 7 个字节(目的是只获取 0x0007 ),然后 CPU 在从 0x0008 - 0x0015 读取 8 个字节出来放入临时寄存器中并右移1个字节(目的是获取 0x0008 - 0x0014 )最后与结果寄存器或运算。最终得到 0x0007 - 0x0014 地址段上的 8 个字节。


从上面过程我们可以看出,CPU 访问一段未进行 word size 对齐的内存,需要两次访存操作。

内存对齐的好处还有很多,比如,CPU 访问对齐的内存都是原子性的,对齐内存中的数据会独占 cache line ,不会与其他数据共享 cache line,避免 false sharing。

这里大家只需要简单了解为什么要进行内存对齐即可,关于内存对齐的详细内容,感兴趣的读者可以回看下 《内存对齐的原理及其应用》 一文中的 “ 5. 内存对齐 ” 小节。

基于以上原因,我们不能简单的按照对象尺寸 object size 来划分内存块,而是需要考虑到对象内存地址要按照 word size 进行对齐。于是上面的 slab 对象池的内存布局又有了新的变化。

如果被池化对象的尺寸 object size 本来就是和 word size 对齐的,那么我们不需要做任何事情,但是如果 object size 没有和 word size 对齐,我们就需要填充一些字节,目的是要让对象的 object size 按照 word size 进行对齐,提高 CPU 访问对象的速度。

但是上面的这些工作对于一个工业级的对象池来说还远远不够,工业级的对象池需要应对很多复杂的诡异场景,比如,我们偶尔在复杂生产环境中会遇到的内存读写访问越界的情况,这会导致很多莫名其妙的异常。

内核为了应对内存读写越界的场景,于是在对象内存的周围插入了一段不可访问的内存区域,这些内存区域用特定的字节 0xbb 填充,当进程访问的到内存是 0xbb 时,表示已经越界访问了。这段内存区域在 slab 中的术语为 red zone,大家可以理解为红色警戒区域。

插入 red zone 之后,slab 对象池的内存布局近一步演进为下图所示的布局:

  • 如果对象尺寸 object size 本身就是 word size 对齐的,那么就需要在对象左右两侧填充两段 red zone 区域,red zone 区域的长度一般就是 word size 大小。

  • 如果对象尺寸 object size 是通过填充 padding 之后,才与 word size 对齐。内核会巧妙的利用对象右边的这段 padding 填充区域作为 red zone。只需要额外的在对象内存区域的左侧填充一段 red zone 即可。

其实 slab 的本质就是一个或者多个物理内存页 page,内核会根据上图展示的 slab 对象的内存布局,计算出对象的真实内存占用 size。最后根据这个 size 在 slab 背后依赖的这一个或者多个物理内存页 page 中划分出多个大小相同的内存块出来。

所以在内核中,都是用 struct page 结构来表示 slab,如果 slab 背后依赖的是多个物理内存页

struct page {      
            // 首页 page 中的 flags 会被设置为 PG_head 表示复合页的第一页
            unsigned long flags;	
            // 其余尾页会通过该字段指向首页
            unsigned long compound_head;   
            // 用于释放复合页的析构函数,保存在首页中
            unsigned char compound_dtor;
            // 该复合页有多少个 page 组成,order 还是分配阶的概念,在首页中保存
            // 本例中的 order = 2 表示由 4 个普通页组成
            unsigned char compound_order;
            // 该复合页被多少个进程使用,内存页反向映射的概念,首页中保存
            atomic_t compound_mapcount;
            // 复合页使用计数,首页中保存
            atomic_t compound_pincount;
      }

slab 的具体信息也是在 struct page 中存储,下面笔者提取了 struct page 结构中和 slab 相关的字段:

struct page {

        struct {    /*  slub 相关字段 */
            union {
                // slab 所在的管理链表
                struct list_head slab_list;
                struct {    /* Partial pages */
                    // 用 next 指针在相应管理链表中串联起 slab
                    struct page *next;
#ifdef CONFIG_64BIT
                    // slab 所在管理链表中的包含的 slab 总数
                    int pages;  
                    // slab 所在管理链表中包含的对象总数
                    int pobjects; 
#else
                    short int pages;
                    short int pobjects;
#endif
                };
            };
            // 指向 slab cache,slab cache 就是真正的对象池结构,里边管理了多个 slab
            // 这多个 slab 被 slab cache 管理在了不同的链表上
            struct kmem_cache *slab_cache;
            // 指向 slab 中第一个空闲对象
            void *freelist;     /* first free object */
            union {
                struct {            /* SLUB */
                    // slab 中已经分配出去的独享
                    unsigned inuse:16;
                    // slab 中包含的对象总数
                    unsigned objects:15;
                    // 该 slab 是否在对应 slab cache 的本地 CPU 缓存中
                    // frozen = 1 表示缓存再本地 cpu 缓存中
                    unsigned frozen:1;
                };
            };
        };

}

在笔者当前所在的内核版本 5.4 中,内核是使用 struct page 来表示 slab 的,但是考虑到 struct page 结构已经非常庞大且复杂,为了减少 struct page 的内存占用以及提高可读性,内核在 5.17 版本中专门为 slab 引入了一个管理结构 struct slab,将原有 struct page 中 slab 相关的字段全部删除,转移到了 struct slab 结构中。这一点,大家只做了解即可。

slab 的总体架构设计


在上一小节的内容中,笔者带大家从 slab 的微观层面详细的介绍了 slab 对象的内存布局,首先 slab 会从伙伴系统中申请一个或多个物理内存页 page,然后根据 slab 对象的内存布局计算出对象在内存中的真实尺寸 size,并根据这个 size,在物理内存页中划分出多个内存块出来,供内核申请使用。

有了这个基础之后,在本小节中,笔者将继续带大家从 slab 的宏观层面上继续深入 slab 的架构设计。

笔者在前边的内容中多次提及的 slab 对象池其实就是上图中的 slab cache,而上小节中介绍的 slab 只是 slab cache 架构体系中的基本单位,对象的分配和释放最终会落在 slab 这个基本单位上。

如果一个 slab 中的对象全部分配出去了,slab cache 就会将其视为一个 full slab,表示这个 slab 此刻已经满了,无法在分配对象了。slab cache 就会到伙伴系统中重新申请一个 slab 出来,供后续的内存分配使用。

当内核将对象释放回其所属的 slab 之后,如果 slab 中的对象全部归位,slab cache 就会将其视为一个 empty slab,表示 slab 此刻变为了一个完全空闲的 slab。如果超过了 slab cache 中规定的 empty slab 的阈值,slab cache 就会将这些空闲的 empty slab 重新释放回伙伴系统中。

如果一个 slab 中的对象部分被分配出去使用,部分却未被分配仍然在 slab 中缓存,那么内核就会将该 slab 视为一个 partial slab。

这些不同状态的 slab,会在 slab cache 中被不同的链表所管理,同时 slab cache 会控制管理链表中 slab 的个数以及链表中所缓存的空闲对象个数,防止它们无限制的增长。

slab cache 中除了需要管理众多的 slab 之外,还包括了很多 slab 的基础信息。比如:

  • 上小节中提到的 slab 对象内存布局相关的信息

  • slab 中的对象需要按照什么方式进行内存对齐,比如,按照 CPU 硬件高速缓存行 cache line (64 字节) 进行对齐,slab 对象是否需要进行毒化 POISON,是否需要在 slab 对象内存周围插入 red zone,是否需要追踪 slab 对象的分配与回收信息,等等。

  • 一个 slab 具体到底需要多少个物理内存页 page,一个 slab 中具体能够容纳多少个 object (内存块)。

slab 的基础信息管理

slab cache 在内核中的数据结构为 struct kmem_cache,以上介绍的这些 slab 的基本信息以及 slab 的管理结构全部定义在该结构体中:

/*
 * Slab cache management.
 */
struct kmem_cache {
    // slab cache 的管理标志位,用于设置 slab 的一些特性
    // 比如:slab 中的对象按照什么方式对齐,对象是否需要 POISON  毒化,是否插入 red zone 在对象内存周围,是否追踪对象的分配和释放信息 等等
    slab_flags_t flags;
    // slab 对象在内存中的真实占用,包括为了内存对齐填充的字节数,red zone 等等
    unsigned int size;  /* The size of an object including metadata */
    // slab 中对象的实际大小,不包含填充的字节数
    unsigned int object_size;/* The size of an object without metadata */
    // slab 对象池中的对象在没有被分配之前,我们是不关心对象里边存储的内容的。
    // 内核巧妙的利用对象占用的内存空间存储下一个空闲对象的地址。
    // offset 表示用于存储下一个空闲对象指针的位置距离对象首地址的偏移
    unsigned int offset;    /* Free pointer offset */
    // 表示 cache 中的 slab 大小,包括 slab 所需要申请的页面个数,以及所包含的对象个数
    // 其中低 16 位表示一个 slab 中所包含的对象总数,高 16 位表示一个 slab 所占有的内存页个数。
    struct kmem_cache_order_objects oo;
    // slab 中所能包含对象以及内存页个数的最大值
    struct kmem_cache_order_objects max;
    // 当按照 oo 的尺寸为 slab 申请内存时,如果内存紧张,会采用 min 的尺寸为 slab 申请内存,可以容纳一个对象即可。
    struct kmem_cache_order_objects min;
    // 向伙伴系统申请内存时使用的内存分配标识
    gfp_t allocflags; 
    // slab cache 的引用计数,为 0 时就可以销毁并释放内存回伙伴系统重
    int refcount;   
    // 池化对象的构造函数,用于创建 slab 对象池中的对象
    void (*ctor)(void *);
    // 对象的 object_size 按照 word 字长对齐之后的大小
    unsigned int inuse;  
    // 对象按照指定的 align 进行对齐
    unsigned int align; 
    // slab cache 的名称, 也就是在 slabinfo 命令中 name 那一列
    const char *name;  
};

slab_flags_t flags 是 slab cache 的管理标志位,用于设置 slab 的一些特性,比如:

当 flags 设置了 SLAB_HWCACHE_ALIGN 时,表示 slab 中的对象需要按照 CPU 硬件高速缓存行 cache line (64 字节) 进行对齐。

struct kmem_cache 结构中的 size 字段表示 slab 对象在内存中的真实占用大小,该大小包括对象所占内存中各种填充的内存区域大小,比如下图中的 red zone,track 区域,等等。

unsigned int object_size 表示单纯的存储 slab 对象所需要的实际内存大小,如上图中的 object size 蓝色区域所示。

在上小节我们介绍 freepointer 指针的时候提到过,当对象在 slab 中缓存并没有被分配出去之前,其实对象所占内存中存储的是什么,用户根本不会去关心。内核会巧妙的利用对象的内存空间来存储 freepointer 指针,用于指向 slab 中的下一个空闲对象。

但是当 kmem_cache 结构中的 flags 设置了 SLAB_POISON 标志位之后,slab 中的对象会 POISON 毒化,被特殊字节 0x6b 和 0xa5 所填充,这样一来就会覆盖原有的 freepointer,在这种情况下,内核就需要把 freepointer 存储在对象所在内存区域的外面。

所以内核就需要用一个字段来标识 freepointer 的位置,struct kmem_cache 结构中的 unsigned int offset 字段干的就是这个事情,它表示对象的 freepointer 指针距离对象的起始内存地址的偏移 offset。

系统中所有的这些 slab cache 占用的内存总量,我们可以通过 cat /proc/meminfo 命令查看:

  • 22
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Linux 内存管理主要包括内存节点、分区、页框和虚拟内存等概念。 1. 内存节点 Linux 根据 CPU 访问代价的不同将内存划分为不同的分区,即内存节点。内核以 struct zone 来描述内存分区。通常一个节点分为 DMA、Normal 和 High Memory 内存区。其中,DMA 内存区为直接内存访问分区,通常为物理内存的起始16M,供外设使用,外设和内存直接访问数据而无需 CPU 参与;Normal 内存区为从 16M 到 896M 的内存区;HighMemory 内存区为 896M 以后的内存区。 2. 分区 内存节点中的分区是内存管理的基本单位,每个分区都有自己的页框列表和空闲页框列表。页框是内存管理的最小单位,通常为 4KB。内核通过页框来管理内存,将内存分为多个页框,每个页框都有自己的状态,包括已分配、未分配、已使用等。 3. 页框 页框是内存管理的最小单位,通常为 4KB。内核通过页框来管理内存,将内存分为多个页框,每个页框都有自己的状态,包括已分配、未分配、已使用等。内核通过页表来映射虚拟地址和物理地址,将虚拟地址转换为物理地址。 4. 虚拟内存 虚拟内存是一种将硬盘中划出一段 swap 分区当作虚拟的内存,用来存放内存中暂时用不到的内存页,等到需要的时候再从 swap 分区中将对应的内存页调入到内存中的技术。硬盘此时相当于一个虚拟的内存。Linux 通过虚拟内存技术来扩展内存,使得进程可以使用比物理内存更大的内存空间。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值