Shrinker (메모리 회수 콜백)

Shrinker는 Linux 커널이 메모리 압박 상황에서 캐시를 회수하기 위해 사용하는 콜백 인터페이스입니다. 슬랩 캐시, dentry 캐시, 파일시스템 캐시, GPU 드라이버 등이 shrinker를 등록하면, kswapd·직접 회수·OOM 경로에서 메모리가 부족할 때 순서대로 회수를 요청합니다. Linux 6.7에서 shrinker_alloc()/shrinker_register()/shrinker_free() 새 API로 전환되었으며, NUMA-aware 및 memcg-aware 회수, nr_deferred 지연 메커니즘, dentry/inode/XFS/Btrfs/DRM 등 주요 서브시스템별 shrinker 구현 패턴, 안티패턴과 디버깅 기법까지 포괄적으로 다룹니다.

전제 조건: 메모리 관리 (기초)메모리 관리 (심화) 문서를 먼저 읽으세요. Shrinker는 커널 메모리 회수 경로의 일부이므로, 페이지 할당자와 kswapd의 동작 원리를 이해해야 합니다.
일상 비유: Shrinker는 냉장고 정리 대행 서비스와 비슷합니다. 냉장고(메모리)가 가득 차면 각 음식 보관자(캐시 소유자)에게 "줄일 수 있는 것이 몇 개냐?"고 묻고, 실제로 버려달라고 요청합니다. 가장 많이 버릴 수 있는 보관자부터 요청하여 필요한 공간을 확보합니다.

핵심 요약

  • struct shrinker — 회수 콜백 등록 구조체 (count + scan 2개 함수)
  • count_objects — "지금 회수 가능한 객체가 몇 개냐?" 질의 콜백
  • scan_objects — "N개 객체를 실제로 회수하라" 요청 콜백
  • shrink_slab — 커널이 등록된 모든 shrinker를 순회·호출하는 함수
  • SHRINKER_MEMCG_AWARE — memcg(메모리 cgroup)별 회수 지원 플래그

단계별 이해

  1. 메모리 압박 감지
    kswapd 또는 직접 페이지 할당 경로에서 free 페이지가 임계값 이하로 내려가면 회수 경로가 시작됩니다.
  2. shrink_slab 호출
    회수 경로가 shrink_slab()을 호출하면, 등록된 모든 shrinker의 count_objects()가 순서대로 질의됩니다.
  3. 회수 우선순위 계산
    각 shrinker의 응답값과 seeks 힌트를 기반으로 회수 비율을 계산합니다.
  4. scan_objects 호출
    계산된 수량만큼 scan_objects()가 호출됩니다. 실제 캐시 항목을 LRU에서 제거하고 메모리를 반환합니다.
  5. 회수 결과 집계
    반환된 페이지 수를 집계하여 목표 달성 여부를 판단하고, 부족하면 다음 회수 단계(OOM 킬러 등)로 진행합니다.

개요

Linux 커널은 성능을 위해 다양한 캐시를 유지합니다 (dentry, inode, 슬랩, 파일시스템별 캐시). 메모리 압박 시 이 캐시들을 회수해야 하는데, 각 서브시스템이 직접 회수 로직을 구현하면 중복이 발생합니다. Shrinker는 이를 표준화한 콜백 인터페이스입니다.

Shrinker의 역사

Shrinker 메커니즘은 Linux 초기부터 존재했지만, 그 형태는 크게 변화해 왔습니다.

커널 버전변경 사항커밋/패치
~2.6.xset_shrinker()/remove_shrinker() 단일 콜백 인터페이스초기 구현
3.0count/scan 2-콜백 분리, struct shrinker 도입Dave Chinner
3.12NUMA-aware shrinker, shrink_control.nid 추가Glauber Costa
4.0memcg-aware shrinker (SHRINKER_MEMCG_AWARE)Vladimir Davydov
5.2shrinker 디버깅 인터페이스 (/sys/kernel/debug/shrinker)Yang Shi
6.0lockless shrinker 리스트 순회 (RCU 기반)Kirill Tkhai
6.7shrinker_alloc()/shrinker_register()/shrinker_free() 새 APIQi Zheng
6.8+레거시 register_shrinker() 완전 제거Qi Zheng

Shrinker 호출 경로

호출 경로함수설명
kswapd (백그라운드)kswapd()balance_pgdat()워터마크 이하 시 주기적 회수
직접 회수__alloc_pages()try_to_free_pages()할당 실패 시 동기 회수
OOM 전 단계out_of_memory() 직전킬러 호출 전 마지막 시도
memcg 압박mem_cgroup_shrink_node()cgroup 메모리 한도 초과 시
수동 (sysctl)drop_caches 쓰기echo 3 > /proc/sys/vm/drop_caches
트리거 소스 회수 프레임워크 Shrinker 콜백 캐시 서브시스템 kswapd 직접 회수 memcg 압박 drop_caches shrink_node() shrink_slab() shrink_lruvec() (LRU) do_shrink_slab() (per-shrinker) count_objects() scan_objects() nr_deferred 갱신 dentry cache inode cache XFS buf cache DRM GEM conntrack
shrink_slab vs shrink_lruvec: 메모리 회수 시 커널은 두 경로를 병렬로 진행합니다. shrink_lruvec()는 LRU 리스트의 anonymous/file 페이지를 회수하고, shrink_slab()는 등록된 shrinker 콜백을 통해 캐시를 회수합니다. 둘의 비율은 vmscan_balance 로직에 의해 결정됩니다.

회수 비율 계산

슬랩 회수량은 LRU 페이지 회수량과 균형을 맞추도록 설계되어 있습니다. do_shrink_slab()에서의 비율 계산 공식은 다음과 같습니다:

/* mm/shrinker.c (단순화) */
/*
 * scan = (freeable / (lru_pages + 1)) * (nr_to_scan / seeks)
 *        + nr_deferred
 *
 * 여기서:
 *   freeable    = count_objects() 반환값
 *   lru_pages   = 해당 NUMA 노드의 전체 LRU 페이지 수
 *   nr_to_scan  = 상위 회수 레이어가 요청한 스캔 수
 *   seeks       = shrinker->seeks (재생성 비용 힌트)
 *   nr_deferred = 이전에 미회수된 누적량
 */
delta = (4 * sc->nr_to_scan) / shrinker->seeks;
delta *= freeable;
do_div(delta, lru_pages + 1);
total_scan = delta + nr_deferred;
seeks가 높을수록 회수 우선순위가 낮습니다: seeks 값이 크면 delta가 작아져서 해당 shrinker의 캐시가 덜 회수됩니다. NFS inode처럼 재생성 비용이 높은 캐시는 seeks = 100 이상으로 설정하여 보호합니다.

struct shrinker 구조

/* include/linux/shrinker.h (Linux 6.7+) */
struct shrinker {
    /*
     * count_objects: 회수 가능한 객체 수를 반환
     * sc->nr_to_scan == 0이면 dry-run (실제 회수 없이 카운트만)
     */
    unsigned long (*count_objects)(struct shrinker *s,
                                    struct shrink_control *sc);

    /*
     * scan_objects: 실제로 객체를 회수하고 회수한 수를 반환
     * SHRINK_STOP 반환 시 이 shrinker에 대한 회수 중단
     */
    unsigned long (*scan_objects)(struct shrinker *s,
                                   struct shrink_control *sc);

    long          batch;     /* 한 번에 회수할 최소 단위 (0이면 기본값 128) */
    int           seeks;     /* 객체 재생성 비용 힌트 (높을수록 회수 우선순위 낮음) */
    unsigned      flags;     /* SHRINKER_* 플래그 */

    /* 내부 사용 필드 */
    struct list_head  list;       /* 전역 shrinker_list에 연결 */
    int               id;         /* memcg-aware 시 shrinker_idr에서 할당 */
    refcount_t        refcount;   /* 동시 해제 보호 */
    struct completion  done;       /* 해제 완료 대기 */
    struct rcu_head    rcu;        /* RCU 보호 해제 */
    void             *private_data;  /* 드라이버 전용 데이터 */

    /* memcg aware인 경우: per-memcg per-node 카운터 배열 */
    atomic_long_t    *nr_deferred;
};

주요 필드 상세

필드타입설명설정 주체
count_objects함수 포인터회수 가능 객체 수 반환. SHRINK_EMPTY 반환 시 캐시 비어있음드라이버
scan_objects함수 포인터객체 회수 실행. 실제 해제한 수 반환. SHRINK_STOP 시 중단드라이버
batchlong최소 스캔 단위. 0이면 SHRINK_BATCH(128) 사용드라이버
seeksint재생성 비용. DEFAULT_SEEKS=2. 높을수록 보호됨드라이버
flagsunsigned동작 플래그 조합 (NUMA, MEMCG, NONSLAB)드라이버
nr_deferredatomic_long_t *per-node 지연 회수 카운터 배열커널 내부
listlist_head전역 shrinker_list에 연결커널 내부
private_datavoid *드라이버 전용 컨텍스트 저장드라이버

struct shrink_control

struct shrink_control {
    gfp_t               gfp_mask;    /* 할당 플래그 (GFP_*) — 어떤 메모리 풀에서 왔는지 */
    int                 nid;         /* 대상 NUMA 노드 (NUMA_NO_NODE이면 전체) */
    unsigned long       nr_to_scan;  /* 회수 요청 수 (0이면 count-only) */
    unsigned long       nr_scanned;  /* 실제 스캔한 수 (scan_objects가 설정) */
    struct mem_cgroup  *memcg;      /* 대상 memcg (NULL이면 전역) */
};
struct shrink_control gfp_mask: GFP_KERNEL | __GFP_FS nid: 0 (NUMA node) nr_to_scan: 128 nr_scanned: 0 (콜백이 설정) memcg: (cgroup 포인터 또는 NULL) count_objects(shrinker, sc) 반환: freeable 객체 수 scan_objects(shrinker, sc) 반환: 실제 해제한 객체 수 전달 전달
seeks 힌트: seeks는 회수한 객체를 다시 만들어내는 데 필요한 디스크 탐색 횟수 추정값입니다. DEFAULT_SEEKS(=2)가 기본이며, 재생성 비용이 높은 캐시(예: NFS inode)는 높은 값을 설정해 회수 우선순위를 낮춥니다.

nr_deferred: 지연 회수 메커니즘

회수 요청이 들어왔는데 count_objects()가 0을 반환하거나 scan_objects()가 충분히 회수하지 못하면, 미회수 수량이 nr_deferred에 누적됩니다. 다음 회수 시도에서 이 값이 더해져 더 많은 회수를 요청합니다.

/* shrink_slab 내부 nr_deferred 사용 패턴 (단순화) */
unsigned long do_shrink_slab(struct shrink_control *sc,
                               struct shrinker *shrinker,
                               int priority)
{
    unsigned long freeable, nr, total_scan;

    /* 1. 현재 회수 가능 수 질의 */
    freeable = count_objects(shrinker, sc);
    if (freeable == 0 || freeable == SHRINK_EMPTY)
        return freeable;

    /* 2. 지연 누적 + 새 요청 합산 */
    nr = atomic_long_xchg(&shrinker->nr_deferred[nid], 0);
    total_scan = nr + sc->nr_to_scan * freeable / (shrinker->seeks ?: 1);

    /* 3. 실제 회수 */
    sc->nr_to_scan = min(total_scan, (freeable + 1) * 2);
    nr = scan_objects(shrinker, sc);

    /* 4. 미회수분 다시 저장 */
    if (total_scan > sc->nr_to_scan)
        atomic_long_add(total_scan - sc->nr_to_scan,
                        &shrinker->nr_deferred[nid]);
    return nr;
}
nr_deferred 방지책: count_objects()가 항상 실제 캐시 크기를 정확히 반환해야 nr_deferred의 폭주를 막을 수 있습니다. 과소 보고하면 지연 카운터가 계속 누적되어 다음 번에 과도한 회수 요청이 발생합니다.
shrink_slab 호출 nr_to_scan = 100 scan_objects 실행 freed = 60 (40 미회수) nr_deferred += 40 누적: 40 다음 shrink_slab 호출 total = 100 + 40 = 140 scan_objects 실행 freed = 140, deferred = 0

shrink_control의 priority 필드

Linux 3.12부터 shrink_controlpriority 필드가 추가되었습니다. 값이 낮을수록 메모리 압박이 심각하며 더 공격적으로 회수해야 합니다.

priority 값의미권장 동작
DEF_PRIORITY (12)낮은 압박, 워터마크 근처최근 미사용 항목만 회수
6 ~ 11중간 압박LRU 하위 절반 회수
1 ~ 5높은 압박더 공격적 회수
0OOM 직전 최후 시도가능한 모든 항목 회수
/* priority를 활용한 스마트 회수 */
static unsigned long smart_scan_objects(struct shrinker *s,
                                          struct shrink_control *sc)
{
    unsigned long freed = 0;
    int nr = sc->nr_to_scan;

    spin_lock(&my_lock);

    if (sc->priority == 0) {
        /* OOM 직전: 모든 항목 회수 시도 */
        freed = drain_all_cache(&my_lru);
    } else if (sc->priority < 6) {
        /* 높은 압박: LRU 하위 절반 */
        freed = drain_lru_bottom_half(&my_lru, nr);
    } else {
        /* 낮은 압박: 최근 미사용 항목만 */
        freed = drain_lru_tail(&my_lru, nr);
    }

    spin_unlock(&my_lock);
    return freed;
}

count/scan 반환값 규약

반환값콜백의미
0count_objects현재 회수 가능 객체 없음. nr_deferred 누적 없음
SHRINK_EMPTYcount_objects캐시가 완전히 비어있음. memcg 해제 시 최적화 힌트
양수 Ncount_objectsN개의 객체가 회수 가능
SHRINK_STOPscan_objects회수 중단 (예: 잠금 획득 실패). 이 shrinker 건너뜀
양수 Nscan_objectsN개의 객체를 실제로 해제함
0scan_objects요청은 받았으나 실제 해제한 객체 없음

Shrinker 플래그

