슬랩 할당자 (SLUB)
Linux 커널의 Slab 할당자는 자주 사용되는 커널 객체(task_struct, inode 등)를 효율적으로 관리하는 캐싱 메커니즘입니다. SLUB, SLOB 구현과 kmem_cache API, Per-CPU 최적화, 성능 튜닝까지 종합적으로 다룹니다.
핵심 요약
- 객체 캐싱 — 자주 생성/삭제되는 커널 객체를 미리 할당해 둡니다.
- 내부 단편화(Fragmentation) 감소 — 동일 크기 객체만 관리하여 메모리 낭비를 줄입니다.
- Per-CPU 캐시(Cache) — 각 CPU가 독립적인 프리리스트를 가져 lock contention을 제거합니다.
- SLUB vs SLOB — SLUB은 일반 시스템용 기본 구현, SLOB은 임베디드용 경량 구현입니다.
- kmalloc 백엔드 — kmalloc()은 내부적으로 Slab 할당자를 사용합니다.
단계별 이해
- 핵심 요소 확인
이 문서에서 다루는 자료구조/API를 먼저 정리합니다. - 처리 흐름 추적
요청 시작부터 완료까지 실행 경로를 순서대로 확인합니다. - 문제 지점 점검
실패 경로, 경합(Contention) 구간, 성능 병목(Bottleneck)을 체크합니다.
개요 (Overview)
Slab 할당자는 Buddy Allocator 위에 구축된 2차 할당자입니다:
- Buddy Allocator — 페이지(Page) 단위(4KB) 물리 메모리(Physical Memory)를 할당
- Slab Allocator — 페이지 내에서 작은 객체(8B~8KB)를 관리
왜 Slab 할당자가 필요한가?
Buddy Allocator는 물리 메모리를 2의 거듭제곱 크기(4KB, 8KB, 16KB…)로만 할당합니다. 커널 객체는 대부분 이보다 훨씬 작으므로, Buddy Allocator만으로는 심각한 내부 단편화(Internal Fragmentation)가 발생합니다:
| 커널 객체 | 실제 크기 | Buddy 할당 | 낭비율 |
|---|---|---|---|
struct dentry | ~192B | 4,096B (1 page) | 95.3% |
struct inode | ~600B | 4,096B (1 page) | 85.4% |
struct task_struct | ~6KB | 8,192B (2 pages) | 26.8% |
struct file | ~256B | 4,096B (1 page) | 93.8% |
Slab 할당자는 이 문제를 해결합니다. Buddy에서 받은 페이지를 같은 크기의 객체 슬롯으로 분할하여, 하나의 4KB 페이지에 dentry 21개(192B × 21 = 4,032B, 낭비 1.6%)를 배치할 수 있습니다. 추가로 객체 캐싱(Object Caching)을 통해 생성자/소멸자 호출 비용을 줄이고, Per-CPU 프리리스트로 락 경합을 최소화합니다.
핵심 이점 3가지: (1) 내부 단편화 제거 — 객체 크기에 맞춰 페이지를 분할합니다. (2) 할당 속도 향상 — Per-CPU 프리리스트에서 lock-free로 할당합니다. (3) 캐시 효율 — 같은 타입 객체가 연속 배치되어 CPU 캐시 히트율이 높아집니다.
기본 사용 API
슬랩 할당자는 두 가지 주요 인터페이스를 제공합니다. kmalloc()은 범용 크기별 할당이고, kmem_cache_alloc()은 특정 타입의 전용 캐시 할당입니다:
/* 1. kmalloc: 범용 슬랩 할당 (크기별 자동 캐시 선택) */
struct my_data *p = kmalloc(sizeof(*p), GFP_KERNEL);
if (!p)
return -ENOMEM;
/* 사용 후 해제 */
kfree(p);
/* 2. kzalloc: 0 초기화 버전 (정보 유출 방지) */
struct my_data *q = kzalloc(sizeof(*q), GFP_KERNEL);
/* 3. kmem_cache: 전용 캐시 생성 (빈번한 고정 크기 객체에 최적) */
static struct kmem_cache *my_cachep;
static int __init my_module_init(void)
{
my_cachep = kmem_cache_create(
"my_objects", /* /proc/slabinfo에 표시될 이름 */
sizeof(struct my_obj), /* 객체 크기 */
0, /* 정렬 (0 = 기본) */
SLAB_HWCACHE_ALIGN, /* 캐시 라인 정렬 */
NULL /* 생성자 (없으면 NULL) */
);
if (!my_cachep)
return -ENOMEM;
return 0;
}
/* 전용 캐시에서 객체 할당/해제 */
struct my_obj *obj = kmem_cache_alloc(my_cachep, GFP_KERNEL);
kmem_cache_free(my_cachep, obj);
/* 모듈 종료 시 캐시 해제 */
kmem_cache_destroy(my_cachep);
코드 설명
-
2행
kmalloc()은 내부적으로 요청 크기에 맞는 기존 슬랩 캐시(kmalloc-96, kmalloc-192 등)에서 할당합니다. 별도 캐시 생성이 불필요하여 간편합니다. -
16-22행
kmem_cache_create()는 특정 크기 객체 전용 캐시를 생성합니다.SLAB_HWCACHE_ALIGN은 객체를 CPU 캐시 라인 경계에 정렬하여 false sharing을 방지합니다. -
28행
kmem_cache_alloc()은 Per-CPU 프리리스트에서 lock-free로 할당하므로kmalloc()보다 빠릅니다. 같은 타입 객체가 반복 할당/해제될 때 최적입니다.
kmalloc(100) 내부 동작 추적
커널 코드에서 kmalloc(100, GFP_KERNEL)을 호출하면 내부적으로 어떤 일이 일어나는지 단계별로 추적합니다. 이 과정을 이해하면 슬랩 할당자의 전체 동작을 직관적으로 파악할 수 있습니다:
/* kmalloc(100)의 실제 내부 호출 체인 (단순화) */
/* 1단계: 크기 → 캐시 인덱스 변환 (컴파일 타임 최적화) */
static __always_inline void *kmalloc(size_t size, gfp_t flags)
{
if (__builtin_constant_p(size)) {
/* 컴파일 시 크기가 상수이면 인덱스를 즉시 결정 */
unsigned int index = kmalloc_index(size);
/* index = 7 (2^7 = 128 ≥ 100) */
return kmem_cache_alloc(
kmalloc_caches[KMALLOC_NORMAL][index], flags);
}
return __kmalloc(size, flags);
}
/* 2단계: kmalloc_index() — 크기를 캐시 인덱스로 변환
* 1~8 → index 3 (kmalloc-8)
* 9~16 → index 4 (kmalloc-16)
* 17~32 → index 5 (kmalloc-32)
* 33~64 → index 6 (kmalloc-64)
* 65~96 → 6 (kmalloc-96, 비2^n 예외)
* 97~128 → index 7 (kmalloc-128) ← 100B는 여기!
* ...
*/
코드 설명
-
6~10행
__builtin_constant_p(size)는 GCC 내장 함수로, 크기가 컴파일 타임 상수인지 확인합니다. 상수이면kmalloc_index()가 인라인되어 배열 인덱싱만으로 올바른 캐시를 선택합니다. 런타임 오버헤드가 거의 없습니다. -
16~25행
kmalloc_index()는 요청 크기를 2^n 경계로 올림하여 캐시 인덱스를 결정합니다. 100B 요청은 index 7(128B)에 매핑됩니다. 65~96B 범위에는 비2^n 예외 캐시인kmalloc-96이 존재하여 단편화를 줄입니다.
성능 팁: kmalloc()에 상수 크기를 전달하면 컴파일러가 캐시 인덱스를 미리 계산합니다. 변수 크기를 전달하면 런타임에 __kmalloc()을 거치므로 약간 느립니다. 자주 할당하는 고정 크기 객체는 kmem_cache_create()로 전용 캐시를 만드는 것이 가장 효율적입니다.
구현 종류 (Implementations)
| 구현 | 파일 | 용도 | 특징 |
|---|---|---|---|
| SLUB | mm/slub.c |
범용 시스템 (기본) | 단순한 구조, 높은 성능, 디버깅(Debugging) 지원 |
| SLOB | mm/slob.c |
임베디드 시스템 | 최소 메모리 오버헤드 (~4KB) |
| SLAB | mm/slab.c |
레거시 (6.5에서 제거) | 복잡한 큐 관리, 많은 오버헤드 |
현재 추세: 커널 6.5부터 SLAB이 제거되고 SLUB이 기본입니다. 대부분의 시스템은 SLUB을 사용하며, 초소형 임베디드만 SLOB을 선택합니다.
SLAB vs SLUB vs SLOB 아키텍처 비교
세 가지 구현은 같은 kmem_cache API를 제공하지만, 내부 아키텍처가 근본적으로 다릅니다. SLAB은 복잡한 다단계 큐 구조를 가지고, SLUB은 이를 단순화하여 메타데이터 오버헤드를 줄였으며, SLOB은 최소 메모리 시스템을 위해 단일 프리리스트로 동작합니다:
SLUB 핵심 자료구조
SLUB 할당자의 동작을 이해하려면 세 가지 핵심 구조체를 알아야 합니다. kmem_cache는 캐시 전체를 관리하고, kmem_cache_cpu는 Per-CPU 빠른 경로를, kmem_cache_node는 NUMA 노드별 partial 목록을 관리합니다:
/* include/linux/slub_def.h */
struct kmem_cache {
/* Per-CPU 데이터 (빠른 경로) */
struct kmem_cache_cpu __percpu *cpu_slab;
/* 객체 레이아웃 */
unsigned int object_size; /* 요청된 객체 크기 */
unsigned int size; /* 실제 슬롯 크기 (정렬+메타 포함) */
unsigned int offset; /* freelist 포인터 오프셋 */
/* 슬랩 페이지 관리 */
unsigned int oo; /* order + objects (최적) */
unsigned int min; /* 최소 order + objects */
gfp_t allocflags; /* 페이지 할당 플래그 */
/* NUMA 노드별 관리 */
struct kmem_cache_node *node[MAX_NUMNODES];
/* 디버깅/통계 */
const char *name; /* /proc/slabinfo 이름 */
slab_flags_t flags; /* SLAB_HWCACHE_ALIGN 등 */
};
/* Per-CPU 구조체: lock-free 빠른 경로의 핵심 */
struct kmem_cache_cpu {
union {
struct {
void **freelist; /* 다음 할당 가능 객체 포인터 */
unsigned long tid; /* cmpxchg 트랜잭션 ID */
};
freelist_aba_t freelist_tid; /* 원자적 접근용 */
};
struct slab *slab; /* 현재 사용 중인 슬랩 */
struct slab *partial; /* Per-CPU partial 목록 헤드 */
unsigned stat[NR_SLUB_STAT_ITEMS];
};
/* NUMA 노드별 구조체: 느린 경로에서 사용 */
struct kmem_cache_node {
spinlock_t list_lock; /* partial 목록 보호 */
unsigned long nr_partial; /* partial 슬랩 수 */
struct list_head partial; /* partial 슬랩 연결 목록 */
atomic_long_t nr_slabs; /* 총 슬랩 수 */
atomic_long_t total_objects; /* 총 객체 수 */
};
코드 설명
-
kmem_cache.oo
oo는 order(상위 16비트)와 objects(하위 16비트)를 하나의unsigned int에 인코딩합니다. order-2(16KB) 슬랩에 192B 객체 84개가 들어간다면oo = (2 << 16) | 84입니다. -
kmem_cache_cpu.tid
트랜잭션 ID는
cmpxchg_double()로 freelist와 tid를 원자적으로 갱신하는 데 사용됩니다. ABA 문제를 방지하여 lock-free 할당을 가능하게 합니다. - kmem_cache_cpu.partial Per-CPU partial 목록은 노드 락 없이 빠르게 새 슬랩을 가져올 수 있게 합니다. 현재 슬랩이 가득 차면 여기서 다음 슬랩을 꺼냅니다.
SLAB vs SLUB 할당 경로 비교
SLAB(레거시)은 다단계 캐시를 거치는 복잡한 할당 경로를 가졌고, SLUB은 이를 대폭 단순화했습니다. 아래 코드는 두 할당자의 빠른 경로(fast path)를 비교합니다:
/*
* [SLAB 레거시] 할당 경로 — 3단계 캐시 계층
*
* 1. Per-CPU array cache → 배열에서 꺼냄 (batchcount 단위)
* 2. Shared array cache → spinlock 획득 → 배치 전송
* 3. Per-node slab list → partial/free 슬랩에서 할당
* → 없으면 Buddy에서 새 슬랩 페이지 할당
*
* 문제: 3단계마다 다른 락, off-slab 메타데이터 참조,
* 캐시 미스 빈번, NUMA 확장성 제한
*/
/*
* [SLUB 현재] 할당 경로 — lock-free 빠른 경로
*/
static __always_inline void *slab_alloc_node(
struct kmem_cache *s, gfp_t gfpflags,
int node, unsigned long addr, size_t orig_size)
{
void *object;
struct kmem_cache_cpu *c;
struct slab *slab;
unsigned long tid;
/* 빠른 경로: preempt 비활성 + cmpxchg (락 없음) */
redo:
c = raw_cpu_ptr(s->cpu_slab);
tid = c->tid;
barrier();
object = c->freelist;
slab = c->slab;
if (unlikely(!object || !slab ||
!node_match(slab, node))) {
/* 느린 경로: Per-CPU partial → 노드 partial → 새 슬랩 */
object = __slab_alloc(s, gfpflags, node, addr, c);
} else {
void *next = get_freepointer_safe(s, object);
/* 원자적 갱신: freelist=next, tid=next_tid */
if (unlikely(!__update_cpu_freelist_fast(
s, object, next, tid)))
goto redo; /* 경합 시 재시도 (드묾) */
stat(s, ALLOC_FASTPATH);
}
return object;
}
코드 설명
-
26-29행
SLUB 빠른 경로는 Per-CPU 포인터를 읽고
cmpxchg로 갱신합니다. spinlock 없이 동작하므로 SLAB의 Per-CPU array cache보다 오버헤드가 적습니다. - 34행 느린 경로는 (1) Per-CPU partial 목록 → (2) 노드 partial 목록(spinlock) → (3) Buddy Allocator에서 새 슬랩 할당 순서로 진행합니다.
-
38-39행
__update_cpu_freelist_fast()는cmpxchg_double을 사용하여 freelist와 tid를 원자적으로 갱신합니다. 다른 CPU의 간섭 시 redo로 재시도합니다.
Slab 할당자 발전 역사
| 시기 | 이벤트 | 핵심 변경 |
|---|---|---|
| 1994 | Jeff Bonwick, SunOS에서 Slab 개념 발표 | 객체 캐싱으로 할당 비용 제거, 생성자(constructor) 패턴 도입 |
| v2.2 (1999) | Linux SLAB 도입 | Bonwick의 설계를 Linux에 이식, Per-CPU array cache 추가 |
| v2.6.16 (2006) | SLOB 추가 | ~600줄 최소 구현, 임베디드 환경용 |
| v2.6.22 (2007) | SLUB 추가 (Christoph Lameter) | SLAB의 구조적 문제 해결: off-slab 메타데이터 제거, per-CPU 단순화 |
| v3.x~4.x | SLUB 기본 할당자 전환 | 대부분의 배포판이 SLUB을 기본으로 채택 |
| v5.14 (2021) | SLAB deprecated 선언 | CONFIG_SLAB 선택 시 경고 메시지 |
| v6.4 (2023) | SLOB 제거 | SLUB의 소형 시스템 대응 개선으로 SLOB 불필요 |
| v6.5 (2023) | SLAB 제거 | mm/slab.c 삭제, SLUB이 유일한 구현 |
| v6.8 (2024) | struct slab 도입 완료 | struct page에서 slab 메타데이터 분리, 타입 안전성 향상 |
| v6.18 (2025) | Sheaves per-CPU 캐싱 | 배치 처리 레이어 추가, 고코어 시스템 확장성 개선 |
SLAB이 제거된 이유: SLAB의 3-리스트 관리(full/partial/free)와 off-slab 메타데이터는 메모리 오버헤드가 컸고, 코드 복잡도(5,600줄 vs SLUB 3,200줄)가 유지보수를 어렵게 만들었습니다. 특히 NUMA 시스템에서의 확장성이 SLUB보다 열등했고, 디버깅 인터페이스도 제한적이었습니다. SLUB이 Sheaves(v6.18)로 SLAB의 마지막 장점이었던 배치 처리까지 흡수하면서 SLAB의 존재 이유가 완전히 사라졌습니다.
아키텍처 (Architecture)
Slab 할당자는 Buddy Allocator가 할당한 페이지를 받아 작은 객체로 분할·관리합니다. Per-CPU 캐시와 NUMA 노드 캐시의 두 계층으로 락 경합을 최소화하고 확장성을 확보합니다:
kmem_cache 구조
/* include/linux/slub_def.h */
struct kmem_cache {
struct kmem_cache_cpu __percpu *cpu_slab; /* Per-CPU 캐시 */
unsigned long flags;
unsigned long min_partial;
unsigned int size; /* 객체 크기 */
unsigned int object_size; /* 실제 객체 크기 */
unsigned int offset; /* Free pointer offset */
struct kmem_cache_order_objects oo;
struct kmem_cache_node *node[MAX_NUMNODES];
const char *name;
};
/* Per-CPU 캐시 */
struct kmem_cache_cpu {
void **freelist; /* 빠른 할당을 위한 프리리스트 */
unsigned long tid; /* Transaction ID */
struct page *page; /* 현재 사용 중인 slab */
};
할당 경로 (Allocation Path)
/* Fast path (Per-CPU cache hit) */
void *kmem_cache_alloc(struct kmem_cache *s, gfp_t flags)
{
struct kmem_cache_cpu *c = this_cpu_ptr(s->cpu_slab);
void *object;
/* Fast path: freelist에서 즉시 할당 */
object = c->freelist;
if (likely(object)) {
c->freelist = get_freepointer(s, object);
return object;
}
/* Slow path: partial list 또는 새 slab */
return __slab_alloc(s, flags, c);
}
할당 경로 흐름도
슬랩 레이아웃 (Slab Layout)
하나의 슬랩 페이지(또는 compound page) 안에서 객체들은 고정 크기로 순서대로 배치됩니다. 해제된 객체는 offset 위치에 다음 프리 객체 주소를 저장하여 프리리스트 체인을 형성합니다:
Freelist 조작 단계별 시각화
할당과 해제 시 freelist 포인터가 어떻게 변경되는지 단계별로 추적합니다. 4개 객체가 있는 작은 슬랩을 예시로 사용합니다:
/* Freelist 조작의 핵심 원리 (의사 코드) */
/* 할당: freelist head에서 객체를 꺼냄 */
void *alloc(void) {
void *object = freelist; /* head 읽기 */
freelist = object->next_free; /* head를 다음으로 이동 */
return object; /* 꺼낸 객체 반환 */
}
/* 해제: freelist head에 객체를 삽입 (LIFO) */
void free(void *object) {
object->next_free = freelist; /* 기존 head를 next로 연결 */
freelist = object; /* 해제된 객체를 새 head로 */
}
/* 실제 SLUB에서는 cmpxchg_double로 원자적으로 수행하며,
* next_free 포인터는 객체 내부의 offset 위치에 저장됩니다.
* CONFIG_SLAB_FREELIST_HARDENED 시 XOR 난독화가 적용됩니다. */
코드 설명
-
4~7행
할당은 freelist의 head를 꺼내고 head를 다음 포인터로 갱신하는 단순한 연결 리스트 pop 연산입니다. SLUB에서는
cmpxchg_double로 이 두 동작을 원자적으로 수행합니다. - 11~13행 해제는 기존 head 앞에 삽입하는 push 연산입니다. LIFO 방식이므로 가장 최근에 해제된 객체가 다음 할당에서 반환되어, CPU 캐시에 남아있는 "뜨거운" 메모리를 재활용합니다.
/* mm/slub.c - freelist 포인터 읽기 */
static inline void *get_freepointer(struct kmem_cache *s, void *object)
{
object = kasan_reset_tag(object);
return freelist_dereference(s, object + s->offset);
}
/* freelist 포인터 쓰기 (객체 해제 시) */
static inline void set_freepointer(struct kmem_cache *s,
void *object, void *fp)
{
unsigned long freeptr_addr = (unsigned long)object + s->offset;
freelist_ptr_encode(s, fp, freeptr_addr);
}
Use-After-Free 위험: 해제된 객체의 offset 위치 바이트가 freelist 포인터로 덮어씌워집니다. SLAB_POISON 플래그를 설정하면 해제 시 0x6b 패턴을 채워 use-after-free를 탐지할 수 있습니다. 프로덕션 환경에서는 KASAN이 더 정밀합니다.
객체 생명주기 (Object Lifecycle)
슬랩 객체는 할당 → 사용 → 해제 → 재할당의 사이클을 반복합니다. 슬랩이 완전히 비면 Buddy Allocator로 페이지를 반환하고, 새 객체가 필요하면 새 슬랩 페이지를 요청합니다:
cmpxchg_double Lock-free 메커니즘
SLUB fast path의 핵심은 this_cpu_cmpxchg_double()입니다. freelist 포인터와 Transaction ID(TID)를 원자적으로 동시에 비교·교체하여, 락 없이도 CPU 마이그레이션과 동시 접근을 안전하게 처리합니다:
cmpxchg_double: 두 값을 원자적으로 비교·교체
this_cpu_cmpxchg_double(freelist, tid,
old_freelist, old_tid,
new_freelist, new_tid)
의사 코드:
if (freelist == old_freelist && tid == old_tid) {
freelist = new_freelist;
tid = new_tid;
return true; // 성공
}
return false; // 실패 → redo
- x86: CMPXCHG16B 명령어 (128비트 원자적 비교·교체)
- ARM64: LDXP/STXP 명령어 쌍 (LL/SC 방식)
TID가 필요한 이유 (ABA 문제):
- CPU 0: freelist=A 읽음
- CPU 0: 선점(preempt) → CPU 1로 이동
- 다른 태스크: A 해제, B 할당, A 재할당 → freelist=A 복원
- CPU 0: 복귀, cmpxchg(A → ...) — freelist는 A로 같지만 상태 변경됨!
- TID가 매번 증가하므로 이 경우 TID 불일치로 실패 감지
왜 spinlock이 아닌 cmpxchg인가: spinlock은 획득·해제 시 캐시라인 바운싱이 발생하여 고코어 시스템에서 심각한 성능 저하를 유발합니다. cmpxchg_double은 낙관적 동시성 제어(Optimistic Concurrency Control) 방식으로, 충돌이 드문 fast path에서 거의 항상 한 번에 성공합니다. 통계적으로 fast path 성공률이 95% 이상이므로 재시도(redo) 비용은 무시할 수 있습니다.
슬랩 상태 관리 (Slab States)
SLUB 할당자에서 각 슬랩 페이지는 세 가지 상태 중 하나에 속합니다. 이 상태 전이를 이해하면 슬랩 메모리 사용 패턴과 성능 특성을 정확히 파악할 수 있습니다:
| 상태 | 위치 | 조건 | 특징 |
|---|---|---|---|
| Active (Frozen) | kmem_cache_cpu.page |
현재 CPU에 바인딩됨 | Lock-free 할당/해제, 다른 CPU에서 접근 불가 |
| Partial | kmem_cache_node.partial |
일부 객체만 사용 중 | list_lock 보호, Per-CPU 미스 시 refill 소스 |
| Full | 추적 안 함 | 모든 객체 할당됨 | 어떤 리스트에도 없음 — 해제 시 partial로 전환 |
Frozen vs Unfrozen 메커니즘
SLUB에서 "frozen" 상태는 해당 슬랩이 특정 CPU에 독점적으로 할당되어 있어 다른 CPU의 간섭 없이 lock-free로 접근 가능하다는 것을 의미합니다:
/* mm/slub.c — 슬랩 상태 전이 */
/* 1. 새 슬랩 할당 시: Buddy → Active (frozen) */
static struct slab *new_slab(struct kmem_cache *s, gfp_t flags, int node)
{
struct slab *slab = allocate_slab(s, flags, node);
slab->frozen = 1; /* CPU에 바인딩 — lock-free 접근 가능 */
return slab;
}
/* 2. Per-CPU에서 제거 시: Active → Partial (unfrozen) */
static void deactivate_slab(struct kmem_cache *s,
struct slab *slab,
struct kmem_cache_cpu *c)
{
slab->frozen = 0; /* unfrozen — 노드 partial 목록으로 이동 */
add_partial(n, slab, DEACTIVATE_TO_TAIL);
}
/* 3. 마지막 객체 해제 시: Partial → Buddy 반환 */
/* slab->inuse == 0이면 __free_pages()로 페이지 반환 */
Full 슬랩을 추적하지 않는 이유: Full 슬랩은 할당할 객체가 없으므로 freelist가 비어있습니다. 추적하지 않으면 리스트 관리 오버헤드가 줄어들고, 객체가 해제되면 자동으로 partial 목록에 추가됩니다. 이는 SLUB이 SLAB보다 단순한 핵심 설계 결정입니다.
캐시 병합 (Cache Merging)
SLUB은 동일하거나 유사한 크기의 객체를 사용하는 여러 캐시를 자동으로 병합하여 메모리 효율을 높입니다. 예를 들어 struct file(256B)과 struct my_obj(248B)은 같은 256바이트 슬랩에 병합될 수 있습니다:
/* mm/slub.c — 캐시 병합 조건 검사 */
static struct kmem_cache *find_mergeable(unsigned int size,
unsigned int align,
slab_flags_t flags)
{
struct kmem_cache *s;
list_for_each_entry_reverse(s, &slab_caches, list) {
/* 병합 불가 조건: 생성자 있음, 디버그 플래그, 다른 정렬 */
if (s->ctor)
continue;
if (s->flags & SLAB_NO_MERGE)
continue;
/* 크기가 호환 가능하면 병합 */
if (size <= s->size && s->size <= size * 2)
return s; /* 기존 캐시에 병합 */
}
return NULL; /* 병합 대상 없음 — 새 캐시 생성 */
}
| 조건 | 병합 가능 | 이유 |
|---|---|---|
ctor 없음, 크기 유사 |
가능 | 생성자가 없으면 객체 초기 상태가 중요하지 않음 |
ctor 있음 |
불가 | 생성자가 다르면 초기화 동작이 충돌 |
SLAB_TYPESAFE_BY_RCU |
불가 | RCU 보호 객체는 타입 안전성이 필요 |
SLAB_NO_MERGE 플래그 |
불가 | 명시적 병합 금지 (보안/격리(Isolation) 목적) |
디버그 플래그 (POISON, RED_ZONE) |
불가 | 디버그 메타데이터가 레이아웃을 변경 |
# 병합된 캐시 확인: :t- 접미사가 붙은 alias 캐시
cat /sys/kernel/slab/*/aliases 2>/dev/null | sort -rn | head -10
# 8 kmalloc-256 ← 8개 캐시가 kmalloc-256으로 병합됨
# 5 kmalloc-128
# 3 kmalloc-64
# 병합 비활성화 (디버깅 시): 부팅 파라미터
# slub_nomerge ← 모든 캐시를 개별 관리 (메모리 증가)
보안 관점: 캐시 병합은 서로 다른 타입의 객체가 같은 슬랩을 공유하게 만들어, use-after-free 공격 시 공격자가 원하는 타입의 객체를 같은 주소에 할당받기 쉬워집니다. 보안이 중요한 시스템에서는 slub_nomerge 부팅 파라미터나 SLAB_NO_MERGE 플래그를 사용하여 캐시 격리를 유지하세요.
벌크 할당 API (Bulk Allocation)
커널 4.6부터 여러 객체를 한 번에 할당/해제하는 벌크 API가 추가되었습니다. 네트워크 스택(Network Stack)이나 블록 I/O처럼 짧은 시간에 대량의 객체가 필요한 경우 개별 할당 대비 오버헤드를 크게 줄입니다:
/* include/linux/slab.h — 벌크 할당/해제 API */
/* 최대 size개 객체를 한 번에 할당하여 p[] 배열에 저장
* 반환값: 실제 할당된 객체 수 (≤ size) */
int kmem_cache_alloc_bulk(
struct kmem_cache *s,
gfp_t flags,
size_t size, /* 요청 객체 수 */
void **p /* 결과 포인터 배열 */
);
/* 여러 객체를 한 번에 해제 */
void kmem_cache_free_bulk(
struct kmem_cache *s,
size_t size, /* 해제할 객체 수 */
void **p /* 해제할 포인터 배열 */
);
/* 사용 예: 네트워크 패킷 배치 할당 */
#define BATCH_SIZE 64
void *objs[BATCH_SIZE];
int allocated;
/* 64개 객체를 한 번에 할당 — Per-CPU 경로 최적화 */
allocated = kmem_cache_alloc_bulk(my_cache, GFP_KERNEL, BATCH_SIZE, objs);
if (allocated < BATCH_SIZE)
pr_warn("partial alloc: %d/%d\n", allocated, BATCH_SIZE);
/* 배치 처리 후 한 번에 해제 */
kmem_cache_free_bulk(my_cache, allocated, objs);
| 방식 | 64개 할당 비용 | IRQ 비활성화 횟수 | cmpxchg 횟수 |
|---|---|---|---|
개별 kmem_cache_alloc() × 64 |
~640 ns | 64회 | 64회 |
kmem_cache_alloc_bulk(64) |
~180 ns | 1회 | 1~2회 |
벌크 할당의 핵심 이점: IRQ 비활성화와 cmpxchg(Compare-And-Swap) 연산을 배치 전체에 대해 1회만 수행하므로, 64개 객체 기준 약 3.5배 빠릅니다. XDP(eXpress Data Path)의 page_pool과 네트워크 NAPI 경로에서 이 API를 적극 활용합니다. 자세한 내용은 NAPI 페이지와 BPF/XDP 페이지를 참고하세요.
SLAB_TYPESAFE_BY_RCU
RCU(Read-Copy-Update) 보호가 필요한 객체에 사용하는 특수 플래그입니다. 슬랩 페이지 자체가 RCU grace period 동안 유지되어, 해제된 객체의 메모리가 즉시 다른 타입으로 재사용되지 않습니다:
/* SLAB_TYPESAFE_BY_RCU: 슬랩 페이지가 RCU grace period 동안 보존
*
* 일반 슬랩: 객체 해제 → 즉시 재사용 가능 (다른 타입일 수 있음)
* RCU 슬랩: 객체 해제 → 같은 타입의 새 객체로만 재사용
* → grace period 후 슬랩 페이지 반환 가능
*
* 주의: 객체 내용이 유지되는 것이 아님!
* 타입(캐시)만 보장됨 → 재검증(seqcount 등) 필수
*/
/* 사용 예: VFS dentry 캐시 */
dentry_cache = kmem_cache_create(
"dentry",
sizeof(struct dentry),
0,
SLAB_RECLAIM_ACCOUNT | SLAB_TYPESAFE_BY_RCU,
NULL
);
/* RCU 읽기 측: 해제된 객체에 접근해도 같은 타입이 보장됨 */
rcu_read_lock();
struct dentry *d = rcu_dereference(parent->d_child);
/* d가 해제·재할당되었을 수 있으므로 seqcount로 재검증 */
if (read_seqcount_retry(&d->d_seq, seq))
goto retry;
rcu_read_unlock();
흔한 오해: SLAB_TYPESAFE_BY_RCU는 객체 내용의 유효성을 보장하지 않습니다. 슬랩 페이지가 grace period 동안 반환되지 않아 객체 메모리의 타입(캐시)만 보장합니다. 읽기 측에서는 반드시 seqcount나 generation counter로 재검증해야 합니다. 일반 kfree_rcu()와 혼동하지 마세요. RCU 상세는 RCU 페이지를 참고하세요.
RCU 기반 슬랩 해제 경로
SLAB_TYPESAFE_BY_RCU와 kfree_rcu()는 모두 RCU를 활용하지만 동작 방식이 근본적으로 다릅니다. kfree_rcu()는 개별 객체의 해제를 grace period 후로 지연시키고, SLAB_TYPESAFE_BY_RCU는 슬랩 페이지 자체의 반환을 지연시킵니다:
/*
* kfree_rcu(): 개별 객체의 RCU 지연 해제
* - 객체 해제 자체가 grace period 후 수행
* - grace period 전까지 객체 내용 유효 (읽기 가능)
*/
struct my_entry {
struct rcu_head rcu;
int key;
int value;
};
/* 삭제 측: RCU 보호 해제 */
void delete_entry(struct my_entry *entry)
{
list_del_rcu(&entry->node);
kfree_rcu(entry, rcu); /* grace period 후 kfree() 호출 */
}
/*
* SLAB_TYPESAFE_BY_RCU 슬랩의 내부 해제 경로
* mm/slub.c - 슬랩 페이지 반환 시 RCU 콜백 등록
*/
static void free_slab(struct kmem_cache *s, struct slab *slab)
{
if (unlikely(s->flags & SLAB_TYPESAFE_BY_RCU)) {
/* 슬랩 페이지를 즉시 반환하지 않고 RCU 콜백 등록 */
call_rcu(&slab->rcu_head, rcu_free_slab);
} else {
/* 일반 슬랩: 즉시 Buddy Allocator에 반환 */
__free_slab(s, slab);
}
}
/* grace period 후 실제 슬랩 페이지 반환 */
static void rcu_free_slab(struct rcu_head *h)
{
struct slab *slab = container_of(h, struct slab, rcu_head);
__free_slab(slab->slab_cache, slab);
}
코드 설명
-
16행
kfree_rcu()는rcu_head를 객체에 내장하여 grace period 후kfree()를 호출합니다. 객체 내용이 grace period 동안 보존됩니다. -
25-28행
SLAB_TYPESAFE_BY_RCU슬랩에서 모든 객체가 해제되어도 슬랩 페이지는 즉시 반환되지 않습니다.call_rcu()로 grace period 후 반환을 예약합니다. -
34-38행
Grace period가 지나면
rcu_free_slab()이 호출되어 슬랩 페이지를 Buddy Allocator에 반환합니다. 이 시점까지 해당 슬랩의 메모리는 같은 캐시 타입으로만 재사용됩니다.
memcg 메모리 계정 (Memory Accounting)
SLAB_ACCOUNT 플래그를 설정하면 해당 슬랩 캐시의 할당이 cgroup 메모리 계정에 포함됩니다. 컨테이너(Docker, Kubernetes Pod) 환경에서 프로세스별 슬랩 사용량을 정확히 추적하고 제한할 수 있습니다:
/* SLAB_ACCOUNT: cgroup 메모리 컨트롤러에 슬랩 사용량 보고 */
/* task_struct — 프로세스별 추적 필수 */
task_struct_cachep = kmem_cache_create(
"task_struct",
sizeof(struct task_struct),
ARCH_MIN_TASKALIGN,
SLAB_PANIC | SLAB_ACCOUNT, /* ← memcg 계정 포함 */
NULL
);
/* mm_struct, vm_area_struct, files_struct 등 주요 커널 객체도
* SLAB_ACCOUNT를 사용하여 컨테이너별 메모리 제한에 포함 */
# cgroup v2에서 슬랩 메모리 확인
cat /sys/fs/cgroup/my-container/memory.stat | grep slab
# slab 12345678 ← 총 슬랩 바이트
# slab_reclaimable 8000000
# slab_unreclaimable 4345678
# 특정 컨테이너의 커널 메모리(slub) 사용량이
# memory.max 제한에 도달하면 OOM Killer가 동작
# 커널 5.9+: memcg별 슬랩 통계 (/proc/slabinfo 대체)
cat /sys/kernel/slab/task_struct/memcg_params
memcg 과금 흐름
슬랩 객체가 할당될 때 memcg 과금이 어떻게 이루어지는지 커널 내부 코드를 통해 살펴봅니다. 핵심은 memcg_slab_post_alloc_hook()에서 객체의 obj_cgroup 소유권을 설정하는 것입니다:
/* mm/slub.c - 슬랩 할당 시 memcg 과금 후크 */
static void memcg_slab_post_alloc_hook(
struct kmem_cache *s, struct obj_cgroup *objcg,
gfp_t flags, size_t size, void **p)
{
struct slab *slab;
unsigned long off;
size_t i;
for (i = 0; i < size; i++) {
slab = virt_to_slab(p[i]);
off = obj_to_index(s, slab, p[i]);
/* 객체별 obj_cgroup 소유권 설정 */
slab->obj_cgroups[off] = objcg;
obj_cgroup_get(objcg); /* 참조 카운트 증가 */
}
/* memcg 메모리 카운터에 과금 */
obj_cgroup_charge(objcg, flags, size * obj_full_size(s));
}
/* 해제 시 과금 해제 */
static void memcg_slab_free_hook(
struct kmem_cache *s, struct slab *slab,
void **p, int objects)
{
for (int i = 0; i < objects; i++) {
unsigned int off = obj_to_index(s, slab, p[i]);
struct obj_cgroup *objcg = slab->obj_cgroups[off];
if (objcg) {
obj_cgroup_uncharge(objcg, obj_full_size(s));
obj_cgroup_put(objcg);
slab->obj_cgroups[off] = NULL;
}
}
}
코드 설명
-
14행
slab->obj_cgroups[off]는 슬랩 내 각 객체의 memcg 소유권을 저장하는 배열입니다. 커널 5.9+에서는 캐시 단위가 아닌 객체 단위로 추적하므로 메모리 효율이 높습니다. -
19행
obj_cgroup_charge()는 해당 cgroup의memory.current카운터를 증가시킵니다.memory.max를 초과하면 직접 회수(direct reclaim)나 OOM이 발생합니다. -
31행
해제 시
obj_cgroup_uncharge()로 과금을 해제합니다. 참조 카운트가 0이 되면obj_cgroup구조체도 해제됩니다.
커널 5.9 변경점: 이전에는 memcg별로 별도의 kmem_cache 인스턴스를 생성했지만(메모리 낭비), 5.9부터 단일 캐시에서 객체별로 memcg 소유권을 추적하는 방식으로 변경되어 메모리 효율이 크게 개선되었습니다. 컨테이너 메모리 관리(Memory Management) 상세는 네임스페이스(Namespace) 페이지를 참고하세요.
sysfs 튜닝 인터페이스
/sys/kernel/slab/<cache-name>/ 디렉터리에서 각 슬랩 캐시의 파라미터를 런타임에 조정할 수 있습니다. 성능 분석과 튜닝의 핵심 인터페이스입니다:
| 파일 | 읽기/쓰기 | 설명 |
|---|---|---|
object_size |
읽기 | 실제 객체 크기 (사용자가 요청한 크기) |
slab_size |
읽기 | 메타데이터 포함 슬랩 내 객체 크기 |
objs_per_slab |
읽기 | 슬랩 페이지당 객체 수 |
cpu_partial |
읽기/쓰기 | Per-CPU에 보관할 partial 슬랩 수 (높을수록 캐시 히트 증가, 메모리 사용 증가) |
min_partial |
읽기/쓰기 | 노드당 최소 partial 슬랩 수 (이하로 내려가지 않음) |
order |
읽기 | 슬랩 페이지의 Buddy order (0=4KB, 1=8KB, ...) |
aliases |
읽기 | 이 캐시에 병합된 다른 캐시 수 |
slabs |
읽기 | 현재 할당된 총 슬랩 수 |
partial |
읽기 | partial 상태인 슬랩 수 |
# dentry 캐시의 현재 설정 확인
ls /sys/kernel/slab/dentry/
cat /sys/kernel/slab/dentry/object_size # 192
cat /sys/kernel/slab/dentry/objs_per_slab # 21
cat /sys/kernel/slab/dentry/cpu_partial # 30
cat /sys/kernel/slab/dentry/min_partial # 5
# 고부하 시스템에서 dentry 캐시 Per-CPU partial 슬랩 수 증가
# → slow path 빈도 감소, 메모리 사용량 약간 증가
echo 60 > /sys/kernel/slab/dentry/cpu_partial
# 노드당 최소 partial 슬랩 수 증가 (NUMA 시스템에서 유용)
echo 10 > /sys/kernel/slab/dentry/min_partial
# 모든 캐시의 단편화율 확인 (활성 객체 / 총 객체)
# 비율이 낮으면 내부 단편화가 심한 것
for cache in /sys/kernel/slab/*/; do
name=$(basename "$cache")
total=$(cat "$cache/total_objects" 2>/dev/null || echo 0)
active=$(cat "$cache/objects" 2>/dev/null || echo 0)
[ "$total" -gt 100 ] && \
echo "$name: active=$active total=$total ratio=$(( active * 100 / total ))%"
done | sort -t= -k4 -n | head -20
cpu_partial 튜닝 가이드: cpu_partial이 너무 낮으면 노드 락 경합이 잦아지고, 너무 높으면 유휴 메모리가 증가합니다. 기본값은 객체 크기에 따라 자동 설정됩니다 (작은 객체일수록 높은 값). alloc_slowpath 대비 alloc_fastpath 비율을 모니터링하면서 조정하세요.
API (Application Programming Interface)
캐시 생성
/* include/linux/slab.h */
struct kmem_cache *kmem_cache_create(
const char *name,
unsigned int size,
unsigned int align,
slab_flags_t flags,
void (*ctor)(void *)
);
/* 사용 예 */
struct kmem_cache *my_cache;
my_cache = kmem_cache_create(
"my_object",
sizeof(struct my_object),
0,
SLAB_HWCACHE_ALIGN,
NULL
);
할당/해제
/* 객체 할당 */
void *kmem_cache_alloc(struct kmem_cache *s, gfp_t flags);
/* 객체 해제 */
void kmem_cache_free(struct kmem_cache *s, void *x);
/* 캐시 삭제 */
void kmem_cache_destroy(struct kmem_cache *s);
/* 사용 예 */
struct my_object *obj = kmem_cache_alloc(my_cache, GFP_KERNEL);
kmem_cache_free(my_cache, obj);
GFP 플래그
슬랩 할당 시 두 번째 인자로 전달하는 GFP(Get Free Pages) 플래그는 할당 동작 방식을 제어합니다:
| 플래그 | sleep 허용 | 사용 컨텍스트 | 설명 |
|---|---|---|---|
GFP_KERNEL |
허용 | 프로세스(Process) 컨텍스트 | 메모리 회수(Memory Reclaim)/스왑(Swap) 시도 가능 — 가장 일반적 |
GFP_ATOMIC |
금지 | 인터럽트(Interrupt), spinlock 구간 | Sleep 불가 — 긴급 예비 메모리에서 할당 시도 |
GFP_NOWAIT |
금지 | 약한 실시간(Real-time) 코드 | 메모리 부족 시 즉시 실패 (reclaim 없음) |
GFP_NOIO |
부분 | I/O 경로 내부 | I/O 없이 메모리 확보 — 데드락 방지 |
GFP_NOFS |
부분 | 파일시스템(Filesystem) 내부 | 파일시스템 호출 없이 확보 — 재진입 방지 |
GFP_KERNEL | __GFP_ZERO |
허용 | 0 초기화 필요 시 | kzalloc() 내부 동작과 동일 |
GFP_ATOMIC 남용 주의: GFP_ATOMIC은 커널 예비 메모리를 소모합니다. 할당 실패 시 NULL이 반환되므로 반드시 반환값을 검사해야 합니다. 프로세스 컨텍스트에서는 항상 GFP_KERNEL을 우선 사용하세요.
kmem_cache_create_usercopy
사용자 공간(User Space)으로 데이터를 복사할 수 있는 슬랩 캐시를 생성할 때 사용합니다. copy_to_user() 등의 함수가 슬랩 객체를 안전하게 접근할 수 있는 범위를 명시합니다:
/* include/linux/slab.h */
struct kmem_cache *kmem_cache_create_usercopy(
const char *name,
unsigned int size,
unsigned int align,
slab_flags_t flags,
unsigned int useroffset, /* 사용자 접근 허용 시작 offset */
unsigned int usersize, /* 사용자 접근 허용 크기 */
void (*ctor)(void *)
);
/* 예: task_struct에서 comm 필드(task 이름)만 사용자 복사 허용 */
task_struct_cachep = kmem_cache_create_usercopy(
"task_struct",
sizeof(struct task_struct),
ARCH_MIN_TASKALIGN,
SLAB_PANIC | SLAB_ACCOUNT,
offsetof(struct task_struct, comm), /* useroffset */
sizeof(task->comm), /* usersize */
NULL
);
메모리 누수 주의: 모듈 언로드 시 반드시 kmem_cache_destroy()를 호출해야 합니다. 누락 시 커널 메모리가 영구 누수됩니다. 또한 kmem_cache_destroy() 호출 전에 해당 캐시의 모든 객체가 반환(kmem_cache_free())되어야 하며, 활성 객체가 남아있으면 BUG()가 발생합니다.
Slab 부트스트랩 (Bootstrap)
Slab 할당자에는 순환 의존성(chicken-and-egg) 문제가 있습니다: kmem_cache 구조체를 할당하려면 Slab 할당자가 필요하지만, Slab 할당자를 초기화하려면 kmem_cache 구조체가 필요합니다. 커널은 이 문제를 정적 초기화 + 2단계 부트스트랩으로 해결합니다:
/* mm/slub.c — 부트스트랩 핵심 코드 */
/* 정적 부트스트랩 구조체 (.bss 섹션) */
static struct kmem_cache *kmem_cache_node;
static struct kmem_cache boot_kmem_cache;
static struct kmem_cache boot_kmem_cache_node;
void __init kmem_cache_init(void)
{
/* 1단계: 정적 구조체로 자기 자신의 캐시 초기화 */
__kmem_cache_create(&boot_kmem_cache,
SLAB_HWCACHE_ALIGN | SLAB_PANIC);
kmem_cache_node = &boot_kmem_cache_node;
__kmem_cache_create(&boot_kmem_cache_node,
SLAB_HWCACHE_ALIGN | SLAB_PANIC);
/* 2단계: 동적 구조체로 교체 */
kmem_cache = bootstrap(&boot_kmem_cache);
kmem_cache_node = bootstrap(&boot_kmem_cache_node);
/* 3단계: kmalloc 캐시 생성 */
create_kmalloc_caches();
/* 이후 모든 슬랩 API 사용 가능 */
}
static struct kmem_cache *bootstrap(
struct kmem_cache *static_cache)
{
/* 정적 캐시에서 동적 캐시로 복사·교체 */
struct kmem_cache *s = kmem_cache_alloc(
kmem_cache, GFP_NOWAIT);
memcpy(s, static_cache, kmem_cache->object_size);
list_add(&s->list, &slab_caches);
return s;
}
부트스트랩의 핵심 트릭: boot_kmem_cache는 "kmem_cache를 할당하는 캐시"입니다. 이 캐시 자체는 정적 변수이므로 메모리 할당 없이 초기화할 수 있습니다. 일단 이 캐시가 동작하면 kmem_cache_alloc()으로 다른 모든 캐시의 kmem_cache 구조체를 동적 할당할 수 있게 됩니다. 이 패턴은 자기 참조(self-hosting) 문제 해결의 전형적인 예시입니다.
kmalloc과의 관계
kmalloc()은 내부적으로 미리 생성된 Slab 캐시를 사용합니다:
/* mm/slab_common.c - 부팅 시 생성되는 kmalloc 캐시 */
struct kmem_cache *kmalloc_caches[KMALLOC_SHIFT_HIGH + 1];
/* kmalloc-8, kmalloc-16, kmalloc-32, ..., kmalloc-8192 */
kmalloc(24, GFP_KERNEL); → kmalloc-32 캐시 사용
kmalloc(200, GFP_KERNEL); → kmalloc-256 캐시 사용
kmalloc 크기 클래스 상세
kmalloc은 2의 거듭제곱 크기의 사전 생성된 슬랩 캐시를 사용합니다. 요청 크기는 다음 2^n 경계로 올림(round-up)되며, 이 과정에서 내부 단편화가 발생합니다:
| 캐시 이름 | 객체 크기 | Buddy order | 페이지당 객체 수 | 최악 단편화 | 대표 사용처 |
|---|---|---|---|---|---|
kmalloc-8 |
8 B | 0 (4 KB) | 512 | 0% (최소 단위) | 작은 구조체 포인터 |
kmalloc-16 |
16 B | 0 | 256 | 47% (9B 요청) | 리스트 노드 |
kmalloc-32 |
32 B | 0 | 128 | 47% (17B 요청) | 소형 버퍼 |
kmalloc-64 |
64 B | 0 | 64 | 49% (33B 요청) | file_lock, fasync_struct |
kmalloc-96 |
96 B | 0 | 42 | 33% (65B 요청) | 중간 크기 최적화 |
kmalloc-128 |
128 B | 0 | 32 | 24% (97B 요청) | cred_struct |
kmalloc-192 |
192 B | 0 | 21 | 33% (129B 요청) | dentry, nsproxy |
kmalloc-256 |
256 B | 0 | 16 | 25% (193B 요청) | file, pid |
kmalloc-512 |
512 B | 0 | 8 | 50% (257B 요청) | inode (파일시스템별) |
kmalloc-1k |
1024 B | 0 | 4 | 50% (513B 요청) | task_struct (일부 아키텍처) |
kmalloc-2k |
2048 B | 0 | 2 | 50% (1025B 요청) | 네트워크 버퍼 |
kmalloc-4k |
4096 B | 0 | 1 | 50% (2049B 요청) | 페이지 크기 버퍼 |
kmalloc-8k |
8192 B | 1 (8 KB) | 1 | 50% (4097B 요청) | 대형 커널 버퍼 |
96B, 192B 캐시가 존재하는 이유: 순수 2^n 체계에서 65~128B 범위의 요청은 128B로 올림되어 최대 49%가 낭비됩니다. 96B와 192B 캐시를 추가하면 이 구간의 단편화를 크게 줄일 수 있습니다. 커널 5.x에서 KMALLOC_MIN_SIZE와 함께 도입되었으며, CONFIG_SLUB_TINY에서는 비활성화됩니다.
kmalloc 변형 API
| 함수 | 특징 | 내부 동작 |
|---|---|---|
kmalloc(size, flags) |
기본 할당 | size를 2^n 올림 → 해당 kmalloc-N 캐시에서 할당 |
kzalloc(size, flags) |
0 초기화 | kmalloc(size, flags | __GFP_ZERO) |
kcalloc(n, size, flags) |
배열 할당 + 오버플로 검사 | n * size 오버플로 검사 후 kzalloc() |
krealloc(ptr, size, flags) |
재할당 | 같은 캐시 내이면 그대로, 아니면 새 할당 + 복사 |
kvmalloc(size, flags) |
자동 전환 | 작으면 kmalloc, 크면 vmalloc으로 자동 fallback |
kmalloc_large(size, flags) |
페이지 단위 대형 할당 | slab 우회 → alloc_pages() 직접 호출 |
kmalloc_node(size, flags, node) |
NUMA 노드 지정 | 특정 NUMA 노드의 슬랩에서 할당 |
/* kvmalloc: kmalloc 실패 시 vmalloc으로 자동 전환 */
void *kvmalloc_node(size_t size, gfp_t flags, int node)
{
void *ret;
/* 1차: kmalloc 시도 (__GFP_NOWARN으로 경고 억제) */
ret = kmalloc_node(size, flags | __GFP_NOWARN, node);
if (ret || size <= PAGE_SIZE)
return ret;
/* 2차: vmalloc fallback (물리 연속 불필요) */
return __vmalloc_node(size, 1, flags, node,
__builtin_return_address(0));
}
/* kvfree: kmalloc/vmalloc 구분 자동 해제 */
void kvfree(const void *addr)
{
if (is_vmalloc_addr(addr))
vfree(addr);
else
kfree(addr);
}
kvmalloc 사용 주의: kvmalloc()은 편리하지만, vmalloc 경로로 갈 경우 TLB 미스와 페이지 테이블 워크 비용이 추가됩니다. 성능에 민감한 핫 패스에서는 크기가 확정된 경우 kmalloc()이나 전용 kmem_cache를 직접 사용하세요. 또한 DMA에 사용할 버퍼는 물리적으로 연속이어야 하므로 kvmalloc()을 사용하면 안 됩니다.
할당기 선택 가이드
상황에 따라 적절한 커널 메모리 할당기를 선택하는 것이 중요합니다. 잘못된 선택은 성능 저하, 메모리 낭비, 심하면 데드락으로 이어질 수 있습니다:
| 상황 | 권장 API | 이유 |
|---|---|---|
| 자주 생성/삭제되는 고정 크기 객체 | kmem_cache_alloc() |
캐싱으로 할당 비용 최소화, 내부 단편화 없음 |
| 임시 소형 버퍼 (크기 ≤ 8KB) | kmalloc() |
편리한 API, 내부적으로 슬랩 캐시 사용 |
| 0 초기화 필요 버퍼 | kzalloc() |
kmalloc() + memset(0) 단축형, 정보 유출 방지 |
| 배열·연속 물리 메모리 (크기 > 8KB) | alloc_pages() |
Buddy Allocator 직접 사용, 물리 연속성 보장 |
| 크기가 크고 물리 연속성 불필요 | vmalloc() |
가상 주소(Virtual Address) 연속, 물리 주소(Physical Address) 비연속 — 큰 버퍼에 적합 |
| Per-CPU 전용 데이터 | alloc_percpu() |
CPU별 격리, false sharing 방지, lock 불필요 |
| DMA 버퍼 (하드웨어 접근) | dma_alloc_coherent() |
물리 연속 + 캐시 코히런시 보장 |
크기별 선택 규칙: 객체가 고정 크기이고 빈번하게 할당/해제된다면 kmem_cache를 사용하세요. 일회성이거나 크기가 동적이면 kmalloc()이 적합합니다. 8KB를 넘고 물리 연속성이 불필요하면 vmalloc()을 고려하세요. 각 할당기의 상세 비교는 메모리 관리를 참고하세요.
워크로드별 할당자 선택 의사결정
워크로드별 커널 CONFIG 권장 설정
# 1. 서버 워크로드 (범용, 높은 코어 수)
CONFIG_SLUB=y # 기본 (6.5+ 유일한 선택)
CONFIG_SLUB_CPU_PARTIAL=y # Per-CPU partial 목록 활성화
CONFIG_SLAB_FREELIST_RANDOM=y # 보안: freelist 랜덤화
CONFIG_SLAB_FREELIST_HARDENED=y # 보안: freelist 포인터 난독화
CONFIG_MEMCG=y # 컨테이너 메모리 계정
# 2. 임베디드 워크로드 (메모리 절약)
CONFIG_SLUB=y
CONFIG_SLUB_TINY=y # 6.3+: Per-CPU partial 비활성화, 메모리 절약
# CONFIG_SLAB_FREELIST_RANDOM is not set # 오버헤드 제거
# 3. 디버깅 워크로드 (개발 중)
CONFIG_SLUB=y
CONFIG_SLUB_DEBUG=y # 런타임 디버깅 지원
CONFIG_SLUB_DEBUG_ON=y # 부팅 시 전체 캐시 디버깅 활성화
CONFIG_KASAN=y # 커널 주소 살균기
# 4. 실시간 워크로드 (PREEMPT_RT)
CONFIG_SLUB=y
CONFIG_SLUB_CPU_PARTIAL=y
# PREEMPT_RT 환경에서는 SLUB이 raw_spinlock 대신
# local_lock을 사용하여 우선순위 역전을 방지합니다
SLUB_TINY 참고: 커널 6.3에서 도입된 CONFIG_SLUB_TINY는 SLOB 제거 후 초소형 시스템을 위한 SLUB 경량 모드입니다. Per-CPU partial 목록을 비활성화하고 최소 order 슬랩을 사용하여 메모리 오버헤드를 줄입니다. 성능 대신 메모리 효율을 우선시하는 IoT/임베디드 환경에 적합합니다.
NUMA 인식 할당 (NUMA-Aware Allocation)
SLUB은 NUMA 토폴로지(Topology)를 인식하여 각 NUMA 노드마다 독립적인 partial 슬랩 목록을 유지합니다. 이를 통해 원격 노드 메모리 접근(remote NUMA access)을 최소화하여 지연(Latency) 시간을 줄입니다:
kmem_cache_node 구조
/* mm/slab.h */
struct kmem_cache_node {
spinlock_t list_lock; /* partial 목록 보호 */
unsigned long nr_partial; /* partial 슬랩 수 */
struct list_head partial; /* partial 슬랩 목록 */
atomic_long_t nr_slabs; /* 총 슬랩 수 */
atomic_long_t total_objects; /* 총 객체 수 (활성 + 프리) */
};
/* kmem_cache는 NUMA 노드당 하나의 kmem_cache_node를 가짐 */
struct kmem_cache {
/* ... */
struct kmem_cache_node *node[MAX_NUMNODES]; /* 노드당 캐시 */
};
NUMA 할당 우선순위(Priority)
/*
* 할당 우선순위 (현재 CPU → 로컬 NUMA 노드 → 새 페이지):
*
* 1. kmem_cache_cpu.freelist (per-CPU, lock-free, 매우 빠름)
* ↓ miss
* 2. kmem_cache_cpu.page.freelist (현재 CPU 슬랩의 잔여 객체)
* ↓ empty
* 3. kmem_cache_node[local].partial (로컬 NUMA 노드 partial 목록)
* ↓ empty
* 4. alloc_pages(GFP_KERNEL | __GFP_THISNODE, ...) (로컬 노드 새 페이지)
* ↓ 실패 시 원격 노드 fallback
*/
/* 특정 NUMA 노드에서 객체 할당 */
void *obj = kmem_cache_alloc_node(my_cache, GFP_KERNEL, numa_node_id());
/* 노드 1에 강제 할당 (NUMA 정책 테스트 목적) */
void *obj2 = kmem_cache_alloc_node(my_cache, GFP_KERNEL, 1);
NUMA 성능 팁: kmem_cache_alloc_node()로 객체를 로컬 노드에 생성하고, 해당 노드의 CPU에만 작업을 핀닝(CPU affinity)하면 원격 NUMA 접근 지연을 효과적으로 제거할 수 있습니다. NUMA 토폴로지 파악은 NUMA 페이지를 참고하세요.
보안 강화 (Security Hardening)
SLUB은 힙 기반 취약점(Vulnerability) 악용을 어렵게 만드는 보안 메커니즘을 제공합니다. 현대 배포판 커널(Ubuntu, RHEL, Debian 등)은 대부분 이 옵션들을 활성화합니다:
| CONFIG 옵션 | 보호 기법 | 방어 대상 | 성능 영향 |
|---|---|---|---|
SLAB_FREELIST_RANDOM |
freelist 순서 무작위화 | 힙 스프레이, 힙 레이아웃 예측 공격 | 초기화 시 1회 (미미) |
SLAB_FREELIST_HARDENED |
freelist 포인터 XOR 난독화 | freelist 포인터 위조 (힙 오버플로) | 할당/해제 시 XOR 2회 |
INIT_ON_ALLOC_DEFAULT_ON |
할당 시 자동 0 초기화 | 초기화 전 메모리 정보 유출 | 할당마다 memset |
INIT_ON_FREE_DEFAULT_ON |
해제 시 자동 0 초기화 | use-after-free 정보 유출 | 해제마다 memset |
SLAB_FREELIST_HARDENED 구현
freelist 포인터를 저장할 때 ptr XOR s→random XOR ptr_addr로 난독화합니다. 힙 오버플로로 freelist 포인터를 덮어써도, 커널 부팅 시 생성된 s→random 비밀값을 모르면 예측 가능한 주소로 조작하기 어려워집니다:
/* mm/slub.c — CONFIG_SLAB_FREELIST_HARDENED */
static inline void *freelist_ptr_decode(const struct kmem_cache *s,
void *ptr,
unsigned long ptr_addr)
{
#ifdef CONFIG_SLAB_FREELIST_HARDENED
/*
* 디코딩: encoded XOR s->random XOR ptr_addr = 원본 포인터
* 인코딩 시 secret(s->random)과 저장 주소를 XOR했으므로
* 공격자가 s->random을 모르면 위조된 포인터 예측 불가
*/
return (void *)((unsigned long)ptr ^ s->random ^ ptr_addr);
#else
return ptr;
#endif
}
static inline void freelist_ptr_encode(const struct kmem_cache *s,
void *ptr,
unsigned long ptr_addr)
{
#ifdef CONFIG_SLAB_FREELIST_HARDENED
unsigned long encoded = (unsigned long)ptr ^ s->random ^ ptr_addr;
*(void **)ptr_addr = (void *)encoded;
#else
*(void **)ptr_addr = ptr;
#endif
}
/* s->random: 커널 부팅 시 get_random_long()으로 생성 */
static int __init kmem_cache_init(void)
{
s->random = get_random_long(); /* 재부팅 시마다 변경 */
/* ... */
}
CONFIG_SLAB_FREELIST_RANDOM 작동: 슬랩 초기화 시 shuffle_freelist()가 Fisher-Yates 알고리즘으로 객체 순서를 섞습니다. 이로 인해 kmem_cache_alloc()이 반환하는 주소를 예측하기 어려워져 힙 스프레이 공격의 효과를 크게 떨어뜨립니다.
SLAB_FREELIST_RANDOM 상세 동작
CONFIG_SLAB_FREELIST_RANDOM이 활성화되면, 새 슬랩 페이지를 초기화할 때 shuffle_freelist()가 Fisher-Yates 알고리즘으로 객체 순서를 무작위로 섞습니다. 이로 인해 공격자가 할당 주소를 예측하기 어려워집니다:
/* mm/slub.c — shuffle_freelist() 핵심 로직 */
static bool shuffle_freelist(struct kmem_cache *s,
struct slab *slab)
{
void *cur, *next;
unsigned int idx, pos, page_limit, freelist_count;
void **freelist; /* 임시 배열 */
if (!static_branch_unlikely(&slab_freelist_random))
return false;
freelist_count = oo_objects(s->oo);
pos = get_random_u32_below(freelist_count);
/* 임시 배열에 순서대로 객체 주소 저장 */
for (idx = 0; idx < freelist_count; idx++)
freelist[idx] = fixup_red_left(s,
slab_address(slab) + s->size * idx);
/* Fisher-Yates 셔플: O(n) 무편향 순열 */
for (idx = freelist_count - 1; idx > 0; idx--) {
unsigned int j = get_random_u32_below(idx + 1);
swap(freelist[idx], freelist[j]);
}
/* 셔플된 순서로 freelist 체인 구성 */
for (idx = 0; idx < freelist_count - 1; idx++)
set_freepointer(s, freelist[idx], freelist[idx + 1]);
set_freepointer(s, freelist[freelist_count - 1], NULL);
slab->freelist = freelist[0];
return true;
}
KFENCE (Kernel Electric-Fence)
KFENCE는 프로덕션 환경에서도 사용 가능한 샘플링 기반 메모리 안전성 검사기입니다. 기존 KASAN(전수 검사)과 달리 통계적으로 일부 할당만 특수 풀에서 수행하여 경계 침범과 use-after-free를 탐지합니다:
# KFENCE 활성화 (부팅 파라미터)
# 100ms마다 1개 할당을 KFENCE 특수 풀에서 수행하여 검사
kfence.sample_interval=100
# KFENCE 통계 확인 (런타임)
cat /sys/kernel/debug/kfence/stats
# total allocs: 18420, faults: 2
# out-of-bounds: 1, use-after-free: 1, invalid-free: 0
# 오류 발생 시 커널 로그 예시:
# BUG: KFENCE: use-after-free read in my_func+0x3c/0xa0
# Use-after-free read at 0xffff888... (in kfence-#42):
# my_func+0x3c/0xa0
# do_work+0x18/0x40
# Freed by task 1234:
# kfree+0x5a/0x90
# cleanup_obj+0x12/0x30
KASAN vs KFENCE 비교: KASAN은 개발·테스트 환경에서 모든 할당을 검사하므로 메모리 사용량이 2~3배 증가하고 성능이 크게 저하됩니다. KFENCE는 프로덕션 커널에서 CONFIG_KFENCE=y로 활성화하여 성능 영향 없이 장기 운영 중 확률적으로 버그를 탐지합니다. 자세한 내용은 디버깅 도구를 참고하세요.
디버깅 (Debugging)
slabinfo 확인
# 모든 slab 캐시 정보 보기
cat /proc/slabinfo
# 출력 예:
# name active num_objs objsize objperslab pagesperslab
task_struct 120 120 7104 4 8
dentry 8000 8400 192 21 1
inode_cache 5000 5120 608 13 2
slabtop 모니터링
# 실시간 slab 사용량 모니터링
sudo slabtop
# 정렬 옵션:
# -s c : 캐시 크기 정렬
# -s o : 객체 수 정렬
sudo slabtop -s c
KASAN 연동
CONFIG_KASAN 활성화 시 SLUB과 통합되어 use-after-free, heap-buffer-overflow를 런타임에 탐지합니다:
/* CONFIG_KASAN 환경에서의 kmem_cache_free 흐름 */
void kmem_cache_free(struct kmem_cache *s, void *x)
{
/* KASAN: 객체 범위 외 접근 검사 (red zone) */
kasan_slab_free(s, x, _RET_IP_);
/* SLUB: freelist에 반환 */
slab_free(s, virt_to_slab(x), x, NULL, 1, _RET_IP_);
}
# KASAN 오류 발생 시 커널 로그 예시
# BUG: KASAN: slab-use-after-free in my_function+0x42/0x80
# Read of size 4 at addr ffff888... by task process/1234
# SLUB 디버그 통계 확인 (CONFIG_SLUB_STATS=y 필요)
cat /sys/kernel/slab/<cache-name>/alloc_fastpath
cat /sys/kernel/slab/<cache-name>/alloc_slowpath
슬랩 디버그 빌드 옵션: 커널 빌드 시 CONFIG_SLUB_DEBUG=y와 CONFIG_KASAN=y를 함께 활성화하면 슬랩 경계 위반, use-after-free, double-free를 정확히 잡아낼 수 있습니다. slub_debug=FZP 커널 파라미터로 Freeing poison, Zero-on-free, Padding check을 런타임에 활성화할 수도 있습니다.
메모리 압박과 Shrinker 연동
SLAB_RECLAIM_ACCOUNT 플래그를 설정하면 해당 슬랩은 메모리 압박 시 커널이 회수할 수 있는 reclaimable 메모리로 집계됩니다. VFS inode/dentry 캐시처럼 "비워도 되는" 캐시에 적합합니다:
/* SLAB_RECLAIM_ACCOUNT: 메모리 압박 시 회수 대상으로 등록 */
inode_cachep = kmem_cache_create(
"inode_cache",
sizeof(struct inode),
0,
SLAB_RECLAIM_ACCOUNT | SLAB_PANIC | SLAB_MEM_SPREAD,
init_once
);
/*
* /proc/meminfo에서 슬랩 분류:
* SReclaimable: SLAB_RECLAIM_ACCOUNT 플래그 슬랩 (dentry, inode 등)
* SUnreclaim: 회수 불가 슬랩 (task_struct, mm_struct 등)
*/
# /proc/meminfo에서 슬랩 통계 확인
grep -E "Slab|SReclaim|SUnreclaim" /proc/meminfo
# Slab: 512000 kB
# SReclaimable: 420000 kB ← 회수 가능
# SUnreclaim: 92000 kB ← 회수 불가
# 수동 회수 (디버깅 전용 — 프로덕션 환경 주의)
echo 2 > /proc/sys/vm/drop_caches # dentries, inodes만
echo 3 > /proc/sys/vm/drop_caches # pagecache + dentries + inodes
Shrinker 등록과 슬랩 수축 구현
슬랩 기반 캐시(dentry, inode 등)는 shrinker를 등록하여 메모리 압박 시 커널이 자동으로 캐시를 수축할 수 있게 합니다. 아래는 VFS의 dentry 캐시가 shrinker를 통해 슬랩 객체를 회수하는 과정입니다:
/* fs/dcache.c - dentry 캐시 shrinker 등록 */
static struct shrinker *dcache_shrinker;
static int __init dcache_init(void)
{
dcache_shrinker = shrinker_alloc(SHRINKER_NUMA_AWARE, "dcache");
if (!dcache_shrinker)
return -ENOMEM;
dcache_shrinker->count_objects = dcache_count;
dcache_shrinker->scan_objects = dcache_scan;
dcache_shrinker->seeks = DEFAULT_SEEKS;
shrinker_register(dcache_shrinker);
return 0;
}
/* 회수 가능 객체 수 보고 */
static unsigned long dcache_count(
struct shrinker *shrink,
struct shrink_control *sc)
{
/* LRU에 있는 미사용 dentry 수 반환 */
return list_lru_shrink_count(&dentry_lru, sc);
}
/* 실제 수축: 지정된 수만큼 dentry 회수 */
static unsigned long dcache_scan(
struct shrinker *shrink,
struct shrink_control *sc)
{
return list_lru_shrink_walk(&dentry_lru, sc,
dentry_lru_isolate, &sc->nr_to_scan);
/* dentry_lru_isolate()가 개별 dentry를 분리하고
* dentry_kill()이 kmem_cache_free()로 슬랩에 반환 */
}
/* mm/shrinker.c - 메모리 압박 시 shrinker 호출 흐름 */
/* kswapd / direct reclaim
* → shrink_slab()
* → do_shrink_slab()
* → shrinker->count_objects() // 회수 가능 수 확인
* → shrinker->scan_objects() // 실제 회수 수행
* → kmem_cache_free() // 슬랩에 객체 반환
* → 빈 슬랩 → Buddy에 페이지 반환
*/
코드 설명
-
6행
shrinker_alloc()은 커널 6.7+의 새 API입니다. 이전에는register_shrinker()를 사용했으나, 메모리 부족 시 등록 실패를 처리하기 어려워 할당/등록이 분리되었습니다. -
10-11행
count_objects는 회수 가능한 객체 수를 보고하고,scan_objects는 실제 회수를 수행합니다. 커널은 메모리 압박 정도에 따라 scan_objects에 회수할 수를 전달합니다. -
24행
list_lru_shrink_count()는 NUMA 노드별 LRU 목록에서 미사용 dentry 수를 합산합니다.SHRINKER_NUMA_AWARE플래그 덕분에 노드별로 독립적으로 수축합니다.
drop_caches 주의: echo 3 > /proc/sys/vm/drop_caches는 프로덕션 서버에서 심각한 성능 저하를 유발합니다. 슬랩 캐시는 커널 kswapd가 필요에 따라 자동 회수하므로, 수동 해제는 메모리 분석 목적으로만 사용하세요. 관련 내용은 Shrinker 페이지를 참고하세요.
성능 최적화 (Performance)
캐시 플래그
| 플래그 | 설명 | 용도 |
|---|---|---|
SLAB_HWCACHE_ALIGN |
캐시 라인(Cache Line) 정렬 | False sharing 방지 |
SLAB_POISON |
메모리 독 패턴 채우기 | 디버깅 (use-after-free 탐지) |
SLAB_RED_ZONE |
Red zone 추가 | 버퍼 오버플로(Buffer Overflow) 탐지 |
SLAB_PANIC |
생성 실패 시 panic | 중요 캐시 |
모범 사례
/* 좋은 예: 전용 캐시 + 생성자 */
static struct kmem_cache *task_struct_cache;
static void task_struct_ctor(void *obj)
{
struct task_struct *task = obj;
memset(task, 0, sizeof(*task));
/* 초기화 로직 */
}
task_struct_cache = kmem_cache_create(
"task_struct",
sizeof(struct task_struct),
ARCH_MIN_TASKALIGN,
SLAB_HWCACHE_ALIGN | SLAB_PANIC,
task_struct_ctor
);
실사용 사례
inode 캐시
/* fs/inode.c */
static struct kmem_cache *inode_cachep;
static void init_once(void *foo)
{
struct inode *inode = (struct inode *) foo;
inode_init_once(inode);
}
void inode_init(void)
{
inode_cachep = kmem_cache_create(
"inode_cache",
sizeof(struct inode),
0,
SLAB_RECLAIM_ACCOUNT | SLAB_PANIC | SLAB_MEM_SPREAD,
init_once
);
}
dentry 캐시
VFS 디렉터리 엔트리 캐시는 파일 경로 탐색 성능의 핵심입니다. 이름 조회(lookup) 결과를 캐시하여 디스크 접근을 방지합니다:
/* fs/dcache.c */
static struct kmem_cache *dentry_cache __read_mostly;
void __init vfs_caches_init_early(void)
{
dentry_cache = kmem_cache_create_usercopy(
"dentry",
sizeof(struct dentry),
0,
SLAB_RECLAIM_ACCOUNT | SLAB_PANIC | SLAB_MEM_SPREAD | SLAB_ACCOUNT,
offsetof(struct dentry, d_iname),
DNAME_INLINE_LEN,
NULL
);
}
/* dentry 할당 */
static struct dentry *__d_alloc(struct super_block *sb, const struct qstr *name)
{
struct dentry *dentry;
dentry = kmem_cache_alloc(dentry_cache, GFP_KERNEL);
/* ... 초기화 ... */
return dentry;
}
네트워크 sk_buff 캐시
네트워크 스택에서 패킷(Packet)을 표현하는 sk_buff는 초당 수백만 개가 할당·해제되는 핵심 객체입니다. 전용 슬랩 캐시로 할당 비용을 최소화합니다:
/* net/core/skbuff.c */
static struct kmem_cache *skbuff_head_cache __read_mostly;
void __init skb_init(void)
{
skbuff_head_cache = kmem_cache_create_usercopy(
"skbuff_head_cache",
sizeof(struct sk_buff),
0,
SLAB_HWCACHE_ALIGN | SLAB_PANIC,
offsetof(struct sk_buff, cb),
sizeof_field(struct sk_buff, cb),
NULL
);
}
/* sk_buff 할당 (네트워크 패킷 수신 시) */
struct sk_buff *alloc_skb(unsigned int size, gfp_t priority)
{
struct sk_buff *skb;
skb = kmem_cache_alloc(skbuff_head_cache, priority);
/* ... 데이터 영역(skb->data) 별도 할당 ... */
return skb;
}
슬랩 캐시를 쓰는 주요 커널 객체: task_struct(프로세스), mm_struct(주소 공간(Address Space)), vm_area_struct(가상 메모리(Virtual Memory) 영역), file(열린 파일), inode(파일시스템 노드), dentry(경로 캐시), sk_buff(네트워크 패킷), socket(소켓(Socket)), bio(블록 I/O) 등 커널 핵심 객체 대부분이 전용 슬랩 캐시를 사용합니다.
완전한 커널 모듈(Kernel Module) 예제
슬랩 캐시의 전체 생명주기(생성 → 할당 → 사용 → 해제 → 삭제)를 보여주는 완전한 커널 모듈 예제입니다. 실제 드라이버 작성 시 이 패턴을 참고하세요:
/* my_cache_module.c — slab cache 전체 생명주기 예제 */
#include <linux/module.h>
#include <linux/slab.h>
#include <linux/spinlock.h>
#include <linux/list.h>
MODULE_LICENSE("GPL");
/* 1. 캐시에 저장할 구조체 */
struct my_object {
int id;
char name[32];
unsigned long timestamp;
struct list_head list;
};
static struct kmem_cache *my_cache;
static LIST_HEAD(my_objects);
static DEFINE_SPINLOCK(my_lock);
/* 2. 생성자: 슬랩 페이지 초기화 시 한 번만 호출됨
* 매 kmem_cache_alloc() 호출 시 실행되지 않으므로
* 재사용 시에도 유효해야 하는 필드만 초기화 */
static void my_object_ctor(void *obj)
{
struct my_object *o = obj;
INIT_LIST_HEAD(&o->list);
}
/* 3. 할당 헬퍼 — 할당 후 가변 필드는 직접 초기화 */
static struct my_object *my_object_alloc(int id, const char *name)
{
struct my_object *o = kmem_cache_alloc(my_cache, GFP_KERNEL);
if (!o)
return NULL;
o->id = id;
o->timestamp = jiffies;
strscpy(o->name, name, sizeof(o->name));
spin_lock(&my_lock);
list_add(&o->list, &my_objects);
spin_unlock(&my_lock);
return o;
}
/* 4. 해제 헬퍼 */
static void my_object_free(struct my_object *o)
{
spin_lock(&my_lock);
list_del(&o->list);
spin_unlock(&my_lock);
kmem_cache_free(my_cache, o); /* 슬랩 freelist로 반환 */
}
static int __init my_module_init(void)
{
struct my_object *o1, *o2;
/* 5. 캐시 생성 — SLAB_PANIC: 실패 시 커널 패닉 */
my_cache = kmem_cache_create(
"my_object",
sizeof(struct my_object),
0, /* align: 0 = 자동 */
SLAB_HWCACHE_ALIGN | SLAB_PANIC, /* 캐시라인 정렬 */
my_object_ctor
);
pr_info("my_cache: created, object_size=%zu\n",
sizeof(struct my_object));
/* 6. 객체 할당 및 사용 */
o1 = my_object_alloc(1, "alpha");
o2 = my_object_alloc(2, "beta");
/* 7. 객체 해제 */
my_object_free(o1);
my_object_free(o2);
return 0;
}
static void __exit my_module_exit(void)
{
struct my_object *o, *tmp;
/* 8. 남은 객체 모두 반환 — kmem_cache_destroy() 전에 필수 */
spin_lock(&my_lock);
list_for_each_entry_safe(o, tmp, &my_objects, list) {
list_del(&o->list);
kmem_cache_free(my_cache, o);
}
spin_unlock(&my_lock);
/* 9. 캐시 삭제 (활성 객체가 남아있으면 BUG() 발생) */
kmem_cache_destroy(my_cache);
pr_info("my_cache: destroyed\n");
}
module_init(my_module_init);
module_exit(my_module_exit);
생성자 오해 주의: ctor는 슬랩 페이지가 처음 초기화될 때만 호출되며, 매 kmem_cache_alloc() 호출 시 실행되지 않습니다. 따라서 매 할당마다 초기화해야 하는 필드(id, timestamp 등)는 할당 후 직접 설정해야 합니다. 생성자는 재사용 시에도 항상 유효한 필드(리스트 헤드, 뮤텍스(Mutex), spinlock 등)의 초기화에만 사용하세요.
내부 단편화 분석 (Internal Fragmentation)
Slab 할당자의 내부 단편화는 두 가지 수준에서 발생합니다: 객체 수준(요청 크기 → 캐시 크기 올림)과 슬랩 수준(페이지 크기에 객체를 채우고 남는 공간). 이 두 단편화를 이해하면 메모리 사용 효율을 정확히 평가할 수 있습니다:
# 실제 시스템에서 캐시별 단편화율 계산
# object_size vs slab_size 비교로 객체 수준 단편화 확인
for cache in /sys/kernel/slab/*/; do
name=$(basename "$cache")
obj_size=$(cat "$cache/object_size" 2>/dev/null || echo 0)
slab_size=$(cat "$cache/slab_size" 2>/dev/null || echo 0)
objs=$(cat "$cache/objs_per_slab" 2>/dev/null || echo 0)
[ "$obj_size" -gt 0 ] && [ "$slab_size" -gt 0 ] && \
frag=$(( (slab_size - obj_size) * 100 / slab_size )) && \
[ "$frag" -gt 20 ] && \
echo "$name: obj=$obj_size slab=$slab_size frag=${frag}% objs/slab=$objs"
done | sort -t= -k4 -rn | head -15
# 전체 슬랩 메모리 중 단편화 비율 추정
# SReclaimable + SUnreclaim 대비 active_objs * obj_size 합산
awk 'NR>2 && $3>0 {
total += $4 * $3;
active += $4 * $2;
} END {
printf "활성: %.1f MB / 총: %.1f MB / 효율: %.1f%%\n",
active/1048576, total/1048576, active*100/total
}' /proc/slabinfo
단편화 최소화 전략: ① 빈번한 고정 크기 객체는 kmem_cache_create()로 전용 캐시를 만들어 크기 올림을 없앱니다. ② SLAB_HWCACHE_ALIGN은 false sharing 방지에 필수적이지만 단편화를 증가시키므로, 캐시라인 크기(보통 64B) 미만의 객체에만 적용하는 것이 효율적입니다. ③ /sys/kernel/slab/*/slab_size와 object_size를 비교하여 단편화가 심한 캐시를 식별하세요.
성능 분석 도구
슬랩 할당이 성능 병목인지 확인하고 분석하는 방법입니다:
perf kmem
# 5초간 시스템 전체 슬랩 할당 이벤트 캡처
sudo perf kmem record -a sleep 5
# 슬랩 통계 분석 (캐시별 할당 횟수 · 크기 · 단편화율)
sudo perf kmem stat --slab
# 출력 예:
# Alloc Ptr |Alloc Bytes|Freed Ptr |Freed Bytes|Caller
# 12420 | 9.5 MB | 12180 | 9.3 MB | kmalloc-256
# 8800 | 1.7 MB | 8600 | 1.6 MB | dentry
ftrace 함수 추적
# kmem_cache_alloc 함수만 추적
echo 'kmem_cache_alloc' > /sys/kernel/debug/tracing/set_ftrace_filter
echo function > /sys/kernel/debug/tracing/current_tracer
echo 1 > /sys/kernel/debug/tracing/tracing_on
sleep 1
echo 0 > /sys/kernel/debug/tracing/tracing_on
cat /sys/kernel/debug/tracing/trace | head -30
# 특정 캐시의 sysfs 통계 (CONFIG_SLUB_STATS=y 필요)
cat /sys/kernel/slab/dentry/alloc_fastpath
cat /sys/kernel/slab/dentry/alloc_slowpath
cat /sys/kernel/slab/dentry/free_fastpath
vmstat 슬랩 모니터링
# 1초 간격 슬랩 관련 카운터 모니터링
vmstat -s | grep -i slab
# /proc/meminfo 슬랩 요약
grep -E "Slab|Buffers|Cached" /proc/meminfo
# 캐시별 상세 — 객체 크기 · 페이지 수 · 활성 객체 비율
awk 'NR>2 {printf "%-30s objs=%d size=%dB\n", $1, $3, $4}' /proc/slabinfo \
| sort -t= -k3 -rn | head -20
병목 판단 기준: alloc_slowpath / alloc_fastpath 비율이 10% 이상이면 해당 캐시에서 Per-CPU 캐시 미스가 잦다는 의미입니다. min_partial 값을 늘리거나 cpu_partial 값을 조정하여 완화할 수 있습니다.
SLAB/SLUB 캐시 감사 체크리스트
슬랩 문제는 누수와 오염(corruption)으로 나뉩니다. 캐시 단위 통계와 디버그 옵션을 함께 사용해 원인을 빠르게 좁히는 것이 핵심입니다.
- 증가 캐시 식별:
/proc/slabinfo상위 항목 비교 - 회수 가능성 판단:
SReclaimable/SUnreclaim비율 확인 - 오염 검사:
slub_debug=FZPU로 redzone/poison 활성화 - 할당 경로 추적: perf kmem/ftrace로 hot path 확인
# 슬랩 상태 핵심 수집
grep -E "Slab|SReclaimable|SUnreclaim" /proc/meminfo
cat /proc/slabinfo | head -n 50
sudo perf kmem stat --slab 2>/dev/null || true
SLUB Sheaves: Per-CPU 캐싱 최적화 (v6.18+)
커널 6.18에서 도입된 sheaves는 SLUB 할당자에 새로운 per-CPU 캐싱 레이어를 추가하는 최적화입니다. 기존 SLUB의 per-CPU 프리리스트 방식을 개선하여 객체 할당/해제 시 노드 레벨 락 경합을 크게 줄입니다.
도입 배경
기존 SLUB 할당자는 per-CPU 프리리스트가 소진되면 partial slab 리스트에서 새 slab을 가져오기 위해 노드 레벨 락(list_lock)을 잡아야 했습니다. 이는 CPU 코어 수가 많은 시스템(4096코어 이상, v6.14)에서 심각한 경합을 일으킬 수 있습니다.
설계
Sheaves 구조 개요 (v6.18+, mm/slub.c)
기존 SLUB: CPU → per-CPU freelist → partial list (node lock) Sheaves 적용 후: CPU → sheaf (로컬 배치) → per-CPU freelist → partial list
sheaf는 일정 수의 객체를 배치(batch)로 관리하여 node lock 접근 빈도를 줄입니다.
- 할당: sheaf에서 객체를 꺼냅니다 (lock-free)
- 해제: sheaf에 객체를 반환합니다 (lock-free)
- sheaf 소진/가득 참: 새 sheaf를 교체합니다 (infrequent lock)
| 구분 | 기존 SLUB | SLUB + Sheaves (v6.18+) |
|---|---|---|
| per-CPU 캐싱 | 단일 프리리스트 | sheaf 배치 + 프리리스트 |
| 락 경합 | 프리리스트 소진 시 node lock | sheaf 교체 시에만 lock (빈도 감소) |
| 배치 처리 | 1개씩 refill | 배치 단위 refill/drain |
| 메모리 오버헤드 | 낮음 | 약간 증가 (sheaf 구조체(Struct)) |
| 효과 | 기준 | 고코어 시스템에서 할당/해제 처리량(Throughput) 향상 |
kmalloc() SLUB 할당 경로 심층 분석
kmalloc() 호출은 내부적으로 SLUB 할당자의 fast path와 slow path를 거칩니다. 전체 호출 체인은 kmalloc() → __kmalloc() → slab_alloc_node() → ___slab_alloc()으로 이어지며, 각 단계에서 점진적으로 비용이 높은 할당 경로로 fallback합니다.
할당 호출 체인 개요
SLUB 할당자의 핵심은 slab_alloc_node() 함수입니다. 이 함수는 per-CPU freelist를 먼저 확인하고(fast path), 실패하면 ___slab_alloc()으로 진입하여 slab freelist, partial list, 새 페이지 할당을 순차적으로 시도합니다(slow path).
/* mm/slub.c — slab_alloc_node() 핵심 로직 (단순화) */
static __always_inline void *slab_alloc_node(
struct kmem_cache *s, struct list_lru *lru,
gfp_t gfpflags, int node, unsigned long addr,
size_t orig_size)
{
void *object;
struct kmem_cache_cpu *c;
struct slab *slab;
unsigned long tid;
/* ① preemption 비활성화 후 per-CPU 포인터 획득 */
redo:
tid = this_cpu_read(s->cpu_slab->tid);
c = raw_cpu_ptr(s->cpu_slab);
/* ② Fast path: per-CPU freelist에서 즉시 할당 */
object = c->freelist;
slab = c->slab;
if (unlikely(!object || !slab ||
!node_match(slab, node))) {
/* ③ Slow path: ___slab_alloc()으로 진입 */
object = ___slab_alloc(s, gfpflags, node,
addr, c, orig_size);
} else {
void *next_object = get_freepointer_safe(s, object);
/* ④ cmpxchg_double로 원자적 갱신 (lock-free) */
if (unlikely(!this_cpu_cmpxchg_double(
s->cpu_slab->freelist,
s->cpu_slab->tid,
object, tid,
next_object, next_tid(tid))))
goto redo;
prefetch_freepointer(s, next_object);
}
return object;
}
코드 설명
- 12행
this_cpu_read()로 현재 CPU의 transaction ID를 읽습니다. TID는 CPU 마이그레이션 감지에 사용됩니다. - 13행
raw_cpu_ptr()로 per-CPUkmem_cache_cpu구조체에 접근합니다. preemption disable 없이 접근하되, cmpxchg로 일관성을 보장합니다. - 16~17행per-CPU freelist의 첫 번째 객체와 현재 slab을 읽습니다. 둘 다 유효해야 fast path를 탈 수 있습니다.
- 19~22행freelist가 비어있거나, slab이 없거나, NUMA 노드가 불일치하면 slow path로 진입합니다.
- 26~31행
this_cpu_cmpxchg_double()로 freelist와 tid를 원자적으로 갱신합니다. CPU 마이그레이션이 발생했으면 tid가 달라져 cmpxchg가 실패하고 redo로 재시도합니다. - 33행
prefetch_freepointer()로 다음 할당될 객체의 freelist 포인터를 CPU 캐시에 미리 로드합니다.
Slow Path: ___slab_alloc() 상세
___slab_alloc()은 fast path 실패 시 호출되며, 3단계 fallback 경로를 거칩니다:
/* mm/slub.c — ___slab_alloc() 핵심 로직 (단순화) */
static void *___slab_alloc(
struct kmem_cache *s, gfp_t gfpflags,
int node, unsigned long addr,
struct kmem_cache_cpu *c, size_t orig_size)
{
void *freelist;
struct slab *slab;
local_lock_irqsave(&s->cpu_slab->lock, flags);
/* 단계 1: 현재 slab의 freelist 확인 */
slab = c->slab;
if (slab) {
freelist = slab->freelist;
if (freelist) {
/* slab freelist에서 할당 가능 */
c->freelist = get_freepointer(s, freelist);
slab->freelist = NULL;
goto load_freelist;
}
/* 현재 slab 소진 → deactivate */
deactivate_slab(s, slab, c);
}
/* 단계 2: per-CPU partial list에서 slab 가져오기 */
slab = slub_percpu_partial(c);
if (slab) {
c->slab = slab;
c->freelist = slab->freelist;
goto load_freelist;
}
/* 단계 3: 새 slab 생성 */
freelist = new_slab_objects(s, gfpflags, node, &slab);
if (unlikely(!freelist)) {
local_unlock_irqrestore(&s->cpu_slab->lock, flags);
return NULL; /* OOM */
}
load_freelist:
c->slab = slab;
local_unlock_irqrestore(&s->cpu_slab->lock, flags);
return freelist;
}
코드 설명
- 10행
local_lock_irqsave()로 IRQ를 비활성화하고 per-CPU 락을 획득합니다. slow path에서는 preemption/인터럽트 보호가 필요합니다. - 13~21행단계 1: 현재 CPU에 바인딩된 slab의 freelist를 확인합니다. fast path에서 per-CPU freelist는 비었지만, slab 자체의 freelist에는 객체가 남아있을 수 있습니다.
- 23행현재 slab이 완전히 소진되면
deactivate_slab()으로 노드의 partial 리스트로 이동시킵니다. - 27~32행단계 2: per-CPU partial 리스트에서 부분 사용 중인 slab을 가져옵니다. 노드 락 없이 접근 가능합니다.
- 35행단계 3:
new_slab_objects()는 노드의 partial 리스트를 확인하고(노드 락 필요), 없으면allocate_slab()→alloc_pages()로 Buddy Allocator에서 새 페이지를 할당합니다. - 37~39행모든 경로가 실패하면 NULL을 반환합니다(OOM 상황). GFP 플래그에 따라 메모리 회수나 OOM Killer가 동작할 수 있습니다.
| 경로 | 함수 | 락 | 지연 시간 | 발생 빈도 |
|---|---|---|---|---|
| Fast path | slab_alloc_node() | 없음 (cmpxchg) | ~10 ns | ~95% |
| Slow 1 | ___slab_alloc() slab freelist | local IRQ disable | ~50 ns | ~3% |
| Slow 2 | per-CPU partial | local IRQ disable | ~100 ns | ~1.5% |
| Slow 3 | new_slab_objects() 노드 partial | node list_lock | ~200 ns | ~0.4% |
| Slow 4 | allocate_slab() Buddy | zone lock | ~1000+ ns | ~0.1% |
struct kmem_cache 심층 분석
SLUB 할당자의 핵심 자료구조인 struct kmem_cache와 그 하위 구조체들을 필드별로 분석합니다. 이 구조체는 특정 크기의 커널 객체를 관리하는 슬랩 캐시 하나를 완전히 기술합니다.
kmem_cache 주요 필드
/* include/linux/slub_def.h — struct kmem_cache 전체 필드 */
struct kmem_cache {
/* ── Hot path 필드 (캐시라인 0) ── */
struct kmem_cache_cpu __percpu *cpu_slab; /* per-CPU 캐시 포인터 */
slab_flags_t flags; /* SLAB_HWCACHE_ALIGN 등 동작 플래그 */
unsigned long min_partial; /* 노드당 유지할 최소 partial 수 */
unsigned int size; /* 메타데이터 포함 실제 할당 크기 */
unsigned int object_size; /* 사용자가 요청한 순수 객체 크기 */
struct reciprocal_value reciprocal_size; /* 나눗셈 최적화 */
unsigned int offset; /* freelist 포인터 저장 오프셋 */
/* ── Order/객체 수 ── */
struct kmem_cache_order_objects oo; /* 최적 order + 객체 수 */
struct kmem_cache_order_objects min; /* 최소 order (메모리 부족 시) */
struct kmem_cache_order_objects max; /* 최대 order */
gfp_t allocflags; /* 페이지 할당 시 사용할 GFP 플래그 */
/* ── 디버그/보안 ── */
int refcount; /* 캐시 병합 참조 카운트 */
unsigned int inuse; /* 객체 내 사용 영역 크기 (메타 제외) */
unsigned int align; /* 객체 정렬 바이트 */
unsigned int red_left_pad; /* 좌측 red zone 패딩 크기 */
const char *name; /* 캐시 이름 ("dentry", "inode" 등) */
void (*ctor)(void *); /* 객체 생성자 (slab 초기화 시 1회) */
/* ── NUMA 노드별 캐시 ── */
struct kmem_cache_node *node[MAX_NUMNODES];
#ifdef CONFIG_SLAB_FREELIST_HARDENED
unsigned long random; /* freelist 포인터 암호화 키 */
#endif
#ifdef CONFIG_KASAN
struct kasan_cache kasan_info; /* KASAN 메타데이터 */
#endif
};
코드 설명
- 4행
cpu_slab: 각 CPU마다 독립적인kmem_cache_cpu구조체를 가리킵니다.__percpu매크로로 per-CPU 할당됩니다. - 8행
size: red zone, 패딩, freelist 포인터 등 메타데이터를 포함한 실제 할당 단위 크기입니다.object_size ≤ inuse ≤ size관계입니다. - 9행
object_size: 사용자가kmem_cache_create()에서 지정한 순수 객체 크기입니다. - 11행
offset: 해제된 객체 내에서 freelist 포인터가 저장되는 위치(바이트 오프셋)입니다. 일반적으로 객체 시작이지만,SLAB_TYPESAFE_BY_RCU시 변경될 수 있습니다. - 14행
oo: 슬랩 페이지의 Buddy order와 페이지당 객체 수를 하나의 값에 인코딩합니다. 상위 비트가 order, 하위 비트가 객체 수입니다. - 22행
red_left_pad: 디버그 모드에서 객체 왼쪽에 추가되는 red zone 크기입니다. 경계 침범 감지에 사용됩니다. - 29행
random:CONFIG_SLAB_FREELIST_HARDENED활성화 시 freelist 포인터를 XOR 난독화하는 키입니다. 부팅 시 랜덤 생성됩니다.
kmem_cache_cpu 상세
/* include/linux/slub_def.h */
struct kmem_cache_cpu {
void **freelist; /* 현재 할당 가능한 객체 포인터 */
unsigned long tid; /* cmpxchg용 Transaction ID */
struct slab *slab; /* 현재 CPU에 바인딩된 활성 slab */
#ifdef CONFIG_SLUB_CPU_PARTIAL
struct slab *partial; /* per-CPU partial slab 리스트 */
#endif
local_lock_t lock; /* slow path 보호용 로컬 락 */
};
코드 설명
- 3행
freelist: fast path에서 lock 없이 접근하는 핵심 포인터입니다.cmpxchg_double로 원자적으로 갱신됩니다. - 4행
tid: Transaction ID로, CPU 마이그레이션 감지에 사용됩니다. 각 할당마다 증가하며, cmpxchg 실패 시 다른 CPU로 마이그레이션되었음을 의미합니다. - 5행
slab: 현재 CPU에 frozen 상태로 바인딩된 활성 slab 페이지입니다. 이 slab에서만 lock-free 할당이 가능합니다. - 7행
partial: 노드 partial 리스트에 접근하기 전에 먼저 확인하는 per-CPU partial slab 목록입니다. 노드 락 없이 접근 가능하여 slow path 성능을 개선합니다.
kmem_cache_node 상세
/* mm/slab.h */
struct kmem_cache_node {
spinlock_t list_lock; /* partial 리스트 보호 */
unsigned long nr_partial; /* partial slab 개수 */
struct list_head partial; /* partial slab 연결 리스트 */
#ifdef CONFIG_SLUB_DEBUG
atomic_long_t nr_slabs; /* 이 노드의 총 slab 수 */
atomic_long_t total_objects; /* 총 객체 수 (할당+프리) */
struct list_head full; /* 디버그: full slab 추적 */
#endif
};
코드 설명
- 3행
list_lock: partial 리스트를 보호하는 spinlock입니다. slow path에서 노드의 partial slab을 가져올 때 이 락을 잡습니다. 고코어 시스템에서 경합 지점이 됩니다. - 4~5행
nr_partial과partial: partial slab의 수와 연결 리스트입니다.min_partial이하로 내려가면 slab을 유지하여 반복 할당/해제 오버헤드를 방지합니다. - 9행
full: 디버그 빌드에서만 full slab을 추적합니다. 프로덕션에서는 full slab을 추적하지 않아 오버헤드를 줄입니다.
size vs object_size vs inuse: object_size는 사용자가 요청한 크기, inuse는 객체 본문 + freelist 포인터 공간, size는 정렬과 red zone까지 포함한 최종 슬롯 크기입니다. 예: object_size=192, inuse=200(freelist 포인터 8B), size=256(64B 정렬 패딩). /sys/kernel/slab/<name>/object_size와 slab_size에서 확인할 수 있습니다.
SLUB 해제 경로 심층 분석
kfree()와 kmem_cache_free()의 내부 구현은 할당과 마찬가지로 fast/slow path 구조를 가집니다. 전체 호출 체인은 kfree() → slab_free() → do_slab_free()이며, 대부분의 경우 fast path에서 lock-free로 완료됩니다.
Fast Path: do_slab_free()
/* mm/slub.c — do_slab_free() 핵심 로직 (단순화) */
static __always_inline void do_slab_free(
struct kmem_cache *s, struct slab *slab,
void *head, void *tail, int cnt,
unsigned long addr)
{
void *prior;
unsigned long tid;
redo:
tid = this_cpu_read(s->cpu_slab->tid);
/* ① 해제 대상이 현재 CPU의 활성 slab인지 확인 */
if (likely(slab == this_cpu_read(s->cpu_slab->slab))) {
/* ② Fast path: per-CPU freelist 앞에 삽입 */
prior = this_cpu_read(s->cpu_slab->freelist);
set_freepointer(s, tail, prior);
/* ③ cmpxchg로 원자적 업데이트 */
if (unlikely(!this_cpu_cmpxchg_double(
s->cpu_slab->freelist,
s->cpu_slab->tid,
prior, tid,
head, next_tid(tid))))
goto redo;
} else {
/* ④ Slow path: 다른 CPU의 slab 또는 partial slab */
__slab_free(s, slab, head, tail, cnt, addr);
}
}
코드 설명
- 13행해제할 객체가 속한 slab이 현재 CPU의 활성 slab인지 비교합니다. 같은 CPU에서 할당·해제하는 패턴이 가장 흔하므로, 이 조건은 대부분 참입니다.
- 15~16행per-CPU freelist의 현재 head를 읽고, 해제할 객체의 freelist 포인터를 기존 head로 설정합니다. LIFO(Last-In-First-Out) 방식으로 삽입합니다.
- 19~24행
cmpxchg_double로 freelist와 tid를 원자적으로 갱신합니다. 할당과 동일한 메커니즘으로 CPU 마이그레이션을 감지합니다. - 27행해제 대상이 다른 CPU의 slab이면
__slab_free()slow path로 진입합니다. 이 경우 slab의 상태 전이(full → partial, partial → 반환)가 발생할 수 있습니다.
Slow Path: __slab_free()
/* mm/slub.c — __slab_free() 핵심 로직 (단순화) */
static void __slab_free(
struct kmem_cache *s, struct slab *slab,
void *head, void *tail, int cnt,
unsigned long addr)
{
void *prior;
int was_frozen;
do {
prior = slab->freelist;
was_frozen = slab->frozen;
set_freepointer(s, tail, prior);
/* ① cmpxchg로 slab freelist에 원자적 삽입 */
} while (!cmpxchg_double(&slab->freelist, &slab->counters,
prior, counters,
head, new_counters));
/* ② 상태 전이 판단 */
if (unlikely(!was_frozen && !prior)) {
/* full → partial 전이: 노드 partial 리스트에 추가 */
add_partial(n, slab, DEACTIVATE_TO_TAIL);
}
if (unlikely(!was_frozen && slab->inuse == 0)) {
/* ③ 모든 객체 해제 → slab 반환 판단 */
if (n->nr_partial > s->min_partial) {
remove_partial(n, slab);
discard_slab(s, slab); /* __free_pages() */
}
}
}
코드 설명
- 10~17행CAS 루프로 slab의 freelist에 해제 객체를 삽입합니다. frozen slab이면 다른 CPU도 이 slab에 해제할 수 있으므로 cmpxchg가 필요합니다.
- 20~23행
was_frozen=0(unfrozen)이고prior=NULL(이전에 freelist가 비어있었음)이면, full 상태에서 partial로 전이된 것입니다. 노드 partial 리스트에 추가합니다. - 25~30행모든 객체가 해제되고(
inuse==0) 노드의 partial 수가min_partial을 초과하면, 이 slab을discard_slab()으로 Buddy Allocator에 반환합니다.
| 해제 경로 | 조건 | 동작 | 비용 |
|---|---|---|---|
| Fast path | 현재 CPU의 활성 slab | per-CPU freelist에 삽입 (cmpxchg) | ~8 ns |
| Slow: frozen slab | 다른 CPU의 frozen slab | slab freelist에 CAS 삽입 | ~30 ns |
| Slow: full → partial | unfrozen + freelist 비었음 | 노드 partial 리스트 추가 (list_lock) | ~100 ns |
| Slow: discard | unfrozen + inuse==0 + nr_partial 초과 | Buddy 반환 (__free_pages) | ~500 ns |
kmem_cache_create() 내부 동작
kmem_cache_create()는 슬랩 캐시를 생성하는 API입니다. 내부적으로 __kmem_cache_create()를 호출하여 객체 레이아웃을 결정하고, calculate_sizes()에서 정렬, red zone, KASAN 메타데이터 등을 고려한 최종 크기를 계산합니다.
생성 호출 체인
/* mm/slab_common.c — kmem_cache_create() 단순화 */
struct kmem_cache *kmem_cache_create(
const char *name, unsigned int size,
unsigned int align, slab_flags_t flags,
void (*ctor)(void *))
{
struct kmem_cache *s;
/* ① 캐시 병합 시도 */
s = find_mergeable(size, align, flags, name, ctor);
if (s) {
s->refcount++;
return s; /* 기존 캐시에 병합 */
}
/* ② 새 kmem_cache 구조체 할당 */
s = kmem_cache_zalloc(kmem_cache, GFP_KERNEL);
s->name = name;
s->object_size = size;
s->align = align;
s->ctor = ctor;
/* ③ SLUB 초기화: 크기 계산, 노드 초기화 */
__kmem_cache_create(s, flags);
/* ④ 글로벌 캐시 리스트에 등록 */
list_add(&s->list, &slab_caches);
return s;
}
코드 설명
- 10~14행
find_mergeable()로 크기와 플래그가 호환되는 기존 캐시를 찾습니다. 병합되면 새 캐시를 만들지 않고 기존 캐시의 refcount만 증가시킵니다. - 17행
kmem_cache자체도 슬랩에서 할당됩니다(부트스트랩 문제는kmem_cache_init()에서 정적 초기화로 해결). - 25행
__kmem_cache_create()가 SLUB 특화 초기화를 수행합니다:calculate_sizes()로 레이아웃 결정, per-CPU slab 할당, 노드별kmem_cache_node초기화.
calculate_sizes(): 객체 레이아웃 결정
calculate_sizes()는 디버그 메타데이터, 정렬 요구사항, KASAN 메타데이터를 모두 고려하여 최종 객체 크기를 결정합니다:
/* mm/slub.c — calculate_sizes() 핵심 로직 (단순화) */
static int calculate_sizes(struct kmem_cache *s)
{
unsigned int size = s->object_size;
slab_flags_t flags = s->flags;
/* ① Red zone (좌측): 오버플로 탐지 패딩 */
if (flags & SLAB_RED_ZONE) {
s->red_left_pad = sizeof(unsigned long);
size += 2 * sizeof(unsigned long); /* 좌+우 */
}
/* ② freelist 포인터 위치 결정 */
if ((flags & SLAB_POISON) &&
!(flags & SLAB_TYPESAFE_BY_RCU)) {
/* poison: freelist를 객체 외부에 배치 */
s->offset = size;
size += sizeof(void *);
} else {
/* 기본: freelist를 객체 내부(offset 0)에 배치 */
s->offset = 0;
}
s->inuse = size;
/* ③ 정렬 적용 */
if (flags & SLAB_HWCACHE_ALIGN)
size = ALIGN(size, cache_line_size());
else
size = ALIGN(size, s->align);
s->size = size;
/* ④ order와 페이지당 객체 수 결정 */
s->oo = oo_make(calculate_order(size), size);
s->min = oo_make(get_order(size), size);
return !!oo_objects(s->oo);
}
코드 설명
- 8~11행
SLAB_RED_ZONE: 객체 양쪽에unsigned long크기의 red zone을 추가합니다. 할당 시 매직 넘버를 채우고, 해제 시 검증하여 경계 침범을 감지합니다. - 14~22행
SLAB_POISON활성화 시 freelist 포인터를 객체 외부에 배치하여, 해제된 객체 전체를 poison 패턴(0x6b)으로 채울 수 있게 합니다. 기본적으로는 객체 시작 위치(offset 0)에 배치합니다. - 28~31행
SLAB_HWCACHE_ALIGN이면 CPU 캐시라인(보통 64B)에 정렬합니다. 이는 false sharing을 방지하지만 객체 간 빈 공간(내부 단편화)이 발생합니다. - 36~37행
calculate_order()는 내부 단편화를 최소화하면서 적절한 Buddy order를 선택합니다.oo는 최적 order,min은 메모리 부족 시 fallback order입니다.
디버그 모드 객체 레이아웃
| 영역 | 크기 | 조건 | 내용 |
|---|---|---|---|
| Red zone (좌) | 8B | SLAB_RED_ZONE | 매직 넘버 0xbb |
| 객체 본문 | object_size | 항상 | 사용자 데이터 / poison(해제 시 0x6b) |
| Red zone (우) | 8B | SLAB_RED_ZONE | 매직 넘버 0xbb |
| Freelist 포인터 | 8B | SLAB_POISON | 다음 프리 객체 주소 (HARDENED 시 XOR 인코딩) |
| 패딩 | 가변 | 정렬 필요 시 | ALIGN(size, align) 맞춤 |
| KASAN shadow | 가변 | CONFIG_KASAN | 접근 추적 메타데이터 |
디버그 오버헤드: SLAB_RED_ZONE + SLAB_POISON을 모두 활성화하면 객체당 최소 24바이트가 추가됩니다(좌 red zone 8B + 우 red zone 8B + 외부 freelist 포인터 8B). 192바이트 dentry의 경우 size가 256바이트로 증가하여 33%의 공간 오버헤드가 발생합니다. 프로덕션에서는 KFENCE의 샘플링 방식이 권장됩니다.
SLUB 디버깅 내부 구현
SLUB 디버그 시스템은 check_object(), check_slab() 등의 검증 함수와 freelist 포인터 암호화(freelist_ptr())로 구성됩니다. slub_debug=FZP 커널 파라미터로 런타임에 활성화할 수 있습니다.
check_object(): 객체 무결성 검증
/* mm/slub.c — check_object() 단순화 */
static int check_object(struct kmem_cache *s,
struct slab *slab,
void *object, u8 val)
{
u8 *p = object;
/* ① Red zone 검증 (좌측) */
if (s->flags & SLAB_RED_ZONE) {
if (!check_bytes_and_report(s, slab, object,
"Left Redzone",
object - s->red_left_pad,
SLUB_RED_INACTIVE, s->red_left_pad))
return 0;
/* 우측 Red zone */
if (!check_bytes_and_report(s, slab, object,
"Right Redzone",
p + s->object_size,
SLUB_RED_INACTIVE,
s->inuse - s->object_size))
return 0;
}
/* ② Poison 패턴 검증 (해제된 객체) */
if (s->flags & SLAB_POISON) {
if (val == SLUB_RED_INACTIVE) {
/* 해제 상태: 0x6b 패턴이어야 함 */
if (!check_bytes_and_report(s, slab, object,
"Poison", p,
POISON_FREE, s->object_size - 1))
return 0;
/* 마지막 바이트: 0xa5 (엔드 마커) */
if (p[s->object_size - 1] != POISON_END)
return 0;
}
}
return 1; /* 검증 통과 */
}
코드 설명
- 9~23행Red zone 검증: 객체 양쪽에 배치된 red zone이
SLUB_RED_INACTIVE(0xbb) 패턴으로 유지되는지 확인합니다. 경계 침범(buffer overflow/underflow)이 발생하면 이 패턴이 깨져 감지됩니다. - 26~36행Poison 검증: 해제된 객체의 본문이
POISON_FREE(0x6b) 패턴으로 유지되는지 확인합니다. use-after-free로 데이터가 변경되면 패턴이 깨져 감지됩니다. 마지막 바이트는POISON_END(0xa5) 엔드 마커입니다.
freelist 포인터 암호화
CONFIG_SLAB_FREELIST_HARDENED 활성화 시, freelist 포인터는 XOR 기반으로 암호화되어 저장됩니다. 이는 힙 오버플로 공격으로 freelist 포인터를 위조하는 것을 방지합니다:
/* mm/slub.c — freelist_ptr() 암호화/복호화 */
static inline void *freelist_ptr(
const struct kmem_cache *s,
void *ptr, unsigned long ptr_addr)
{
#ifdef CONFIG_SLAB_FREELIST_HARDENED
/*
* 인코딩: encoded = ptr XOR s->random XOR ptr_addr
* 디코딩: ptr = encoded XOR s->random XOR ptr_addr
*
* s->random: 캐시 생성 시 get_random_long()으로 생성
* ptr_addr: 포인터가 저장되는 메모리 주소
*
* 공격자가 알아야 하는 것:
* 1. s->random (커널 메모리 — 직접 읽기 불가)
* 2. ptr_addr (ASLR/KASLR로 예측 어려움)
*
* 둘 다 모르면 위조 포인터 생성이 사실상 불가능
*/
return (void *)((unsigned long)ptr ^
s->random ^
swab(ptr_addr));
#else
return ptr;
#endif
}
/* 유효성 검증: 디코딩된 포인터가 slab 범위 내인지 확인 */
static inline bool freelist_corrupted(
const struct kmem_cache *s,
struct slab *slab,
void **freelist, void *nextfree)
{
#ifdef CONFIG_SLAB_FREELIST_HARDENED
if (unlikely(nextfree &&
!check_valid_pointer(s, slab, nextfree))) {
/* freelist 포인터가 slab 범위를 벗어남 → 손상 */
slab_err(s, slab,
"Freechain corrupt");
*freelist = NULL;
slab_fix(s, "Isolate corrupted freechain");
return true;
}
#endif
return false;
}
코드 설명
- 8~22행XOR 인코딩은 대칭적이므로 같은 연산으로 인코딩과 디코딩이 모두 가능합니다.
swab()으로 ptr_addr의 바이트를 뒤집어 XOR 패턴의 예측 난이도를 높입니다. - 28~44행
freelist_corrupted()는 디코딩된 포인터가 slab 페이지 범위 내의 유효한 객체 주소인지 검증합니다. 범위를 벗어나면 freelist가 손상된 것으로 판단하고 해당 slab의 freelist를 NULL로 격리합니다.
slub_debug 부팅 파라미터 상세
| 플래그 | 기능 | 검출 대상 | 성능 영향 |
|---|---|---|---|
F | Sanity checks (consistency) | freelist 손상, double-free | 낮음 |
Z | Red zoning | buffer overflow/underflow | 중간 (객체당 16B 추가) |
P | Poisoning | use-after-free | 중간 (memset 추가) |
U | User tracking | 할당/해제 호출 추적 | 높음 (스택 저장) |
T | Trace | 할당/해제 이벤트 로깅 | 높음 |
# 전체 슬랩에 FZP 디버그 활성화
# 커널 부팅 파라미터:
slub_debug=FZP
# 특정 캐시만 디버그 (dentry와 inode_cache만)
slub_debug=FZP,dentry,inode_cache
# 런타임 확인: 디버그 플래그 상태
cat /sys/kernel/slab/dentry/sanity_checks # 1 = F 활성
cat /sys/kernel/slab/dentry/red_zone # 1 = Z 활성
cat /sys/kernel/slab/dentry/poison # 1 = P 활성
cat /sys/kernel/slab/dentry/store_user # 1 = U 활성
# 오류 감지 시 커널 로그 예시:
# =============================================================================
# BUG kmalloc-256 (Not tainted): Redzone overwritten
# -----------------------------------------------------------------------------
# Redzone 00000000deadbeef: bb bb bb bb bb bb bb 42 .......B
# Object ffff88800abcdef0: 68 65 6c 6c 6f 00 6b 6b hello.kk
# Padding ffff88800abce000: 5a 5a 5a 5a 5a 5a 5a 5a ZZZZZZZZ
slub_debug=FZP로 부팅하여 어떤 캐시에서 문제가 발생하는지 확인합니다. 손상 캐시를 특정하면 slub_debug=FZPU,<cache_name>으로 해당 캐시만 상세 추적(U 플래그)하여 할당/해제 호출 스택을 확인합니다. 프로덕션에서는 KFENCE(kfence.sample_interval=100)로 성능 영향 없이 장기 모니터링하는 방식을 권장합니다.
흔한 실수와 주의사항
슬랩 할당자를 사용할 때 자주 발생하는 실수와 그 해결 방법을 정리합니다. 커널 모듈 개발자와 디버거가 반드시 알아야 할 핵심 사항입니다:
실수 1: 생성자(ctor)의 호출 시점 오해
/* ❌ 잘못된 예: 생성자에서 매 할당마다 초기화해야 할 필드를 설정 */
static void bad_ctor(void *obj)
{
struct my_conn *c = obj;
c->state = CONN_IDLE; /* 문제: 재사용 시 이전 값이 남아있음! */
c->refcount = 1; /* 문제: 생성자는 slab 초기화 시만 호출 */
c->timestamp = jiffies; /* 문제: slab 초기화 시점의 jiffies 값 */
INIT_LIST_HEAD(&c->list); /* ✓ 올바름: 재사용 시에도 유효 */
}
/* ✅ 올바른 예: 생성자는 불변 필드만, 가변 필드는 할당 후 직접 초기화 */
static void good_ctor(void *obj)
{
struct my_conn *c = obj;
INIT_LIST_HEAD(&c->list); /* 리스트 헤드: 재사용 시 항상 유효 */
spin_lock_init(&c->lock); /* spinlock: 재사용 시 항상 유효 */
}
struct my_conn *conn_alloc(void)
{
struct my_conn *c = kmem_cache_alloc(conn_cache, GFP_KERNEL);
if (!c) return NULL;
c->state = CONN_IDLE; /* 매 할당마다 직접 초기화 */
c->refcount = 1;
c->timestamp = jiffies;
return c;
}
실수 2: 잘못된 컨텍스트에서 GFP_KERNEL 사용
/* ❌ 잘못된 예: 인터럽트 핸들러 또는 spinlock 보유 중에 GFP_KERNEL 사용
* GFP_KERNEL은 sleep 가능 → 인터럽트/spinlock 컨텍스트에서 데드락 발생! */
irqreturn_t my_irq_handler(int irq, void *dev)
{
struct event *e = kmalloc(sizeof(*e), GFP_KERNEL); /* ❌ BUG! */
/* ... */
}
/* ✅ 올바른 예: 인터럽트 컨텍스트에서는 GFP_ATOMIC 사용 */
irqreturn_t my_irq_handler(int irq, void *dev)
{
struct event *e = kmalloc(sizeof(*e), GFP_ATOMIC); /* ✓ 올바름 */
if (!e)
return IRQ_HANDLED; /* GFP_ATOMIC은 실패 가능 — 반드시 검사! */
/* ... */
}
실수 3: kmem_cache_destroy() 누락 또는 활성 객체 잔존
/* ❌ 잘못된 예: 모듈 언로드 시 캐시 해제 누락 → 메모리 누수 */
static void __exit bad_exit(void)
{
/* my_cache가 해제되지 않음 — /proc/slabinfo에 영구 잔존 */
}
/* ❌ 잘못된 예: 활성 객체가 남아있는데 캐시 삭제 시도 → BUG() */
static void __exit bad_exit2(void)
{
/* obj가 아직 해제되지 않은 상태 */
kmem_cache_destroy(my_cache); /* ❌ BUG()! 활성 객체 잔존 */
}
/* ✅ 올바른 예: 모든 객체 해제 → 캐시 삭제 */
static void __exit good_exit(void)
{
struct my_obj *obj, *tmp;
/* 1. 모든 활성 객체를 순회하며 해제 */
list_for_each_entry_safe(obj, tmp, &obj_list, node) {
list_del(&obj->node);
kmem_cache_free(my_cache, obj);
}
/* 2. 캐시 삭제 (활성 객체 없음 확인 후) */
kmem_cache_destroy(my_cache); /* ✓ 안전 */
}
실수 4: Use-After-Free와 Double-Free
/* ❌ Use-After-Free: 해제 후 접근 — freelist 포인터가 덮어씌워진 상태 */
kmem_cache_free(cache, obj);
pr_info("name: %s\n", obj->name); /* ❌ BUG! obj 내부가 freelist 포인터로 변경됨 */
/* ❌ Double-Free: 같은 객체를 두 번 해제 — freelist 체인 손상 */
kmem_cache_free(cache, obj);
kmem_cache_free(cache, obj); /* ❌ BUG! freelist에 순환이 발생하여 같은 메모리를 두 번 할당 */
/* ✅ 올바른 패턴: 해제 후 포인터를 NULL로 설정 */
kmem_cache_free(cache, obj);
obj = NULL; /* 이후 접근 시도 시 NULL dereference로 즉시 감지 */
실수 요약 체크리스트
| 실수 | 증상 | 감지 방법 | 예방 |
|---|---|---|---|
| 생성자에서 가변 필드 초기화 | 재사용 시 이전 데이터가 남음 | SLAB_POISON으로 0x6b 패턴 확인 | 생성자는 불변 필드만, 가변 필드는 할당 후 초기화 |
| 인터럽트에서 GFP_KERNEL | soft lockup, 데드락 | might_sleep() 경고 메시지 |
인터럽트/spinlock에서 GFP_ATOMIC 사용 |
| 캐시 해제 누락 | /proc/slabinfo에 잔존, 메모리 누수 | slabtop으로 비정상 캐시 확인 |
모듈 exit에서 반드시 kmem_cache_destroy() |
| 활성 객체 남은 채 캐시 삭제 | BUG() 커널 패닉 |
커널 로그 "Objects remaining in ..." | 모든 객체 kmem_cache_free() 후 삭제 |
| Use-After-Free | 손상된 데이터 읽기, freelist 파괴 | KASAN, SLAB_POISON, KFENCE | 해제 후 포인터 NULL 설정, RCU 사용 |
| Double-Free | freelist 순환, 같은 메모리 이중 할당 | SLUB_DEBUG=F (freelist 검증) | 해제 후 포인터 NULL 설정 |
| kfree 반환값 미검사 | NULL 포인터 역참조 | 커널 OOPS | kmalloc()/kmem_cache_alloc() 반환값 항상 검사 |
디버깅 권장 설정: 개발 환경에서는 CONFIG_SLUB_DEBUG=y + slub_debug=FZPU 커널 파라미터로 freelist 검증(F), red zone(Z), poison(P), 호출 추적(U)을 모두 활성화하세요. 프로덕션에서는 CONFIG_KFENCE=y로 성능 영향 없이 샘플링 기반 감지를 운용하는 것이 권장됩니다.
참고자료
커널 문서
- Slab Allocation — Slab 할당자 개요 문서입니다
- SLUB Design — SLUB 할당자 디자인 문서입니다
- Memory Allocation Guide — 커널 메모리 할당 API 가이드입니다
LWN 기사
- The SLUB allocator — SLUB 할당자의 설계 철학과 구현을 설명합니다 (2007)
- The SLOB allocator — 임베디드 환경을 위한 SLOB 할당자를 다룹니다 (2005)
- Removing SLAB — 기존 SLAB 할당자 제거 논의입니다 (2021)
- Slab allocators in the kernel — 커널 Slab 할당자 비교 분석입니다 (2013)
커널 소스 코드
- mm/slub.c — SLUB 할당자 핵심 구현입니다
- include/linux/slab.h — Slab API 헤더 파일입니다
- mm/slab_common.c — Slab 할당자 공통 코드입니다
참고 논문
- Jeff Bonwick, The Slab Allocator: An Object-Caching Kernel Memory Allocator — Slab 할당자의 원 설계를 제안한 USENIX 논문입니다 (1994)