从伙伴系统到 Slab:Linux 内核内存分配的进化逻辑与工程取舍
从伙伴系统到 SlabLinux 内核内存分配的进化逻辑与工程取舍一、当伙伴系统遇上小对象4KB 页面装不下 64 字节的尴尬Linux 内核运行着数万个小对象。每个进程对应一个task_struct约 1.7KB。每个打开文件对应一个file结构约 256B。每个目录缓存项对应一个dentry约 192B。这些对象生命周期短、分配频率极高每秒可能创建和销毁数十万次。伙伴系统是内核物理内存分配的基础设施。它把空闲页面按 2 的幂次组织成 11 个链表。范围从2^04KB到2^104MB。申请内存时从合适的链表取出一个块如果当前链表为空就从更大一级的链表分裂。分裂后一半返回给用户另一半加入对应的空闲链表。释放时检查相邻兄弟块是否空闲如果是就合并回上一级。这套机制的优点是外部碎片少、分配连续物理内存快。但伙伴系统的最小分配单位是一个物理页4KB。当内核需要分配一个 64 字节的dentry伙伴系统必须给它一整页浪费率高达 98.4%。这不是偶发现象——内核中 80% 以上的分配请求都远小于 4KB。系统运行几小时后大量页面会被这类小对象撑满。每个页面内实际只用了几十到几百字节物理内存消耗却按页面计算。这就是 Slab 分配器要解决的核心问题让内核在同一页面内复用空间。为高频小对象提供高效、低碎片的分配方式。二、双层分配架构伙伴系统管物理页Slab 管内碎片复用Slab 分配器建立在伙伴系统之上形成两级分配模型。伙伴系统以页面为单位向 Slab 供货Slab 再把页面拆成等大对象槽位向内核各子系统分发。flowchart TB subgraph 内核调用层 A1[kmalloc-32] A2[kmalloc-64] A3[kmem_cache_allocbr/自定义对象] end subgraph Slab分配器层 B1[per-CPU 数组缓存] B2[per-node 共享缓存] B3[slab 链表br/full / partial / free] end subgraph 伙伴系统层 C1[free_area[0] 4KB] C2[free_area[1] 8KB] C3[free_area[2] 16KB] C4[free_area[N] ...] end subgraph 物理内存 D[物理页框] end A1 A2 A3 -- B1 B1 -- 缓存未命中 -- B2 B2 -- 无空闲对象 -- B3 B3 -- 需要新 slab -- C1 B3 -- C2 B3 -- C3 B3 -- C4 C1 C2 C3 C4 -- D一个 Slab 由一页或多页连续物理内存组成被划分为 N 个等大小的槽位。以kmalloc-64缓存为例一页 4KB 减去 slab 头部元数据后约可容纳 50 多个 64 字节对象。伙伴系统只做了一次页面分配Slab 却可以服务 50 多次对象请求。Slab 核心数据结构是kmem_cache。每个缓存维护三个 slab 链表full所有对象都在使用无可分配槽位partial部分对象空闲优先从这里分配free所有对象都空闲可能被整体回收给伙伴系统分配流程图如下sequenceDiagram participant Caller as 调用者 participant CPU as per-CPU 缓存 participant Node as per-node 共享缓存 participant Slab as slab 链表 participant Buddy as 伙伴系统 Caller-CPU: 请求对象 alt CPU 缓存有可用对象 CPU--Caller: 返回对象地址 else CPU 缓存为空 CPU-Node: 从共享缓存批量拉取 end alt 共享缓存也为空 Node-Slab: 查找 partial 链表 alt partial 链表非空 Slab--Node: 返回一个 slab Node--CPU: 填充缓存 CPU--Caller: 返回对象地址 else partial 链表为空 Slab-Buddy: 申请新页面 Buddy--Slab: 返回连续物理页 Slab--Node: 初始化并返回新 slab Node--CPU: 填充缓存 CPU--Caller: 返回对象地址 end end这套体系的关键设计在于per-CPU 缓存。每个 CPU 预留一组空闲对象本地分配无需加锁、无需跨核同步。只有当本地缓存耗尽时才向共享缓存批量请求。这在高并发分配场景下锁竞争几乎为零。三、生产级观测与自定义缓存实现直接读取/proc/slabinfo可以观察到系统中所有活跃缓存kmalloc-64 123456 124000 64 64 1 : tunables ... dentry 87654 88200 192 21 1 : tunables ... task_struct 3200 3240 1728 4 2 : tunables ...字段依次为缓存名、活跃对象数、总对象数、对象大小、每 slab 对象数、每 slab 页数等。活跃对象数持续接近总对象数时说明该缓存需要扩容。下面是一个内核模块创建自定义kmem_cache演示带错误处理的完整分配与释放流程#include linux/module.h #include linux/slab.h #include linux/string.h #include linux/err.h struct worker_ctx { int id; unsigned long flags; char name[32]; struct list_head list; /* 对齐到缓存行避免 false sharing */ } ____cacheline_aligned; static struct kmem_cache *worker_cache; static int check_alignment(void) { if (sizeof(struct worker_ctx) KMALLOC_MAX_SIZE) { pr_err(worker_ctx exceeds kmalloc limit (%zu %zu)\n, sizeof(struct worker_ctx), (size_t)KMALLOC_MAX_SIZE); return -EINVAL; } return 0; } static struct worker_ctx *worker_alloc(int id) { struct worker_ctx *w; unsigned int count; w kmem_cache_alloc(worker_cache, GFP_KERNEL); if (!w) { pr_warn(worker_cache alloc failed for id%d\n, id); return NULL; } /* 初始化阶段记录缓存统计 */ count kmem_cache_alloc_bulk ? 0 : 1; w-id id; w-flags 0; snprintf(w-name, sizeof(w-name), worker-%d, id); INIT_LIST_HEAD(w-list); return w; } /* 批量释放生产环境中可将待回收对象收集到链表统一释放 */ static void worker_free_bulk(struct worker_ctx **workers, int n) { int i; struct worker_ctx *tmp; for (i 0; i n; i) { tmp workers[i]; if (WARN_ON(!tmp)) continue; /* 释放前检查避免 UAF */ if (!list_empty(tmp-list)) list_del_init(tmp-list); kmem_cache_free(worker_cache, tmp); } } static int __init worker_cache_init(void) { int ret; ret check_alignment(); if (ret) return ret; worker_cache kmem_cache_create( worker_ctx, sizeof(struct worker_ctx), 0, /* align: 0 表示默认 */ SLAB_HWCACHE_ALIGN | SLAB_PANIC | SLAB_ACCOUNT, NULL /* 构造函数不推荐使用 */ ); if (!worker_cache) { pr_err(kmem_cache_create failed\n); return -ENOMEM; } pr_info(worker_cache created, obj_size%zu\n, kmem_cache_size(worker_cache)); return 0; } static void __exit worker_cache_exit(void) { /* 注意卸载前必须确保所有对象已归还 */ kmem_cache_destroy(worker_cache); pr_info(worker_cache destroyed\n); } module_init(worker_cache_init); module_exit(worker_cache_exit); MODULE_LICENSE(GPL);几个关键点值得注意SLAB_HWCACHE_ALIGN让对象起始地址对齐到 CPU 缓存行减少跨核缓存行争用。SLAB_PANIC创建失败直接 panic。在内核初始化阶段缓存创建失败意味着系统无法正常工作panic 比静默失败更合适。构造函数不推荐kmem_cache_create的构造函数并不会在每次对象分配时调用。它只在新 slab 创建时对全部对象调用一次。行为与直觉差异较大现代内核代码基本弃用。卸载前保证释放kmem_cache_destroy时缓存必须为空内存泄漏检查工具如 Kmemleak会检测这类问题。在生产环境中还可以通过tracepoint挂载点采集分配事件echo 1 /sys/kernel/debug/tracing/events/kmem/kmem_cache_alloc/enable echo 1 /sys/kernel/debug/tracing/events/kmem/kmem_cache_free/enable cat /sys/kernel/debug/tracing/trace_pipe结合 eBPF 可以进一步统计各缓存命中率、空闲槽位比例和回收频率。这些数据是内存容量规划的核心输入。四、三种实现的选择逻辑与场景边界Linux 内核中 Slab 分配器有三种实现SLAB、SLUB、SLOB。现代内核默认使用 SLUBUnqueued Slab Allocator。SLAB原始实现为每个缓存维护三个链表并缓存初始化过的空闲对象。优点是逻辑直观。缺点有两个链表数量随对象增多线性膨胀空闲对象缓存放在 slab 描述符中描述符自身也有内存开销。在高端服务器上SLAB 的排队缓存容易成为热点。SLUB当前默认取消了复杂的排队机制把每个 slab 的元数据嵌入到页面结构中。它复用struct page的字段来记录空闲链表头和对象数量消除了独立的 slab 描述符。per-CPU 层面改用struct kmem_cache_cpu只保存当前活跃 slab 的指针和一个 freelist。代码量比 SLAB 少约 60%在 NUMA 和多核场景下性能更优。SLOB简单实现不区分缓存类型直接在页面内用 first-fit 算法分配。适合内存极度受限的嵌入式环境但并发性能和碎片控制都很弱。选择矩阵场景推荐实现理由通用服务器 / 桌面SLUB默认选项NUMA 友好per-CPU 性能好大规模 SMP64 核以上SLUB 大页面配置减少 TLB miss配合 slub_min_order 调节嵌入式 / 低内存32MBSLOB极简实现代码量小内核调试 / 开发SLUB KASANSLUB 的调试支持最成熟与 Kmemleak、UBSAN 集成好Slab 的局限性也必须明确仍然依赖伙伴系统当伙伴系统碎片化严重时Slab 申请新页面会失败。上层分配也随之失败。这不是 Slab 的问题但会在日志中表现为kmem_cache_alloc失败。缓存膨胀内核新建了大量kmem_cache后每个缓存至少占一页累计内存消耗不可忽略。/proc/slabinfo中num_slabs列乘以pagesperslab可以估算 Slab 总内存占用。着色失效SLAB 的着色旨在错开不同 slab 的起始偏移以此减少缓存行冲突。但现代 CPU 缓存相联度提升后着色的收益已很微弱。SLUB 默认关闭着色。不适用于巨量对象对象超过KMALLOC_MAX_SIZE通常为 8KB应改用vmalloc或直接调alloc_pages。Slab 设计目标是加速小对象分配大对象分配会破坏缓存的密集度优势。一个实际案例某网络中间件需要每秒分配和释放约 200 万个struct sk_buff。使用默认 SLUB 后per-CPU 缓存命中率超过 95%sk_buff分配路径的 P99 延迟低于 200ns。当业务对象大小跨多个缓存档位时如 128B 和 512B 混合分配应显式创建独立缓存。使用kmem_cache_create替代通用缓存避免不同大小对象间的槽位竞争。五、总结伙伴系统以 2 的幂次管理物理页面。通过分裂与合并机制外部碎片保持可控。但最小分配粒度为 4KB对小对象的内碎片浪费严重。Slab 分配器建立在伙伴系统之上在页面内复用等大对象槽位。它将页面分配成本分摊到数十次对象分配中并通过 per-CPU 缓存消除多核分配时的锁竞争。内核当前默认的 SLUB 实现取消了独立 slab 描述符将元数据嵌入struct page。这简化了队列管理并在 NUMA 和大核数场景下表现出更优的扩展性。对于自定义对象分配应使用kmem_cache_create显式创建专用缓存。配合SLAB_HWCACHE_ALIGN和 cgroup accounting 标记可获得最佳性能与可观测性。内存问题的排查不应止步于/proc/slabinfo的快照。应结合 tracepoint、eBPF 统计和业务层延迟指标。建立按缓存类型的多维度监控覆盖分配频率、命中率和回收压力。