슬랩 할당자 (SLUB)

Linux 커널의 Slab 할당자는 자주 사용되는 커널 객체(task_struct, inode 등)를 효율적으로 관리하는 캐싱 메커니즘입니다. SLUB, SLOB 구현과 kmem_cache API, Per-CPU 최적화, 성능 튜닝까지 종합적으로 다룹니다.

일상 비유: Slab 할당자는 식당의 미리 준비된 접시 스택과 비슷합니다. 손님이 올 때마다 창고에서 접시를 꺼내는(malloc) 대신, 자주 쓰는 크기의 접시를 미리 깨끗이 씻어 쌓아두면(slab cache) 훨씬 빠릅니다.

핵심 요약

  • 객체 캐싱 — 자주 생성/삭제되는 커널 객체를 미리 할당해 둡니다.
  • 내부 단편화(Fragmentation) 감소 — 동일 크기 객체만 관리하여 메모리 낭비를 줄입니다.
  • Per-CPU 캐시(Cache) — 각 CPU가 독립적인 프리리스트를 가져 lock contention을 제거합니다.
  • SLUB vs SLOB — SLUB은 일반 시스템용 기본 구현, SLOB은 임베디드용 경량 구현입니다.
  • kmalloc 백엔드 — kmalloc()은 내부적으로 Slab 할당자를 사용합니다.

단계별 이해

  1. 핵심 요소 확인
    이 문서에서 다루는 자료구조/API를 먼저 정리합니다.
  2. 처리 흐름 추적
    요청 시작부터 완료까지 실행 경로를 순서대로 확인합니다.
  3. 문제 지점 점검
    실패 경로, 경합(Contention) 구간, 성능 병목(Bottleneck)을 체크합니다.

개요 (Overview)

Slab 할당자는 Buddy Allocator 위에 구축된 2차 할당자입니다:

왜 Slab 할당자가 필요한가?

Buddy Allocator는 물리 메모리를 2의 거듭제곱 크기(4KB, 8KB, 16KB…)로만 할당합니다. 커널 객체는 대부분 이보다 훨씬 작으므로, Buddy Allocator만으로는 심각한 내부 단편화(Internal Fragmentation)가 발생합니다:

커널 객체실제 크기Buddy 할당낭비율
struct dentry~192B4,096B (1 page)95.3%
struct inode~600B4,096B (1 page)85.4%
struct task_struct~6KB8,192B (2 pages)26.8%
struct file~256B4,096B (1 page)93.8%

Slab 할당자는 이 문제를 해결합니다. Buddy에서 받은 페이지를 같은 크기의 객체 슬롯으로 분할하여, 하나의 4KB 페이지에 dentry 21개(192B × 21 = 4,032B, 낭비 1.6%)를 배치할 수 있습니다. 추가로 객체 캐싱(Object Caching)을 통해 생성자/소멸자 호출 비용을 줄이고, Per-CPU 프리리스트로 락 경합을 최소화합니다.

Buddy Allocator 직접 사용 vs Slab Allocator 사용 비교 Buddy Allocator만 사용 (비효율적) dentry(192B) 4개 할당 요청 192B 사용 3904B 낭비 4KB 페이지 192B 사용 3904B 낭비 4KB 페이지 192B 사용 3904B 낭비 4KB 페이지 192B 낭비 4KB 4 페이지 × 4096B = 16,384B 소비 실제 사용: 768B (4.7%) / 낭비: 15,616B (95.3%) Slab Allocator 사용 (효율적) dentry(192B) 4개 할당 요청 — 1 페이지에 21개 수용 4KB 페이지 1개 192B 사용① 192B 사용② 192B 사용③ 192B 사용④ 프리⑤ 프리⑥ 프리⑦ ···⑧~㉑ 잔여: 4096 − 192×21 = 64B (1.6%) 추가 할당 시 같은 페이지의 프리 슬롯 사용 → 새 페이지 할당 없이 17개 더 할당 가능 1 페이지 × 4096B = 4,096B 소비 실제 사용: 768B (18.8%) / 활용 가능: 4,032B (98.4%) 메모리 절약: 16,384B → 4,096B (75% 절감)
그림. Buddy Allocator 직접 사용 vs Slab Allocator: 192B dentry 4개 할당 시 Buddy만 사용하면 4페이지(16KB)가 필요하지만, Slab은 1페이지(4KB)에 21개를 수용합니다.
ℹ️

