深入 Linux 内核内存管理:slab/slub 分配器原理剖析

在讲物理内存管理时,我们绕不开伙伴系统(buddy system)。但伙伴系统有一个天然的粒度问题:它以「页」为最小分配单位(通常 4KB)。而内核里绝大多数对象------一个 task_struct、一个 inode、一个 dentry------往往只有几十到几百字节。如果每次申请一个几十字节的对象都要从伙伴系统拿走一整页,内部碎片会大到无法接受。

slab 分配器就是为了解决这个矛盾而生的:它站在伙伴系统之上,把伙伴系统给的整页「切」成大小相等的小块,管理内核中频繁分配/释放的小对象。本文从设计动机出发,讲清 slab 的核心思想,再对比现代内核默认使用的 slub 实现。

一、为什么需要 slab

内核对象分配有三个非常鲜明的特征:

  1. 对象小且大小固定 :同一类对象(比如所有 inode)大小完全一样。
  2. 分配/释放极其频繁:文件系统、网络栈每秒创建销毁海量对象。
  3. 初始化开销可复用:很多对象释放后再分配,其内部结构(锁、链表头)其实可以保持初始化状态,省去重复 init。

针对这三点,slab 提出了三个对应的核心设计:

复制代码
特征              →  slab 的对策
------------------------------------------------
小对象、大小固定  →  按对象大小建立专用缓存(cache),一页切成多个 object
分配释放频繁      →  空闲对象用链表串起来,分配就是摘链表头,O(1)
初始化可复用      →  对象释放不销毁,保留已初始化状态(构造/析构语义)

二、三层结构:cache → slab → object

slab 分配器的经典模型是三层结构:

复制代码
   kmem_cache (一类对象的缓存,如 "inode_cache")
        |
        +--- slab (一个或多个连续物理页)
        |        |
        |        +-- object object object object ...  (切好的等大小块)
        |
        +--- slab
                 +-- object object ...
  • kmem_cache:描述「某一类对象」的缓存。每种对象一个 cache,记录对象大小、对齐、构造函数等元信息。
  • slab:一块从伙伴系统申请来的连续物理页,被切分成若干个等大小的 object。
  • object:最终交给调用者的那个小内存块。

每个 slab 根据自身 object 的使用情况,处于三种状态之一:

复制代码
full   : slab 里的 object 全部被占用
partial: slab 里部分被占用、部分空闲   ← 分配优先从这里拿
empty  : slab 里 object 全部空闲,可回收还给伙伴系统

分配时优先从 partial slab 摘一个空闲 object;partial 用完了就去 empty 拿;都没有就向伙伴系统申请新页建一个新 slab。

三、经典 slab 的问题

经典 slab(Bonwick 在 Solaris 上提出、Linux 早期采用)设计精巧,但随着 SMP 系统核数越来越多,暴露出几个问题:

  • 元数据开销大 :每个 slab 头部要维护复杂的管理结构(kmem_bufctl_t 数组等),对象越多元数据越占空间。
  • 多层队列复杂:为了 SMP 扩展性引入了 per-CPU array cache、共享队列、多个 NUMA 节点队列,代码路径长、维护困难。
  • cache footprint 大:管理结构本身占用大量 cache line,影响性能。

于是社区演进出了 slub(S 表示 unqueued / simple),并成为现代内核(2.6.23 之后)的默认分配器。

四、slub:更简洁的实现

slub 的核心思想是「能不维护的元数据就不维护」。它做了几个关键简化:

1. 把管理信息塞进对象本身

slub 不再为每个 slab 维护独立的 bufctl 数组,而是把「下一个空闲对象的指针」直接存在空闲对象自己的内存里,形成一条隐式的空闲链表(free list):

复制代码
free_list ──► obj0.next ──► obj3.next ──► obj7.next ──► NULL
             (指针就写在空闲对象的头几个字节里,
               反正对象空闲时里面的内容没用)

分配 = 取 free_list 指向的对象,free_list 前移到 obj->next。释放 = 把对象插回链表头。全程 O(1),且几乎没有额外元数据。

2. per-CPU 的 active slab

每个 CPU 持有一个当前正在分配的 slab(kmem_cache_cpu),大部分分配走的是「无锁快路径」:

