Shrinker (메모리 회수(Memory Reclaim) 콜백(Callback))

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

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

핵심 요약

  • struct shrinker — 회수 콜백 등록 구조체 (count_objects + scan_objects 2개 함수). include/linux/shrinker.h에 정의되어 있습니다.
  • count_objects — "지금 회수 가능한 객체가 몇 개냐?" 질의 콜백. 경량(O(1))이어야 하며 atomic 카운터 또는 list_lru_shrink_count()를 사용합니다.
  • scan_objects — "N개 객체를 실제로 회수하라" 요청 콜백. trylock 패턴을 사용하여 잠금 대기를 피해야 합니다.
  • shrink_slab — 커널이 등록된 모든 shrinker를 순회·호출하는 함수. mm/shrinker.c에 구현되어 있으며, shrink_node()에서 호출됩니다.
  • SHRINKER_MEMCG_AWARE — memcg(메모리 cgroup)별 회수 지원 플래그. 컨테이너/Kubernetes 환경에서 정확한 cgroup별 회수에 필수입니다.
  • 새 API (6.7+)shrinker_alloc()/shrinker_register()/shrinker_free()로 기존 register_shrinker()를 대체합니다.
# Shrinker 상태 빠른 확인

# 1) 등록된 shrinker 목록 확인 (debugfs)
cat /sys/kernel/debug/shrinker
# 출력: shrinker_name count_objects scan_objects nr_deferred

# 2) 슬랩 캐시 현황 (회수 가능한 SReclaimable)
grep -E "^(SReclaimable|SUnreclaim|Slab)" /proc/meminfo

# 3) dentry/inode 캐시 상태 (가장 큰 shrinker 대상)
cat /proc/sys/fs/dentry-state    # total, unused, age_limit, ...
cat /proc/sys/fs/inode-state     # total, free, preshrink

# 4) 수동 캐시 drop (테스트/디버깅용)
echo 3 > /proc/sys/vm/drop_caches  # page cache + slab

# 5) vmscan tracepoint로 shrinker 호출 추적
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_pipe

단계별 이해

  1. 메모리 압박 감지
    kswapd(balance_pgdat()) 또는 직접 페이지 할당 경로(__alloc_pages_slowpath()try_to_free_pages())에서 free 페이지가 워터마크 이하로 내려가면 shrink_node()가 호출되어 회수 경로가 시작됩니다.
  2. shrink_slab 호출
    shrink_node() 내부에서 shrink_slab()(mm/shrinker.c)을 호출하면, RCU 기반으로 등록된 shrinker 리스트를 순회하며 각 shrinker의 count_objects()를 질의합니다.
  3. 회수 우선순위(Priority) 계산
    do_shrink_slab()(mm/shrinker.c)에서 각 shrinker의 count_objects() 응답값과 seeks 힌트, 그리고 nr_deferred(이전 회수에서 미처리된 양)를 기반으로 실제 회수 비율(delta)을 계산합니다.
  4. scan_objects 호출
    계산된 수량만큼 scan_objects()가 호출됩니다. 콜백은 실제 캐시 항목을 LRU에서 제거하고 메모리를 반환하며, 실제 해제한 객체 수를 반환합니다. 회수하지 못한 양은 nr_deferred에 누적됩니다.
  5. 회수 결과 집계
    shrink_slab()이 모든 shrinker의 결과를 집계하여 총 회수량을 반환합니다. shrink_node()는 LRU 회수량과 합산하여 목표 달성 여부를 판단하고, 부족하면 priority를 낮추거나 OOM 킬러로 진행합니다.

개요

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

Shrinker의 역사

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

커널 버전변경 사항커밋/패치(Patch)
~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()워터마크(Watermark) 이하 시 주기적 회수
직접 회수__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 / scan_objectsshrinker의 핵심 콜백 2개입니다. count_objects는 현재 회수 가능한 객체 수를 반환하고, scan_objects는 실제로 객체를 해제합니다. include/linux/shrinker.h에 정의되어 있습니다.
  • batch한 번의 scan_objects 호출에서 최소한 처리할 객체 수입니다. 0이면 커널 기본값 SHRINK_BATCH(128)을 사용합니다.
  • seeks회수된 객체를 다시 생성하는 비용을 디스크 탐색 횟수로 추정한 값입니다. DEFAULT_SEEKS(=2)가 기본이며, 값이 클수록 do_shrink_slab()에서 계산되는 total_scan이 작아져 회수 우선순위가 낮아집니다.
  • flagsSHRINKER_NUMA_AWARE, SHRINKER_MEMCG_AWARE, SHRINKER_NONSLAB 등의 플래그 조합으로, shrinker의 동작 범위와 특성을 지정합니다.
  • nr_deferredper-node 지연 회수 카운터 배열입니다. 이전 회수에서 처리하지 못한 수량이 여기에 누적되며, mm/vmscan.cdo_shrink_slab()이 다음 회수 시 이 값을 합산하여 더 많은 회수를 요청합니다.
  • refcount / done / rcuLinux 6.7+ 새 API에서 안전한 해제를 위한 내부 필드들입니다. shrinker_free() 호출 시 refcount가 0이 될 때까지 대기하고, RCU grace period 이후에 메모리가 해제됩니다.

주요 필드 상세

