SLUB 的类三级缓存结构

前言

本文基于 6.1 版本的 Linux 内核代码,阐述笔者对 SLUB 分配器原理的个人理解。本文的分析忽略以下内容:

  • 所有 debug 相关特性(如 KASAN)
  • 所有安全增强特性(如 CONFIG_SLAB_FREELIST_HARDENED
  • SLUB per-cpu partial cache 特性(CONFIG_SLUB_CPU_PARTIAL=n

换言之,本文只关注最底层最基础的 SLUB 运行逻辑——一种类三级缓存的结构。该想法是在上班通勤途中阅读此文 获得的启发,遂希望通过阅读代码和画图整理,将之梳理清楚。

概述

  • 一级缓存:per-cpu SLAB cache
    • 代表结构:struct kmem_cache_cpu 中的 freelist
    • 单向链表,缓存的是 slab 对象
  • 二级缓存:Slab-page SLAB cache
    • 代表结构:struct page(v5.17-rc1 后改为 struct slab)中的 freelist
    • 单向链表,缓存的是 slab 对象
  • 三级缓存:Node cache, or partial list
    • 代表结构:struct kmem_cache_node 中的 partial
    • 双向链表,缓存的是 SLAB 页

hierarchy

一、二级缓存之间的交互

当一个内存页被 kmem_cache_cpu 占用,若此时别的 cpu 向它归还 slab objects,则是归还到二级缓存,即该页的 page.freelist 中。二级缓存 page.freelist 的生长与 kmem_cache_cpu.freelist 不冲突,两者是同一个内存页上互不相交的两条单链。前者总是从 NULL 开始生长,但凡长出一段,就随时可能被 kmem_cache_cpu 整个拿走,即上升到一级缓存。拿走后 page.freelist 又会重新被置为 NULL

二级缓存的 page.frozen 表示该页是否已被某 CPU 独占(i.e. 为 kmem_cache_cpu.page 所指)。被独占期间,一级缓存可以从该页攫取空间。攫取过程的关键点在于 get_freelist() 函数:

  • 若获取成功,则二级缓存对应的 page 仍是被独占的(即 page.frozen 仍为 1)。该过程被称为 ALLOC_REFILL
  • 若获取失败,则说明该 page 占满,已无可用空间。此时一级缓存会放弃对该 page 的独占(即将 page.frozen 改为 0),转而去获取新的1 slab page 并独占之;二级缓存待有空间被归还后,加入三级缓存的 partial list。

需要说明的是,一级、二级缓存只是逻辑上的区分。实际上两者处于同一个内存页上。

二级、三级缓存之间的交互

二级退入三级

当一级缓存从二级缓存获取空间失败时,一级缓存会放弃对二级缓存的独占(参见 get_freelist())。此时的二级缓存俨然成了“孤魂野鬼”:既不为 kmem_cache_cpu 所引用,又没有挂在 partial list 上。

当有属于该页的 slab objects 被归还,该页的 struct page 可通过 virt_to_head_page()(v5.17-rc1 后改为 virt_to_slab())找回(参见 kmem_cache_free() 中的实现)。归还后该页的剩余空间从无到有,而该页又未被任何 cpu 独占,因此将该页加入到三级缓存 partial list 中。

若该页一直在 partial list 上未被取走,则待其剩余空间全部被归还后,则将其从 partial list 上摘下并退回给 buddy system。

三级进入二级

当一级缓存放弃对旧的二级缓存的独占时,会尝试从 partial list 上获取一个新的内存页来作为新的二级缓存。后者就完成从三级缓存向二级缓存的转移。

代码详解

一级缓存

level1

从一级缓存获取空间属于快速路径:slab_alloc_node() 中除 __slab_alloc() 以外的其他部分。获取空间前先就 c->freelistc->slab 进行检查(check per-cpu kmem_cache_cpu),有问题的话就会进入慢速路径 __slab_alloc()

1	object = c->freelist;
2	slab = c->slab;
3
4	if (!USE_LOCKLESS_FAST_PATH() ||
5		unlikely(!object || !slab || !node_match(slab, node))) {
6		object = __slab_alloc(s, gfpflags, node, addr, c, orig_size);
7	} else {
8		...

没问题的话,就获取一级缓存头部的 slab object(即 c->freelist 的首节点),并让一级缓存的 freelist 指向下一项(即次节点 )。后者需要从当前 free obj 内存空间中获取指向下一个 free obj 的指针,该过程涉及多个因素(s->offset 以及 KASAN,SLAB_FREELIST_HARDENED 等特性),因此用一个专门的函数 get_freepointer_safe() 来处理:

 1redo:
 2	...
 3	object = c->freelist;
 4	...
 5	} else {
 6		void *next_object = get_freepointer_safe(s, object);
 7		if (unlikely(!this_cpu_cmpxchg_double(
 8				s->cpu_slab->freelist, s->cpu_slab->tid,
 9				object, tid,
10				next_object, next_tid(tid)))) {
11			note_cmpxchg_failure("slab_alloc", s, tid);
12			goto redo;
13		}
14		prefetch_freepointer(s, next_object);
15		stat(s, ALLOC_FASTPATH);
16		...

二级缓存

level2

从二级缓存开始到三级缓存,整个都属于慢速路径,由 __slab_alloc() 实现。

先检查有没有二级缓存,没有二级缓存的话直接去三级缓存搞一个内存页回来:

1reread_slab:
2	slab = READ_ONCE(c->slab);
3	if (!slab) {
4		...
5		goto new_slab;
6	}

随后对二级缓存进行各种检查(check if c->slab is good),若有不对劲的情况都把当前的二级缓存卸了(deactivate c->slab),然后去三级缓存获取新的内存页:

 1redo:
 2	if (unlikely(!node_match(slab, node))) {
 3		if (!node_isset(node, slab_nodes)) {
 4			node = NUMA_NO_NODE;
 5		} else {
 6			stat(s, ALLOC_NODE_MISMATCH);
 7			goto deactivate_slab;
 8		}
 9	}
10	if (unlikely(!pfmemalloc_match(slab, gfpflags)))
11		goto deactivate_slab;
12	...
13deactivate_slab:
14	...
15new_slab:

若二级缓存没问题,则把 kmem_cache_cpu 锁上(因为后面涉及到对该结构的调整),然后再确认一下在“之前检查”与“当前上锁”之间的这段时间里 c->slab 没有被改变(lock s->cpu_slab and compare c->slab):在可抢占内核上,这种情况是完全可能的。若真的改变了,则跳回前面的步骤重新获取并检查。

1	local_lock_irqsave(&s->cpu_slab->lock, flags);
2	if (unlikely(slab != c->slab)) {
3		local_unlock_irqrestore(&s->cpu_slab->lock, flags);
4		goto reread_slab;
5	}
6	...

在确保 slab page 存在且各种检查通过之后,再来尝试获取一级缓存(get c->freelist2)。若其存在,则直接复用 load_freelist 的代码,(假装)把这条 freelist 链表(重新)装载到 kmem_cache_cpu 上。

1	freelist = c->freelist;
2	if (freelist)
3		goto load_freelist;

所谓“装载”,其实就是获取这条 freelist 的次节点并将其赋予 c->freelist,然后将首节点返回出去供外界使用(load freelist to c2)。该过程与一级缓存中的无异,分配的过程逻辑上从一级缓存走向结果。

1load_freelist:
2	lockdep_assert_held(this_cpu_ptr(&s->cpu_slab->lock));
3	VM_BUG_ON(!c->slab->frozen);
4	c->freelist = get_freepointer(s, freelist);
5	c->tid = next_tid(c->tid);
6	local_unlock_irqrestore(&s->cpu_slab->lock, flags);
7	return freelist;

若是发现一级缓存上没有获取到空间,则调用 get_freelist() 攫取二级缓存(get freelist from c->slab):若成功则顺道向下执行,将获取的 freelist 装载到 kmem_cache_cpu 上;

 1	freelist = c->freelist;
 2	if (freelist)
 3		...
 4
 5	freelist = get_freelist(s, slab);
 6	if (!freelist) {
 7		...
 8	}
 9	stat(s, ALLOC_REFILL);
10load_freelist:
11	...

若失败则走入三级缓存。

1	freelist = get_freelist(s, slab);
2	if (!freelist) {
3		c->slab = NULL;
4		local_unlock_irqrestore(&s->cpu_slab->lock, flags);
5		stat(s, DEACTIVATE_BYPASS);
6		goto new_slab;
7	}

三级缓存

level3

三级缓存先尝试从 paritial list 中获取 slab page 并取走它的可用空间(get partial slab page and take away freelist)。若成功,则直接跳去检查这个页(check):

1new_slab:
2	...
3new_objects:
4	pc.flags = gfpflags;
5	pc.slab = &slab;
6	pc.orig_size = orig_size;
7	freelist = get_partial(s, node, &pc);
8	if (freelist)
9		goto check_new_slab;
1get_partial
2  get_partial_node // 加载 page  kmem_cache_cpu
3                   // 不开 SLUB_CPU_PARTIAL 时,`object` 只会等于 NULL,循环只有一次
4    acquire_slab   // `mode` 一定为 truenew.freelist 一定为 NULL

若失败,则创建一个全新的 slab page(new slab page),设置其为独占并拿走它的 freelist(freeze it and take away freelist)。这些步骤完成后最终也走向检查(check):

 1new_objects:
 2	...
 3	slab = new_slab(s, gfpflags, node);
 4	...
 5	stat(s, ALLOC_SLAB);
 6	...
 7	freelist = slab->freelist;
 8	slab->freelist = NULL;
 9	slab->inuse = slab->objects;
10	slab->frozen = 1;
11	...
12check_new_slab:

检查(check)这一步其实没有什么重要的内容,完成后就会将(从 partial list 获取的或全新创建的)slab page 装载到 kmem_cache_cpu 上(load slab page to c),然后(逻辑上)返回二级缓存:

1check_new_slab:
2	...
3retry_load_slab:
4	local_lock_irqsave(&s->cpu_slab->lock, flags);
5	...
6	c->slab = slab;
7	goto load_freelist;

  1. 此处所谓“新的“ slab page 不一定是全新,也可以是 partial list 上的。 ↩︎

  2. 该步骤虽在慢速路径,但操作的对象是一级缓存,因此图中背景色标上了一级缓存的颜色。 ↩︎ ↩︎


用于内核测试的内核模块
Things That Happen Around Function Calls