1. 收到这个Patch时,我第一反应是:还有哪次解引用能省?
先说结论,这个标题真正值钱的部分,不在“slab allocations”本身,而在“pointer dereference”到底指哪一次访问。很多人一听说slab分配器优化,第一反应就是“减少lock contention”或者“优化percpu partial列表”,但这次要抠的是更底层的一件事:每次从kmem_cache里取一个对象,CPU为了知道“下一个空闲对象在哪”,都必须额外去读一次刚刚取出来的那个对象的内存首部。这一个读取动作,就是标题里说的pointer dereference。
我在代码评审里看到这个思路的第一反应是:内核的SLUB分配器已经这么成熟了,kmalloc那条路走了二十年,怎么还会有这么基础的间接访问可以移除?直到我把slab_alloc_node这条路径重新捋了一遍才意识到,问题出在free list的载体上。SLUB里,空闲对象之间是用指针串成链表的,而这些指针不是放在某个单独的管理结构里,而是直接存放在对象本身的内存中。这意味着,分配器把对象A交给你之前,必须先从A的内存里读出A中记录的next指针,才能更新per-CPU的free list头。
反直觉的地方就在这里:上层代码拿到这个对象,通常马上要往里写数据;可分配器为了准备下一次分配,得在这之前就把对象内存的头几个字节读一遍。更麻烦的是,这个对象刚从free list上摘下来,它的cache line状态未必是热的。如果这个slab刚被refill、对象很久没有被访问,这一次get_freepointer就很可能成为一整条分配路径上最不可预测的cache miss。
1.1 kmalloc只是入口,真正的热路径是slab_alloc_node
我们平时在驱动里写kzalloc(64, GFP_KERNEL),以为内核分配器就是简单从某个池子里切一块内存。实际上kmalloc只是个转发层,它会根据请求大小找到对应的kmem_cache,然后走进kmem_cache_alloc -> slab_alloc_node。对SLUB而言,绝大多数分配根本不会走到伙伴系统,而是在struct kmem_cache_cpu这个per-CPU变量上直接完成。
简化后的逻辑大致是这样的:
c复制static void *slab_alloc_node(struct kmem_cache *s, ...)
{
struct kmem_cache_cpu *c = raw_cpu_ptr(s->cpu_slab);
void *object;
object = c->freelist;
if (!object)
object = ___slab_alloc(s, ...); // refill路径
// 取出当前object之后,立刻读它内部的next指针
c->freelist = get_freepointer(s, object);
...
return object;
}
注意看,代码里并没有在“下一次分配”时才去读next。当前这次分配里,get_freepointer(s, object)就会访问object这个地址,从它的某个偏移处解引用出下一个空闲对象。s->offset通常为0,也就是说,空闲对象头几个字节就是freepointer。
这背后的设计假设是:对象一旦被释放回slab,它里面的数据对上层已经无意义了。既然如此,干脆把对象内存的头8个字节复用,当作指向下一个空闲对象的指针。整个free list就这样被嵌入到对象集体里,不需要额外为每个对象准备一份“元数据”。
1.2 取出object的同时要回头读object本体,这很反直觉
很多第一次看SLUB源码的人会困惑:c->freelist不是已经告诉我们下一个对象在哪了吗?为什么还要再调用一次get_freepointer?因为c->freelist只保存了free list的“头”。在链式free list里,头节点本身并不携带“下一个是谁”的信息,你必须通过头节点内部存放的指针去找下一跳。于是每次分配,都必须跟随一次额外的指针访问:
code复制当前free list: freelist -> objA -> objB -> objC
第一步:从c->freelist拿到objA的地址
第二步:读objA内存中的next指针,得到objB
第三步:把c->freelist更新为objB
关键就在第二步。如果SLUB把free list用单独的内存区域维护,那这次读取会集中在一块总是被访问的元数据区里,cache命中率会高得多。但SLUB偏不,它把每一跳的next都散落在各个对象曾经栖身的内存位置。对象用过一次、释放、再分配,内