필드타입설명설정 주체
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이면 전역) */
};
코드 설명
  • gfp_mask메모리 할당 컨텍스트를 나타내는 GFP 플래그입니다. __GFP_FS가 없으면 파일시스템 shrinker가 호출되지 않아 교착 상태를 방지합니다. mm/vmscan.cshrink_slab()이 이 값을 설정합니다.
  • nid대상 NUMA 노드 번호입니다. SHRINKER_NUMA_AWARE 플래그가 설정된 shrinker에서 노드별로 분리된 캐시를 회수할 때 사용합니다. NUMA_NO_NODE이면 전체 노드를 대상으로 합니다.
  • nr_to_scando_shrink_slab()이 계산한 회수 요청 수입니다. 값이 0이면 count_objects만 호출하는 dry-run(카운트 전용) 모드입니다.
  • nr_scannedscan_objects 콜백이 실제로 스캔한 객체 수를 기록하는 출력 필드입니다. 회수 통계 집계에 사용됩니다.
  • memcg대상 메모리 cgroup 포인터입니다. SHRINKER_MEMCG_AWARE shrinker에서 cgroup별 캐시만 선택적으로 회수할 때 사용합니다. NULL이면 전역(root) 회수입니다.
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;
}
코드 설명
  • 1단계: count_objects 질의count_objects()를 호출하여 현재 회수 가능한 객체 수(freeable)를 얻습니다. 0 또는 SHRINK_EMPTY이면 회수할 것이 없으므로 즉시 반환합니다.
  • 2단계: total_scan 계산atomic_long_xchg()로 이전에 누적된 nr_deferred 값을 가져오고 0으로 리셋합니다. 이 값에 freeableseeks를 기반으로 계산한 새 요청량을 더하여 total_scan을 결정합니다. mm/vmscan.c에 구현되어 있습니다.
  • 3단계: scan_objects 실행total_scan(freeable + 1) * 2로 상한 제한하여 과도한 회수를 방지한 뒤, scan_objects()를 호출합니다.
  • 4단계: 미회수분 저장실제 스캔 수가 total_scan보다 적으면 차이를 nr_deferred에 다시 누적합니다. 이 메커니즘으로 다음 회수 시 부족분이 자동으로 보충됩니다.
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회수 중단 (예: 잠금(Lock) 획득 실패). 이 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 격리(Isolation) 캐시컨테이너별 메모리 제한 준수
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);
코드 설명
  • shrinker_alloc()Linux 6.7+에서 도입된 할당 함수입니다. flagsSHRINKER_NUMA_AWARE, SHRINKER_MEMCG_AWARE 등을 지정하고, printf 형식 이름을 전달하여 /sys/kernel/debug/shrinker에서 식별 가능하게 합니다. mm/shrinker.c에 구현되어 있습니다.
  • shrinker_register()count_objects, scan_objects, seeks 등의 필드를 설정한 후 호출합니다. 전역 shrinker_list에 등록되어 이 시점부터 shrink_slab()에 의해 콜백이 호출될 수 있습니다.
  • shrinker_free()등록 해제와 메모리 해제를 동시에 수행합니다. 내부적으로 refcount 드레인 후 RCU grace period를 거쳐 안전하게 메모리를 해제합니다. 등록 전에 호출해도 안전합니다(초기화 실패 처리용).
새 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();
}
코드 설명
  • my_init: 1단계 할당shrinker_alloc()으로 shrinker 구조체를 동적 할당합니다. SHRINKER_NUMA_AWARE | SHRINKER_MEMCG_AWARE 플래그로 NUMA 노드별, memcg별 독립 회수를 지원합니다. 실패 시 -ENOMEM을 반환합니다.
  • my_init: 2단계 설정콜백 함수(count_objects, scan_objects)와 파라미터(seeks, batch)를 설정합니다. 아직 등록 전이므로 콜백이 호출되지 않아 안전합니다.
  • my_init: 초기화 실패 처리init_my_cache() 실패 시 shrinker_free()를 호출합니다. 등록 전이므로 전역 리스트에서 제거할 필요 없이 즉시 메모리만 해제됩니다. 이것이 새 API의 핵심 장점입니다.
  • my_init: 3단계 등록shrinker_register() 호출 이후부터 shrink_slab()이 이 shrinker의 콜백을 호출할 수 있습니다. 모든 초기화가 완료된 후에 등록하여 불완전한 상태에서의 콜백 호출을 방지합니다.
  • my_exitshrinker_free()가 등록 해제 + 진행 중인 콜백 완료 대기 + 메모리 해제를 원자적으로 수행합니다. 그 이후에 destroy_my_cache()를 호출하므로 캐시 해제 시점에 콜백이 실행되지 않음을 보장합니다.
Shrinker 라이프사이클 상태 머신 Unallocated shrinker_alloc() Allocated (필드 설정 가능) shrinker_register() Registered (콜백 활성) shrinker_free() Unregistering (refcount 드레인) kfree_rcu() RCU Grace Period → Freed 초기화 실패 시 shrinker_free() — 등록 전이므로 즉시 해제 Registered 상태에서만 shrink_slab()이 count_objects/scan_objects 콜백을 호출 shrinker_free()는 진행 중인 콜백 완료를 대기(refcount drain)한 뒤 RCU grace period 후 메모리 해제

레거시 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()이 동적 할당
등록 실패 처리모든 초기화 후 등록, 실패 시 롤백(Rollback) 복잡할당 후 등록 전에 초기화, 실패 시 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

컨테이너(Container) 환경에서 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 플래그 검증__GFP_FS가 없으면 즉시 반환합니다. 파일시스템 내부에서 GFP_NOFS로 할당할 때 파일시스템 shrinker 재진입을 방지하여 교착 상태를 막는 핵심 안전장치입니다.
  • RCU 보호 순회rcu_read_lock() 하에 list_for_each_entry_rcu()로 전역 shrinker_list를 순회합니다. Linux 6.0에서 도입된 lockless 패턴으로, 기존의 shrinker_rwsem 병목을 제거했습니다.
  • memcg 필터링memcg가 지정된 경우 SHRINKER_MEMCG_AWARE 플래그가 없는 shrinker는 건너뜁니다. cgroup 메모리 한도 초과 시 해당 cgroup의 캐시만 선택적으로 회수하기 위한 필터입니다.
  • shrinker_try_get / shrinker_putrefcount 기반 생존 보장입니다. shrinker_free()로 해제 중인 shrinker는 shrinker_try_get()이 실패하여 건너뜁니다. RCU 잠금을 해제한 뒤 do_shrink_slab()을 호출하고, 완료 후 shrinker_put()으로 참조를 반환합니다.
  • do_shrink_slab 호출각 shrinker에 대해 do_shrink_slab()을 호출하여 count_objects + scan_objects를 실행합니다. 반환된 해제 수를 freed에 누적합니다. mm/shrinker.c에 구현되어 있습니다.