핵심 이점 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, GFP_KERNEL) — 내부 동작 추적 1 크기 클래스 결정: 100B → kmalloc-128 캐시 선택 kmalloc()은 요청 크기(100B)를 다음 2^n 경계(128B)로 올림합니다. 96B 캐시도 존재하지만 100 > 96이므로 128B가 선택됩니다. 내부: kmalloc_caches[7] → "kmalloc-128" (2^7 = 128), 28B 내부 단편화 발생 (100/128 = 78% 활용) 2 Fast Path: Per-CPU freelist 확인 (lock-free, ~10 ns) 현재 CPU의 kmem_cache_cpu.freelist를 읽습니다. freelist가 비어있지 않으면 첫 번째 객체를 꺼냅니다. freelist: [obj_A] → [obj_B] → [obj_C] → NULL ⟹ obj_A를 반환, freelist = [obj_B] → [obj_C] → NULL 3 원자적 갱신: cmpxchg_double(freelist, tid) freelist=obj_A, tid=42를 freelist=obj_B, tid=43으로 원자적으로 교체합니다. 성공(95%+): obj_A 주소를 반환 → 호출자가 128B 영역을 사용합니다. 실패: goto redo (CPU 마이그레이션 발생) 4 결과: void *ptr = 0xffff8880_0abc_1200 (128B 슬롯 내의 주소) 호출자는 이 포인터로부터 100B까지 사용 가능합니다. 나머지 28B는 정렬 패딩으로 존재하지만 접근하면 안 됩니다. kfree(ptr) 호출 시 이 객체는 다시 Per-CPU freelist의 head에 삽입됩니다 (LIFO — CPU 캐시 재활용). 반환된 128B 슬롯의 메모리 레이아웃 128B 슬롯 (kmalloc-128) 호출자 사용 가능 영역: 100B 패딩 28B (접근 금지) ↑ kfree() 후: offset=0 위치(슬롯 시작)에 8B freelist 포인터가 저장됩니다 사용 가능 (100B) 내부 단편화 (28B) 전체 과정: ~10 ns (spinlock 없음, 인터럽트 비활성화 없음)
그림. kmalloc(100, GFP_KERNEL) 내부 동작: 크기 클래스 결정 → Per-CPU freelist에서 lock-free 할당 → cmpxchg 원자적 갱신 → 128B 슬롯 반환
/* 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은 최소 메모리 시스템을 위해 단일 프리리스트로 동작합니다:

세 가지 Slab 할당자 아키텍처 비교 SLAB (레거시, v6.5 제거) Per-CPU Array Cache 배열 기반, batchcount 단위 Shared Array Cache CPU 간 공유, spinlock 보호 3-리스트 관리 (per-node) slabs_full / partial / free Off-slab 메타데이터 별도 관리 구조체(kmem_bufctl_t) 메타데이터: 큼 (수 KB/캐시) 코드 복잡도: ~5,600줄 장점: 배치 처리 성능 단점: 복잡성, 디버깅 어려움 SLUB (기본, v2.6.22~) Per-CPU freelist lock-free cmpxchg, 직접 포인터 Per-CPU partial list 노드 락 없이 refill 노드 partial list spinlock 보호, full 추적 안 함 In-slab 메타데이터 struct page/slab + freelist 포인터 메타데이터: 최소 (page struct 활용) 코드 복잡도: ~3,200줄 장점: 단순, 빠름, 디버그 용이 단점: 고코어에서 경합 (→ Sheaves) SLOB (임베디드, v6.4 제거) 단일 글로벌 freelist Per-CPU 캐시 없음 3개 크기별 리스트 small / medium / large First-fit 할당 페이지 내 가변 크기 객체 관리 최소 메타데이터 2-word 헤더만 (size + next) 메타데이터: 극소 (~4KB 전체) 코드 복잡도: ~600줄 장점: 최소 메모리 오버헤드 단점: 느림, 외부 단편화 대체
그림. SLAB / SLUB / SLOB 아키텍처 비교: SLUB은 SLAB의 복잡한 다단계 큐를 단순화하고, SLOB은 최소 메모리를 위해 단일 freelist를 사용합니다.

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 노드 캐시의 두 계층으로 락 경합을 최소화하고 확장성을 확보합니다:

물리 메모리 (Physical RAM) 4KB 페이지 단위 — DIMM에 직접 매핑 Buddy Allocator (mm/page_alloc.c) 2^n 페이지 블록 관리 — alloc_pages() / __get_free_pages() Slab 할당자 / SLUB (mm/slub.c) kmem_cache — 페이지를 동일 크기 객체 슬랩으로 분할 관리 Per-CPU 캐시 (kmem_cache_cpu) freelist → [obj] → [obj] → NULL Lock-free 빠른 할당 (fast path) CPU당 1개 — lock contention 없음 NUMA 노드 캐시 (kmem_cache_node) partial 슬랩 목록 (spinlock 보호) Per-CPU miss 시 refill 소스 NUMA 노드당 1개 활성 슬랩 페이지 [obj₀][obj₁][obj₂]…[objₙ] — 동일 크기 객체 Partial 슬랩 페이지 [obj][free][obj]…[free] — 부분 사용/회수 대기
그림 1. 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);
}

할당 경로 흐름도

kmem_cache_alloc() Per-CPU freelist 비어있지 않음? YES fast path ~10 ns NO 현재 슬랩 freelist 사용 가능? YES slow path ~50 ns NO NUMA 노드 partial 슬랩 있음? YES refill + 반환 NO Buddy에서 새 페이지 할당? YES 새 슬랩 생성 + 반환 NO NULL 반환 (OOM / GFP 정책)
그림 2. SLUB 할당 경로 흐름도: fast path(~10 ns) → slow path → partial 목록 → 새 페이지 순으로 탐색합니다.

슬랩 레이아웃 (Slab Layout)

하나의 슬랩 페이지(또는 compound page) 안에서 객체들은 고정 크기로 순서대로 배치됩니다. 해제된 객체는 offset 위치에 다음 프리 객체 주소를 저장하여 프리리스트 체인을 형성합니다:

페이지 (4096B) — N = PAGE_SIZE / object_size = 4096 / 32 = 128개 obj[0] (할당됨) obj[1] (프리) obj[2] (할당됨) · · · (할당됨) obj[N] (프리) NULL 할당됨 프리 freelist 체인 obj[1].offset → obj[N] → NULL freelist 체인: 해제된 객체끼리 offset 필드로 단방향 연결
그림 3. 슬랩 페이지 레이아웃과 Freelist 체인: 32B 객체 128개가 배치되고, 프리 객체끼리 offset으로 연결됩니다.

Freelist 조작 단계별 시각화

할당과 해제 시 freelist 포인터가 어떻게 변경되는지 단계별로 추적합니다. 4개 객체가 있는 작은 슬랩을 예시로 사용합니다:

Freelist 조작 과정: 할당 → 해제 → 재할당 초기 상태: 슬랩 초기화 직후 (모든 객체 프리) freelist → obj[0] obj[1] obj[2] obj[3] NULL 프리: 4개, 할당: 0개 ① kmem_cache_alloc() → obj[0] 반환 freelist → obj[0] 할당 (반환됨) obj[1] obj[2] obj[3] NULL 프리: 3개, 할당: 1개 ② kmem_cache_alloc() → obj[1] 반환 freelist → obj[0] 할당 obj[1] 할당 (반환됨) obj[2] obj[3] NULL 프리: 2개, 할당: 2개 ③ kmem_cache_free(obj[0]) → obj[0]이 freelist head로 삽입 (LIFO) freelist → obj[0] 프리 (head 삽입) obj[1] 할당 obj[2] obj[3] NULL 프리: 3개, 할당: 1개 ④ kmem_cache_alloc() → obj[0] 반환 (LIFO: 방금 해제한 객체 재사용) freelist → obj[0] 할당 (재사용!) obj[1] 할당 obj[2] obj[3] NULL 프리: 2개, 할당: 2개 핵심: LIFO 순서로 "뜨거운(hot)" 메모리를 재사용 ③에서 해제한 obj[0]이 ④에서 즉시 재할당됩니다. CPU L1/L2 캐시에 아직 남아있는 메모리를 재활용하여 캐시 히트율을 극대화합니다. 할당: freelist head 꺼냄, 해제: freelist head에 삽입 — 둘 다 포인터 1개 변경으로 O(1) 완료 Freelist 포인터의 물리적 저장 위치 할당된 객체: 사용자 데이터가 전체 슬롯을 채움 → freelist 포인터가 존재하지 않음 프리 객체: 슬롯의 offset 위치(보통 시작)에 8B freelist 포인터 저장 → 다음 프리 객체 주소를 가리킴 → 해제된 객체의 사용자 데이터 영역 일부가 freelist 포인터로 덮어씌워지므로, 해제 후 접근하면 손상된 데이터를 읽게 됩니다 (use-after-free)
그림. Freelist 조작 과정: 할당 시 head에서 꺼내고, 해제 시 head에 삽입하는 LIFO 방식으로 CPU 캐시 재활용을 극대화합니다.
/* 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로 페이지를 반환하고, 새 객체가 필요하면 새 슬랩 페이지를 요청합니다:

Buddy Allocator alloc_pages() / __free_pages() 새 페이지 ctor() 호출 슬랩 초기화 [free][free][free]···[free] freelist 완전 구성 freelist 연결 Per-CPU freelist obj₀ → obj₁ → obj₂ → NULL lock-free 빠른 접근 kmem_cache_alloc() 할당됨 (사용 중) 커널 코드가 객체 사용 freelist에서 제거된 상태 kmem_cache_free() freelist 복귀 슬랩 반환 모든 객체 해제 시 __free_pages() 호출 슬랩 완전 비어있음 할당 (fast path) 해제 / 슬랩 반환 초기화 / 페이지 획득
그림 3. 슬랩 객체 생명주기: Buddy Allocator에서 페이지를 받아 Per-CPU freelist로 관리하고, 모든 객체가 해제되면 페이지를 반환합니다.

cmpxchg_double Lock-free 메커니즘

SLUB fast path의 핵심은 this_cpu_cmpxchg_double()입니다. freelist 포인터와 Transaction ID(TID)를 원자적으로 동시에 비교·교체하여, 락 없이도 CPU 마이그레이션과 동시 접근을 안전하게 처리합니다:

cmpxchg_double: freelist + TID 원자적 갱신 성공 케이스: 같은 CPU에서 연속 실행 CPU 0 | freelist: obj_A → obj_B | tid: 42 ① freelist=obj_A, tid=42 읽기 ② cmpxchg(obj_A,42 → obj_B,43) CPU 0 | freelist: obj_B → NULL | tid: 43 ✓ 매칭 성공 → obj_A 반환 (~10 ns) 락 없이 완료, 인터럽트 비활성화 없음 실패 케이스: CPU 마이그레이션 감지 CPU 0 | freelist: obj_A → obj_B | tid: 42 ① freelist=obj_A, tid=42 읽기 ⚡ 스케줄러가 태스크를 CPU 1로 이동 CPU 1 | freelist: obj_X → obj_Y | tid: 99 ② cmpxchg(obj_A,42 → ...) — tid 불일치! ✗ 매칭 실패 → goto redo (재시도) Transaction ID (TID) 구조 tid = cpu_nr | (allocation_count << TID_STEP_SHIFT) 하위 비트: CPU 번호 → CPU 마이그레이션 감지 상위 비트: 할당 카운터 → 매 할당마다 증가하여 ABA 문제 방지 Cross-CPU 해제: CPU 0에서 할당, CPU 1에서 해제 CPU 0: kmem_cache_alloc() → obj_A (CPU 0의 frozen slab에서 할당) CPU 1: kmem_cache_free(obj_A) → slab ≠ CPU 1의 활성 slab → slow path (__slab_free) → obj_A는 원래 slab의 freelist에 CAS로 반환 (frozen이면 lock-free, unfrozen이면 상태 전이 가능)
그림. cmpxchg_double 메커니즘: TID로 CPU 마이그레이션을 감지하고, 실패 시 redo 루프로 재시도합니다.

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

TID가 필요한 이유 (ABA 문제):

  1. CPU 0: freelist=A 읽음
  2. CPU 0: 선점(preempt) → CPU 1로 이동
  3. 다른 태스크: A 해제, B 할당, A 재할당 → freelist=A 복원
  4. 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()로 페이지 반환 */
