5、Slab分配器原理:Slab、Cache、Object三层结构、着色(Coloring)技术、Linux Slab分配器借鉴
内存碎片,尤其是内部碎片,是内核级开发里最让人头疼的问题之一。你想想看,如果频繁地分配和释放固定大小的结构体(比如 task_struct、inode),通用分配器(比如 buddy system)会留下大量无法利用的小空洞。我早年做嵌入式内核裁剪时,就被这个问题折磨过——系统跑着跑着,明明内存总量还够,却分配不出一个几 KB 的对象。
Slab 分配器就是专门解决这个问题的。它不跟通用分配器抢大块内存,而是从 buddy 系统拿一整页或几页,然后自己切成等长的小块来管理。说白了,它是个「二道贩子」——批量进货,零售出货。
5.1 三层结构:Cache、Slab、Object
Slab 分配器的核心是三层架构。我习惯把它想象成一个工厂流水线:
- Cache(缓存):负责管理某一种固定大小的对象。比如一个专门分配 256 字节结构体的 Cache。
- Slab(板):Cache 下面挂着一组 Slab,每个 Slab 对应一块连续物理内存(通常是一页或多页)。
- Object(对象):Slab 被切分成若干个等长的 Object,每个 Object 就是一个可分配的最小单元。
每个 Cache 内部维护三个链表:
- 完全空闲的 Slab 链表(slabs_free)
- 部分空闲的 Slab 链表(slabs_partial)
- 完全占用的 Slab 链表(slabs_full)
分配时,优先从 slabs_partial 里找空闲 Object。如果找不到,再从 slabs_free 里拿一个 Slab 出来切。如果 slabs_free 也空了,就向 buddy 系统申请新的物理页。
核心思想:Slab 分配器通过「对象缓存 + 预切分」的方式,把分配和释放的时间复杂度降到 O(1)。你每次拿到的 Object 地址,其实就是在 Slab 内部做一次简单的指针偏移。
我曾经在一个网络协议栈项目里,把每个数据包的缓冲区从 kmalloc 改成 Slab 分配。结果呢?分配延迟从微秒级降到了纳秒级,而且内存碎片几乎消失了。嗯,这就是 Slab 的魅力。
5.2 着色(Coloring)技术
着色技术是 Slab 分配器里一个容易被忽略、但极其重要的细节。为什么要着色?因为 CPU 缓存(Cache)有对齐问题。
你想想看,如果每个 Slab 里的 Object 都从同一个偏移量开始,那么不同 Slab 中相同索引的 Object 就会映射到 CPU 缓存的同一组(Cache Set)上。这会导致严重的缓存行冲突——明明内存还有空位,CPU 却频繁地踢出和加载缓存行,性能直接腰斩。
着色的做法很简单:每个 Slab 在开头留出一段「偏移量」,这个偏移量就是颜色。不同 Slab 使用不同的偏移量,让 Object 在物理地址上错开,从而均匀分布在 CPU 缓存的各个组里。
我的经验:着色值通常取 CPU 缓存行大小(64 字节)的整数倍。我曾经在一个多核处理器上做过对比测试:开启着色后,多线程并发分配的性能提升了约 30%。别小看这几百字节的偏移,它在高并发场景下就是质变。
着色技术本质上是用「空间换时间」——每个 Slab 会浪费一点点内存(偏移量占用的区域),但换来了 CPU 缓存的友好性。在内存充裕、性能敏感的场景下,这笔买卖绝对划算。
5.3 Linux Slab 分配器借鉴
Linux 内核的 Slab 分配器经历了三个主要版本:原始的 Slab、SLUB(Unqueued Slab Allocator)、SLOB(Simple List Of Blocks)。目前主流内核默认用的是 SLUB,但 Slab 的思想贯穿始终。
Linux Slab 分配器有几个设计点非常值得我们在用户态分配器中借鉴:
| 特性 | 说明 | 借鉴价值 |
|---|---|---|
| per-CPU 缓存 | 每个 CPU 核心维护一个本地对象缓存,减少锁竞争 | 高并发场景下,用本地缓存 + 批量回退机制 |
| 着色偏移 | 不同 Slab 使用不同偏移量,避免 CPU 缓存行冲突 | 用户态大页分配时,手动对齐到缓存行边界 |
| 构造函数/析构函数 | 分配和释放时调用用户自定义的初始化/清理函数 | 对象池模式中,复用对象时重置状态 |
| Slab 合并 | 大小相近的 Cache 可以共享 Slab 内存 | 减少内存浪费,但会增加管理复杂度 |
我个人习惯在用户态实现轻量级分配器时,只取 Slab 的「预切分 + 部分空闲链表」这两个核心思想。着色和 per-CPU 缓存可以根据场景按需添加——不要一开始就把所有特性都塞进去,否则调试起来会非常痛苦。
避坑指南:我曾经在一个项目中照搬了 Linux Slab 的完整设计,结果在用户态遇到了严重的锁竞争问题。为什么?因为内核态有中断上下文、自旋锁等机制,而用户态只有 pthread_mutex。后来我把全局锁改成了 per-CPU 的本地锁 + 原子操作,性能才恢复正常。记住:借鉴不等于照搬,一定要考虑运行环境的差异。
5.4 一个简化的 Slab 分配器示例
下面是一个极简的 Slab 分配器核心逻辑。它只演示了「从 Slab 中分配 Object」和「释放 Object 回 Slab」的过程,省略了 Cache 管理和着色细节。
#include <stddef.h>
#include <stdlib.h>
#include <string.h>
/* 每个 Slab 管理一个连续内存块 */
struct slab {
void *start; /* 内存块起始地址 */
size_t obj_size; /* 每个对象的大小 */
size_t total_objs; /* 对象总数 */
unsigned long *bitmap;/* 位图:0=空闲,1=占用 */
struct slab *next; /* 链表指针 */
};
/* 从 Slab 中分配一个对象 */
void *slab_alloc(struct slab *s) {
for (size_t i = 0; i < s->total_objs; i++) {
/* 检查位图中第 i 位是否为 0 */
if (!(s->bitmap[i / (8 * sizeof(unsigned long))] &
(1UL << (i % (8 * sizeof(unsigned long)))))) {
/* 标记为占用 */
s->bitmap[i / (8 * sizeof(unsigned long))] |=
(1UL << (i % (8 * sizeof(unsigned long))));
/* 返回对象地址 */
return (char *)s->start + i * s->obj_size;
}
}
return NULL; /* 没有空闲对象 */
}
/* 释放对象回 Slab */
void slab_free(struct slab *s, void *obj) {
ptrdiff_t offset = (char *)obj - (char *)s->start;
size_t index = offset / s->obj_size;
/* 清除位图中对应位 */
s->bitmap[index / (8 * sizeof(unsigned long))] &=
~(1UL << (index % (8 * sizeof(unsigned long))));
/* 可选:清空对象内容,防止信息泄露 */
memset(obj, 0, s->obj_size);
}
这段代码虽然简单,但已经体现了 Slab 分配器的核心:用位图管理空闲对象,分配和释放都是 O(n) 的线性扫描。实际工程中会用 free list(空闲链表)来做到 O(1),但位图的好处是内存开销小,适合嵌入式场景。
5.5 三层结构的 SVG 示意图
下面这张图展示了 Cache、Slab、Object 三层之间的关系,以及着色偏移的效果:
从图中可以看到,每个 Slab 实例的着色偏移不同(0、64、128 字节),这使得 Object 在内存中的分布更加均匀。Cache 通过三个链表来管理 Slab 的生命周期——分配时从 partial 或 free 中取,释放时回收到 partial 或 free。
嗯,Slab 分配器的设计思想其实很朴素:把「通用」变成「专用」。通用分配器追求的是各种大小都能分配,但代价是碎片和性能。Slab 分配器只专注一种大小,用空间换时间,用预切分换低延迟。这个思路在数据库连接池、线程池、对象池等场景中同样适用。
最后说一句:如果你要在用户态实现自己的分配器,不妨从 Slab 开始。它足够简单,又足够强大。我见过不少高性能服务器,底层就是一套定制化的 Slab 分配器在支撑。别被「内核专属」这个标签吓到——Slab 的思想,放之四海而皆准。