GFP_NOFS 컨텍스트: 파일시스템 내부에서 메모리를 할당할 때 GFP_NOFS를 사용하면 __GFP_FS 플래그가 없으므로 파일시스템 shrinker(dentry, inode 등)가 호출되지 않습니다. 이는 교착 상태(Deadlock)를 방지하기 위한 설계입니다.

메모리 압박 전파 메커니즘

메모리 압박이 감지되면 커널은 단계적으로 회수 강도를 높여갑니다. 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);
}
코드 설명
  • 호출 경로 주석mem_cgroup_charge()에서 시작하여 try_charge()try_to_free_mem_cgroup_pages()shrink_node()shrink_slab()으로 이어지는 memcg 압박 전파 경로입니다. mm/memcontrol.cmm/vmscan.c에 걸쳐 구현되어 있습니다.
  • SHRINKER_MEMCG_AWARE 필터shrink_slab() 내부에서 memcg가 전달되면 SHRINKER_MEMCG_AWARE 플래그가 없는 shrinker는 건너뜁니다. per-memcg nr_deferred 배열을 사용하여 cgroup별로 지연 회수를 독립 관리합니다.
  • sc->memcg NULL 분기SHRINKER_MEMCG_AWARE로 등록해도 전역 회수(root cgroup) 시에는 sc->memcgNULL로 전달됩니다. 반드시 두 경우를 모두 처리해야 하며, NULL 체크 없이 per-memcg 데이터에 접근하면 커널 패닉이 발생합니다.
memcg NULL 체크 필수: SHRINKER_MEMCG_AWARE로 등록하더라도 sc->memcgNULL로 호출되는 경우(전역 회수)가 있습니다. NULL 체크 없이 per-memcg 데이터에 접근하면 커널 패닉(Kernel Panic)이 발생합니다.

커널 주요 Shrinker 사용처

서브시스템회수 대상파일플래그
dentry 캐시사용되지 않는 dentryfs/dcache.cNUMA | MEMCG
inode 캐시사용되지 않는 inodefs/inode.cNUMA | MEMCG
BtrfsB-tree 블록 캐시fs/btrfs/super.c0
XFSinode/dquot 버퍼(Buffer)fs/xfs/xfs_icache.cNUMA | MEMCG
NFSdcache/inodefs/nfs/super.c0
GPU (DRM)GPU 버퍼 객체drivers/gpu/drm/*/0
네트워크연결 추적(Connection Tracking) 항목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
zswap압축 스왑 캐시 항목mm/zswap.cMEMCG
z3fold압축 페이지 풀mm/z3fold.c0
zsmalloc압축 슬랩 페이지mm/zsmalloc.c0
quota디스크 할당량 캐시fs/quota/dquot.c0
cephMDS 캐시 (cap/dentry)fs/ceph/super.c0
gfs2glock/inode 캐시fs/gfs2/glock.c0
f2fsextent/NAT 캐시fs/f2fs/super.cMEMCG
bcacheB-tree 노드 캐시drivers/md/bcache/btree.c0
dm-bufio블록 버퍼 캐시drivers/md/dm-bufio.c0
# 커널 주요 shrinker 사용 현황 확인

# 1) 현재 시스템에 등록된 shrinker 목록
cat /sys/kernel/debug/shrinker

# 2) dentry/inode 캐시 크기 (가장 큰 shrinker 대상)
grep -E "^(dentry|inode_cache)" /proc/slabinfo | awk '{print $1, $2, $3}'

# 3) 파일시스템별 캐시 사용량 (마운트된 FS)
grep -E "^(SReclaimable|SUnreclaim)" /proc/meminfo

# 4) XFS shrinker 동작 확인
grep "xfs" /sys/kernel/debug/shrinker 2>/dev/null

# 5) GPU shrinker 확인 (DRM 드라이버)
grep "drm\|gpu" /sys/kernel/debug/shrinker 2>/dev/null

위 표에서 볼 수 있듯이, 커널 shrinker는 크게 네 가지 범주로 분류됩니다. 파일시스템(FS) 계열이 가장 많으며, 메모리 서브시스템, 드라이버, 네트워크 순입니다. 각 범주의 특성과 분포를 아래 다이어그램으로 정리합니다.

커널 Shrinker 범주별 분류 파일시스템 (FS) dentry/inode XFS Btrfs ext4 NFS ceph gfs2 f2fs quota 메모리 서브시스템 workingset zswap z3fold zsmalloc 드라이버 DRM/GPU bcache dm-bufio 네트워크 nf_conntrack sunrpc MEMCG_AWARE: dentry, inode, XFS, ext4, workingset, zswap, f2fs NUMA_AWARE: dentry, inode, XFS — 대규모 NUMA 서버에서 per-node 회수 지원

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_cache_count: container_ofcontainer_of()로 shrinker 포인터에서 소유자인 struct super_block을 역참조합니다. 각 마운트된 파일시스템의 super_block이 자체 shrinker(s_shrink)를 보유하는 구조입니다. fs/dcache.c에 구현되어 있습니다.
  • super_cache_count: list_lru_shrink_countdentry LRU(s_dentry_lru)와 inode LRU(s_inode_lru)의 미사용 항목 수를 합산합니다. list_lru는 NUMA/memcg-aware LRU 인프라로, shrink_controlnidmemcg에 맞는 항목만 카운트합니다.
  • super_cache_count: SHRINK_EMPTY합산이 0이면 SHRINK_EMPTY를 반환하여 이 super_block에 회수 대상이 없음을 알립니다. shrink_slab()은 이 값을 받으면 해당 shrinker를 건너뜁니다.
  • super_cache_scan: trylock_supers_umount 읽기 잠금을 trylock으로 시도합니다. 실패하면 SHRINK_STOP을 반환하여 이 shrinker에 대한 회수를 중단합니다. umount 진행 중인 파일시스템과의 교착을 방지합니다.
  • super_cache_scan: prune_dcache_sb / prune_icache_sbdentry를 먼저 회수합니다. dentry 해제 시 참조 카운트가 0이 된 inode가 연쇄적으로 해제되므로, dentry 우선 회수가 효율적입니다. fs/dcache.cfs/inode.c에 각각 구현되어 있습니다.
  • super_cache_scan: free_cached_objects파일시스템별 추가 캐시(예: XFS 버퍼, ext4 extent status)를 s_op->free_cached_objects()를 통해 회수합니다. 이 콜백은 선택적이며, 구현하지 않는 파일시스템도 있습니다.
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;
}

static unsigned long
btrfs_cache_shrink_scan(struct shrinker *shrink,
                        struct shrink_control *sc)
{
    struct btrfs_fs_info *fs_info;
    long nr_to_scan = sc->nr_to_scan;
    long freed = 0;

    fs_info = container_of(shrink, struct btrfs_fs_info,
                             shrinker);

    /*
     * extent_map 퇴거(eviction) 전략:
     * 1. 참조 카운트가 0인 extent_map을 LRU 순서로 탐색
     * 2. PINNED/LOGGING 플래그가 없는 항목만 회수 대상
     * 3. 메타데이터 예약 공간 확보를 위해 stale 항목 우선 제거
     */
    freed = btrfs_free_extent_maps(fs_info, nr_to_scan);

    return freed;
}