Buddy Allocator 페이지 풀 Active (Frozen) kmem_cache_cpu.page Lock-free 할당/해제 Partial (Unfrozen) kmem_cache_node.partial list_lock 보호 Full (추적 안 함) new_slab() deactivate refill 모든 obj 할당 obj 해제 inuse==0 상태 전이 트리거 할당 / 활성화 해제 / 비활성화 상태 전환 페이지 반환
그림 4. SLUB 슬랩 상태 전이: Active(frozen) → Partial(unfrozen) → Full 간 전환과 Buddy 반환 경로
ℹ️

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_RCUkfree_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단계 부트스트랩으로 해결합니다:

부팅 순서 1 정적 kmem_cache 선언 (컴파일 타임) boot_kmem_cache, boot_kmem_cache_node를 .bss에 정적 할당 → 아직 Slab이 없으므로 동적 할당 불가 — 정적 변수로 우회 2 kmem_cache_init() — 1차 부트스트랩 boot_kmem_cache로 "kmem_cache" 캐시 초기화 (자기 자신의 캐시) boot_kmem_cache_node로 "kmem_cache_node" 캐시 초기화 3 동적 kmem_cache로 교체 이제 Slab이 동작하므로 kmem_cache_alloc()으로 실제 구조체 할당 정적 boot_kmem_cache 내용을 동적 할당된 구조체로 복사 후 교체 4 create_kmalloc_caches() — kmalloc 캐시 생성 kmalloc-8, kmalloc-16, ..., kmalloc-8192 표준 캐시 생성 → 이후 kmalloc() 호출 가능, 일반 커널 초기화 진행 5 Slab 할당자 완전 가동 — 모든 kmem_cache_create() 사용 가능 파일시스템, 네트워크, 드라이버 등이 전용 슬랩 캐시 생성
그림. Slab 부트스트랩: 정적 구조체로 시작하여 자기 자신의 캐시를 초기화한 뒤, 동적 할당으로 전환합니다.
/* 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()을 고려하세요. 각 할당기의 상세 비교는 메모리 관리를 참고하세요.

워크로드별 할당자 선택 의사결정

커널 메모리 할당자 선택 의사결정 트리 커널 메모리 할당 필요 DMA 필요? Yes dma_alloc_coherent() No Per-CPU 전용? Yes alloc_percpu() No 크기 > 8KB? Yes 물리 연속? No vmalloc() Yes alloc_pages() No 고정 크기 + 빈번 할당/해제? Yes kmem_cache_alloc() No kmalloc() / kzalloc() 판단 분기 권장 API
그림. 커널 메모리 할당자 선택 의사결정 트리: DMA, Per-CPU, 크기, 할당 패턴에 따라 최적 API를 선택합니다.

워크로드별 커널 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;
}
Freelist 순서 비교: RANDOM 비활성 vs 활성 RANDOM 비활성: 예측 가능한 순차 배치 obj[0] obj[1] obj[2] obj[3] ··· → 주소 간격 일정 = 힙 스프레이 용이 RANDOM 활성: 무작위 순서 (Fisher-Yates 셔플) obj[5] obj[2] obj[7] obj[0] ··· → 주소 예측 불가 = 힙 스프레이 무력화 성능 영향: 슬랩 초기화 시 1회만 실행 (할당/해제 경로에 영향 없음)
그림. FREELIST_RANDOM: Fisher-Yates 셔플로 객체 순서를 무작위화하여 힙 스프레이 공격을 무력화합니다.

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=yCONFIG_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 할당자의 내부 단편화는 두 가지 수준에서 발생합니다: 객체 수준(요청 크기 → 캐시 크기 올림)과 슬랩 수준(페이지 크기에 객체를 채우고 남는 공간). 이 두 단편화를 이해하면 메모리 사용 효율을 정확히 평가할 수 있습니다:

두 가지 내부 단편화 ① 객체 수준 단편화 요청 200B → kmalloc-256 (256B) → 56B 낭비 사용: 200B (78%) 낭비: 56B 단편화율 = (256 − 200) / 256 = 22% ② 슬랩 수준 단편화 4096B 페이지 / 192B 객체 = 21개 (128B 잔여) 객체 21개: 4032B (98.4%) 잔여 = 4096 − (192 × 21) = 64B (1.6% 낭비) 실전 단편화 계산 예시: dentry 캐시 요청: struct dentry = 192B, 캐시: kmem_cache "dentry" (object_size=192, size=256) 객체 단편화: (256 − 192) / 256 = 25% (정렬 패딩 + freelist 포인터) 슬랩 단편화: 4096 / 256 = 16개 → 4096 − (256 × 16) = 0B (0%) 총 유효 활용률: 192 / 256 = 75% (나머지는 메타데이터·정렬·보안 패딩) → SLAB_HWCACHE_ALIGN 제거 시 size=200B로 줄일 수 있으나, false sharing 위험 증가
그림. 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_sizeobject_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)으로 나뉩니다. 캐시 단위 통계와 디버그 옵션을 함께 사용해 원인을 빠르게 좁히는 것이 핵심입니다.

  1. 증가 캐시 식별: /proc/slabinfo 상위 항목 비교
  2. 회수 가능성 판단: SReclaimable/SUnreclaim 비율 확인
  3. 오염 검사: slub_debug=FZPU로 redzone/poison 활성화
  4. 할당 경로 추적: 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 접근 빈도를 줄입니다.

구분기존 SLUBSLUB + Sheaves (v6.18+)
per-CPU 캐싱단일 프리리스트sheaf 배치 + 프리리스트
락 경합프리리스트 소진 시 node locksheaf 교체 시에만 lock (빈도 감소)
배치 처리1개씩 refill배치 단위 refill/drain
메모리 오버헤드낮음약간 증가 (sheaf 구조체(Struct))
효과기준고코어 시스템에서 할당/해제 처리량(Throughput) 향상
기존 SLUB vs Sheaves 적용 후 비교 기존 SLUB (v6.17 이전) Per-CPU freelist obj → obj → obj → NULL (단일 체인) 소진 시 Per-CPU partial list slab ↔ slab ↔ slab 소진 시 노드 partial list (list_lock!) spinlock 경합 — 고코어 병목 Buddy Allocator (alloc_pages) SLUB + Sheaves (v6.18+) Sheaf (배치 객체 캐시) ✦ NEW [obj][obj]...[obj] 배치 단위, lock-free 소진 시 Sheaf 풀 (배치 교체) ✦ NEW 가득 찬 sheaf ↔ 빈 sheaf 교체 풀 비어있을 때만 Per-CPU freelist + partial (기존 경로, 빈도 대폭 감소) 노드 partial → Buddy list_lock 접근 빈도 90%+ 감소 핵심: sheaf 교체는 배치 단위 → 노드 락 접근 횟수 = 1/N
그림. SLUB Sheaves: 배치 객체 캐시(sheaf) 레이어를 추가하여 노드 레벨 락 경합을 크게 줄입니다.
Sheaves의 의의: 4096 CPU 코어 지원(v6.14)과 함께 대규모 시스템에서의 slab 할당 성능 병목을 해결합니다. SLAB 할당자(2024년 제거)가 가지고 있던 배치 처리의 장점을 SLUB에 재도입한 형태입니다.

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-CPU kmem_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가 동작할 수 있습니다.
kmalloc() / kmem_cache_alloc() __kmalloc() → slab_alloc_node() FAST PATH (~10 ns, lock-free) per-CPU freelist 비어있지 않음? YES cmpxchg 반환 ~10 ns NO SLOW PATH: ___slab_alloc() (IRQ disabled) 현재 slab의 freelist 있음? YES freelist 갱신 ~50 ns NO per-CPU partial slab 있음? YES slab 교체 ~100 ns NO new_slab_objects() 노드 partial → allocate_slab() 노드 partial list_lock 필요 allocate_slab() → alloc_pages() Buddy Allocator ~1000+ ns 성공 → 객체 반환 점진적 비용 증가
그림. SLUB 할당 Fast/Slow path 결정 트리: per-CPU freelist(lock-free) → slab freelist → per-CPU partial → 노드 partial → Buddy Allocator 순으로 탐색합니다.
경로함수지연 시간발생 빈도
Fast pathslab_alloc_node()없음 (cmpxchg)~10 ns~95%
Slow 1___slab_alloc() slab freelistlocal IRQ disable~50 ns~3%
Slow 2per-CPU partiallocal IRQ disable~100 ns~1.5%
Slow 3new_slab_objects() 노드 partialnode list_lock~200 ns~0.4%
Slow 4allocate_slab() Buddyzone 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_partialpartial: partial slab의 수와 연결 리스트입니다. min_partial 이하로 내려가면 slab을 유지하여 반복 할당/해제 오버헤드를 방지합니다.
  • 9행full: 디버그 빌드에서만 full slab을 추적합니다. 프로덕션에서는 full slab을 추적하지 않아 오버헤드를 줄입니다.
struct kmem_cache name: "dentry" object_size: 192, size: 256 offset: 0, align: 64 flags: SLAB_RECLAIM_ACCOUNT oo: order=0, objs=16 cpu_slab → node[] → random: 0xa3f1...(HARDENED) __percpu per-node struct kmem_cache_cpu (CPU 0) freelist: obj → obj → NULL tid: 0x1a3f (transaction ID) slab: → [frozen slab page] partial: → [partial slab 1] → [partial slab 2] lock-free fast path 진입점 struct kmem_cache_node (Node 0) list_lock: spinlock_t nr_partial: 5 partial: [slab] ↔ [slab] ↔ ... nr_slabs: 42 (atomic) total_objects: 672 (atomic) spinlock 보호, slow path 소스 struct slab (frozen) frozen: 1 (CPU 독점) inuse: 12/16 freelist: → [4개 프리 객체] objects: 16 per slab struct slab (partial) frozen: 0 (공유 가능) inuse: 8/16 freelist: → [8개 프리 객체] lru: list_head (partial 연결)
그림. kmem_cache 계층 구조: 최상위 kmem_cache가 per-CPU 캐시(kmem_cache_cpu)와 NUMA 노드 캐시(kmem_cache_node)를 소유하고, 각각이 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_sizeslab_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의 활성 slabper-CPU freelist에 삽입 (cmpxchg)~8 ns
Slow: frozen slab다른 CPU의 frozen slabslab freelist에 CAS 삽입~30 ns
Slow: full → partialunfrozen + freelist 비었음노드 partial 리스트 추가 (list_lock)~100 ns
Slow: discardunfrozen + inuse==0 + nr_partial 초과Buddy 반환 (__free_pages)~500 ns
LIFO 해제의 성능 이점: fast path에서 해제된 객체는 per-CPU freelist의 head에 삽입되므로, 바로 다음 할당에서 같은 객체가 반환됩니다. 이는 CPU 캐시에 아직 남아있는 "뜨거운(hot)" 메모리를 재사용하여 캐시 히트율을 극대화합니다.

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 (좌)8BSLAB_RED_ZONE매직 넘버 0xbb
객체 본문object_size항상사용자 데이터 / poison(해제 시 0x6b)
Red zone (우)8BSLAB_RED_ZONE매직 넘버 0xbb
Freelist 포인터8BSLAB_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 부팅 파라미터 상세

플래그기능검출 대상성능 영향
FSanity checks (consistency)freelist 손상, double-free낮음
ZRed zoningbuffer overflow/underflow중간 (객체당 16B 추가)
PPoisoninguse-after-free중간 (memset 추가)
UUser tracking할당/해제 호출 추적높음 (스택 저장)
TTrace할당/해제 이벤트 로깅높음
# 전체 슬랩에 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로 성능 영향 없이 샘플링 기반 감지를 운용하는 것이 권장됩니다.

참고자료

커널 문서

LWN 기사

커널 소스 코드

참고 논문