플래그의미요구 사항
SHRINKER_MEMCG_AWAREBIT(1)memcg별 회수 지원sc->memcg를 참조하여 per-cgroup 회수 구현
SHRINKER_NUMA_AWAREBIT(0)NUMA 노드별 회수 지원sc->nid를 참조하여 per-node 회수 구현
SHRINKER_NONSLABBIT(2)슬랩이 아닌 메모리를 회수통계가 NR_SLAB_RECLAIMABLE 대신 별도 계산

플래그 조합 패턴

실제 커널 코드에서 사용되는 플래그 조합 패턴입니다.

조합사용처설명
0 (플래그 없음)단순 글로벌 캐시전역 LRU에서 FIFO 회수. 가장 간단한 구현
SHRINKER_NUMA_AWARE노드별 분리 캐시NUMA-local 회수로 원격 접근 최소화
SHRINKER_MEMCG_AWAREcgroup 격리 캐시컨테이너별 메모리 제한 준수
NUMA | MEMCGdentry/inode 캐시NUMA + cgroup 이중 분리. 가장 정교한 회수
NUMA | MEMCG | NONSLAB파일시스템 메타데이터슬랩 외 메모리 회수 + 완전 격리
/* 플래그 조합에 따른 콜백 분기 예시 */
static unsigned long my_count(struct shrinker *s,
                                struct shrink_control *sc)
{
    if (s->flags & SHRINKER_NUMA_AWARE) {
        /* sc->nid에 해당하는 노드의 캐시만 카운트 */
        if (sc->nid == NUMA_NO_NODE)
            return total_all_nodes();
        return per_node_count(sc->nid);
    }
    if (s->flags & SHRINKER_MEMCG_AWARE) {
        /* sc->memcg에 해당하는 cgroup의 캐시만 카운트 */
        if (!sc->memcg)
            return global_count();
        return per_memcg_count(sc->memcg);
    }
    return global_count();
}

등록 및 해제 API

Linux 6.7+ 새 API

Linux 6.7에서 Qi Zheng이 shrinker API를 전면 개편했습니다. 기존 register_shrinker()의 문제점을 해결하고, 라이프사이클 관리를 명확히 분리했습니다.

/* include/linux/shrinker.h (Linux 6.7+) */

/* 1단계: 할당 - shrinker 구조체 동적 할당 */
struct shrinker *shrinker_alloc(unsigned int flags,
                                  const char *fmt, ...);

/* 2단계: 등록 - 콜백 설정 후 전역 리스트에 추가 */
void shrinker_register(struct shrinker *shrinker);

/* 3단계: 해제 - 안전한 등록 해제 + 메모리 해제 */
void shrinker_free(struct shrinker *shrinker);
새 API의 장점:
  • 분리된 라이프사이클: 할당(alloc) → 설정 → 등록(register)이 분리되어 초기화 도중 실패 처리가 깔끔해졌습니다
  • 안전한 해제: shrinker_free()가 refcount + RCU를 통해 진행 중인 회수 완료를 보장합니다
  • 디버깅 이름: shrinker_alloc()에 printf 형식 이름을 전달하여 /sys/kernel/debug/shrinker에서 식별 가능
shrinker_alloc() 구조체 할당 필드 설정 count/scan/seeks shrinker_register() 전역 리스트 등록 shrinker_free() 해제 + RCU 대기 회수 콜백 활성 구간 실패 시 shrinker_free()

새 API 사용 패턴

static struct shrinker *my_shrinker;

static int __init my_init(void)
{
    int ret;

    /* 1단계: 할당 */
    my_shrinker = shrinker_alloc(SHRINKER_NUMA_AWARE | SHRINKER_MEMCG_AWARE,
                                   "mydriver-%s", "cache");
    if (!my_shrinker)
        return -ENOMEM;

    /* 2단계: 콜백 및 파라미터 설정 */
    my_shrinker->count_objects = my_count_objects;
    my_shrinker->scan_objects  = my_scan_objects;
    my_shrinker->seeks         = DEFAULT_SEEKS;
    my_shrinker->batch         = 64;

    /* 여기서 다른 초기화 수행 가능 */
    ret = init_my_cache();
    if (ret) {
        /* 초기화 실패 시 안전하게 해제 (등록 전이므로 콜백 호출 없음) */
        shrinker_free(my_shrinker);
        return ret;
    }

    /* 3단계: 등록 (이후부터 콜백 호출 가능) */
    shrinker_register(my_shrinker);
    return 0;
}

static void __exit my_exit(void)
{
    /* 등록 해제 + 진행 중인 콜백 완료 대기 + 메모리 해제 */
    shrinker_free(my_shrinker);
    destroy_my_cache();
}

레거시 API (Linux 6.6 이하)

/* Linux 6.6 이하 (deprecated) */
int  register_shrinker(struct shrinker *shrinker, const char *fmt, ...);
void unregister_shrinker(struct shrinker *shrinker);
API 마이그레이션 필수: Linux 6.8부터 레거시 API가 완전히 제거되었습니다. 아래 변환 패턴을 따르세요:
/* === 레거시 (Linux 6.6 이하) === */
static struct shrinker old_shrinker = {
    .count_objects = my_count,
    .scan_objects  = my_scan,
    .seeks         = DEFAULT_SEEKS,
};

/* 초기화 */
ret = register_shrinker(&old_shrinker, "my_shrinker");

/* 해제 */
unregister_shrinker(&old_shrinker);

/* === 신규 (Linux 6.7+) === */
static struct shrinker *new_shrinker;

/* 초기화 */
new_shrinker = shrinker_alloc(0, "my_shrinker");
if (!new_shrinker) return -ENOMEM;
new_shrinker->count_objects = my_count;
new_shrinker->scan_objects  = my_scan;
new_shrinker->seeks         = DEFAULT_SEEKS;
shrinker_register(new_shrinker);

/* 해제 */
shrinker_free(new_shrinker);
항목레거시 API새 API (6.7+)
구조체 할당드라이버가 정적/동적 할당shrinker_alloc()이 동적 할당
등록 실패 처리모든 초기화 후 등록, 실패 시 롤백 복잡할당 후 등록 전에 초기화, 실패 시 shrinker_free()
해제 안전성진행 중인 콜백과 경쟁 가능refcount + RCU로 완료 보장
디버깅 이름선택적 (register_shrinker 인자)필수 (shrinker_alloc 인자)

Shrinker 구현 예제

간단한 캐시 회수 구현

#include <linux/shrinker.h>
#include <linux/list.h>
#include <linux/spinlock.h>

/* 예제 캐시 구조체 */
struct my_cache_entry {
    struct list_head lru;
    struct rcu_head  rcu;
    void            *data;
    unsigned long   last_access;  /* jiffies 타임스탬프 */
};

static LIST_HEAD(my_lru);
static DEFINE_SPINLOCK(my_lock);
static atomic_long_t my_cache_count = ATOMIC_LONG_INIT(0);

/* count_objects: 현재 회수 가능한 항목 수 반환 */
static unsigned long my_count_objects(struct shrinker *s,
                                        struct shrink_control *sc)
{
    unsigned long count = atomic_long_read(&my_cache_count);

    /* 캐시가 비어있으면 SHRINK_EMPTY 반환 (memcg 최적화) */
    if (count == 0)
        return SHRINK_EMPTY;

    return count;
}

/* scan_objects: 실제로 객체 회수 */
static unsigned long my_scan_objects(struct shrinker *s,
                                       struct shrink_control *sc)
{
    unsigned long freed = 0;
    unsigned long nr = sc->nr_to_scan;
    struct my_cache_entry *entry, *tmp;
    LIST_HEAD(to_free);

    if (!spin_trylock(&my_lock))
        return SHRINK_STOP;  /* 잠금 경합 시 즉시 포기 */

    list_for_each_entry_safe(entry, tmp, &my_lru, lru) {
        if (freed >= nr)
            break;
        list_move(&entry->lru, &to_free);
        atomic_long_dec(&my_cache_count);
        freed++;
    }
    spin_unlock(&my_lock);

    /* 잠금 밖에서 메모리 해제 (RCU 또는 즉시) */
    list_for_each_entry_safe(entry, tmp, &to_free, lru) {
        list_del(&entry->lru);
        kfree(entry->data);
        kfree(entry);
    }

    return freed;
}

static struct shrinker *my_shrinker;

static int __init my_cache_init(void)
{
    my_shrinker = shrinker_alloc(0, "my_driver:cache");
    if (!my_shrinker)
        return -ENOMEM;

    my_shrinker->count_objects = my_count_objects;
    my_shrinker->scan_objects  = my_scan_objects;
    my_shrinker->seeks         = DEFAULT_SEEKS;  /* 기본 재생성 비용 */

    shrinker_register(my_shrinker);
    return 0;
}

static void __exit my_cache_exit(void)
{
    shrinker_free(my_shrinker);  /* 등록 해제 + 메모리 해제 */
}

memcg Aware Shrinker

컨테이너 환경에서 cgroup별 메모리 제한을 정확히 준수하려면, shrinker가 SHRINKER_MEMCG_AWARE를 선언하고 sc->memcg에 따라 per-cgroup 캐시를 독립적으로 회수해야 합니다.

#include <linux/shrinker.h>
#include <linux/memcontrol.h>

/*
 * per-memcg 캐시 관리 구조체
 * 각 cgroup마다 별도의 LRU 리스트와 카운터를 유지
 */
struct my_memcg_cache {
    struct list_head    lru;
    spinlock_t          lock;
    atomic_long_t       count;
};

/* memcg에서 per-memcg 캐시 구조를 가져오는 헬퍼 */
static struct my_memcg_cache *get_memcg_cache(struct mem_cgroup *memcg)
{
    if (!memcg)
        return &global_cache;
    return (struct my_memcg_cache *)mem_cgroup_get_data(memcg, MY_CACHE_ID);
}

/* memcg별 회수를 지원하는 count 콜백 */
static unsigned long my_count_memcg(struct shrinker *s,
                                      struct shrink_control *sc)
{
    struct my_memcg_cache *cache = get_memcg_cache(sc->memcg);
    unsigned long count;

    if (!cache)
        return 0;

    count = atomic_long_read(&cache->count);
    return count ?: SHRINK_EMPTY;
}

/* memcg별 회수를 지원하는 scan 콜백 */
static unsigned long my_scan_memcg(struct shrinker *s,
                                     struct shrink_control *sc)
{
    struct my_memcg_cache *cache = get_memcg_cache(sc->memcg);
    unsigned long freed = 0, nr = sc->nr_to_scan;
    struct my_cache_entry *e, *tmp;
    LIST_HEAD(dead);

    if (!cache || !spin_trylock(&cache->lock))
        return SHRINK_STOP;

    list_for_each_entry_safe(e, tmp, &cache->lru, lru) {
        if (freed >= nr) break;
        list_move(&e->lru, &dead);
        atomic_long_dec(&cache->count);
        freed++;
    }
    spin_unlock(&cache->lock);

    list_for_each_entry_safe(e, tmp, &dead, lru) {
        list_del(&e->lru);
        kfree(e);
    }

    return freed;
}

/* 등록 시 SHRINKER_MEMCG_AWARE 플래그 지정 */
static int __init my_memcg_init(void)
{
    my_shrinker = shrinker_alloc(SHRINKER_MEMCG_AWARE,
                                   "my:memcg_cache");
    if (!my_shrinker) return -ENOMEM;

    my_shrinker->count_objects = my_count_memcg;
    my_shrinker->scan_objects  = my_scan_memcg;
    my_shrinker->seeks         = DEFAULT_SEEKS;
    shrinker_register(my_shrinker);
    return 0;
}

NUMA-aware Shrinker 완전 예제

#include <linux/shrinker.h>
#include <linux/nodemask.h>

/* per-NUMA 노드 캐시 구조 */
struct my_node_cache {
    struct list_head  lru;
    spinlock_t        lock;
    atomic_long_t     count;
} ____cacheline_aligned;

static struct my_node_cache my_caches[MAX_NUMNODES];

static unsigned long my_count_numa(struct shrinker *s,
                                    struct shrink_control *sc)
{
    int nid = sc->nid;

    if (nid == NUMA_NO_NODE) {
        /* 전체 노드 합산 */
        unsigned long total = 0;
        int i;
        for_each_node_state(i, N_NORMAL_MEMORY)
            total += atomic_long_read(&my_caches[i].count);
        return total;
    }
    return atomic_long_read(&my_caches[nid].count);
}

static unsigned long my_scan_numa(struct shrinker *s,
                                   struct shrink_control *sc)
{
    int nid = (sc->nid == NUMA_NO_NODE) ? numa_node_id() : sc->nid;
    struct my_node_cache *cache = &my_caches[nid];
    unsigned long freed = 0, nr = sc->nr_to_scan;
    struct my_cache_entry *e, *tmp;
    LIST_HEAD(dead);

    spin_lock(&cache->lock);
    list_for_each_entry_safe(e, tmp, &cache->lru, lru) {
        if (freed >= nr) break;
        list_move(&e->lru, &dead);
        atomic_long_dec(&cache->count);
        freed++;
    }
    spin_unlock(&cache->lock);

    list_for_each_entry_safe(e, tmp, &dead, lru)
        kfree(e);

    return freed;
}

static struct shrinker *my_numa_shrinker;

static int __init my_numa_cache_init(void)
{
    int i;

    for_each_node_state(i, N_NORMAL_MEMORY) {
        INIT_LIST_HEAD(&my_caches[i].lru);
        spin_lock_init(&my_caches[i].lock);
        atomic_long_set(&my_caches[i].count, 0);
    }

    my_numa_shrinker = shrinker_alloc(SHRINKER_NUMA_AWARE,
                                       "my:numa_cache");
    if (!my_numa_shrinker) return -ENOMEM;

    my_numa_shrinker->count_objects = my_count_numa;
    my_numa_shrinker->scan_objects  = my_scan_numa;
    my_numa_shrinker->seeks         = DEFAULT_SEEKS;
    shrinker_register(my_numa_shrinker);
    return 0;
}

내부 동작 흐름

shrink_slab()에서 각 shrinker가 호출되는 상세 흐름을 단계별로 살펴봅니다.