Btrfs의 extent_map 퇴거 전략은 다음과 같은 우선순위를 따릅니다:

Btrfs shrinker 진화: Linux 6.3 이전에는 Btrfs에 전용 shrinker가 없었으며, extent_map은 inode 해제 시에만 정리되었습니다. Linux 6.3에서 btrfs_free_extent_maps()와 함께 전용 shrinker가 도입되어 메모리 압박 시 능동적으로 extent_map을 회수할 수 있게 되었습니다. 이전 커널에서는 대규모 Btrfs 볼륨에서 extent_map이 수 GB까지 누적되어 OOM을 유발하는 사례가 보고되었습니다.

DRM (GPU) Shrinker

GPU 드라이버의 shrinker는 GPU 메모리에서 시스템 메모리로 스왑(Swap) 가능한 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);
}

workingset Shadow Nodes Shrinker

workingset shrinker는 페이지 캐시(Page Cache)에서 퇴거된 페이지의 그림자(shadow) 항목을 관리합니다. 페이지가 퇴거되면 해당 슬롯(slot)에 shadow 항목이 남아 재결함 거리(refault distance)를 추적합니다. 이 shadow 항목들이 과도하게 누적되면 XArray 노드 메모리를 소모하므로, shrinker가 오래된 shadow 노드를 회수합니다.

/* mm/workingset.c - shadow node shrinker (단순화) */
static unsigned long
shadow_lru_count(struct shrinker *shrinker,
                  struct shrink_control *sc)
{
    struct list_lru *shadow_nodes = &workingset_shadow_nodes;
    unsigned long count;

    /* shadow 노드가 저장된 list_lru의 항목 수를 반환
     * SHRINKER_NONSLAB 플래그: 슬랩이 아닌 별도 LRU로 관리 */
    count = list_lru_shrink_count(shadow_nodes, sc);
    return count;
}

static unsigned long
shadow_lru_scan(struct shrinker *shrinker,
                 struct shrink_control *sc)
{
    /* XArray 노드에서 shadow 항목만 제거
     * 노드의 모든 슬롯이 비면 노드 자체를 해제
     *
     * 콜백: shadow_lru_isolate()
     * - xa_node에서 exceptional 항목(shadow) 제거
     * - 빈 노드를 LRU에서 분리하고 free */
    return list_lru_shrink_walk(&workingset_shadow_nodes, sc,
                                  shadow_lru_isolate, NULL);
}
재결함 거리(Refault Distance)와 shadow 항목: 페이지가 퇴거될 때 현재의 비활성(inactive) 리스트 위치를 shadow 항목에 기록합니다. 나중에 같은 페이지가 다시 읽히면(refault), 퇴거 시점과 현재 시점의 비활성 리스트 크기 차이로 재결함 거리를 계산합니다. 이 거리가 활성(active) 리스트 크기보다 짧으면, 해당 페이지는 즉시 활성 리스트로 승격됩니다. shadow 항목이 너무 많으면 XArray 노드가 과도한 메모리를 차지하므로, workingset shrinker가 오래된 shadow를 정리합니다. 일반적으로 shadow 항목은 실제 페이지 캐시 크기의 50% 정도까지 유지됩니다.

nf_conntrack Shrinker

네트워크 연결 추적(Connection Tracking) 테이블은 방화벽(Firewall)과 NAT의 핵심 자료구조입니다. nf_conntrack shrinker는 메모리 압박 시 만료(expired)되었거나 미확인(unconfirmed) 상태의 conntrack 항목을 조기에 정리합니다.

/* net/netfilter/nf_conntrack_core.c (단순화) */
static unsigned long
nf_conntrack_count_objects(struct shrinker *shrink,
                           struct shrink_control *sc)
{
    struct net *net = container_of(shrink, struct net,
                                     ct.nf_conntrack_shrinker);
    /* 현재 네트워크 네임스페이스의 conntrack 항목 수 */
    return atomic_read(&net->ct.count);
}

static unsigned long
nf_conntrack_scan_objects(struct shrinker *shrink,
                          struct shrink_control *sc)
{
    struct net *net = container_of(shrink, struct net,
                                     ct.nf_conntrack_shrinker);
    unsigned int nr_to_scan = sc->nr_to_scan;
    unsigned int freed = 0;

    /*
     * 해시 테이블(Hash Table)을 순회하면서:
     * 1. 이미 만료된(expired) conntrack 항목 제거
     * 2. 확인되지 않은(unconfirmed) 항목 제거
     * 3. nf_ct_is_dying() 상태의 항목 정리
     *
     * 주의: 활성 연결은 절대 건드리지 않음
     */
    freed = nf_conntrack_shrink_scan(net, nr_to_scan);
    return freed;
}
conntrack 테이블 크기 vs shrinker: nf_conntrack_max는 conntrack 테이블의 최대 항목 수를 제한하지만, shrinker는 테이블이 가득 차기 전에 메모리 압박에 대응하여 만료 항목을 조기 정리합니다. 대규모 NAT 게이트웨이에서 nf_conntrack_max를 너무 크게 설정하면 conntrack 슬랩이 수 GB까지 증가할 수 있으므로, shrinker의 역할이 중요합니다. sysctl net.netfilter.nf_conntrack_count로 현재 항목 수를 모니터링하세요.

