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) 기법까지 포괄적으로 다룹니다.
핵심 요약
- struct shrinker — 회수 콜백 등록 구조체 (
count_objects+scan_objects2개 함수).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
단계별 이해
- 메모리 압박 감지
kswapd(balance_pgdat()) 또는 직접 페이지 할당 경로(__alloc_pages_slowpath()→try_to_free_pages())에서 free 페이지가 워터마크 이하로 내려가면shrink_node()가 호출되어 회수 경로가 시작됩니다. - shrink_slab 호출
shrink_node()내부에서shrink_slab()(mm/shrinker.c)을 호출하면, RCU 기반으로 등록된 shrinker 리스트를 순회하며 각 shrinker의count_objects()를 질의합니다. - 회수 우선순위(Priority) 계산
do_shrink_slab()(mm/shrinker.c)에서 각 shrinker의count_objects()응답값과seeks힌트, 그리고nr_deferred(이전 회수에서 미처리된 양)를 기반으로 실제 회수 비율(delta)을 계산합니다. - scan_objects 호출
계산된 수량만큼scan_objects()가 호출됩니다. 콜백은 실제 캐시 항목을 LRU에서 제거하고 메모리를 반환하며, 실제 해제한 객체 수를 반환합니다. 회수하지 못한 양은nr_deferred에 누적됩니다. - 회수 결과 집계
shrink_slab()이 모든 shrinker의 결과를 집계하여 총 회수량을 반환합니다.shrink_node()는 LRU 회수량과 합산하여 목표 달성 여부를 판단하고, 부족하면 priority를 낮추거나 OOM 킬러로 진행합니다.
개요
Linux 커널은 성능을 위해 다양한 캐시를 유지합니다 (dentry, inode, 슬랩, 파일시스템별 캐시). 메모리 압박 시 이 캐시들을 회수해야 하는데, 각 서브시스템이 직접 회수 로직을 구현하면 중복이 발생합니다. Shrinker는 이를 표준화한 콜백 인터페이스입니다.
Shrinker의 역사
Shrinker 메커니즘은 Linux 초기부터 존재했지만, 그 형태는 크게 변화해 왔습니다.
| 커널 버전 | 변경 사항 | 커밋/패치(Patch) |
|---|---|---|
| ~2.6.x | set_shrinker()/remove_shrinker() 단일 콜백 인터페이스 | 초기 구현 |
| 3.0 | count/scan 2-콜백 분리, struct shrinker 도입 | Dave Chinner |
| 3.12 | NUMA-aware shrinker, shrink_control.nid 추가 | Glauber Costa |
| 4.0 | memcg-aware shrinker (SHRINKER_MEMCG_AWARE) | Vladimir Davydov |
| 5.2 | shrinker 디버깅 인터페이스 (/sys/kernel/debug/shrinker) | Yang Shi |
| 6.0 | lockless shrinker 리스트 순회 (RCU 기반) | Kirill Tkhai |
| 6.7 | shrinker_alloc()/shrinker_register()/shrinker_free() 새 API | Qi 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 |
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 값이 크면 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이 작아져 회수 우선순위가 낮아집니다. - flags
SHRINKER_NUMA_AWARE,SHRINKER_MEMCG_AWARE,SHRINKER_NONSLAB등의 플래그 조합으로, shrinker의 동작 범위와 특성을 지정합니다. - nr_deferredper-node 지연 회수 카운터 배열입니다. 이전 회수에서 처리하지 못한 수량이 여기에 누적되며,
mm/vmscan.c의do_shrink_slab()이 다음 회수 시 이 값을 합산하여 더 많은 회수를 요청합니다. - refcount / done / rcuLinux 6.7+ 새 API에서 안전한 해제를 위한 내부 필드들입니다.
shrinker_free()호출 시refcount가 0이 될 때까지 대기하고, RCU grace period 이후에 메모리가 해제됩니다.
주요 필드 상세
| 필드 | 타입 | 설명 | 설정 주체 |
|---|---|---|---|
count_objects | 함수 포인터 | 회수 가능 객체 수 반환. SHRINK_EMPTY 반환 시 캐시 비어있음 | 드라이버 |
scan_objects | 함수 포인터 | 객체 회수 실행. 실제 해제한 수 반환. SHRINK_STOP 시 중단 | 드라이버 |
batch | long | 최소 스캔 단위. 0이면 SHRINK_BATCH(128) 사용 | 드라이버 |
seeks | int | 재생성 비용. DEFAULT_SEEKS=2. 높을수록 보호됨 | 드라이버 |
flags | unsigned | 동작 플래그 조합 (NUMA, MEMCG, NONSLAB) | 드라이버 |
nr_deferred | atomic_long_t * | per-node 지연 회수 카운터 배열 | 커널 내부 |
list | list_head | 전역 shrinker_list에 연결 | 커널 내부 |
private_data | void * | 드라이버 전용 컨텍스트 저장 | 드라이버 |
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.c의shrink_slab()이 이 값을 설정합니다. - nid대상 NUMA 노드 번호입니다.
SHRINKER_NUMA_AWARE플래그가 설정된 shrinker에서 노드별로 분리된 캐시를 회수할 때 사용합니다.NUMA_NO_NODE이면 전체 노드를 대상으로 합니다. - nr_to_scan
do_shrink_slab()이 계산한 회수 요청 수입니다. 값이 0이면count_objects만 호출하는 dry-run(카운트 전용) 모드입니다. - nr_scanned
scan_objects콜백이 실제로 스캔한 객체 수를 기록하는 출력 필드입니다. 회수 통계 집계에 사용됩니다. - memcg대상 메모리 cgroup 포인터입니다.
SHRINKER_MEMCG_AWAREshrinker에서 cgroup별 캐시만 선택적으로 회수할 때 사용합니다.NULL이면 전역(root) 회수입니다.
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으로 리셋합니다. 이 값에freeable과seeks를 기반으로 계산한 새 요청량을 더하여total_scan을 결정합니다.mm/vmscan.c에 구현되어 있습니다. - 3단계: scan_objects 실행
total_scan을(freeable + 1) * 2로 상한 제한하여 과도한 회수를 방지한 뒤,scan_objects()를 호출합니다. - 4단계: 미회수분 저장실제 스캔 수가
total_scan보다 적으면 차이를nr_deferred에 다시 누적합니다. 이 메커니즘으로 다음 회수 시 부족분이 자동으로 보충됩니다.
count_objects()가 항상 실제 캐시 크기를 정확히 반환해야 nr_deferred의 폭주를 막을 수 있습니다. 과소 보고하면 지연 카운터가 계속 누적되어 다음 번에 과도한 회수 요청이 발생합니다.
shrink_control의 priority 필드
Linux 3.12부터 shrink_control에 priority 필드가 추가되었습니다. 값이 낮을수록 메모리 압박이 심각하며 더 공격적으로 회수해야 합니다.
| priority 값 | 의미 | 권장 동작 |
|---|---|---|
| DEF_PRIORITY (12) | 낮은 압박, 워터마크 근처 | 최근 미사용 항목만 회수 |
| 6 ~ 11 | 중간 압박 | LRU 하위 절반 회수 |
| 1 ~ 5 | 높은 압박 | 더 공격적 회수 |
| 0 | OOM 직전 최후 시도 | 가능한 모든 항목 회수 |
/* 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 반환값 규약
| 반환값 | 콜백 | 의미 |
|---|---|---|
0 | count_objects | 현재 회수 가능 객체 없음. nr_deferred 누적 없음 |
SHRINK_EMPTY | count_objects | 캐시가 완전히 비어있음. memcg 해제 시 최적화 힌트 |
| 양수 N | count_objects | N개의 객체가 회수 가능 |
SHRINK_STOP | scan_objects | 회수 중단 (예: 잠금(Lock) 획득 실패). 이 shrinker 건너뜀 |
| 양수 N | scan_objects | N개의 객체를 실제로 해제함 |
0 | scan_objects | 요청은 받았으나 실제 해제한 객체 없음 |
Shrinker 플래그
| 플래그 | 값 | 의미 | 요구 사항 |
|---|---|---|---|
SHRINKER_MEMCG_AWARE | BIT(1) | memcg별 회수 지원 | sc->memcg를 참조하여 per-cgroup 회수 구현 |
SHRINKER_NUMA_AWARE | BIT(0) | NUMA 노드별 회수 지원 | sc->nid를 참조하여 per-node 회수 구현 |
SHRINKER_NONSLAB | BIT(2) | 슬랩이 아닌 메모리를 회수 | 통계가 NR_SLAB_RECLAIMABLE 대신 별도 계산 |
플래그 조합 패턴
실제 커널 코드에서 사용되는 플래그 조합 패턴입니다.
| 조합 | 사용처 | 설명 |
|---|---|---|
0 (플래그 없음) | 단순 글로벌 캐시 | 전역 LRU에서 FIFO 회수. 가장 간단한 구현 |
SHRINKER_NUMA_AWARE | 노드별 분리 캐시 | NUMA-local 회수로 원격 접근 최소화 |
SHRINKER_MEMCG_AWARE | cgroup 격리(Isolation) 캐시 | 컨테이너별 메모리 제한 준수 |
NUMA | MEMCG | dentry/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+에서 도입된 할당 함수입니다.
flags로SHRINKER_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를 거쳐 안전하게 메모리를 해제합니다. 등록 전에 호출해도 안전합니다(초기화 실패 처리용).
- 분리된 라이프사이클: 할당(alloc) → 설정 → 등록(register)이 분리되어 초기화 도중 실패 처리가 깔끔해졌습니다
- 안전한 해제:
shrinker_free()가 refcount + RCU를 통해 진행 중인 회수 완료를 보장합니다 - 디버깅 이름:
shrinker_alloc()에 printf 형식 이름을 전달하여/sys/kernel/debug/shrinker에서 식별 가능
새 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_exit
shrinker_free()가 등록 해제 + 진행 중인 콜백 완료 대기 + 메모리 해제를 원자적으로 수행합니다. 그 이후에destroy_my_cache()를 호출하므로 캐시 해제 시점에 콜백이 실행되지 않음을 보장합니다.
레거시 API (Linux 6.6 이하)
/* Linux 6.6 이하 (deprecated) */
int register_shrinker(struct shrinker *shrinker, const char *fmt, ...);
void unregister_shrinker(struct shrinker *shrinker);
/* === 레거시 (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 핵심 코드 분석
/* 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_FS 플래그가 없으므로 파일시스템 shrinker(dentry, inode 등)가 호출되지 않습니다.
이는 교착 상태(Deadlock)를 방지하기 위한 설계입니다.
메모리 압박 전파 메커니즘
메모리 압박이 감지되면 커널은 단계적으로 회수 강도를 높여갑니다. 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.c와mm/vmscan.c에 걸쳐 구현되어 있습니다. - SHRINKER_MEMCG_AWARE 필터
shrink_slab()내부에서memcg가 전달되면SHRINKER_MEMCG_AWARE플래그가 없는 shrinker는 건너뜁니다. per-memcgnr_deferred배열을 사용하여 cgroup별로 지연 회수를 독립 관리합니다. - sc->memcg NULL 분기
SHRINKER_MEMCG_AWARE로 등록해도 전역 회수(root cgroup) 시에는sc->memcg가NULL로 전달됩니다. 반드시 두 경우를 모두 처리해야 하며, NULL 체크 없이 per-memcg 데이터에 접근하면 커널 패닉이 발생합니다.
SHRINKER_MEMCG_AWARE로 등록하더라도
sc->memcg가 NULL로 호출되는 경우(전역 회수)가 있습니다.
NULL 체크 없이 per-memcg 데이터에 접근하면 커널 패닉(Kernel Panic)이 발생합니다.
커널 주요 Shrinker 사용처
| 서브시스템 | 회수 대상 | 파일 | 플래그 |
|---|---|---|---|
| dentry 캐시 | 사용되지 않는 dentry | fs/dcache.c | NUMA | MEMCG |
| inode 캐시 | 사용되지 않는 inode | fs/inode.c | NUMA | MEMCG |
| Btrfs | B-tree 블록 캐시 | fs/btrfs/super.c | 0 |
| XFS | inode/dquot 버퍼(Buffer) | fs/xfs/xfs_icache.c | NUMA | MEMCG |
| NFS | dcache/inode | fs/nfs/super.c | 0 |
| GPU (DRM) | GPU 버퍼 객체 | drivers/gpu/drm/*/ | 0 |
| 네트워크 | 연결 추적(Connection Tracking) 항목 | net/netfilter/nf_conntrack_core.c | 0 |
| sunrpc | RPC 캐시 항목 | net/sunrpc/cache.c | 0 |
| ext4 extent | extent status 캐시 | fs/ext4/extents_status.c | MEMCG |
| workingset | 그림자(shadow) 노드 | mm/workingset.c | MEMCG | NONSLAB |
| zswap | 압축 스왑 캐시 항목 | mm/zswap.c | MEMCG |
| z3fold | 압축 페이지 풀 | mm/z3fold.c | 0 |
| zsmalloc | 압축 슬랩 페이지 | mm/zsmalloc.c | 0 |
| quota | 디스크 할당량 캐시 | fs/quota/dquot.c | 0 |
| ceph | MDS 캐시 (cap/dentry) | fs/ceph/super.c | 0 |
| gfs2 | glock/inode 캐시 | fs/gfs2/glock.c | 0 |
| f2fs | extent/NAT 캐시 | fs/f2fs/super.c | MEMCG |
| bcache | B-tree 노드 캐시 | drivers/md/bcache/btree.c | 0 |
| dm-bufio | 블록 버퍼 캐시 | drivers/md/dm-bufio.c | 0 |
# 커널 주요 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) 계열이 가장 많으며, 메모리 서브시스템, 드라이버, 네트워크 순입니다. 각 범주의 특성과 분포를 아래 다이어그램으로 정리합니다.
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_of
container_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_control의nid와memcg에 맞는 항목만 카운트합니다. - super_cache_count: SHRINK_EMPTY합산이 0이면
SHRINK_EMPTY를 반환하여 이 super_block에 회수 대상이 없음을 알립니다.shrink_slab()은 이 값을 받으면 해당 shrinker를 건너뜁니다. - super_cache_scan: trylock_super
s_umount읽기 잠금을trylock으로 시도합니다. 실패하면SHRINK_STOP을 반환하여 이 shrinker에 대한 회수를 중단합니다. umount 진행 중인 파일시스템과의 교착을 방지합니다. - super_cache_scan: prune_dcache_sb / prune_icache_sbdentry를 먼저 회수합니다. dentry 해제 시 참조 카운트가 0이 된 inode가 연쇄적으로 해제되므로, dentry 우선 회수가 효율적입니다.
fs/dcache.c와fs/inode.c에 각각 구현되어 있습니다. - super_cache_scan: free_cached_objects파일시스템별 추가 캐시(예: XFS 버퍼, ext4 extent status)를
s_op->free_cached_objects()를 통해 회수합니다. 이 콜백은 선택적이며, 구현하지 않는 파일시스템도 있습니다.
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_shrinker | XFS inode 캐시 | fs/xfs/xfs_icache.c | DEFAULT_SEEKS |
xfs_buf_shrinker | XFS 버퍼 캐시 (메타데이터) | fs/xfs/xfs_buf.c | DEFAULT_SEEKS |
xfs_qm_shrinker | XFS 디스크 쿼타 캐시 | fs/xfs/xfs_qm.c | DEFAULT_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 퇴거 전략은 다음과 같은 우선순위를 따릅니다:
- 1순위: 참조 카운트(refcount)가 0이고
EXTENT_FLAG_STALE플래그가 설정된 항목 - 2순위: 참조 카운트가 0이고 최근 접근 시간이 오래된 항목 (LRU 순서)
- 3순위: 트랜잭션(Transaction) 커밋 이후 더 이상 필요하지 않은 예약 extent_map
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);
}
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;
}
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;
}
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 고려 사항 | NUMA_AWARE shrinker | 비-NUMA shrinker |
|---|---|---|
| 회수 범위 | sc->nid에 해당하는 로컬 노드만 | 전역 캐시 전체 |
| 원격 메모리 접근 | 최소화 | 빈번히 발생 가능 |
| nr_deferred | per-node 배열 (각 노드 독립) | 단일 전역 카운터 |
| 캐시 구조 | per-node LRU 필요 | 단일 LRU 충분 |
| 적합한 경우 | 대규모 NUMA 서버, 메모리 집약적 워크로드 | 단일 노드, 소규모 캐시 |
메모리 핫플러그(Hotplug)와 Shrinker
메모리 핫플러그(Memory Hotplug)는 시스템 운영 중에 NUMA 노드를 추가하거나 제거하는 기능입니다. 이 과정에서 shrinker의 per-node 자료구조가 올바르게 조정되어야 합니다.
영향을 받는 자료구조
nr_deferred배열:SHRINKER_NUMA_AWAREshrinker는 per-nodenr_deferred배열을 유지합니다. 새 노드가 온라인(online)되면 이 배열이 확장되어야 합니다. Linux 6.7+의 새 API에서는shrinker_alloc()시점에nr_node_ids크기로 사전 할당하여, 핫플러그 시 재할당이 불필요합니다.list_lruper-node 배열:list_lru는 per-node LRU 리스트를 유지합니다. 메모리 핫플러그 시memcg_update_list_lru_node()가 호출되어 새 노드에 대한 LRU 리스트를 초기화합니다.- shrinker_map 비트맵: memcg-aware 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_rwsem 데드락(deadlock)이 발생할 수 있습니다.
Linux 6.4에서 shrinker_rwsem이 shrinker_mutex로 변경되고,
6.7에서 RCU 기반 순회로 전환되면서 이 문제가 해결되었습니다.
프로덕션에서 메모리 핫플러그를 사용하는 경우, Linux 6.7 이상을 권장합니다.
memcg-aware Shrinker
컨테이너 환경에서 각 cgroup의 메모리 한도를 정확히 준수하려면 shrinker가 memcg를 인식해야 합니다.
SHRINKER_MEMCG_AWARE 플래그가 없는 shrinker는 memcg 회수 경로에서 호출되지 않으므로,
특정 cgroup만 메모리 압박을 받는 상황에서 캐시가 회수되지 않을 수 있습니다.
memcg Shrinker 라이프사이클
memcg-aware shrinker의 내부 동작:
shrinker_alloc(SHRINKER_MEMCG_AWARE, ...)호출 시:shrinker_idr에서 고유 ID를 할당합니다.nr_deferred배열이 per-node * per-memcg 차원으로 할당됩니다.
- 새 memcg 생성 시 (
css_online):memcg->shrinker_map에 비트맵을 할당합니다.- 각 shrinker ID에 대응하는 비트로 활성 여부를 추적합니다.
- 캐시 항목 추가 시:
list_lru_add()가 해당 memcg의 shrinker 비트를 설정합니다.- 이후 해당 memcg 회수 시 이 shrinker가 호출됩니다.
- memcg 삭제 시 (
css_offline):- reparent: 자식 memcg의 캐시를 부모로 이동합니다.
shrinker_map비트를 정리합니다.
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
/proc/pressure/memory를 통해
메모리 압박의 심각도를 실시간(Real-time)으로 확인할 수 있습니다. some은 일부 작업이 지연되는 비율,
full은 모든 작업이 멈추는 비율입니다. shrinker 튜닝 시 이 지표를 기준으로 효과를 측정하세요.
scan_objects()는 메모리 압박 상황에서 호출됩니다. 콜백 내에서 GFP_KERNEL 할당을 시도하면 재귀적 회수가 발생할 수 있습니다. 가능하면 미리 할당하거나 GFP_ATOMIC 또는 GFP_NOWAIT를 사용하세요.
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_pressure | 100 | dentry/inode 캐시 회수 강도. 높을수록 공격적 회수 |
min_free_kbytes | /proc/sys/vm/min_free_kbytes | 시스템 의존 | 최소 free 페이지. 높이면 회수 조기 시작 |
watermark_boost_factor | /proc/sys/vm/watermark_boost_factor | 15000 | 워터마크 부스트 비율 (0=비활성) |
watermark_scale_factor | /proc/sys/vm/watermark_scale_factor | 10 | 워터마크 간격 비율 |
drop_caches | /proc/sys/vm/drop_caches | 0 | 수동 캐시 회수 트리거 |
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은 매우 위험합니다
shrink_slab()에서 dentry/inode shrinker의 회수량을 계산할 때
total_scan = (freeable * delta) / (lru_pages + 1) 공식에서 delta에 vfs_cache_pressure / 100을 곱합니다.
즉, 200이면 기본의 2배 회수, 50이면 절반 회수합니다.
시나리오별 튜닝 가이드
| 시나리오 | vfs_cache_pressure | min_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 경로의 비용과 동시성 안전성을 먼저 검증해야 합니다.
- count 경량화: lock 경합(Contention) 없이 빠르게 추정값 반환 (atomic 카운터 권장)
- scan 안전성: 회수 중 리스트/객체 수명주기 보호 (trylock + 잠금 밖 해제)
- 재귀 회수 방지: 콜백 내부 과도한 할당/슬립(Sleep) 금지 (GFP_NOWAIT 사용)
- memcg 대응: cgroup 환경에서 분리 회수 정책 검증 (NULL memcg 체크)
- NUMA 대응: per-node 캐시 분리 또는 nid 무시 여부 결정
- 해제 안전성:
shrinker_free()호출 시점에 캐시 사용자 부재 보장 - 디버깅 이름:
shrinker_alloc()에 의미 있는 이름 전달 - 반환값 준수:
SHRINK_EMPTY,SHRINK_STOP규약 준수
| 오류 패턴 | 영향 | 대응 |
|---|---|---|
| count 과소/과대 반환 | 회수 비효율/지연 | 추정 로직과 실제 회수량 정합성 점검 |
| scan에서 긴 락 보유 | system stall | batch 단위 분할, 락 범위 축소 |
| 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으로 재귀 회수를 방지합니다.
완전한 드라이버 예제
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);
Shrinker와 관련 서브시스템
OOM Killer와의 상호작용
OOM killer가 호출되기 전에 커널은 shrinker를 통한 마지막 회수 시도를 합니다.
단순화된 OOM 결정 흐름 (mm/oom_kill.c):
try_to_free_pages()→shrink_node()→shrink_slab()을 호출하며 priority를 감소시킵니다.- priority가 0까지 떨어져도 충분히 회수하지 못하면...
__alloc_pages_may_oom()에 진입합니다.out_of_memory()→ OOM killer가 호출됩니다.
shrinker가 충분히 회수하면 OOM을 피할 수 있습니다. 따라서 shrinker가 빠르고 효과적으로 회수하는 것이 중요합니다.
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);
}
소스 코드 맵
| 파일 | 내용 | 주요 함수/구조체 |
|---|---|---|
include/linux/shrinker.h | shrinker API 헤더 | struct shrinker, struct shrink_control, 플래그 상수 |
mm/shrinker.c | shrinker 코어 구현 (6.7+) | shrink_slab(), do_shrink_slab(), shrinker_alloc/register/free() |
mm/vmscan.c | VM 스캔/회수 메인 | shrink_node(), try_to_free_pages(), kswapd() |
mm/list_lru.c | list_lru 인프라 | list_lru_shrink_walk(), list_lru_shrink_count() |
fs/dcache.c | dentry 캐시 | super_cache_count(), super_cache_scan() |
fs/inode.c | inode 캐시 | prune_icache_sb() |
fs/super.c | superblock shrinker 등록 | alloc_super() 내 shrinker 설정 |
fs/xfs/xfs_icache.c | XFS inode shrinker | xfs_reclaim_inodes_count/nr() |
fs/xfs/xfs_buf.c | XFS 버퍼 shrinker | xfs_buftarg_shrink_scan/count() |
mm/workingset.c | workingset shadow 노드 | shadow_lru_isolate() |
mm/zswap.c | zswap 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_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);
}
shrink_slab()은 RCU read-side에서 shrinker 리스트를
순회합니다. shrinker_free()가 list_del_rcu()로 리스트에서 제거해도, 이미 해당 shrinker를 참조 중인
CPU에서는 계속 콜백이 실행될 수 있습니다. 이를 위해 refcount + completion 패턴으로 진행 중인 모든 콜백 완료를
대기한 후, kfree_rcu()로 RCU grace period 이후에 메모리를 해제합니다.
레거시 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 spike | freed >= 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_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;
}
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;
}
seeks = DEFAULT_SEEKS를 사용하지만,
allocation group header나 B+tree 루트 노드처럼 빈번히 접근되는 메타데이터는 b_hold이
0이 되지 않으므로 자연스럽게 회수에서 보호됩니다. 과도한 drop_caches를 반복하면
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 콜백 실행 시간 히스토그램
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/meminfo의 SReclaimable이 수 GB인데도 메모리 압박이 발생하고, kswapd가 활발히 동작하지만 슬랩 메모리는 줄어들지 않음 |
| 원인 | 커스텀 커널 모듈이 shrinker를 등록했지만, count_objects()에서 항상 0을 반환하여 shrink_slab()이 해당 캐시를 건너뜀. 실제로는 수백만 개의 객체가 캐시에 존재하지만 shrinker가 인식하지 못하는 상태 |
| 진단 방법 |
|
| 해결 방법 | count_objects() 콜백이 실제 캐시 크기를 정확히 반환하도록 수정. percpu_counter 또는 atomic_long으로 캐시 항목 수를 추적하고, 해당 값을 반환하도록 구현 |
사례 2: 특정 컨테이너에서만 OOM 발생
| 항목 | 내용 |
|---|---|
| 증상 | 메모리 한도가 4GB인 컨테이너에서 실제 프로세스 RSS 합계는 2GB 이하인데 OOM이 반복적으로 발생. 호스트의 전체 메모리는 충분한 상태 |
| 원인 | 컨테이너 내에서 사용하는 파일시스템의 shrinker가 SHRINKER_MEMCG_AWARE 플래그 없이 등록되어 있어서, memcg 회수 경로에서 호출되지 않음. 결과적으로 해당 컨테이너의 dentry/inode 캐시가 회수되지 않아 메모리 한도 초과 |
| 진단 방법 |
|
| 해결 방법 | 해당 파일시스템 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)가 크면 재구축에 오랜 시간이 소요됨 |
| 진단 방법 |
|
| 해결 방법 | 프로덕션 환경에서 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) 위반 |
| 진단 방법 |
|
| 해결 방법 | shrinker 콜백 내에서 페이지 할당 경로와 공유하는 잠금을 사용하지 않도록 재설계. scan_objects()에서는 trylock을 사용하고, 잠금 획득 실패 시 SHRINK_STOP을 반환하여 나중에 재시도. GFP 플래그에 __GFP_FS/__GFP_IO가 없으면 파일시스템/I/O 관련 잠금을 아예 시도하지 않도록 sc->gfp_mask를 확인 |
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 | 원자적 카운터로 대체 |
CONFIG_LOCKDEP을 활성화하면
런타임에 자동으로 감지됩니다. 새 shrinker를 구현할 때는 반드시 lockdep이 활성화된 커널에서
테스트하세요. fs_reclaim 잠금 클래스가 shrinker 콜백 진입 시 자동으로 설정됩니다.
성능 함정 요약
| 함정 | 발생 조건 | 증상 | 진단 방법 | 해결 |
|---|---|---|---|---|
| nr_deferred 폭발 | count 과소 보고 | 갑작스러운 대량 회수, 캐시 cold | /proc/vmstat slabs_scanned 급증 | 정확한 count 반환 |
| Thundering Herd | 다수 CPU 동시 압박 | 잠금 경합, softlockup | perf lock contention | 동시 스캐너 제한 |
| False Positive | count/scan 불일치 | nr_deferred 누적, 비효율 회수 | debugfs count vs 실제 freed 비교 | 회수 가능 객체만 count |
| 잠금 순서 위반 | 콜백 내 잠금 획득 | D-state, 교착 | lockdep 경고 | trylock, 비동기 해제 |
| 캐시 thrashing | seeks 값 너무 낮음 | 회수 → 재생성 반복, 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;
}
shrinker_try_get()은 refcount_inc_not_zero()를 사용하여 해제가 시작된 shrinker를
건너뜁니다. shrinker_put()은 refcount_dec_and_test()를 호출하여 마지막 참조가
해제되면 completion을 완료합니다. 이 패턴으로 진행 중인 모든 콜백이 완료된 후에만
메모리가 해제됩니다.
Shrinker와 메모리 압력 전파
메모리 압력이 커널의 다양한 서브시스템으로 전파되는 과정에서 shrinker는 핵심적인 중재 역할을 합니다. 이 섹션에서는 압력 전파의 전체 경로와 shrinker의 위치를 분석합니다.
메모리 압력 전파 전체 경로
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 변화:
priority=12: nr_to_scan = lru_size / 4096 (매우 적음)priority=8: nr_to_scan = lru_size / 256priority=4: nr_to_scan = lru_size / 16priority=0: nr_to_scan = lru_size (전체)
계산 예시: lru_size=1,048,576 (1M 페이지), freeable=50,000, seeks=2
| priority | delta | scan |
|---|---|---|
| 12 | (4 * 256) / 2 = 512 | 512 * 50000 / 1048577 ≈ 24 |
| 8 | (4 * 4096) / 2 = 8,192 | 8192 * 50000 / 1048577 ≈ 390 |
| 4 | (4 * 65536) / 2 = 131,072 | 131072 * 50000 / 1048577 ≈ 6,250 |
| 0 | (4 * 1048576) / 2 = 2,097,152 | 2097152 * 50000 / 1048577 ≈ 100,000 |
| priority | nr_to_scan (LRU 1M 기준) | total_scan (freeable=50K, seeks=2) | 회수 강도 |
|---|---|---|---|
| 12 (DEF_PRIORITY) | 256 | ~24 | 최소 (kswapd 초기) |
| 10 | 1024 | ~98 | 낮음 |
| 8 | 4096 | ~390 | 중간 |
| 6 | 16384 | ~1562 | 높음 (직접 회수) |
| 4 | 65536 | ~6250 | 매우 높음 |
| 2 | 262144 | ~25000 | 공격적 |
| 0 | 1048576 | ~100000 | 최대 (OOM 직전) |
total_scan이 freeable의 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;
}
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_objects | count_objects + scan_objects (동일) |
| 회수 비율 계산 | LRU 크기 기반 | 세대별 크기 기반 (더 정확) |
| 활성화 조건 | 기본값 | CONFIG_LRU_GEN=y + lru_gen_enabled |
CONFIG_LRU_GEN=y로 활성화해야 합니다.
런타임에서는 /sys/kernel/mm/lru_gen/enabled를 통해 제어할 수 있습니다.
값이 0x0007이면 MGLRU의 모든 기능(에이징, 퇴거, 멀티세대)이 활성화된 상태입니다.
MGLRU가 활성화되면 기존의 shrink_inactive_list()/shrink_active_list() 대신
lru_gen_shrink_node()가 호출되지만, 슬랩 shrinker의 동작은 변하지 않습니다.
참고자료
커널 문서
- Shrinker 인터페이스 문서 — Linux Kernel Documentation
- VFS 문서 — Linux Kernel Documentation — VFS 내 shrinker 활용 방식을 설명합니다
LWN 기사
- LWN: Shrinker improvements (2013) — shrinker 인터페이스 개선 과정을 다룹니다
- LWN: Making shrinkers more efficient (2018) — shrinker 효율성 향상 방안을 다룹니다
- LWN: Dealing with the OOM killer (2018) — OOM 상황에서 shrinker 역할을 다룹니다
- LWN: A new shrinker API (2023) — 새로운 shrinker API 설계를 다룹니다
커널 소스
- mm/shrinker.c — shrinker 핵심 구현 소스입니다
- include/linux/shrinker.h — shrinker API 헤더입니다
- mm/vmscan.c — vmscan 내 shrinker 호출 로직입니다
관련 문서
- 메모리 관리 개요 — 페이지 할당자와 kswapd 개요
- 메모리 관리 개요 — LRU, 메모리 압박, OOM 상세
- Page Cache — dentry/inode shrinker가 관리하는 캐시 레이어
- Slab Allocator — 슬랩 캐시의 내부 구조와 회수 메커니즘
- LRU Cache — LRU 리스트 관리와 list_lru 인프라
- zswap — 압축 스왑 캐시와 zswap shrinker
- NUMA — NUMA 토폴로지와 per-node 메모리 관리
- cgroups — 메모리 cgroup과 memcg-aware 회수
- IDR/IDA — shrinker 등록 시 내부적으로 사용하는 ID 할당 메커니즘