메모리 압박 감지 shrink_slab() 호출 RCU 보호 shrinker_list 순회 shrinker->count_objects() 각 shrinker 회수 비율 계산 (seeks/LRU 기반) nr_deferred 합산 → total_scan shrinker->scan_objects(nr) 잔여분 nr_deferred 저장 freed 페이지 수 반환 목표 달성? 예 → 종료 / 아니오 → 다음 라운드

shrink_slab 핵심 코드 분석

/* mm/shrinker.c - shrink_slab() 핵심 흐름 (단순화) */
unsigned long shrink_slab(gfp_t gfp_mask, int nid,
                          struct mem_cgroup *memcg,
                          int priority)
{
    struct shrinker *shrinker;
    unsigned long freed = 0;

    /* GFP 플래그 검증: __GFP_FS가 없으면 파일시스템 shrinker 건너뜀 */
    if (!(gfp_mask & __GFP_FS))
        return 0;

    /* RCU 보호 하에 shrinker 리스트 순회 */
    rcu_read_lock();
    list_for_each_entry_rcu(shrinker, &shrinker_list, list) {
        struct shrink_control sc = {
            .gfp_mask = gfp_mask,
            .nid      = nid,
            .memcg    = memcg,
        };

        /* memcg-aware 필터링 */
        if (memcg && !(shrinker->flags & SHRINKER_MEMCG_AWARE))
            continue;

        /* refcount 획득 (해제 중인 shrinker 건너뜀) */
        if (!shrinker_try_get(shrinker))
            continue;
        rcu_read_unlock();

        /* do_shrink_slab: count + scan 실행 */
        freed += do_shrink_slab(&sc, shrinker, priority);

        shrinker_put(shrinker);
        rcu_read_lock();
    }
    rcu_read_unlock();

    return freed;
}
GFP_NOFS 컨텍스트: 파일시스템 내부에서 메모리를 할당할 때 GFP_NOFS를 사용하면 __GFP_FS 플래그가 없으므로 파일시스템 shrinker(dentry, inode 등)가 호출되지 않습니다. 이는 교착 상태를 방지하기 위한 설계입니다.

메모리 압박 전파 메커니즘

메모리 압박이 감지되면 커널은 단계적으로 회수 강도를 높여갑니다. Shrinker는 이 전파 체인의 핵심 구성 요소입니다.

단계 1: 낮은 압박 priority = 12 kswapd 백그라운드 cold 캐시만 회수 단계 2: 중간 압박 priority = 6~11 직접 회수 시작 LRU + slab 회수 단계 3: 높은 압박 priority = 1~5 공격적 회수 모든 shrinker 반복 단계 4: OOM priority = 0 OOM killer 프로세스 종료 Shrinker 관점에서의 동작 priority가 낮아질수록 do_shrink_slab()이 더 많은 객체를 요청 (total_scan 증가) 페이지 워터마크와 회수 트리거 min 이하 (OOM) low (직접 회수) high (kswapd) 충분 (회수 없음) min low high Shrinker 활성 구간

memcg 압박 전파

메모리 cgroup에서 한도를 초과하면, 해당 cgroup에 속한 캐시만 선택적으로 회수합니다.

/* memcg 압박 시 shrinker 호출 경로 (단순화) */
/*
 * mem_cgroup_charge() → try_charge() → try_to_free_mem_cgroup_pages()
 *   → shrink_node() → shrink_slab(gfp, nid, memcg, priority)
 *
 * shrink_slab() 내부:
 *   - SHRINKER_MEMCG_AWARE가 아닌 shrinker는 건너뜀
 *   - sc->memcg가 해당 cgroup을 가리킴
 *   - per-memcg nr_deferred 사용
 */

/* memcg shrinker에서의 주의점 */
static unsigned long my_count(struct shrinker *s,
                                struct shrink_control *sc)
{
    /* sc->memcg가 NULL인 경우 = 전역(root) 회수
     * sc->memcg가 설정된 경우 = 해당 cgroup만 회수
     * 반드시 두 경우 모두 처리해야 함! */
    if (!sc->memcg)
        return global_cache_count();
    return memcg_cache_count(sc->memcg);
}
memcg NULL 체크 필수: SHRINKER_MEMCG_AWARE로 등록하더라도 sc->memcgNULL로 호출되는 경우(전역 회수)가 있습니다. NULL 체크 없이 per-memcg 데이터에 접근하면 커널 패닉이 발생합니다.

커널 주요 Shrinker 사용처

서브시스템회수 대상파일플래그
dentry 캐시사용되지 않는 dentryfs/dcache.cNUMA | MEMCG
inode 캐시사용되지 않는 inodefs/inode.cNUMA | MEMCG
BtrfsB-tree 블록 캐시fs/btrfs/super.c0
XFSinode/dquot 버퍼fs/xfs/xfs_icache.cNUMA | MEMCG
NFSdcache/inodefs/nfs/super.c0
GPU (DRM)GPU 버퍼 객체drivers/gpu/drm/*/0
네트워크연결 추적 항목net/netfilter/nf_conntrack_core.c0
sunrpcRPC 캐시 항목net/sunrpc/cache.c0
ext4 extentextent status 캐시fs/ext4/extents_status.cMEMCG
workingset그림자(shadow) 노드mm/workingset.cMEMCG | NONSLAB

dentry/inode Shrinker 심화

dentry 캐시와 inode 캐시의 shrinker는 Linux 메모리 회수에서 가장 중요한 역할을 합니다. 대부분의 시스템에서 SReclaimable 슬랩 메모리의 80% 이상이 dentry/inode 캐시입니다.

dentry shrinker 구현 분석

/* fs/dcache.c - super_block별 dentry shrinker */
static unsigned long super_cache_count(struct shrinker *shrink,
                                          struct shrink_control *sc)
{
    struct super_block *sb;
    long total_objects = 0;

    sb = container_of(shrink, struct super_block, s_shrink);

    /* s_op->nr_cached_objects가 있으면 파일시스템별 캐시 카운트 */
    if (sb->s_op->nr_cached_objects)
        total_objects = sb->s_op->nr_cached_objects(sb, sc);

    /* dentry LRU의 미사용 항목 수 */
    total_objects += list_lru_shrink_count(&sb->s_dentry_lru, sc);

    /* inode LRU의 미사용 항목 수 */
    total_objects += list_lru_shrink_count(&sb->s_inode_lru, sc);

    if (!total_objects)
        return SHRINK_EMPTY;

    return total_objects;
}

static unsigned long super_cache_scan(struct shrinker *shrink,
                                        struct shrink_control *sc)
{
    struct super_block *sb;
    long freed = 0;
    long dentries, inodes;

    sb = container_of(shrink, struct super_block, s_shrink);

    /* 스핀락이 아닌 trylock - 실패 시 SHRINK_STOP */
    if (!trylock_super(sb))
        return SHRINK_STOP;

    /* dentry와 inode를 비율적으로 회수 */
    dentries = list_lru_shrink_count(&sb->s_dentry_lru, sc);
    inodes = list_lru_shrink_count(&sb->s_inode_lru, sc);

    /* dentry 먼저 회수 (dentry 해제 시 inode도 연쇄 해제) */
    freed = prune_dcache_sb(sb, sc);
    freed += prune_icache_sb(sb, sc);

    /* 파일시스템별 추가 회수 */
    if (sb->s_op->free_cached_objects)
        sb->s_op->free_cached_objects(sb, sc);

    up_read(&sb->s_umount);
    return freed;
}
super_block->s_shrink s_dentry_lru (list_lru) s_inode_lru (list_lru) s_op->free_cached_objects prune_dcache_sb() prune_icache_sb() d_delete() → dentry 해제 evict_inode() → inode 해제 dentry 해제 시 inode 연쇄
list_lru의 역할: list_lru는 NUMA-aware + memcg-aware LRU 리스트 인프라입니다. dentry/inode shrinker는 list_lru_shrink_walk()를 사용하여 NUMA 노드별, memcg별로 분리된 LRU 리스트를 효율적으로 순회합니다. 직접 list_head를 관리하는 것보다 훨씬 안전합니다.

list_lru 인프라

/* include/linux/list_lru.h */
struct list_lru {
    struct list_lru_node  *node;    /* per-node 배열 */
    struct list_head      list;     /* 전역 list_lrus 리스트 */
    int                  shrinker_id; /* 연결된 shrinker ID */
    bool                 memcg_aware;
};

/* list_lru API */
bool          list_lru_add(struct list_lru *lru, struct list_head *item);
bool          list_lru_del(struct list_lru *lru, struct list_head *item);
unsigned long list_lru_count_one(struct list_lru *lru, int nid,
                                   struct mem_cgroup *memcg);
unsigned long list_lru_shrink_count(struct list_lru *lru,
                                      struct shrink_control *sc);
unsigned long list_lru_shrink_walk(struct list_lru *lru,
                                     struct shrink_control *sc,
                                     list_lru_walk_cb isolate,
                                     void *cb_arg);

파일시스템 Shrinker 분석

XFS Shrinker

XFS는 여러 개의 shrinker를 등록하여 다양한 캐시를 독립적으로 관리합니다.

Shrinker회수 대상파일seeks
xfs_inode_shrinkerXFS inode 캐시fs/xfs/xfs_icache.cDEFAULT_SEEKS
xfs_buf_shrinkerXFS 버퍼 캐시 (메타데이터)fs/xfs/xfs_buf.cDEFAULT_SEEKS
xfs_qm_shrinkerXFS 디스크 쿼타 캐시fs/xfs/xfs_qm.cDEFAULT_SEEKS
/* fs/xfs/xfs_buf.c - XFS 버퍼 캐시 shrinker */
static unsigned long
xfs_buftarg_shrink_scan(struct shrinker *shrink,
                        struct shrink_control *sc)
{
    struct xfs_buftarg *btp = container_of(shrink,
                          struct xfs_buftarg, bt_shrinker);
    LIST_HEAD(dispose);
    unsigned long freed;

    /* LRU에서 오래된 버퍼 분리 */
    freed = list_lru_shrink_walk(&btp->bt_lru, sc,
                                    xfs_buftarg_isolate, &dispose);

    /* 분리된 버퍼 일괄 해제 */
    while (!list_empty(&dispose)) {
        struct xfs_buf *bp = list_first_entry(&dispose,
                              struct xfs_buf, b_lru);
        list_del_init(&bp->b_lru);
        xfs_buf_rele(bp);
    }

    return freed;
}

Btrfs Shrinker

Btrfs는 extent 버퍼와 관련 메타데이터 캐시를 회수합니다.

/* fs/btrfs/super.c - Btrfs shrinker */
static unsigned long
btrfs_cache_shrink_count(struct shrinker *shrink,
                          struct shrink_control *sc)
{
    struct btrfs_fs_info *fs_info;
    long nr;

    fs_info = container_of(shrink, struct btrfs_fs_info,
                             shrinker);
    /*
     * extent_buffer 중 사용되지 않는 것들의 수를 반환
     * stale extent map도 포함
     */
    nr = percpu_counter_sum_positive(&fs_info->evictable_extent_maps);
    return nr;
}

DRM (GPU) Shrinker

GPU 드라이버의 shrinker는 GPU 메모리에서 시스템 메모리로 스왑 가능한 GEM 객체를 회수합니다.

/* drivers/gpu/drm/i915/gem/i915_gem_shrinker.c (단순화) */
static unsigned long
i915_gem_shrink_count(struct shrinker *shrink,
                       struct shrink_control *sc)
{
    struct drm_i915_private *i915 =
        container_of(shrink, struct drm_i915_private,
                      mm.shrinker);
    unsigned long count;

    /* 페이지 단위로 퍼지 가능(purgeable) + 바인딩 해제 가능 객체 */
    count = atomic_long_read(&i915->mm.shrink_count);
    return count ?: SHRINK_EMPTY;
}

static unsigned long
i915_gem_shrink_scan(struct shrinker *shrink,
                      struct shrink_control *sc)
{
    /* 1단계: purgeable 객체 (MADV_DONTNEED 마킹) 해제 */
    /* 2단계: 비활성 객체의 backing pages 해제 */
    /* 3단계: GPU에서 바인딩 해제 후 시스템 메모리 반환 */
    return i915_gem_shrink(i915, sc->nr_to_scan, NULL,
                            I915_SHRINK_BOUND |
                            I915_SHRINK_UNBOUND |
                            I915_SHRINK_ACTIVE);
}

NUMA-aware Shrinker 심화

NUMA 시스템에서 shrinker가 올바르게 동작하려면 원격 노드 접근을 최소화하고, 메모리 압박이 있는 노드의 캐시를 우선 회수해야 합니다.

NUMA 토폴로지와 회수 전략

NUMA Node 0 CPU 0-7 Local Memory per-node cache[0] (LRU + count) NUMA Node 1 CPU 8-15 Local Memory per-node cache[1] (LRU + count) QPI shrink_slab(nid=0) → 노드 0 캐시만 회수 NUMA_AWARE가 아닌 shrinker는 nid를 무시하고 전역 캐시에서 회수 → 원격 노드 메모리 접근 발생 가능 → 성능 저하
NUMA 고려 사항NUMA_AWARE shrinker비-NUMA shrinker
회수 범위sc->nid에 해당하는 로컬 노드만전역 캐시 전체
원격 메모리 접근최소화빈번히 발생 가능
nr_deferredper-node 배열 (각 노드 독립)단일 전역 카운터
캐시 구조per-node LRU 필요단일 LRU 충분
적합한 경우대규모 NUMA 서버, 메모리 집약적 워크로드단일 노드, 소규모 캐시

memcg-aware Shrinker 심화

컨테이너 환경에서 각 cgroup의 메모리 한도를 정확히 준수하려면 shrinker가 memcg를 인식해야 합니다. SHRINKER_MEMCG_AWARE 플래그가 없는 shrinker는 memcg 회수 경로에서 호출되지 않으므로, 특정 cgroup만 메모리 압박을 받는 상황에서 캐시가 회수되지 않을 수 있습니다.

memcg Shrinker 라이프사이클