ext4 Extent Status Shrinker

ext4 파일시스템은 파일의 디스크 블록 매핑 정보를 extent status tree에 캐싱합니다. 이 캐시는 읽기/쓰기 경로에서 extent 조회 성능을 높이지만, 파일 수가 많아지면 상당한 메모리를 차지합니다. ext4 extent shrinker는 SHRINKER_MEMCG_AWARE 플래그를 사용하여 컨테이너별로 정확한 회수를 지원합니다.

/* fs/ext4/extents_status.c (단순화) */
static unsigned long
ext4_es_count(struct shrinker *shrink,
               struct shrink_control *sc)
{
    struct ext4_sb_info *sbi;
    unsigned long nr;

    sbi = container_of(shrink, struct ext4_sb_info,
                          s_es_shrinker);
    /* 캐싱된 extent status 항목 중 회수 가능한 수 */
    nr = percpu_counter_read_positive(&sbi->s_es_stats.es_stats_shk_cnt);
    return nr;
}

static unsigned long
ext4_es_scan(struct shrinker *shrink,
              struct shrink_control *sc)
{
    struct ext4_sb_info *sbi;
    unsigned long nr_to_scan = sc->nr_to_scan;
    unsigned long freed = 0;

    sbi = container_of(shrink, struct ext4_sb_info,
                          s_es_shrinker);

    /*
     * inode별로 순회하며 extent status 항목 회수:
     * 1. s_es_list (LRU 순서)에서 inode를 꺼냄
     * 2. 해당 inode의 extent status tree에서 캐시 항목 제거
     * 3. 디스크에서 다시 읽을 수 있는 항목만 제거 (delayed 항목 제외)
     *
     * memcg-aware: sc->memcg가 설정되면 해당 cgroup의 inode만 처리
     */
    freed = __ext4_es_shrink(sbi, nr_to_scan, sc);
    return freed;
}
memcg-aware 동작: ext4 extent shrinker는 SHRINKER_MEMCG_AWARE이므로, 컨테이너 A의 메모리 한도 초과 시 컨테이너 A의 inode에 속한 extent 캐시만 회수됩니다. 이는 멀티테넌트(multi-tenant) 환경에서 한 컨테이너의 메모리 압박이 다른 컨테이너의 파일 접근 성능에 영향을 주지 않도록 보장합니다. /sys/kernel/debug/shrinker/ext4-es-*에서 per-memcg 카운트를 확인할 수 있습니다.

NUMA-aware Shrinker

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

NUMA 토폴로지(Topology)와 회수 전략

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 서버, 메모리 집약적 워크로드단일 노드, 소규모 캐시

메모리 핫플러그(Hotplug)와 Shrinker

메모리 핫플러그(Memory Hotplug)는 시스템 운영 중에 NUMA 노드를 추가하거나 제거하는 기능입니다. 이 과정에서 shrinker의 per-node 자료구조가 올바르게 조정되어야 합니다.

영향을 받는 자료구조

메모리 핫플러그 노티파이어 체인에서의 shrinker 관련 처리:

MEM_GOING_ONLINE
  • list_lru_memcg_alloc()로 새 노드의 per-node LRU를 초기화합니다.
  • memcg shrinker_map에 새 노드 차원을 추가합니다.
MEM_CANCEL_ONLINE / MEM_OFFLINE
  • 해당 노드의 캐시 항목을 다른 노드로 마이그레이션합니다.
  • per-node nr_deferred 값을 초기화합니다.
  • list_lru에서 해당 노드의 리스트를 비웁니다.
