一、目标

本文希望你理解Slab分配器为什么存在,它如何解决伙伴系统的内部碎片问题,以及在真实项目中如何利用Slab来高效管理内核对象的生命周期。

具体聚焦于实战上:所有例子量化都基于真实内核版本(Linux 5.15/LTS和6.6/LTS);proc文件系统和sysfs显示的联系仅限于Linux内核专有文件。

为了后续阅读有知识连接,定义一些基础概念:

  • 地址空间(virtual address space):进程看到的虚拟地址范围。
  • 页表(page table):虚拟地址到物理地址的映射机制。
  • 物理页框(physical page frame):实际的内存单元,由伙伴系统管理。
  • Slab:一个或多个连续的物理页面,被划分成多个相同大小的对象,是伙伴系统的上层分配器。

二、问题背景:为什么需要Slab分配器

2.1 伙伴系统的必然制约:页级粒度分配的局限

伙伴系统(Buddy System)是内核物理内存管理的基石,但它存在一个必然的局限:分配粒度只能是2的幂次页面大小(4KB、8KB、16KB……)。

伙伴系统无法高效处理小于一页的内存需求。例如索引节点结构体只有48字节,若仍按分配一页4KB来分配,那么内存利用率仅为1.17%。内核中大量高频分配的小对象(如task_struct、dentry、inode等)都面临这种浪费问题。

2.2 内部碎片问题

100字节的对象请求4KB的页,利用率仅为2.44%。因此需要设计一种针对小于一页的内存需求的高效分配器,在伙伴系统分配的页面基础上,在页面内部分配更小块。

因此Slab分配器应运而生,它将一个或多个连续的物理页面作为一个Slab(也可以称为内存页槽),然后将这块内存切割成多个相同大小的对象(实体),本质上是一种面向特定类型的对象型内存分配器。

三、Slab分配器架构演进

3.1 三代实现对比

Slab分配器经历了三代实现:

  • SLAB:经典实现(Jeff Bonwick, 1994),最初为Solaris设计,Linux移植后长期使用,但复杂度高,管理开销大,Linux 5.15后已废弃。
  • SLUB:简化实现(Christoph Lameter, 2007),从Linux 2.6.23起引入,现在是默认分配器,代码更简洁,管理开销低。
  • SLOB:极简实现,面向嵌入式小内存系统,使用首次适应算法,管理开销最小但性能较差。
特性SLABSLUB(默认)SLOB
复杂度复杂(已废弃)简洁最简单
用途通用通用(默认)小内存/嵌入式
管理开销大小最小
复杂对象处理支持最佳差

3.2 当前Linux内核中的默认选择

Linux 5.15及以后版本的默认分配器为SLUB。它解决了SLAB的核心问题:

  • 去除了复杂的每Slab本地缓存管理
  • 简化了Slab的管理逻辑
  • 在NUMA系统上表现更优
  • 内存碎片问题通过"合并"策略缓解

四、核心数据结构

4.1 三层结构关系

Slab分配器采用三层数据结构:kmem_cache、slab和object。

最上层是kmem_cache,它对应于一种对象类型的元数据管理,封装了对象的大小、对齐规则、构造/释放函数等。每个kmem_cache被串联在一个双向链表上,通过cache_chain连接。

中间层是slab对象:一个slab管理结构对应一块连续的物理内存区域(通常由一到几个物理页面组成),维护该slab上对象的分配状态。

最底层是object:实际可分配的内存单元,也是用户最终使用的内存块。

4.2 kmem_cache结构体关键字段(Linux 6.6)

struct kmem_cache {
    unsigned int object_size;   // 对象实际大小
    unsigned int size;          // 对象对齐后大小
    unsigned int align;         // 对象对齐参数
    slab_flags_t flags;         // 标志位
    const char *name;           // 缓存名称
    ctor_t ctor;                // 构造函数

    struct list_head slabs_full;    // 已全部分配的Slab链表
    struct list_head slabs_partial; // 部分分配的Slab链表
    struct list_head slabs_free;    // 完全空闲的Slab链表
    unsigned int num;               // 每个Slab包含的对象数
    unsigned int gfporder;          // 创建Slab时请求的页数(2的n次方)

    struct array_cache __percpu *cpu_cache;  // 每CPU对象暂存
};

4.3 Slab管理结构(SLUB实现中无显式slab结构体)

在SLUB实现中,slab信息直接嵌入在页面结构的union中,每个slab(即一组页面)通过一个"slab page"来管理:

// SLUB: slab信息嵌入struct page
struct page {
    // ... 标志位包含 Slab 相关的状态信息
    struct {
        struct { struct list_head *next, *prev; } lru;
        struct kmem_cache *slab_cache;
        void *freelist;    // 空闲对象链表头
        union {
            struct { unsigned inuse:16, objects:16; };
            struct { void *freelist; unsigned long counters; };
        };
    };
};

五、分配流程

当内核请求分配一个对象时(例如调用kmem_cache_alloc),流程如下:

  1. 查找缓存:首先在cache_chain中查找对应的kmem_cache(或由kmalloc根据大小查表找到)。
  2. 查找每CPU缓存:检查当前CPU的array_cache中有无空闲对象,若有则直接返回。
  3. 查找每节点缓存:在NUMA系统上,若CPU本地缓存为空,则尝试从同节点其他CPU缓存中获取。
  4. 查找Slab链表:从slabs_partial链表中取出一个Slab,从其空闲对象列表中取出一个对象。
  5. 申请新Slab:如果slabs_partial和空闲链表都没有可用对象,则通过伙伴系统(alloc_pages)申请新的页面,分割成对象后加入slabs_partial。

这个流程是内核高频分配的核心路径,通过维护对象空闲链表,可以快速定位可用对象,避免遍历开销。

六、释放流程

当对象不再需要时(例如kmem_cache_free),它不会立即归还给伙伴系统,而是被放回当前Slab的空闲对象链表中,以便下次分配时直接重用。

这就是Slab分配器的核心优势:快速重用——无需重新初始化,仅做链表头插入操作。

当某个Slab上所有对象都被释放后,它才会从kmem_cache的Slab列表中移出,归还给伙伴系统。释放过程中可能触发"Slab缩减"(shrink)逻辑,当系统内存压力大时会回收空闲Slab。Slab的状态转移规则为:全部分配 → slabs_full;部分分配 → slabs_partial;全部释放 → slabs_free → 归还伙伴系统。

七、着色(Colouring)机制

着色机制的引入是为了利用CPU缓存行(Cache Line)的特点。CPU缓存行通常64字节,当多个Slab中相同索引的对象被映射到同一缓存行时,会产生缓存行伪共享(False Sharing),导致不同CPU核心访问同一缓存行时产生缓存一致性流量。

着色的做法是在Slab起始处加入一定字节的偏移(colour offset),使得不同Slab中的相同索引对象映射到不同的缓存行位置,从而减少冲突。最大偏移量由 cache_line_size / align 决定。

结构体中 colouroff 字段记录了当前Slab的着色偏移,每次新建一个Slab时,偏移量递增(colour += cache_line_size),到达 colour_off 上限后回绕到零。

八、CPU本地缓存(array_cache)

在多核系统中,如果所有CPU都访问同一个Slab的数据结构来分配/释放对象,会产生严重的锁竞争。Slab分配器通过array_cache机制为每CPU维护一个本地空闲对象缓存来消除锁争用。

struct array_cache {
    unsigned int avail;     // 空闲对象数量(可分配)
    unsigned int limit;     // 最大对象数
    unsigned int touched;   // 最近是否被使用
    void *entry[0];         // 柔性数组:空闲对象指针数组
};

分配路径:首先从array_cache中弹出(pop)一个空闲对象。如果array_cache为空(avail == 0),则从Slab中一次性填充batchcount个对象到array_cache。

释放路径:首先将对象推入(push)array_cache。如果array_cache已满(avail >= limit),则从array_cache中一次性返还batchcount个对象给Slab。

这种设计使得在SMP系统上大多数分配/释放操作无需任何锁即可完成,极大地提升了多核并发性能。batchcount的取值经过调优,通常为1或cache_line能容纳的对象数量。

九、kmalloc系列API

kmalloc是内核中最常用的通用内存分配函数,它是基于Slab分配器的封装,用于分配从几十字节到数MB的内存块。

9.1 核心API

void *kmalloc(size_t size, gfp_t flags);          // 分配,内存不一定清零
void *kzalloc(size_t size, gfp_t flags);          // 分配并清零
void *kmalloc_array(size_t n, size_t size, gfp_t flags);  // 分配数组
void *kcalloc(size_t n, size_t size, gfp_t flags);        // 分配并清零数组
void *krealloc(const void *p, size_t new_size, gfp_t flags); // 重新分配
void kfree(const void *objp);  // 释放