/*
 * memcg-aware shrinker의 내부 동작:
 *
 * 1. shrinker_alloc(SHRINKER_MEMCG_AWARE, ...) 호출 시:
 *    - shrinker_idr에서 고유 ID 할당
 *    - nr_deferred 배열이 per-node * per-memcg 차원으로 할당
 *
 * 2. 새 memcg 생성 시 (css_online):
 *    - memcg->shrinker_map에 비트맵 할당
 *    - 각 shrinker ID에 대응하는 비트로 활성 여부 추적
 *
 * 3. 캐시 항목 추가 시:
 *    - list_lru_add()가 해당 memcg의 shrinker 비트 설정
 *    - 이후 해당 memcg 회수 시 이 shrinker가 호출됨
 *
 * 4. memcg 삭제 시 (css_offline):
 *    - reparent: 자식 memcg의 캐시를 부모로 이동
 *    - shrinker_map 비트 정리
 */
memcg shrinker_map (비트맵) ID 0 ID 1 ID 2 ID 3 ID 4 ... = 이 memcg에 캐시 항목 있음 (회수 대상) = 캐시 항목 없음 (건너뜀) 등록된 shrinker (shrinker_idr) ID 0: sb_dentry ID 2: sb_inode ID 4: xfs_inode memcg 회수 시 비트맵에서 1인 shrinker만 호출 O(1) 탐색으로 불필요한 shrinker 콜백 호출을 방지 → 수백 개 shrinker 등록 시 성능 향상
shrinker_map 최적화: memcg 회수 시 수백 개의 shrinker를 모두 호출하는 대신, 비트맵(shrinker_map)에서 해당 memcg에 실제 캐시가 있는 shrinker만 골라서 호출합니다. 이 최적화는 Linux 5.x에서 도입되었으며, 대규모 컨테이너 환경에서 회수 성능을 크게 개선했습니다.

디버깅 및 모니터링

슬랩 회수 현황 확인

# /proc/slabinfo: 슬랩 캐시별 사용량
cat /proc/slabinfo | head -20

# /proc/meminfo: 전체 슬랩 메모리
grep -E "Slab|SReclaimable|SUnreclaim" /proc/meminfo

# vmstat: 회수 통계
vmstat -m

# 수동 회수 트리거 (1: page cache, 2: dentry/inode, 3: 전체)
echo 2 | sudo tee /proc/sys/vm/drop_caches

# slabtop: 실시간 슬랩 캐시 모니터링
sudo slabtop -s c  # 캐시 크기순 정렬

debugfs shrinker 인터페이스

Linux 5.2+에서 /sys/kernel/debug/shrinker 디렉토리를 통해 등록된 모든 shrinker의 상태를 확인할 수 있습니다.

# debugfs 마운트 (보통 자동 마운트됨)
sudo mount -t debugfs none /sys/kernel/debug

# 등록된 shrinker 목록 확인
ls /sys/kernel/debug/shrinker/

# 출력 예시:
# sb-dentry-cache-0  sb-inode-cache-0  xfs-inodegc-0
# sb-dentry-cache-1  sb-inode-cache-1  nf_conntrack

# 특정 shrinker의 per-node count 확인
cat /sys/kernel/debug/shrinker/sb-dentry-cache-0/count

# 출력 형식: node_id count
# 0 12345
# 1 6789

# memcg-aware shrinker: per-memcg per-node count
cat /sys/kernel/debug/shrinker/sb-dentry-cache-0/count_memcg

# 출력 형식: memcg_id node_id count
# 1 0 3456
# 1 1 1234
# 2 0 567

Tracepoint로 shrinker 추적

# 사용 가능한 vmscan tracepoint 목록
ls /sys/kernel/debug/tracing/events/vmscan/

# shrink_slab 진입/종료 추적
echo 1 > /sys/kernel/debug/tracing/events/vmscan/mm_shrink_slab_start/enable
echo 1 > /sys/kernel/debug/tracing/events/vmscan/mm_shrink_slab_end/enable

# 추적 결과 확인
cat /sys/kernel/debug/tracing/trace

# 출력 예시:
# kswapd0-42 [001] mm_shrink_slab_start: sb-dentry-cache-0
#   nid=0 nr_to_scan=128 priority=10 total_scan=142
# kswapd0-42 [001] mm_shrink_slab_end: sb-dentry-cache-0
#   unused_scan_cnt=128 new_scan_cnt=0 total_scan=142 freed=98

# bpftrace로 shrinker 호출 빈도 분석
sudo bpftrace -e '
tracepoint:vmscan:mm_shrink_slab_start {
    @[args->shrink_name] = count();
}
interval:s:5 { print(@); clear(@); }'

# perf로 shrink_slab 성능 분석
sudo perf stat -e 'vmscan:mm_shrink_slab_start' \
    -e 'vmscan:mm_shrink_slab_end' -- sleep 10

vmstat/zoneinfo로 메모리 압박 모니터링

# /proc/vmstat: 슬랩 회수 관련 통계
grep -E "slabs_scanned|pgscan_kswapd|pgsteal_kswapd|drop" /proc/vmstat

# slabs_scanned: 지금까지 스캔된 슬랩 객체 총수
# pgscan_kswapd: kswapd가 스캔한 페이지 수
# pgsteal_kswapd: kswapd가 실제 회수한 페이지 수

# /proc/zoneinfo: 노드별 워터마크와 free 페이지
grep -A5 "Node 0, zone   Normal" /proc/zoneinfo

# cgroup 메모리 상태 (cgroup v2)
cat /sys/fs/cgroup/my_container/memory.stat | grep -E "slab|file"
cat /sys/fs/cgroup/my_container/memory.pressure

# PSI(Pressure Stall Information)로 메모리 압박 확인
cat /proc/pressure/memory
# some avg10=0.00 avg60=0.50 avg300=1.23 total=45678
# full avg10=0.00 avg60=0.10 avg300=0.34 total=12345
PSI(Pressure Stall Information): Linux 4.20+에서 /proc/pressure/memory를 통해 메모리 압박의 심각도를 실시간으로 확인할 수 있습니다. some은 일부 작업이 지연되는 비율, full은 모든 작업이 멈추는 비율입니다. shrinker 튜닝 시 이 지표를 기준으로 효과를 측정하세요.
scan_objects에서 GFP_KERNEL 주의: scan_objects()는 메모리 압박 상황에서 호출됩니다. 콜백 내에서 GFP_KERNEL 할당을 시도하면 재귀적 회수가 발생할 수 있습니다. 가능하면 미리 할당하거나 GFP_ATOMIC 또는 GFP_NOWAIT를 사용하세요.
SHRINK_STOP 반환 시: scan_objects()에서 SHRINK_STOP을 반환하면 커널은 이 shrinker에 대한 회수를 즉시 중단합니다. 단, 이 shrinker의 지연된(deferred) 카운터는 누적되어 다음 회수 시도에 영향을 줍니다.

안티패턴과 흔한 실수

Shrinker 구현에서 자주 발생하는 실수와 그 해결 방법을 정리합니다. 이 패턴들은 실제 커널 메일링 리스트에서 지적된 사례를 기반으로 합니다.

안티패턴 1: scan_objects에서 긴 잠금 보유

/* ===== 나쁜 예: 전체 스캔 동안 잠금 보유 ===== */
static unsigned long bad_scan(struct shrinker *s,
                                struct shrink_control *sc)
{
    unsigned long freed = 0;

    mutex_lock(&big_mutex);  /* 잠금 획득 대기 → 시스템 스톨! */

    /* 수천 개의 항목을 잠금 안에서 해제 */
    while (freed < sc->nr_to_scan && !list_empty(&cache_list)) {
        struct entry *e = list_first_entry(...);
        list_del(&e->lru);
        kfree(e);  /* 잠금 안에서 kfree - 다른 잠금 순서 위반 가능 */
        freed++;
    }

    mutex_unlock(&big_mutex);
    return freed;
}

/* ===== 좋은 예: trylock + 잠금 밖 해제 ===== */
static unsigned long good_scan(struct shrinker *s,
                                 struct shrink_control *sc)
{
    unsigned long freed = 0;
    LIST_HEAD(to_free);

    /* trylock: 실패 시 즉시 SHRINK_STOP */
    if (!mutex_trylock(&big_mutex))
        return SHRINK_STOP;

    /* 잠금 안에서는 리스트 분리만 */
    while (freed < sc->nr_to_scan && !list_empty(&cache_list)) {
        struct entry *e = list_first_entry(...);
        list_move_tail(&e->lru, &to_free);
        freed++;
    }

    mutex_unlock(&big_mutex);

    /* 잠금 밖에서 실제 해제 */
    free_entries(&to_free);

    return freed;
}

안티패턴 2: count_objects에서 무거운 연산

/* ===== 나쁜 예: 리스트 순회로 카운트 ===== */
static unsigned long bad_count(struct shrinker *s,
                                 struct shrink_control *sc)
{
    unsigned long count = 0;
    struct entry *e;

    spin_lock(&my_lock);
    list_for_each_entry(e, &cache_list, lru)
        count++;  /* O(N) 순회 - N이 크면 매우 느림! */
    spin_unlock(&my_lock);

    return count;
}

/* ===== 좋은 예: 원자적 카운터 ===== */
static atomic_long_t cache_count = ATOMIC_LONG_INIT(0);

static unsigned long good_count(struct shrinker *s,
                                  struct shrink_control *sc)
{
    long count = atomic_long_read(&cache_count);
    return count > 0 ? count : SHRINK_EMPTY;  /* O(1) */
}

안티패턴 3: 콜백 내 메모리 할당

/* ===== 나쁜 예: scan_objects 내에서 GFP_KERNEL 할당 ===== */
static unsigned long bad_scan_alloc(struct shrinker *s,
                                      struct shrink_control *sc)
{
    /* 회수 경로에서 GFP_KERNEL → 재귀적 회수 → 교착! */
    struct work *w = kmalloc(sizeof(*w), GFP_KERNEL);
    if (!w) return SHRINK_STOP;
    /* ... */
}

/* ===== 좋은 예: 사전 할당 또는 GFP_NOWAIT ===== */
static unsigned long good_scan_alloc(struct shrinker *s,
                                       struct shrink_control *sc)
{
    /* 방법 1: GFP_NOWAIT (재귀 회수 방지) */
    struct work *w = kmalloc(sizeof(*w), GFP_NOWAIT);

    /* 방법 2: 사전 할당된 풀에서 가져오기 */
    struct work *w2 = mempool_alloc(my_pool, GFP_NOWAIT);
    /* ... */
}

안티패턴 요약

안티패턴증상해결 방법
scan에서 mutex_lock() (대기 가능)시스템 스톨, D-state 프로세스 증가mutex_trylock() 사용, 실패 시 SHRINK_STOP
count에서 O(N) 리스트 순회높은 CPU 사용, 잠금 경합atomic_long_t 카운터 유지
scan 내 GFP_KERNEL 할당재귀 회수, 교착 상태GFP_NOWAIT 또는 사전 할당
count 과소 보고nr_deferred 폭주, 갑작스러운 대량 회수정확한 카운트 반환
count 과대 보고과도한 회수, 캐시 히트율 저하실제 회수 가능량만 반환
SHRINK_STOP 남발해당 shrinker 캐시 누적, 메모리 부족 악화일시적 실패에만 사용
잠금 안에서 kfree()잠금 순서 위반, lockdep 경고리스트 분리 후 잠금 밖에서 해제
해제 중 shrinker_free() 미호출해제 후 사용(UAF), 커널 패닉모듈 exit에서 반드시 shrinker_free()

Shrinker 테스트 방법

drop_caches를 이용한 기본 테스트

# 1. 캐시를 많이 생성 (파일시스템 탐색)
find / -name "*.c" -exec cat {} > /dev/null 2>&1 \;

# 2. 회수 전 상태 확인
grep -E "Slab|SReclaimable|Cached" /proc/meminfo
echo "=== shrinker count ==="
cat /sys/kernel/debug/shrinker/sb-dentry-cache-*/count 2>/dev/null

# 3. dentry/inode shrinker 트리거
echo 2 | sudo tee /proc/sys/vm/drop_caches

# 4. 회수 후 상태 확인
grep -E "Slab|SReclaimable|Cached" /proc/meminfo

인위적 메모리 압박 테스트

# cgroup v2를 이용한 메모리 압박 테스트

# 1. 테스트용 cgroup 생성
sudo mkdir -p /sys/fs/cgroup/shrinker_test
echo 50M | sudo tee /sys/fs/cgroup/shrinker_test/memory.max

# 2. 현재 셸을 cgroup에 배치
echo $$ | sudo tee /sys/fs/cgroup/shrinker_test/cgroup.procs

# 3. 메모리 할당으로 압박 유발
python3 -c "
import os
data = []
try:
    while True:
        data.append(bytearray(1024 * 1024))  # 1MB씩 할당
except MemoryError:
    print(f'Allocated {len(data)} MB before OOM')
"

# 4. 압박 상태에서 shrinker 동작 확인
cat /sys/fs/cgroup/shrinker_test/memory.stat | grep -E "slab|pgsteal"
cat /sys/fs/cgroup/shrinker_test/memory.pressure

KUnit 기반 shrinker 테스트

#include <kunit/test.h>
#include <linux/shrinker.h>

/* 테스트용 shrinker 콜백 */
static atomic_long_t test_count = ATOMIC_LONG_INIT(100);
static atomic_long_t test_freed = ATOMIC_LONG_INIT(0);

static unsigned long
test_count_objects(struct shrinker *s, struct shrink_control *sc)
{
    return atomic_long_read(&test_count);
}

static unsigned long
test_scan_objects(struct shrinker *s, struct shrink_control *sc)
{
    unsigned long nr = min(sc->nr_to_scan,
                            (unsigned long)atomic_long_read(&test_count));
    atomic_long_sub(nr, &test_count);
    atomic_long_add(nr, &test_freed);
    return nr;
}