핫플러그 관련 알려진 버그: 일부 커널 버전에서 메모리 핫플러그 중 shrinker 등록/해제가 동시에 발생하면 shrinker_rwsem 데드락(deadlock)이 발생할 수 있습니다. Linux 6.4에서 shrinker_rwsemshrinker_mutex로 변경되고, 6.7에서 RCU 기반 순회로 전환되면서 이 문제가 해결되었습니다. 프로덕션에서 메모리 핫플러그를 사용하는 경우, Linux 6.7 이상을 권장합니다.

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를 통해 메모리 압박의 심각도를 실시간(Real-time)으로 확인할 수 있습니다. 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 프로세스(Process) 증가mutex_trylock() 사용, 실패 시 SHRINK_STOP
count에서 O(N) 리스트 순회높은 CPU 사용, 잠금 경합(Lock Contention)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 경합(Contention) 없이 빠르게 추정값 반환 (atomic 카운터 권장)
  2. scan 안전성: 회수 중 리스트/객체 수명주기 보호 (trylock + 잠금 밖 해제)
  3. 재귀 회수 방지: 콜백 내부 과도한 할당/슬립(Sleep) 금지 (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 구현 체크리스트 코드 예시 */

/* [1] count 경량화: atomic 카운터 사용 */
static unsigned long example_count(struct shrinker *s,
                                    struct shrink_control *sc)
{
    /* O(1) — 잠금 없이 즉시 반환 */
    return atomic_long_read(&nr_cached);
}

/* [2] scan 안전성: trylock + batch 분할 */
static unsigned long example_scan(struct shrinker *s,
                                   struct shrink_control *sc)
{
    if (!mutex_trylock(&cache_mutex))
        return SHRINK_STOP;  /* [3] 잠금 실패 시 즉시 포기 */

    /* ... 회수 작업 ... */
    mutex_unlock(&cache_mutex);
    return freed;
}

/* [4] memcg NULL 체크 (SHRINKER_MEMCG_AWARE) */
static unsigned long memcg_aware_count(struct shrinker *s,
                                        struct shrink_control *sc)
{
    /* 전역 회수 시 sc->memcg가 NULL일 수 있음 */
    if (!sc->memcg)
        return global_count();
    return per_memcg_count(sc->memcg);
}

/* [5] 재귀 회수 방지: GFP_NOWAIT 사용 */
/* scan 콜백 내부에서 메모리 할당이 필요한 경우 */
buf = kmalloc(size, GFP_NOWAIT | __GFP_NOWARN);
if (!buf)
    return SHRINK_STOP;  /* 할당 실패 시 회수 중단 */
코드 설명 체크리스트의 각 항목에 대응하는 코드 패턴입니다. count 콜백은 atomic 카운터로 O(1) 반환, scan 콜백은 trylock으로 잠금 실패 시 SHRINK_STOP 반환, memcg-aware shrinker는 sc->memcg NULL 체크 필수, 콜백 내부 할당은 GFP_NOWAIT으로 재귀 회수를 방지합니다.
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를 포함한 완전한 커널 모듈(Kernel Module) 예제입니다. 실제 프로덕션 수준의 패턴을 따릅니다.

/*
 * 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를 통한 마지막 회수 시도를 합니다.

단순화된 OOM 결정 흐름 (mm/oom_kill.c):

  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과의 관계

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

항목Shrinker (shrink_slab)Compaction
목적캐시 해제로 free 페이지 수 증가페이지 이동(Page Migration)으로 연속 영역 확보
호출 시점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()
# 소스 코드 탐색 가이드: shrinker 관련 주요 검색 명령어

# 1) 등록된 모든 shrinker 찾기 (새 API: 6.7+)
grep -rn "shrinker_alloc\|shrinker_register" --include="*.c" | grep -v "test"

# 2) 레거시 API 사용처 찾기 (6.7 이전)
grep -rn "register_shrinker\|unregister_shrinker" --include="*.c"

# 3) count_objects/scan_objects 콜백 구현 찾기
grep -rn "\.count_objects\s*=" --include="*.c"
grep -rn "\.scan_objects\s*=" --include="*.c"

# 4) shrinker 플래그 사용 현황
grep -rn "SHRINKER_MEMCG_AWARE\|SHRINKER_NUMA_AWARE" --include="*.c"

# 5) shrink_slab 호출 경로 추적 (cscope/ctags)
cscope -dL -1 shrink_slab      # 정의 찾기
cscope -dL -3 shrink_slab      # 호출하는 함수 찾기
cscope -dL -3 do_shrink_slab   # 개별 shrinker 호출 로직

# 6) list_lru 관련 구현 추적
grep -rn "list_lru_shrink_walk\|list_lru_shrink_count" --include="*.c"

# 7) nr_deferred 메커니즘 분석
grep -n "nr_deferred" mm/shrinker.c include/linux/shrinker.h
코드 설명 커널 소스에서 shrinker 관련 코드를 탐색하는 실용적인 명령어 모음입니다. 새 API(shrinker_alloc)와 레거시 API(register_shrinker)를 각각 검색하여 마이그레이션 현황을 파악할 수 있습니다. cscope를 활용하면 호출 체인을 빠르게 추적할 수 있습니다.

구현 권장 사항

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

/* [권장] count_objects: atomic 카운터로 O(1) 반환 */
static unsigned long my_count(struct shrinker *shrink,
                              struct shrink_control *sc)
{
    struct my_cache *cache = shrink->private_data;
    return atomic_long_read(&cache->nr_items);
}

/* [권장] scan_objects: trylock + 잠금 밖 해제 */
static unsigned long my_scan(struct shrinker *shrink,
                             struct shrink_control *sc)
{
    struct my_cache *cache = shrink->private_data;
    unsigned long freed = 0;
    LIST_HEAD(dispose);

    /* trylock: 실패하면 즉시 반환 (system stall 방지) */
    if (!spin_trylock(&cache->lock))
        return SHRINK_STOP;

    /* 잠금 안에서는 리스트 분리만 수행 */
    while (freed < sc->nr_to_scan && !list_empty(&cache->lru)) {
        struct my_item *item = list_last_entry(&cache->lru,
                                struct my_item, lru_node);
        list_move(&item->lru_node, &dispose);
        freed++;
    }
    atomic_long_sub(freed, &cache->nr_items);
    spin_unlock(&cache->lock);

    /* 잠금 밖에서 실제 메모리 해제 */
    while (!list_empty(&dispose)) {
        struct my_item *item = list_first_entry(&dispose,
                                struct my_item, lru_node);
        list_del(&item->lru_node);
        kfree(item);
    }
    return freed;
}

/* [권장] list_lru 활용: NUMA/memcg-aware 자동 지원 */
static unsigned long my_lru_count(struct shrinker *shrink,
                                  struct shrink_control *sc)
{
    return list_lru_shrink_count(&my_lru, sc);
}

static unsigned long my_lru_scan(struct shrinker *shrink,
                                 struct shrink_control *sc)
{
    return list_lru_shrink_walk(&my_lru, sc, my_isolate_cb, NULL);
}
코드 설명 첫 번째 패턴은 count_objects에서 atomic 카운터를 사용하여 O(1)으로 응답하는 방법입니다. 두 번째 패턴은 scan_objects에서 trylock으로 잠금을 시도하고, 잠금 안에서는 리스트 분리만 수행하며, 실제 메모리 해제(kfree())는 잠금 밖에서 하는 권장 방식입니다. 세 번째 패턴은 list_lru 인프라를 활용하여 NUMA/memcg-aware 회수를 자동으로 지원받는 가장 간결한 방식입니다.
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)메모리까지 해제됨, 이중 해제(Double Free) 금지
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 — 잠금 해제 후 순회 재시작(Reboot)

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 단위로 등록됩니다. 각 마운트(Mount)된 파일시스템마다 별도의 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의 참조 카운트(Reference Count)가 감소합니다. 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);
}'

실전 트러블슈팅 사례

프로덕션 환경에서 shrinker 관련 문제를 진단하고 해결한 실제 사례들을 정리합니다. 각 사례에는 증상, 원인, 진단 방법, 해결 방법이 포함됩니다.