c 复制代码
/* 极简化的 slub 快路径示意(非真实源码) */
static void *slab_alloc(struct kmem_cache *s)
{
    struct kmem_cache_cpu *c = this_cpu_ptr(s->cpu_slab);
    void *object = c->freelist;          // 取 per-cpu 空闲链表头

    if (likely(object)) {
        c->freelist = get_freepointer(s, object); // 链表前移
        return object;                    // 快路径:无锁,直接返回
    }
    return __slab_alloc(s, c);            // 慢路径:per-cpu 空了,去 partial/伙伴系统
}

快路径只碰 per-CPU 数据,不需要加全局锁,这是 slub 在多核下扩展性好的关键。只有当前 CPU 的 slab 用完时,才走慢路径去 partial 列表或伙伴系统补货。

3. 更少的状态

slub 精简了队列层次,主要维护 per-CPU 的 active slab 和 per-node 的 partial 列表,去掉了经典 slab 里冗长的共享队列,代码量和 cache footprint 都大幅下降。

五、从用户视角:kmalloc 与专用 cache

内核里用到 slab/slub 有两种典型方式:

通用分配 kmalloc :内核预先建立了一组按 2 的幂递增的通用 cache(kmalloc-8kmalloc-16kmalloc-32 ... kmalloc-8192)。kmalloc(30, ...) 会向上取整到 kmalloc-32 这个 cache 里分配。

c 复制代码
void *buf = kmalloc(30, GFP_KERNEL);  // 实际从 kmalloc-32 cache 拿一个 32 字节 object
/* ... 使用 ... */
kfree(buf);

专用 cache :对于频繁分配的特定对象,内核会用 kmem_cache_create 建专用缓存,指定大小、对齐、构造函数:

c 复制代码
struct kmem_cache *my_cache;

my_cache = kmem_cache_create("my_obj",
                             sizeof(struct my_obj),
                             0,               /* 对齐 */
                             SLAB_HWCACHE_ALIGN,
                             NULL);           /* 构造函数,可为 NULL */

struct my_obj *p = kmem_cache_alloc(my_cache, GFP_KERNEL);
/* ... */
kmem_cache_free(my_cache, p);

kmem_cache_destroy(my_cache);

系统里当前有哪些 cache、各自占用多少,可以直接看:

bash 复制代码
cat /proc/slabinfo
# name  <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> ...

六、小结

  • 伙伴系统按页分配,slab/slub 在其上做小对象的精细化管理,解决内部碎片和分配频率问题。
  • slab 的三层模型:kmem_cache → slab → object,slab 按占用分 full/partial/empty 三态,分配优先走 partial。
  • slub 是现代内核的默认实现,核心优化是:空闲指针内嵌进对象(几乎零元数据)、per-CPU active slab 走无锁快路径、精简队列层次。
  • 用户侧两条路:通用 kmalloc(走 kmalloc-N cache)和 kmem_cache_create 建专用 cache。

理解 slab/slub,本质上是理解内核如何在「伙伴系统的页粒度」和「实际对象的字节粒度」之间架起一层高效的桥梁。

相关推荐
H_oRIZoN_1 小时前
Linux入门DAY44 ARM 入门 Day02|ARM 汇编指令、模式切换、栈操作、汇编 C 混合编程
linux·单片机·嵌入式硬件·arm·linux应用编程
程序员-Benothing2 小时前
Linux文件查看与编辑:cat、less、tail、vim快速入门
linux·运维·服务器
AlanBruce9 小时前
摩尔信使MThings功能综述与应用使用指南
linux·自动化·plc·mthings·摩尔信使
Horn Still Sounds11 小时前
ARM嵌入式基础|内核、寄存器、架构、存储、汇编移位全面梳理
arm开发·单片机·嵌入式硬件·硬件架构
陈陈CHENCHEN14 小时前
【Linux】服务器根目录磁盘扩容操作记录
linux·运维·服务器
GeW15 小时前
RHCE快速拿证全攻略:考点拆解+实验强化+时间规划,一次通关
linux
码农小韩16 小时前
Linux应用开发(八)——TCP/IP网络编程基础
linux·嵌入式软件开发·linux操作系统·嵌入式操作系统·linux网络编程·linux应用
迷途之人不知返16 小时前
【进程】-5-进程优先级
linux
fengyehongWorld16 小时前
Linux squid搭建基础代理服务器
linux·运维·服务器