static void test_shrinker_register_free(struct kunit *test)
{
    struct shrinker *s;

    s = shrinker_alloc(0, "kunit-test");
    KUNIT_ASSERT_NOT_NULL(test, s);

    s->count_objects = test_count_objects;
    s->scan_objects  = test_scan_objects;
    s->seeks         = DEFAULT_SEEKS;

    shrinker_register(s);

    /* drop_caches로 회수 트리거 */
    drop_caches_sysctl_handler(2);

    /* 회수 확인 */
    KUNIT_EXPECT_GT(test, atomic_long_read(&test_freed), 0L);

    shrinker_free(s);
}

스트레스 테스트 스크립트

#!/bin/bash
# shrinker 스트레스 테스트

# tracepoint 활성화
echo 1 > /sys/kernel/debug/tracing/events/vmscan/mm_shrink_slab_start/enable
echo 1 > /sys/kernel/debug/tracing/events/vmscan/mm_shrink_slab_end/enable

# 메모리 압박 + 파일시스템 부하 동시 실행
stress-ng --vm 4 --vm-bytes 80% --vm-method all -t 30s &
find / -type f -name "*.h" -exec cat {} > /dev/null 2>&1 \; &

# 30초 후 결과 분석
sleep 30

# 트레이스 수집
cat /sys/kernel/debug/tracing/trace | grep shrink_slab > /tmp/shrinker_trace.log
echo "=== 결과 ==="
echo "shrink_slab 호출 횟수:"
wc -l /tmp/shrinker_trace.log

echo "shrinker별 freed 합계:"
grep "freed=" /tmp/shrinker_trace.log | \
    sed 's/.*shrink: \([^ ]*\).*freed=\([0-9]*\).*/\1 \2/' | \
    awk '{sum[$1]+=$2} END {for (k in sum) print k, sum[k]}' | sort -k2 -rn

# 정리
echo 0 > /sys/kernel/debug/tracing/events/vmscan/mm_shrink_slab_start/enable
echo 0 > /sys/kernel/debug/tracing/events/vmscan/mm_shrink_slab_end/enable

성능 튜닝

관련 커널 파라미터

파라미터경로기본값설명
vfs_cache_pressure/proc/sys/vm/vfs_cache_pressure100dentry/inode 캐시 회수 강도. 높을수록 공격적 회수
min_free_kbytes/proc/sys/vm/min_free_kbytes시스템 의존최소 free 페이지. 높이면 회수 조기 시작
watermark_boost_factor/proc/sys/vm/watermark_boost_factor15000워터마크 부스트 비율 (0=비활성)
watermark_scale_factor/proc/sys/vm/watermark_scale_factor10워터마크 간격 비율
drop_caches/proc/sys/vm/drop_caches0수동 캐시 회수 트리거

vfs_cache_pressure 상세

vfs_cache_pressure는 dentry/inode 캐시 shrinker의 회수 강도를 조절하는 가장 중요한 파라미터입니다.

# 기본값 (100): LRU 페이지와 동일한 비율로 캐시 회수
cat /proc/sys/vm/vfs_cache_pressure
# 100

# 값 낮추기 (50): 캐시 보존 우선 → 파일시스템 메타데이터 성능 향상
echo 50 | sudo tee /proc/sys/vm/vfs_cache_pressure

# 값 높이기 (200): 공격적 캐시 회수 → 메모리 여유 확보
echo 200 | sudo tee /proc/sys/vm/vfs_cache_pressure

# 0: dentry/inode 캐시를 거의 회수하지 않음 (OOM 위험!)
# 주의: 프로덕션에서 0은 매우 위험합니다
vfs_cache_pressure 동작 원리: shrink_slab()에서 dentry/inode shrinker의 회수량을 계산할 때 total_scan = (freeable * delta) / (lru_pages + 1) 공식에서 deltavfs_cache_pressure / 100을 곱합니다. 즉, 200이면 기본의 2배 회수, 50이면 절반 회수합니다.

시나리오별 튜닝 가이드

시나리오vfs_cache_pressuremin_free_kbytes이유
데이터베이스 서버50~70기본DB가 자체 캐시 사용. dentry/inode 보존하여 메타데이터 접근 가속
파일 서버 (NFS/Samba)100~150기본많은 파일 접근으로 dentry/inode 폭증 방지
컨테이너 호스트100높임memcg별 회수에 의존. 전역 파라미터 기본값 유지
임베디드 (저메모리)200~500낮춤적극적 캐시 회수로 OOM 방지
HPC (대용량 메모리)10~50기본메모리 여유 충분. 캐시 보존으로 I/O 최소화

Shrinker 구현 체크리스트

Shrinker는 메모리 압박 시 호출되므로 콜백 내부 제약이 엄격합니다. count/scan 경로의 비용과 동시성 안전성을 먼저 검증해야 합니다.

  1. count 경량화: lock 경합 없이 빠르게 추정값 반환 (atomic 카운터 권장)
  2. scan 안전성: 회수 중 리스트/객체 수명주기 보호 (trylock + 잠금 밖 해제)
  3. 재귀 회수 방지: 콜백 내부 과도한 할당/슬립 금지 (GFP_NOWAIT 사용)
  4. memcg 대응: cgroup 환경에서 분리 회수 정책 검증 (NULL memcg 체크)
  5. NUMA 대응: per-node 캐시 분리 또는 nid 무시 여부 결정
  6. 해제 안전성: shrinker_free() 호출 시점에 캐시 사용자 부재 보장
  7. 디버깅 이름: shrinker_alloc()에 의미 있는 이름 전달
  8. 반환값 준수: SHRINK_EMPTY, SHRINK_STOP 규약 준수
오류 패턴영향대응
count 과소/과대 반환회수 비효율/지연추정 로직과 실제 회수량 정합성 점검
scan에서 긴 락 보유system stallbatch 단위 분할, 락 범위 축소
SHRINK_STOP 오남용회수 정체정상 회수 불가능 상황에서만 사용
GFP_KERNEL 할당재귀 회수 교착GFP_NOWAIT / 사전 할당
memcg NULL 미체크커널 패닉sc->memcg NULL 처리 필수
Shrinker 필요한가? 캐시를 유지하는가? 캐시 없음 → 불필요 아니오 NUMA 다중 노드? cgroup 환경? SHRINKER_NUMA_AWARE per-node cache 구조 SHRINKER_MEMCG_AWARE per-memcg cache 구조 플래그 없음 (0) 단일 전역 LRU seeks 값 결정: 재생성 비용 높으면 ↑, 낮으면 DEFAULT_SEEKS batch 결정: 대량 회수 가능하면 크게, I/O 비용 높으면 작게

완전한 드라이버 예제

NUMA-aware + memcg-aware shrinker를 포함한 완전한 커널 모듈 예제입니다. 실제 프로덕션 수준의 패턴을 따릅니다.

/*
 * my_cache_driver.c - NUMA/memcg-aware shrinker 완전 예제
 *
 * 이 모듈은 per-node LRU 캐시를 관리하며,
 * 메모리 압박 시 shrinker를 통해 자동으로 캐시를 회수합니다.
 */

#include <linux/module.h>
#include <linux/shrinker.h>
#include <linux/list_lru.h>
#include <linux/slab.h>
#include <linux/atomic.h>
#include <linux/nodemask.h>

MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("NUMA/memcg-aware shrinker example");

/* === 캐시 항목 구조체 === */
struct my_object {
    struct list_head   lru_link;     /* list_lru 연결 */
    void              *payload;      /* 실제 데이터 */
    size_t            payload_size;  /* 페이로드 크기 */
    unsigned long     last_access;   /* 마지막 접근 jiffies */
    atomic_t          refcount;      /* 참조 카운터 */
};

/* === 전역 상태 === */
static struct list_lru  my_lru;
static struct shrinker *my_shrinker;
static struct kmem_cache *my_slab;

/* === list_lru 콜백: 회수 가능 여부 판단 === */
static enum lru_status
my_isolate(struct list_head *item,
            struct list_lru_one *lru,
            spinlock_t *lock, void *cb_arg)
{
    struct my_object *obj = container_of(item,
                              struct my_object, lru_link);
    struct list_head *freeable = cb_arg;

    /* 참조 중인 객체는 건너뜀 */
    if (atomic_read(&obj->refcount) > 0)
        return LRU_ROTATE;  /* LRU 뒤로 이동 */

    /* 최근 접근 항목은 보존 */
    if (time_before(jiffies, obj->last_access + HZ * 30))
        return LRU_SKIP;

    /* 분리 (실제 해제는 콜백 밖에서) */
    list_lru_isolate_move(lru, item, freeable);
    return LRU_REMOVED;
}

/* === count_objects 콜백 === */
static unsigned long
my_count_objects(struct shrinker *shrink,
                  struct shrink_control *sc)
{
    unsigned long count;

    count = list_lru_shrink_count(&my_lru, sc);
    return count ?: SHRINK_EMPTY;
}

/* === scan_objects 콜백 === */
static unsigned long
my_scan_objects(struct shrinker *shrink,
                 struct shrink_control *sc)
{
    unsigned long freed;
    LIST_HEAD(freeable);
    struct my_object *obj, *tmp;

    /* list_lru_shrink_walk: NUMA/memcg-aware 자동 처리 */
    freed = list_lru_shrink_walk(&my_lru, sc,
                                    my_isolate, &freeable);

    /* 잠금 밖에서 실제 해제 */
    list_for_each_entry_safe(obj, tmp, &freeable, lru_link) {
        list_del(&obj->lru_link);
        kfree(obj->payload);
        kmem_cache_free(my_slab, obj);
    }

    return freed;
}

/* === 모듈 초기화 === */
static int __init my_cache_init(void)
{
    int err;

    /* 슬랩 캐시 생성 */
    my_slab = kmem_cache_create("my_objects",
                                  sizeof(struct my_object),
                                  0, SLAB_RECLAIM_ACCOUNT, NULL);
    if (!my_slab)
        return -ENOMEM;

    /* list_lru 초기화 (memcg-aware) */
    err = list_lru_init_memcg(&my_lru, NULL);
    if (err)
        goto err_lru;

    /* shrinker 할당 */
    my_shrinker = shrinker_alloc(
        SHRINKER_NUMA_AWARE | SHRINKER_MEMCG_AWARE,
        "my_cache_driver");
    if (!my_shrinker) {
        err = -ENOMEM;
        goto err_shrinker;
    }

    /* 콜백 설정 */
    my_shrinker->count_objects = my_count_objects;
    my_shrinker->scan_objects  = my_scan_objects;
    my_shrinker->seeks         = 1;   /* 재생성 비용 낮음 */

    /* 등록 (이후부터 콜백 호출 가능) */
    shrinker_register(my_shrinker);

    pr_info("my_cache_driver: initialized\n");
    return 0;

err_shrinker:
    list_lru_destroy(&my_lru);
err_lru:
    kmem_cache_destroy(my_slab);
    return err;
}

/* === 모듈 해제 === */
static void __exit my_cache_exit(void)
{
    /* 1. shrinker 해제 (진행 중인 콜백 완료 대기) */
    shrinker_free(my_shrinker);

    /* 2. 남은 캐시 항목 모두 해제 */
    /* (shrinker 해제 후이므로 콜백과 경쟁 없음) */
    flush_remaining_objects();

    /* 3. list_lru 해제 */
    list_lru_destroy(&my_lru);

    /* 4. 슬랩 캐시 해제 */
    kmem_cache_destroy(my_slab);

    pr_info("my_cache_driver: cleaned up\n");
}

module_init(my_cache_init);
module_exit(my_cache_exit);

OOM Killer와의 상호작용

OOM killer가 호출되기 전에 커널은 shrinker를 통한 마지막 회수 시도를 합니다.

/* mm/oom_kill.c (단순화된 OOM 결정 흐름) */
/*
 * 1. try_to_free_pages() → shrink_node() → shrink_slab() [priority 감소]
 * 2. priority가 0까지 떨어져도 충분히 회수 못하면...
 * 3. __alloc_pages_may_oom() 진입
 * 4. out_of_memory() → OOM killer 호출
 *
 * shrinker가 충분히 회수하면 OOM을 피할 수 있음!
 * → 따라서 shrinker가 빠르고 효과적으로 회수하는 것이 중요
 */
페이지 할당 시도 shrink_slab (P=12..0) 회수 성공! 회수 부족 OOM killer 할당 재시도 freed >= target freed < target Shrinker의 효율이 OOM 발생 여부를 직접적으로 좌우합니다 count가 정확하고 scan이 빠르면 OOM을 피할 확률이 높아집니다

compaction과의 관계

메모리 단편화 해소(compaction)과 shrinker는 다른 목적이지만 같은 메모리 압박 상황에서 함께 동작합니다.

항목Shrinker (shrink_slab)Compaction
목적캐시 해제로 free 페이지 수 증가페이지 이동으로 연속 영역 확보
호출 시점free 페이지 부족 시high-order 할당 실패 시
대상슬랩/캐시 메모리이동 가능한(movable) 페이지
호출 관계먼저 시도shrinker 이후 또는 병렬

zswap Shrinker

Linux 6.8+에서 zswap도 shrinker를 등록하여, 메모리 압박 시 압축된 스왑 캐시를 디스크 스왑으로 내보냅니다.

/* mm/zswap.c - zswap shrinker (Linux 6.8+) */
static unsigned long
zswap_shrinker_count(struct shrinker *s,
                      struct shrink_control *sc)
{
    struct mem_cgroup *memcg = sc->memcg;
    struct lruvec *lruvec;
    unsigned long nr_backing, nr_stored;

    /* zswap 풀에 저장된 항목 중 writeback 가능한 수 */
    nr_backing = memcg_page_state(memcg, MEMCG_ZSWAP_B) / PAGE_SIZE;
    nr_stored = memcg_page_state(memcg, MEMCG_ZSWAPPED);

    /* writeback 비용을 고려하여 반환 */
    return nr_stored;
}

static unsigned long
zswap_shrinker_scan(struct shrinker *s,
                     struct shrink_control *sc)
{
    /* zswap 항목을 디스크 스왑으로 writeback하여 메모리 해제 */
    return zswap_writeback_entries(sc);
}
zswap shrinker의 의의: Linux 6.8 이전에는 zswap 풀이 가득 차면 새 항목을 저장할 수 없었습니다. shrinker 도입으로 메모리 압박 시 오래된 zswap 항목을 디스크 스왑으로 내보내어 공간을 확보할 수 있게 되었습니다.