사례 1: SReclaimable 메모리가 회수되지 않는 경우

항목내용
증상 /proc/meminfoSReclaimable이 수 GB인데도 메모리 압박이 발생하고, kswapd가 활발히 동작하지만 슬랩 메모리는 줄어들지 않음
원인 커스텀 커널 모듈이 shrinker를 등록했지만, count_objects()에서 항상 0을 반환하여 shrink_slab()이 해당 캐시를 건너뜀. 실제로는 수백만 개의 객체가 캐시에 존재하지만 shrinker가 인식하지 못하는 상태
진단 방법
# 1. SReclaimable vs 실제 shrinker count 비교
grep SReclaimable /proc/meminfo
for d in /sys/kernel/debug/shrinker/*/; do
  name=$(basename "$d")
  total=$(awk '{sum+=$2} END{print sum}' "$d/count" 2>/dev/null)
  echo "$total $name"
done | sort -rn | head -10

# 2. shrinker 트레이스포인트로 호출 여부 확인
sudo perf trace -e vmscan:mm_shrink_slab_start --duration 10
해결 방법 count_objects() 콜백이 실제 캐시 크기를 정확히 반환하도록 수정. percpu_counter 또는 atomic_long으로 캐시 항목 수를 추적하고, 해당 값을 반환하도록 구현

사례 2: 특정 컨테이너에서만 OOM 발생

항목내용
증상 메모리 한도가 4GB인 컨테이너에서 실제 프로세스 RSS 합계는 2GB 이하인데 OOM이 반복적으로 발생. 호스트의 전체 메모리는 충분한 상태
원인 컨테이너 내에서 사용하는 파일시스템의 shrinker가 SHRINKER_MEMCG_AWARE 플래그 없이 등록되어 있어서, memcg 회수 경로에서 호출되지 않음. 결과적으로 해당 컨테이너의 dentry/inode 캐시가 회수되지 않아 메모리 한도 초과
진단 방법
# 1. 컨테이너의 memcg 슬랩 사용량 확인
cat /sys/fs/cgroup/memory/<container-id>/memory.stat | grep slab

# 2. memcg-aware shrinker 목록 확인
cat /sys/kernel/debug/shrinker/*/count_memcg 2>/dev/null | head

# 3. shrinker 플래그 확인 (커널 소스 또는 debugfs)
sudo bpftrace -e 'kprobe:do_shrink_slab {
  printf("shrinker=%p flags=%x memcg=%p\n",
    arg0, *(uint32*)(arg0+0), arg2);
}'
해결 방법 해당 파일시스템 shrinker에 SHRINKER_MEMCG_AWARE 플래그를 추가하고, list_lru를 memcg-aware 모드(list_lru_init_memcg())로 초기화. 상위 커널 버전으로 업데이트하여 해당 파일시스템의 memcg 지원 패치 적용

사례 3: drop_caches 후 성능 급락

항목내용
증상 운영자가 echo 3 > /proc/sys/vm/drop_caches를 실행한 후 XFS 파일시스템의 I/O 지연시간(latency)이 10배 이상 증가. 수 분간 성능이 회복되지 않음
원인 drop_caches가 XFS 버퍼 캐시(xfs_buf)를 포함한 모든 슬랩 캐시를 강제 회수. XFS의 메타데이터 버퍼(AG 헤더, B-tree 노드 등)가 모두 퇴거되어, 이후 모든 파일 접근에 디스크 I/O가 발생. 특히 XFS의 B-tree 인덱스(index)가 크면 재구축에 오랜 시간이 소요됨
진단 방법
# 1. drop_caches 전후 XFS 버퍼 캐시 크기 확인
grep xfs_buf /proc/slabinfo

# 2. I/O 지연시간 모니터링
sudo biolatency-bpfcc -D 1

# 3. XFS 메타데이터 읽기 급증 확인
sudo xfs_info /dev/sda1
sudo perf stat -e block:block_rq_issue -a sleep 10
해결 방법 프로덕션 환경에서 drop_caches 사용을 금지. 특정 캐시만 정리가 필요한 경우 echo 1(page cache만) 또는 echo 2(dentry/inode만) 사용. XFS 환경에서는 xfs_reclaim_inodes sysctl로 XFS 전용 캐시만 회수하는 방법을 권장

사례 4: shrinker 등록 후 softlockup

항목내용
증상 커스텀 드라이버 로드 직후 간헐적으로 BUG: soft lockup - CPU#N stuck for XXs! 메시지와 함께 시스템이 응답 불능 상태로 빠짐. 콜 스택(call stack)에 shrink_slab과 드라이버의 scan_objects 콜백이 포함됨
원인 드라이버의 scan_objects() 콜백 내에서 글로벌 뮤텍스(mutex)를 획득하는데, 같은 뮤텍스를 페이지 할당 경로에서도 사용. 메모리 압박 시 페이지 할당 → 직접 회수 → scan_objects() → 뮤텍스 대기 → 데드락(deadlock) 형태의 잠금 순서(lock ordering) 위반
진단 방법
# 1. softlockup 발생 시 콜 스택 확인
dmesg | grep -A 30 "soft lockup"

# 2. lockdep 활성화하여 잠금 순서 위반 감지
# CONFIG_PROVE_LOCKING=y, CONFIG_LOCKDEP=y
dmesg | grep "possible circular locking"