9.2 内部实现原理

kmalloc内部通过预定义的一组"size cache"来快速找到匹配的kmem_cache:

static __always_inline void *kmalloc(size_t size, gfp_t flags)
{
    if (size > KMALLOC_MAX_SIZE)
        return NULL;
    if (size <= KMALLOC_MAX_CACHE_SIZE)
        return __kmalloc_cache(size, flags);  // 从预定义缓存数组查找
    return kmalloc_large(size, flags);        // 大内存走伙伴系统
}

预定义缓存的size范围通常从32字节到8KB(具体取决于配置和CPU架构),以2的幂次为主。当请求大小超过8KB时,kmalloc直接调用伙伴系统分配整页内存。

kfree释放时,通过对象地址找到其所属的slab page(通过virt_to_slab或page_address),然后根据情况归还给array_cache或直接归还给Slab。

十、专用缓存API(kmem_cache)

除了kmalloc,内核中很多子系统会为自己的专用对象类型创建独立的缓存,这在需要高频分配同一类型对象的场景下效率极高。

// 创建专用缓存
struct kmem_cache *kmem_cache_create(const char *name, unsigned int size,
                                     unsigned int align, slab_flags_t flags,
                                     void (*ctor)(void *));

// 从缓存分配对象
void *kmem_cache_alloc(struct kmem_cache *cache, gfp_t flags);

// 释放对象回缓存
void kmem_cache_free(struct kmem_cache *cache, void *obj);

// 销毁缓存
void kmem_cache_destroy(struct kmem_cache *cache);

典型使用场景:

  • task_struct:task_struct_cachep,每次fork/exec时分配
  • dentry:dentry_cache,文件路径解析时高频分配
  • inode:inode_cache,文件系统inode缓存
  • sk_buff:skbuff_head_cache,网络数据包管理
  • signal_struct、files_struct等进程相关结构
  • 驱动中自定义的数据结构

十一、Slab调试与监控

11.1 /proc/slabinfo

通过读取/proc/slabinfo可以获取当前系统所有Slab缓存的运行时统计:

cat /proc/slabinfo
# name            <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> : ...
dentry              28512   31071     192   21   1 : ...
inode_cache         18652   19053     656    6   2 : ...
task_struct           234     320    7360    1   4 : ...

各列含义:缓存名、已分配对象数、总对象数、单对象大小、每Slab对象数、每Slab页面数。

11.2 Slabtop工具

slabtop命令提供类似top的实时Slab内存使用视图,可按不同字段排序观察。

11.3 KASAN(内核地址消毒剂)

KASAN在编译时插入检查代码,在每个Slab对象周围设置"红区"(Red Zone),用来检测缓冲区越界和使用已释放内存(Use-After-Free)等严重错误。通过CONFIG_KASAN开启,主要用于内核开发和测试阶段。

11.4 Slab合并调试(SLUB debug)

通过slub_debug内核参数开启Slub调试功能:

slub_debug=FZP  # F=启用对象分配追踪, Z=红区检测, P=启用毒化

常见应用场景:追踪某个对象类型的内存泄漏、检测重复释放。通过echo <pid> > /sys/kernel/slab/<cache>/alloc_from等接口结合ftrace可进一步分析。

十二、性能优化实践

12.1 选择合适的分配器参数

  • 对于频繁分配的小对象(<1KB),使用专用kmem_cache而非kmalloc,避免碎片化
  • 合理设置ctor/dtor避免重复初始化开销
  • 分配标志GFP_KERNEL用于可睡眠上下文,GFP_ATOMIC用于中断/不可睡眠上下文

12.2 避免常见陷阱

  • 不要在中断上下文中使用GFP_KERNEL分配(可能引发睡眠)
  • 避免重复分配/释放相同大小的对象(应复用或预分配)
  • 不要将kmalloc分配的内存通过free_pages归还(必须用kfree)
  • SLUB的freelist指针存储在对象本身内存中,释放后不要立即访问

十三、总结

Slab分配器作为Linux内核内存管理的核心组件,通过对象复用、每CPU缓存、着色技术等机制,在伙伴系统之上构建了一层高效的小对象分配层。理解Slab分配器的工作机制,对于内核开发、驱动开发以及性能优化都有着重要意义。

本文覆盖了Slab分配器的架构设计、数据结构、分配/释放流程、缓存优化、kmalloc接口、调试工具和性能优化的全链路知识,希望能帮助读者建立对内核内存管理的完整认知。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部