소스 코드 맵

파일내용주요 함수/구조체
include/linux/shrinker.hshrinker API 헤더struct shrinker, struct shrink_control, 플래그 상수
mm/shrinker.cshrinker 코어 구현 (6.7+)shrink_slab(), do_shrink_slab(), shrinker_alloc/register/free()
mm/vmscan.cVM 스캔/회수 메인shrink_node(), try_to_free_pages(), kswapd()
mm/list_lru.clist_lru 인프라list_lru_shrink_walk(), list_lru_shrink_count()
fs/dcache.cdentry 캐시super_cache_count(), super_cache_scan()
fs/inode.cinode 캐시prune_icache_sb()
fs/super.csuperblock shrinker 등록alloc_super() 내 shrinker 설정
fs/xfs/xfs_icache.cXFS inode shrinkerxfs_reclaim_inodes_count/nr()
fs/xfs/xfs_buf.cXFS 버퍼 shrinkerxfs_buftarg_shrink_scan/count()
mm/workingset.cworkingset shadow 노드shadow_lru_isolate()
mm/zswap.czswap shrinker (6.8+)zswap_shrinker_count/scan()

구현 권장 사항

항목권장 사항근거
count_objects 속도원자적 카운터 또는 list_lru_shrink_count() 사용회수 경로에서 빈번히 호출되므로 O(1) 필수
scan_objects 잠금trylock 사용, 실패 시 SHRINK_STOP회수 경로에서 잠금 대기는 시스템 스톨 유발
scan_objects 해제리스트 분리 후 잠금 밖에서 kfree()잠금 안에서 kfree()는 잠금 순서 위반 가능
batch 설정적절한 최소 단위 설정 (기본 128)너무 작으면 오버헤드, 너무 크면 latency spike
LRU 활용list_lru 인프라 사용 (직접 구현 지양)NUMA/memcg-aware가 자동 지원됨
memcg 지원가능하면 SHRINKER_MEMCG_AWARE 구현cgroup 환경(컨테이너/k8s)에서 정확한 회수
해제 순서shrinker_free() → 캐시 정리 → 자원 해제콜백 경쟁 방지
디버깅 이름"subsystem:cache_type" 형식debugfs에서 식별 용이
list_lru를 적극 활용하세요: 직접 list_head + spinlock으로 LRU를 구현하는 대신 list_lru 인프라를 사용하면 NUMA-aware, memcg-aware 회수가 자동으로 지원됩니다. list_lru_shrink_walk()는 잠금 관리, 노드/memcg 분리, 항목 격리를 모두 처리합니다.

6.x 새 API 내부 구현 심화

Linux 6.7에서 도입된 shrinker_alloc()/shrinker_register()/shrinker_free() API의 내부 구현을 자세히 분석합니다. 이 API 전환은 Qi Zheng이 주도했으며, 기존 API의 근본적인 설계 결함을 해결합니다.

shrinker_alloc() 내부 구현

/* mm/shrinker.c - shrinker_alloc() 내부 (Linux 6.7) */
struct shrinker *shrinker_alloc(unsigned int flags,
                                  const char *fmt, ...)
{
    struct shrinker *shrinker;
    unsigned int size;
    va_list ap;
    int err;

    /* 1. shrinker 구조체 동적 할당 */
    shrinker = kzalloc(sizeof(*shrinker), GFP_KERNEL);
    if (!shrinker)
        return NULL;

    /* 2. 디버깅 이름 설정 (printf 형식) */
    va_start(ap, fmt);
    shrinker->name = kvasprintf(GFP_KERNEL, fmt, ap);
    va_end(ap);

    /* 3. memcg-aware인 경우 shrinker_idr에서 ID 할당 */
    if (flags & SHRINKER_MEMCG_AWARE) {
        err = idr_alloc(&shrinker_idr, shrinker, 0, 0, GFP_KERNEL);
        if (err < 0)
            goto err_flags;
        shrinker->id = err;
    } else {
        shrinker->id = -1;
    }

    /* 4. nr_deferred 배열 할당 (per-node) */
    if (flags & SHRINKER_NUMA_AWARE)
        size = nr_node_ids;
    else
        size = 1;

    shrinker->nr_deferred = kcalloc(size,
                              sizeof(atomic_long_t), GFP_KERNEL);
    if (!shrinker->nr_deferred)
        goto err_id;

    /* 5. refcount/completion 초기화 */
    refcount_set(&shrinker->refcount, 1);
    init_completion(&shrinker->done);

    shrinker->flags = flags;
    return shrinker;

err_id:
    if (shrinker->id >= 0)
        idr_remove(&shrinker_idr, shrinker->id);
err_flags:
    kfree(shrinker->name);
    kfree(shrinker);
    return NULL;
}

shrinker_register() 내부 구현

/* mm/shrinker.c - shrinker_register() 내부 */
void shrinker_register(struct shrinker *shrinker)
{
    /* 1. 콜백이 설정되었는지 검증 */
    if (WARN_ON_ONCE(!shrinker->count_objects ||
                      !shrinker->scan_objects))
        return;

    /* 2. batch 기본값 설정 */
    if (!shrinker->batch)
        shrinker->batch = SHRINK_BATCH;  /* 128 */

    /* 3. memcg 초기화: 모든 기존 memcg에 shrinker 비트 예약 */
    if (shrinker->flags & SHRINKER_MEMCG_AWARE)
        shrinker_memcg_add(shrinker);

    /* 4. 전역 shrinker_list에 RCU-safe 추가 */
    down_write(&shrinker_rwsem);
    list_add_tail_rcu(&shrinker->list, &shrinker_list);
    shrinker->flags |= SHRINKER_REGISTERED;
    up_write(&shrinker_rwsem);
}

shrinker_free() 내부 구현과 RCU 보호

/* mm/shrinker.c - shrinker_free() 내부 */
void shrinker_free(struct shrinker *shrinker)
{
    if (!shrinker)
        return;

    if (shrinker->flags & SHRINKER_REGISTERED) {
        /* 1. 리스트에서 RCU-safe 제거 */
        down_write(&shrinker_rwsem);
        list_del_rcu(&shrinker->list);
        shrinker->flags &= ~SHRINKER_REGISTERED;
        up_write(&shrinker_rwsem);

        /* 2. refcount 감소 & 진행 중인 콜백 완료 대기 */
        if (refcount_dec_and_test(&shrinker->refcount))
            complete(&shrinker->done);
        wait_for_completion(&shrinker->done);
    }

    /* 3. memcg 관련 자원 정리 */
    if (shrinker->id >= 0) {
        idr_remove(&shrinker_idr, shrinker->id);
        shrinker_memcg_remove(shrinker);
    }

    /* 4. RCU grace period 이후 메모리 해제 */
    kfree_rcu(shrinker, rcu);
}
shrinker_free()와 RCU의 관계: shrink_slab()은 RCU read-side에서 shrinker 리스트를 순회합니다. shrinker_free()list_del_rcu()로 리스트에서 제거해도, 이미 해당 shrinker를 참조 중인 CPU에서는 계속 콜백이 실행될 수 있습니다. 이를 위해 refcount + completion 패턴으로 진행 중인 모든 콜백 완료를 대기한 후, kfree_rcu()로 RCU grace period 이후에 메모리를 해제합니다.
shrinker_list (전역 연결 리스트, RCU 보호) shrinker_rwsem으로 쓰기 보호, rcu_read_lock()으로 읽기 보호 sb-dentry-cache-0 id=0, flags=NUMA|MEMCG refcount=1, seeks=2 nr_deferred[0..N] xfs:inode-cache id=2, flags=NUMA|MEMCG refcount=1, seeks=2 nr_deferred[0..N] my_driver:cache id=-1, flags=0 refcount=1, seeks=1 nr_deferred[0] shrinker_idr (IDR: memcg-aware shrinker만) id=0 → sb-dentry | id=2 → xfs:inode | ... memcg->shrinker_map: [1][0][1][0]... 비트 위치 = shrinker ID, 1 = 해당 memcg에 캐시 있음 id=-1: IDR 미등록 전역 회수에서만 호출됨

레거시 API에서 새 API 마이그레이션 패턴

Linux 6.7 전환 시 커널 내부에서 수백 개의 shrinker가 새 API로 마이그레이션되었습니다. 주요 마이그레이션 패턴을 정리합니다.

마이그레이션 패턴레거시 코드새 코드주의점
정적 할당 → 동적static struct shrinker s = {...};struct shrinker *s = shrinker_alloc(...);포인터로 변경, NULL 체크 필수
등록 + 초기화 분리register_shrinker(&s, ...) (초기화 후)alloc → 설정 → register (3단계)register 전 실패 시 shrinker_free()
해제unregister_shrinker(&s)shrinker_free(s)메모리까지 해제됨, 이중 해제 금지
container_of 사용container_of(s, struct my, shrinker)shrinker->private_data 사용정적 임베딩 불가, private_data 활용
/* container_of 마이그레이션 예시 */

/* === 레거시: shrinker가 구조체에 임베딩 === */
struct my_subsystem {
    struct shrinker shrink;   /* 임베딩 */
    struct list_head lru;
};
/* count 콜백에서: */
struct my_subsystem *ms = container_of(s, struct my_subsystem, shrink);

/* === 새 API: private_data 사용 === */
struct my_subsystem {
    struct shrinker *shrink;  /* 포인터 */
    struct list_head lru;
};
/* 초기화 시: */
ms->shrink = shrinker_alloc(0, "my_subsystem");
ms->shrink->private_data = ms;
/* count 콜백에서: */
struct my_subsystem *ms = s->private_data;

count/scan 콜백 Best Practices

count_objects와 scan_objects 콜백은 메모리 압박 경로에서 호출되므로, 구현에 엄격한 제약이 있습니다. 이 섹션에서는 커널 메인라인에서 검증된 best practice 패턴을 체계적으로 정리합니다.

count_objects 콜백 Best Practices

규칙이유예시
O(1) 복잡도 유지매 회수 시도마다 모든 shrinker의 count가 호출됨atomic_long_read() 또는 list_lru_shrink_count()
잠금 없이 반환count 경로에서 잠금 경합은 회수 latency 증가원자적 카운터 사용
정확한 값 반환과소 보고 → nr_deferred 폭주, 과대 보고 → 과도한 회수캐시 추가/제거 시 카운터 동기 갱신
SHRINK_EMPTY 활용memcg 해제 최적화, 빈 shrinker 조기 건너뜀return count ?: SHRINK_EMPTY;
NUMA 분리 고려NUMA_AWARE 시 특정 노드의 카운트만 반환sc->nid 검사 후 per-node 카운트
/* count_objects 모범 패턴 */
static unsigned long
ideal_count_objects(struct shrinker *s,
                     struct shrink_control *sc)
{
    unsigned long count;

    /* list_lru 사용 시: NUMA/memcg 자동 처리 */
    count = list_lru_shrink_count(&my_lru, sc);

    /* 빈 캐시 최적화 */
    if (count == 0)
        return SHRINK_EMPTY;

    /* vfs_cache_pressure 고려 (dentry/inode 전용) */
    /* count = vfs_pressure_ratio(count); */

    return count;
}

scan_objects 콜백 Best Practices

규칙이유예시
trylock 패턴 사용잠금 대기는 회수 경로 전체를 차단spin_trylock() / mutex_trylock()
잠금 밖에서 해제kfree() 내부에서 다른 잠금 필요 가능분리(isolate) 리스트 패턴
GFP_KERNEL 금지재귀적 회수 → 교착 위험GFP_NOWAIT 또는 사전 할당
batch 크기 준수한 번에 너무 많이 해제하면 latency spikefreed >= sc->nr_to_scan에서 중단
부분 회수 허용요청량 미달이어도 해제한 만큼 반환실제 freed 수 반환
SHRINK_STOP은 일시적 실패에만남발하면 해당 캐시 누적, 메모리 부족 악화잠금 경합, I/O 진행 중에만 사용
/* scan_objects 모범 패턴: isolate-then-free */
static unsigned long
ideal_scan_objects(struct shrinker *s,
                    struct shrink_control *sc)
{
    unsigned long freed;
    LIST_HEAD(dispose);

    /* list_lru_shrink_walk: trylock + NUMA/memcg 자동 처리 */
    freed = list_lru_shrink_walk(&my_lru, sc,
                                    my_isolate_cb, &dispose);

    /* 잠금 밖에서 일괄 해제 */
    dispose_objects(&dispose);

    return freed;
}

/* isolate 콜백: 객체별 회수 판단 */
static enum lru_status
my_isolate_cb(struct list_head *item,
              struct list_lru_one *lru,
              spinlock_t *lock, void *cb_arg)
{
    struct my_object *obj = container_of(item,
                              struct my_object, lru_link);

    /* 참조 중 → LRU 뒤로 회전 */
    if (atomic_read(&obj->refcount) > 0)
        return LRU_ROTATE;

    /* dirty 상태 → 건너뜀 (writeback 필요) */
    if (obj->flags & OBJ_DIRTY)
        return LRU_SKIP;

    /* 분리하여 dispose 리스트로 이동 */
    list_lru_isolate_move(lru, item, cb_arg);
    return LRU_REMOVED;
}
LRU_STATUS 반환값 가이드:
  • LRU_REMOVED — 항목을 LRU에서 분리했음. 해제는 콜백 밖에서
  • LRU_REMOVED_RETRY — 분리 후 잠금을 해제하고 재시도해야 함
  • LRU_ROTATE — 항목을 LRU 뒤쪽으로 회전 (지금은 건드리지 않지만 나중에 회수 가능)
  • LRU_SKIP — 이 항목을 건너뜀 (회수 불가능)
  • LRU_RETRY — 잠금 해제 후 순회 재시작

GFP 마스크 처리 가이드

sc->gfp_mask는 어떤 종류의 메모리 할당 컨텍스트에서 회수가 요청되었는지를 나타냅니다. shrinker 콜백은 이 플래그를 확인하여 안전한 동작만 수행해야 합니다.