# 3. shrinker 콜백 실행 시간 확인
sudo bpftrace -e 'kprobe:do_shrink_slab {
  @start[tid] = nsecs;
}
kretprobe:do_shrink_slab /@start[tid]/ {
  $dur = (nsecs - @start[tid]) / 1000000;
  if ($dur > 100) {
    printf("SLOW shrinker: %d ms\n", $dur);
    print(kstack);
  }
  delete(@start[tid]);
}'
해결 방법 shrinker 콜백 내에서 페이지 할당 경로와 공유하는 잠금을 사용하지 않도록 재설계. scan_objects()에서는 trylock을 사용하고, 잠금 획득 실패 시 SHRINK_STOP을 반환하여 나중에 재시도. GFP 플래그에 __GFP_FS/__GFP_IO가 없으면 파일시스템/I/O 관련 잠금을 아예 시도하지 않도록 sc->gfp_mask를 확인
shrinker 콜백 설계 원칙 요약:
  • scan_objects()에서 장시간 블록(block)하는 잠금을 피하세요 (trylock 사용 권장)
  • count_objects()는 빠르게 실행되어야 합니다 (O(1) 또는 캐시된 값 반환)
  • sc->gfp_mask를 확인하여 __GFP_FS/__GFP_IO 부재 시 파일시스템/I/O 작업을 건너뛰세요
  • 프로덕션 배포 전 CONFIG_PROVE_LOCKING=y로 빌드하여 잠금 순서 위반을 사전 감지하세요

성능 함정과 고급 안티패턴

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

함정 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 획득페이지 폴트(Page Fault) → 회수 → 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는 다음과 같이 계산됩니다:

delta = (4 * sc->nr_to_scan) / shrinker->seeks

sc->nr_to_scan은 priority에 반비례합니다:

nr_to_scan = lruvec_lru_size >> priority

따라서 priority에 따른 nr_to_scan 변화:

계산 예시: lru_size=1,048,576 (1M 페이지), freeable=50,000, seeks=2

prioritydeltascan
12(4 * 256) / 2 = 512512 * 50000 / 1048577 ≈ 24
8(4 * 4096) / 2 = 8,1928192 * 50000 / 1048577 ≈ 390
4(4 * 65536) / 2 = 131,072131072 * 50000 / 1048577 ≈ 6,250
0(4 * 1048576) / 2 = 2,097,1522097152 * 50000 / 1048577 ≈ 100,000
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는 가능한 모든 항목을 회수해야 합니다.

사용자 공간(User Space)에서의 메모리 압력 감지

/* 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와 상호 보완적으로 동작합니다.

MGLRU와 Shrinker 연동

MGLRU(Multi-Gen LRU)는 Linux 6.1에서 도입된 새로운 페이지 회수 프레임워크입니다. 기존의 활성(active)/비활성(inactive) 2개 LRU 리스트 대신, 여러 세대(generation)로 페이지를 분류하여 더 정확한 에이징(aging)과 퇴거(eviction)를 수행합니다. MGLRU는 페이지 에이징에는 lru_gen_look_around()이라는 새로운 메커니즘을 사용하지만, 슬랩 캐시 회수에는 기존 shrinker 인터페이스를 그대로 사용합니다.

MGLRU 회수 경로에서의 Shrinker 호출

MGLRU의 페이지 회수는 lru_gen_shrink_node()를 통해 이루어지며, 이 함수 내부에서 shrink_slab()을 호출하여 슬랩 캐시도 함께 회수합니다. 핵심 호출 경로는 다음과 같습니다:

/* mm/vmscan.c - MGLRU 회수 경로 (단순화) */

/*
 * MGLRU의 노드별 회수 진입점
 * 기존 shrink_node() 대신 호출됨
 */
static void lru_gen_shrink_node(struct pglist_data *pgdat,
                                  struct scan_control *sc)
{
    struct blk_plug plug;
    unsigned long reclaimed = 0;

    blk_start_plug(&plug);

    /* 1단계: 페이지 에이징 - 세대(generation) 기반 분류 */
    while (should_continue_reclaim(pgdat, sc)) {
        /* lru_gen_age_node()로 가장 오래된 세대의 페이지 퇴거 */
        reclaimed += evict_pages(pgdat, sc);

        /* 2단계: 슬랩 캐시 회수 - 기존 shrinker 인터페이스 사용 */
        shrink_slab(sc->gfp_mask, pgdat->node_id,
                    sc->memcg, sc->priority);
    }

    blk_finish_plug(&plug);
}

MGLRU와 기존 LRU 방식의 슬랩 회수 차이를 정리하면 다음과 같습니다:

항목기존 LRU (active/inactive)MGLRU (Multi-Gen)
페이지 에이징mark_page_accessed() + 주기적 스캔lru_gen_look_around() — PTE 접근 비트 기반
페이지 퇴거비활성 리스트 끝에서 퇴거가장 오래된 세대에서 퇴거
슬랩 회수 인터페이스shrink_slab()shrink_slab() (동일)
shrinker 콜백count_objects + scan_objectscount_objects + scan_objects (동일)
회수 비율 계산LRU 크기 기반세대별 크기 기반 (더 정확)
활성화 조건기본값CONFIG_LRU_GEN=y + lru_gen_enabled
CONFIG_LRU_GEN: MGLRU를 사용하려면 커널 빌드 시 CONFIG_LRU_GEN=y로 활성화해야 합니다. 런타임에서는 /sys/kernel/mm/lru_gen/enabled를 통해 제어할 수 있습니다. 값이 0x0007이면 MGLRU의 모든 기능(에이징, 퇴거, 멀티세대)이 활성화된 상태입니다. MGLRU가 활성화되면 기존의 shrink_inactive_list()/shrink_active_list() 대신 lru_gen_shrink_node()가 호출되지만, 슬랩 shrinker의 동작은 변하지 않습니다.
MGLRU 세대 기반 회수와 Shrinker MGLRU 페이지 세대 (Page Generations) Gen 3 (youngest) — 최근 접근된 페이지 Gen 2 — 중간 에이지 페이지 Gen 1 — 오래된 페이지 Gen 0 (oldest) — 퇴거 대상 페이지 evict_pages() 슬랩 Shrinker (기존 인터페이스) dentry shrinker inode shrinker ext4 extent shrinker workingset shrinker XFS buf shrinker ... (기타 shrinker) shrink_slab(nid, memcg, ...) lru_gen_shrink_node() MGLRU는 페이지 에이징 방식만 변경 — 슬랩 shrinker 인터페이스(count_objects/scan_objects)는 동일하게 유지

참고자료

커널 문서

LWN 기사

커널 소스

다음 학습:
  • 메모리 관리 개요 — 페이지 할당자와 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 할당 메커니즘