GFP 플래그의미shrinker 동작
__GFP_FS 있음파일시스템 작업 허용정상 회수 (dentry/inode 해제 가능)
__GFP_FS 없음파일시스템 재진입 금지파일시스템 shrinker 건너뜀 (교착 방지)
__GFP_IO 있음I/O 허용dirty 캐시 writeback 후 해제 가능
__GFP_IO 없음I/O 금지clean 캐시만 해제
__GFP_RECLAIM회수 허용 (직접 회수)정상 shrinker 동작
/* GFP 마스크를 고려한 안전한 scan 구현 */
static unsigned long
safe_scan_objects(struct shrinker *s,
                   struct shrink_control *sc)
{
    /* I/O가 허용되지 않는 컨텍스트에서는 clean 캐시만 */
    if (!(sc->gfp_mask & __GFP_IO)) {
        return free_clean_entries_only(&my_lru,
                                        sc->nr_to_scan);
    }

    /* FS가 허용되지 않으면 파일시스템 메타데이터 접근 불가 */
    if (!(sc->gfp_mask & __GFP_FS)) {
        return free_non_fs_entries(&my_lru,
                                    sc->nr_to_scan);
    }

    /* 정상: 모든 종류의 캐시 회수 가능 */
    return free_all_entries(&my_lru, sc->nr_to_scan);
}

주요 커널 Shrinker 상세 분석

Linux 커널에서 가장 영향력 있는 shrinker 구현체들을 내부 코드 수준에서 분석합니다. 이들은 시스템의 슬랩 메모리 대부분을 관리하므로, 그 동작을 이해하는 것이 메모리 튜닝의 핵심입니다.

dentry_cache shrinker 상세

dentry 캐시 shrinker는 super_block 단위로 등록됩니다. 각 마운트된 파일시스템마다 별도의 shrinker 인스턴스가 존재합니다.

/* fs/super.c - super_block 할당 시 shrinker 설정 */
static struct super_block *alloc_super(
    struct file_system_type *type, int flags,
    struct user_namespace *user_ns)
{
    struct super_block *s;
    /* ... 할당 및 초기화 ... */

    /* dentry/inode LRU 초기화 (memcg-aware) */
    err = list_lru_init_memcg(&s->s_dentry_lru, &s->s_shrink);
    err = list_lru_init_memcg(&s->s_inode_lru, &s->s_shrink);

    /* shrinker 할당 (NUMA + MEMCG) */
    s->s_shrink = shrinker_alloc(
        SHRINKER_NUMA_AWARE | SHRINKER_MEMCG_AWARE,
        "sb-%s-%p", type->name, s);

    s->s_shrink->count_objects = super_cache_count;
    s->s_shrink->scan_objects  = super_cache_scan;
    s->s_shrink->seeks         = DEFAULT_SEEKS;

    return s;
}
dentry 해제의 연쇄 효과: dentry를 해제하면 해당 inode의 참조 카운트가 감소합니다. inode의 참조가 0이 되면 s_inode_lru에 추가되어 inode shrinker의 회수 대상이 됩니다. 따라서 super_cache_scan()은 dentry를 먼저 회수(prune_dcache_sb())하여 간접적으로 inode도 해제되게 합니다.

inode_cache shrinker 상세

/* fs/inode.c - inode LRU 관리 핵심 함수 */

/* inode가 참조 해제될 때 LRU에 추가 */
void iput(struct inode *inode)
{
    if (atomic_dec_and_lock(&inode->i_count, &inode->i_lock)) {
        /* dirty가 아니고 nobody가 참조 중이 아니면 */
        if (!inode_has_data(inode))
            inode_lru_list_add(inode);  /* s_inode_lru에 추가 */
        spin_unlock(&inode->i_lock);
    }
}

/* prune_icache_sb: inode shrinker의 핵심 */
long prune_icache_sb(struct super_block *sb,
                      struct shrink_control *sc)
{
    LIST_HEAD(dispose);
    long freed;

    /* list_lru에서 회수 가능 inode 분리 */
    freed = list_lru_shrink_walk(&sb->s_inode_lru, sc,
                                    inode_lru_isolate, &dispose);

    /* dispose 리스트의 inode 일괄 해제 */
    dispose_list(&dispose);
    return freed;
}

/* inode_lru_isolate: 회수 가능 여부 판단 */
static enum lru_status
inode_lru_isolate(struct list_head *item,
                   struct list_lru_one *lru,
                   spinlock_t *lru_lock, void *arg)
{
    struct inode *inode = container_of(item,
                            struct inode, i_lru);

    /* 참조 카운트 > 0이면 건너뜀 */
    if (atomic_read(&inode->i_count))
        return LRU_SKIP;

    /* I_DIRTY 또는 I_SYNC 상태이면 건너뜀 */
    if (inode->i_state & (I_DIRTY_ALL | I_SYNC))
        return LRU_SKIP;

    /* I_REFERENCED (최근 접근)이면 한 번 기회 부여 */
    if (inode->i_state & I_REFERENCED) {
        inode->i_state &= ~I_REFERENCED;
        return LRU_ROTATE;  /* 두 번째 기회 (second chance) */
    }

    /* 회수 가능: 분리 */
    list_lru_isolate_move(lru, item, arg);
    return LRU_REMOVED;
}

xfs_buf shrinker 상세

XFS 버퍼 캐시는 파일시스템 메타데이터(superblock, AG header, B+tree 노드)를 캐싱합니다. 메모리 압박 시 이 캐시를 회수하면 메타데이터 재읽기 비용이 발생하므로, 신중한 회수 전략이 필요합니다.

/* fs/xfs/xfs_buf.c - XFS 버퍼 isolate 콜백 */
static enum lru_status
xfs_buftarg_isolate(struct list_head *item,
                     struct list_lru_one *lru,
                     spinlock_t *lru_lock, void *arg)
{
    struct xfs_buf *bp = container_of(item,
                            struct xfs_buf, b_lru);

    /* 참조 중인 버퍼는 건너뜀 */
    if (atomic_read(&bp->b_hold) > 0)
        return LRU_SKIP;

    /* dirty 버퍼: writeback 완료 후에만 해제 가능 */
    if (bp->b_flags & XBF_DIRTY) {
        if (!xfs_buf_trylock(bp))
            return LRU_SKIP;
        /* stale이면 바로 제거 가능 */
        if (bp->b_flags & XBF_STALE) {
            bp->b_flags &= ~XBF_DIRTY;
            xfs_buf_unlock(bp);
        } else {
            xfs_buf_unlock(bp);
            return LRU_ROTATE;
        }
    }

    /* clean 버퍼: 분리 */
    list_lru_isolate_move(lru, item, arg);
    return LRU_REMOVED;
}
XFS 메타데이터 캐시 보호: XFS는 seeks = DEFAULT_SEEKS를 사용하지만, allocation group header나 B+tree 루트 노드처럼 빈번히 접근되는 메타데이터는 b_hold이 0이 되지 않으므로 자연스럽게 회수에서 보호됩니다. 과도한 drop_caches를 반복하면 XFS 성능이 급격히 저하될 수 있습니다.
주요 커널 Shrinker 회수 대상과 전략 dentry cache shrinker 대상: 미사용 dentry (d_count=0) 전략: LRU tail에서 prune 플래그: NUMA | MEMCG 부수 효과: inode 연쇄 해제 보통 가장 많은 메모리 회수 inode cache shrinker 대상: 미사용 inode (i_count=0) 전략: second chance (I_REFERENCED) 플래그: NUMA | MEMCG 제한: dirty inode 건너뜀 page cache 해제 연동 xfs_buf shrinker 대상: XFS 메타데이터 버퍼 전략: clean 버퍼 우선 해제 플래그: NUMA 제한: dirty + held 건너뜀 재읽기 비용 높음 (디스크 I/O) 연쇄 회수 효율 비교 dentry: ~50-80% SReclaimable inode: ~15-30% SReclaimable xfs_buf: ~5-15% (XFS 전용)

Shrinker 디버깅 심화

Linux 5.2에서 Yang Shi가 추가한 /sys/kernel/debug/shrinker 인터페이스는 등록된 모든 shrinker의 상태를 실시간으로 확인할 수 있게 해줍니다. 이 섹션에서는 디버깅 인터페이스의 내부 구현과 실전 활용법을 심화합니다.

debugfs 구조와 출력 형식

# debugfs shrinker 디렉토리 구조
/sys/kernel/debug/shrinker/
  sb-ext4-loop0/
    count                 # per-node 카운트
  sb-xfs-sda1/
    count
  nf_conntrack/
    count
  mm-zspool:zswap/        # 6.8+ zswap shrinker
    count

# per-node count 출력 형식
cat /sys/kernel/debug/shrinker/sb-ext4-loop0/count
# 출력 (NUMA 2노드 시스템):
# 0 45678
# 1 23456
# 해석: Node 0에 45678개, Node 1에 23456개 회수 가능 객체

# 전체 shrinker 상태 한눈에 보기
for d in /sys/kernel/debug/shrinker/*/; do
    name=$(basename "$d")
    total=$(awk '{sum+=$2} END{print sum}' "$d/count" 2>/dev/null)
    [ "$total" -gt 0 ] 2>/dev/null && \
        echo "$name: $total objects"
done | sort -t: -k2 -rn | head -20

# 출력 예시:
# sb-ext4-sda1: 234567 objects
# sb-tmpfs-1: 89012 objects
# sb-xfs-sdb1: 56789 objects
# nf_conntrack: 12345 objects

debugfs shrinker 내부 구현

/* mm/shrinker_debug.c - debugfs 인터페이스 구현 */

/* shrinker 등록 시 debugfs 엔트리 생성 */
int shrinker_debugfs_add(struct shrinker *shrinker)
{
    struct dentry *entry;
    char buf[128];

    /* shrinker 이름으로 디렉토리 생성 */
    snprintf(buf, sizeof(buf), "%s-%d",
             shrinker->name, shrinker->id);

    entry = debugfs_create_dir(buf, shrinker_debugfs_root);

    /* "count" 파일: count_objects 호출 결과 */
    debugfs_create_file("count", 0444, entry,
                         shrinker, &shrinker_debugfs_count_fops);

    return 0;
}

/* "count" 파일 읽기: 각 노드의 count_objects 호출 */
static int shrinker_debugfs_count_show(
    struct seq_file *m, void *v)
{
    struct shrinker *shrinker = m->private;
    int nid;

    for_each_node_state(nid, N_NORMAL_MEMORY) {
        struct shrink_control sc = {
            .gfp_mask = GFP_KERNEL,
            .nid = nid,
        };
        unsigned long count;

        count = shrinker->count_objects(shrinker, &sc);
        if (count == SHRINK_EMPTY)
            count = 0;

        seq_printf(m, "%d %lu\n", nid, count);
    }
    return 0;
}

실전 디버깅 워크플로

#!/bin/bash
# shrinker 상태 실시간 모니터링 스크립트

# 1. 메모리 압박 지표 확인
echo "=== 메모리 압박 상태 ==="
cat /proc/pressure/memory
echo ""

# 2. 슬랩 메모리 현황
echo "=== 슬랩 메모리 (MB) ==="
awk '/SReclaimable/{print "Reclaimable: " $2/1024 " MB"}
     /SUnreclaim/{print "Unreclaimable: " $2/1024 " MB"}' /proc/meminfo
echo ""

# 3. 상위 shrinker 카운트
echo "=== 상위 Shrinker (회수 가능 객체 수) ==="
for d in /sys/kernel/debug/shrinker/*/; do
    name=$(basename "$d")
    total=$(awk '{sum+=$2} END{print sum}' "$d/count" 2>/dev/null)
    [ "$total" -gt 0 ] 2>/dev/null && echo "$total $name"
done | sort -rn | head -10
echo ""

# 4. 실시간 shrinker 호출 추적 (10초)
echo "=== 10초간 shrink_slab 추적 ==="
echo 1 > /sys/kernel/debug/tracing/events/vmscan/mm_shrink_slab_start/enable
echo 1 > /sys/kernel/debug/tracing/events/vmscan/mm_shrink_slab_end/enable
sleep 10
echo 0 > /sys/kernel/debug/tracing/events/vmscan/mm_shrink_slab_start/enable
echo 0 > /sys/kernel/debug/tracing/events/vmscan/mm_shrink_slab_end/enable

# 5. 추적 결과 분석
echo "=== shrinker별 호출 빈도 ==="
grep mm_shrink_slab_start /sys/kernel/debug/tracing/trace | \
    awk -F'shrink: ' '{print $2}' | \
    awk '{print $1}' | sort | uniq -c | sort -rn | head -10

echo "=== shrinker별 회수 효율 ==="
grep mm_shrink_slab_end /sys/kernel/debug/tracing/trace | \
    sed 's/.*shrink: \([^ ]*\).*total_scan=\([0-9]*\).*freed=\([0-9]*\).*/\1 \2 \3/' | \
    awk '{scan[$1]+=$2; freed[$1]+=$3}
         END{for(k in scan) printf "%s: scanned=%d freed=%d ratio=%.1f%%\n",
             k, scan[k], freed[k], freed[k]*100/(scan[k]+1)}' | sort -t= -k4 -rn

# 6. 트레이스 버퍼 초기화
echo > /sys/kernel/debug/tracing/trace
bpftrace를 활용한 고급 shrinker 분석: bpftrace를 사용하면 shrinker 콜백의 실행 시간, 잠금 경합 빈도, 노드별 회수 분포를 실시간으로 분석할 수 있습니다.
# bpftrace: shrinker 콜백 실행 시간 히스토그램
sudo bpftrace -e '
kprobe:do_shrink_slab {
    @start[tid] = nsecs;
}
kretprobe:do_shrink_slab /@start[tid]/ {
    @latency_us = hist((nsecs - @start[tid]) / 1000);
    delete(@start[tid]);
}
interval:s:10 { exit(); }
'

# bpftrace: shrinker별 freed 객체 수 추적
sudo bpftrace -e '
tracepoint:vmscan:mm_shrink_slab_end {
    @freed[str(args->shrink_name)] = sum(args->freed);
    @calls[str(args->shrink_name)] = count();
}
interval:s:30 {
    printf("\n--- Shrinker 효율 (30초) ---\n");
    print(@freed); print(@calls);
    clear(@freed); clear(@calls);
}'

성능 함정과 고급 안티패턴

기본적인 안티패턴 외에, 프로덕션 환경에서 흔히 발생하는 고급 성능 함정들을 분석합니다. 이 패턴들은 단위 테스트에서는 드러나지 않고, 대규모 워크로드에서만 나타나는 경우가 많습니다.

함정 1: nr_deferred 폭발

count_objects()가 실제보다 적은 값을 반환하면, nr_deferred가 지속적으로 누적됩니다. 일정 시점에 total_scan이 폭발적으로 증가하여 한 번의 회수 시도에서 캐시 전체가 날아갑니다.

/* nr_deferred 폭발 시나리오 */

/*
 * 시나리오: 실제 캐시 = 10000, count()가 100을 반환
 *
 * 라운드 1: total_scan = 50 + 0 (deferred) = 50
 *           freed = 50, deferred += 0
 *
 * 라운드 2: count=100, scan=50, freed=50, deferred=0
 *           ... 정상적으로 보임 ...
 *
 * 하지만 실제 캐시 크기 10000 중 100만 보고하므로:
 * - shrink_slab은 이 shrinker가 큰 캐시를 가진 것을 모름
 * - LRU/slab 비율 계산이 왜곡됨
 * - 메모리 압박이 심해져도 이 캐시는 적게 회수됨
 * - 결국 OOM이 발생하여 프로세스가 kill됨
 */

/* 해결: 정확한 카운트 반환 */
static unsigned long correct_count(struct shrinker *s,
                                      struct shrink_control *sc)
{
    /* percpu_counter는 약간의 오차가 있지만 O(1) */
    long count = percpu_counter_read_positive(&my_counter);
    return count ?: SHRINK_EMPTY;
}

함정 2: Thundering Herd (떼몰이 회수)

다수의 CPU가 동시에 메모리 압박을 겪으면, 모든 CPU가 동시에 같은 shrinker를 호출합니다. 잠금 경합이 급증하고 시스템이 일시적으로 멈출 수 있습니다.

/* Thundering Herd 완화 패턴 */
static unsigned long
throttled_scan(struct shrinker *s,
                struct shrink_control *sc)
{
    static atomic_t active_scanners = ATOMIC_INIT(0);
    unsigned long freed;

    /* 동시 스캐너 수 제한 (최대 2개) */
    if (atomic_read(&active_scanners) >= 2)
        return SHRINK_STOP;

    atomic_inc(&active_scanners);

    /* 실제 회수 수행 */
    freed = do_actual_scan(&my_lru, sc->nr_to_scan);

    atomic_dec(&active_scanners);
    return freed;
}

함정 3: False Positive (거짓 양성 카운트)

캐시 항목이 논리적으로는 회수 불가능하지만 count에는 포함되는 경우입니다. 예를 들어, 참조 카운트가 0보다 큰 항목을 count에 포함하면 scan에서 모두 건너뛰게 되고, 그 차이가 nr_deferred에 누적됩니다.

/* False Positive 방지: count와 scan의 일관성 */

/* 나쁜 예: count에 모든 항목 포함 */
static unsigned long bad_count(struct shrinker *s,
                                 struct shrink_control *sc)
{
    /* 전체 항목 수 반환 (참조 중인 것 포함) */
    return atomic_long_read(&total_objects);  /* 10000 */
    /* 하지만 scan에서 9000개는 refcount > 0이라 건너뜀 */
    /* → nr_deferred에 9000 누적! */
}

/* 좋은 예: 실제 회수 가능한 수만 반환 */
static unsigned long good_count(struct shrinker *s,
                                  struct shrink_control *sc)
{
    /* 회수 가능(unreferenced) 항목 수만 반환 */
    return atomic_long_read(&freeable_objects);  /* 1000 */
}

함정 4: 잠금 순서 위반

shrinker 콜백은 메모리 회수 경로에서 호출되므로, 커널의 다양한 잠금이 이미 보유된 상태일 수 있습니다. 콜백 내에서 잘못된 순서로 잠금을 획득하면 교착 상태가 발생합니다.

위험 패턴이유안전한 대안
scan 내에서 mmap_lock 획득페이지 폴트 → 회수 → mmap_lock 교착mmap_lock이 필요한 해제는 work queue로 지연
scan 내에서 sb->s_umount 획득umount → shrinker_free → 대기 교착trylock_super() 사용
scan 내에서 I/O 대기I/O 완료에 메모리 할당 필요 → 재귀dirty 캐시는 건너뛰고 clean만 해제
count에서 rw_semaphore 획득빈번한 count 호출에서 writer starvation원자적 카운터로 대체
lockdep 활용: shrinker 관련 잠금 순서 위반은 CONFIG_LOCKDEP을 활성화하면 런타임에 자동으로 감지됩니다. 새 shrinker를 구현할 때는 반드시 lockdep이 활성화된 커널에서 테스트하세요. fs_reclaim 잠금 클래스가 shrinker 콜백 진입 시 자동으로 설정됩니다.

성능 함정 요약

함정발생 조건증상진단 방법해결
nr_deferred 폭발count 과소 보고갑작스러운 대량 회수, 캐시 cold/proc/vmstat slabs_scanned 급증정확한 count 반환
Thundering Herd다수 CPU 동시 압박잠금 경합, softlockupperf lock contention동시 스캐너 제한
False Positivecount/scan 불일치nr_deferred 누적, 비효율 회수debugfs count vs 실제 freed 비교회수 가능 객체만 count
잠금 순서 위반콜백 내 잠금 획득D-state, 교착lockdep 경고trylock, 비동기 해제
캐시 thrashingseeks 값 너무 낮음회수 → 재생성 반복, I/O 급증iostat, 캐시 히트율seeks 값 상향 조정

Lockless Shrinker 리스트 (RCU 기반)

Linux 6.0에서 Kirill Tkhai가 shrinker 리스트 순회를 RCU 기반으로 전환했습니다. 이전에는 shrinker_rwsem 읽기 잠금을 보유한 채 모든 shrinker를 순회했는데, 이는 shrinker 등록/해제 시 쓰기 잠금과 경합하여 시스템 스톨을 유발했습니다.

전환 전후 비교

항목Linux 5.x (rwsem 기반)Linux 6.0+ (RCU 기반)
순회 보호down_read(&shrinker_rwsem)rcu_read_lock()
등록/해제 보호down_write(&shrinker_rwsem)down_write(&shrinker_rwsem) + RCU
경합회수 중 등록/해제 차단됨회수와 등록/해제 독립 진행
해제 안전성rwsem 해제 후 즉시 메모리 해제refcount + RCU grace period 대기
성능수십 개 shrinker도 경합 가능수백 개 shrinker에서도 경합 없음
장점구현 단순확장성 우수, 스톨 방지
/* shrink_slab에서의 RCU 기반 순회 패턴 */
unsigned long shrink_slab(...)
{
    struct shrinker *shrinker;
    unsigned long freed = 0;

    /* RCU read-side 진입: 잠금 없이 리스트 순회 */
    rcu_read_lock();

    list_for_each_entry_rcu(shrinker, &shrinker_list, list) {
        /* refcount 획득 시도: 해제 중인 shrinker 건너뜀 */
        if (!shrinker_try_get(shrinker))
            continue;

        /* RCU 잠금 해제: do_shrink_slab은 시간이 오래 걸릴 수 있음 */
        rcu_read_unlock();

        /* 실제 회수 (RCU 밖에서 실행) */
        freed += do_shrink_slab(&sc, shrinker, priority);

        /* refcount 해제 */
        shrinker_put(shrinker);

        /* RCU 재진입: 다음 shrinker 순회 */
        rcu_read_lock();
    }

    rcu_read_unlock();
    return freed;
}
RCU 기반 Shrinker 리스트 동시 접근 CPU 0: shrink_slab() (읽기 경로) rcu_read_lock shrinker A shrinker B shrinker C 잠금 없이 순회, refcount로 안전 보장 CPU 1: shrinker_free() (쓰기 경로) write_lock list_del_rcu wait refcount kfree_rcu 리스트 제거 → refcount 대기 → RCU 해제 시간 → CPU 0: 순회 진행 중 CPU 1: shrinker B 해제 중 CPU 0은 이미 B의 refcount를 획득했으므로, CPU 1은 refcount 해제까지 대기 → 안전한 해제 보장
shrinker_try_get / shrinker_put 패턴: shrinker_try_get()refcount_inc_not_zero()를 사용하여 해제가 시작된 shrinker를 건너뜁니다. shrinker_put()refcount_dec_and_test()를 호출하여 마지막 참조가 해제되면 completion을 완료합니다. 이 패턴으로 진행 중인 모든 콜백이 완료된 후에만 메모리가 해제됩니다.

Shrinker와 메모리 압력 전파 심화

메모리 압력이 커널의 다양한 서브시스템으로 전파되는 과정에서 shrinker는 핵심적인 중재 역할을 합니다. 이 섹션에서는 압력 전파의 전체 경로와 shrinker의 위치를 심화 분석합니다.

메모리 압력 전파 전체 경로

메모리 할당 실패 (__alloc_pages) try_to_free_pages() kswapd 깨우기 shrink_node() (priority: 12 → 0) shrink_lruvec() anonymous + file LRU 회수 shrink_slab() Shrinker 콜백 순회 shrink_active_list() active → inactive 이동 freed >= nr_to_reclaim? 할당 성공 (재시도) priority-- (재시도) 아니오 (P>0) OOM killer 아니오 (P=0) priority 감소 후 재시도

priority에 따른 shrinker 동작 변화

shrink_node()가 priority를 12에서 0으로 낮추면서 반복 호출할 때, do_shrink_slab()에서 계산되는 total_scan이 어떻게 변화하는지 분석합니다.

/* priority에 따른 total_scan 계산 변화 */

/*
 * do_shrink_slab() 내부:
 *
 * delta = (4 * sc->nr_to_scan) / shrinker->seeks;
 *
 * sc->nr_to_scan은 priority에 반비례:
 *   nr_to_scan = lruvec_lru_size >> priority
 *
 * 따라서:
 *   priority=12: nr_to_scan = lru_size / 4096 (매우 적음)
 *   priority=8:  nr_to_scan = lru_size / 256
 *   priority=4:  nr_to_scan = lru_size / 16
 *   priority=0:  nr_to_scan = lru_size (전체)
 *
 * 예시: lru_size=1048576 (1M 페이지), freeable=50000, seeks=2
 *
 * priority=12: delta = (4 * 256) / 2 = 512
 *              scan = 512 * 50000 / 1048577 ≈ 24
 *
 * priority=8:  delta = (4 * 4096) / 2 = 8192
 *              scan = 8192 * 50000 / 1048577 ≈ 390
 *
 * priority=4:  delta = (4 * 65536) / 2 = 131072
 *              scan = 131072 * 50000 / 1048577 ≈ 6250
 *
 * priority=0:  delta = (4 * 1048576) / 2 = 2097152
 *              scan = 2097152 * 50000 / 1048577 ≈ 100000
 */
prioritynr_to_scan (LRU 1M 기준)total_scan (freeable=50K, seeks=2)회수 강도
12 (DEF_PRIORITY)256~24최소 (kswapd 초기)
101024~98낮음
84096~390중간
616384~1562높음 (직접 회수)
465536~6250매우 높음
2262144~25000공격적
01048576~100000최대 (OOM 직전)
priority=0의 위험: priority가 0에 도달하면 total_scanfreeable의 2배까지 증가할 수 있습니다. 이는 캐시의 거의 전부를 회수하는 것을 의미합니다. 이 시점에서 shrinker가 충분히 회수하지 못하면 OOM killer가 호출됩니다. 따라서 priority=0에서의 scan_objects는 가능한 모든 항목을 회수해야 합니다.

사용자 공간에서의 메모리 압력 감지

/* PSI(Pressure Stall Information)를 활용한 압력 모니터링 */

#include <stdio.h>
#include <poll.h>
#include <fcntl.h>
#include <unistd.h>

/* 사용자 공간에서 메모리 압력 이벤트 감지 */
int monitor_memory_pressure(void)
{
    int fd;
    struct pollfd fds;
    char trigger[] = "some 500000 1000000";
    /* 1초 윈도우에서 500ms 이상 some 압력 시 이벤트 */

    fd = open("/proc/pressure/memory", O_RDWR | O_NONBLOCK);
    if (fd < 0) return -1;

    /* PSI 트리거 등록 */
    write(fd, trigger, sizeof(trigger) - 1);

    fds.fd = fd;
    fds.events = POLLPRI;

    while (1) {
        int ret = poll(&fds, 1, -1);
        if (ret > 0) {
            printf("Memory pressure detected!\n");
            /* 애플리케이션 캐시 축소, 비필수 할당 중단 등 */
            reduce_app_cache();
        }
    }

    close(fd);
    return 0;
}
메모리 압력 대응 전략: PSI 모니터링은 Android의 lmkd(Low Memory Killer Daemon)가 사용하는 핵심 메커니즘입니다. 사용자 공간 데몬이 PSI 이벤트를 감지하면 우선순위가 낮은 프로세스를 선제적으로 종료하여 OOM을 방지합니다. 이는 커널 shrinker와 상호 보완적으로 동작합니다.
다음 학습:
  • 메모리 관리 (기초) — 페이지 할당자와 kswapd 개요
  • 메모리 관리 (심화) — LRU, 메모리 압박, OOM 상세
  • Page Cache — dentry/inode shrinker가 관리하는 캐시 레이어
  • Slab Allocator — 슬랩 캐시의 내부 구조와 회수 메커니즘
  • LRU Cache — LRU 리스트 관리와 list_lru 인프라
  • zswap — 압축 스왑 캐시와 zswap shrinker
  • NUMA — NUMA 토폴로지와 per-node 메모리 관리
  • cgroups — 메모리 cgroup과 memcg-aware 회수
  • IDR/IDA — shrinker 등록 시 내부적으로 사용하는 ID 할당 메커니즘