Shrinker (메모리 회수 콜백)
Shrinker는 Linux 커널이 메모리 압박 상황에서 캐시를 회수하기 위해 사용하는 콜백 인터페이스입니다.
슬랩 캐시, dentry 캐시, 파일시스템 캐시, GPU 드라이버 등이 shrinker를 등록하면,
kswapd·직접 회수·OOM 경로에서 메모리가 부족할 때 순서대로 회수를 요청합니다.
Linux 6.7에서 shrinker_alloc()/shrinker_register()/shrinker_free() 새 API로 전환되었으며,
NUMA-aware 및 memcg-aware 회수, nr_deferred 지연 메커니즘,
dentry/inode/XFS/Btrfs/DRM 등 주요 서브시스템별 shrinker 구현 패턴,
안티패턴과 디버깅 기법까지 포괄적으로 다룹니다.
핵심 요약
- struct shrinker — 회수 콜백 등록 구조체 (count + scan 2개 함수)
- count_objects — "지금 회수 가능한 객체가 몇 개냐?" 질의 콜백
- scan_objects — "N개 객체를 실제로 회수하라" 요청 콜백
- shrink_slab — 커널이 등록된 모든 shrinker를 순회·호출하는 함수
- SHRINKER_MEMCG_AWARE — memcg(메모리 cgroup)별 회수 지원 플래그
단계별 이해
- 메모리 압박 감지
kswapd 또는 직접 페이지 할당 경로에서 free 페이지가 임계값 이하로 내려가면 회수 경로가 시작됩니다. - shrink_slab 호출
회수 경로가shrink_slab()을 호출하면, 등록된 모든 shrinker의count_objects()가 순서대로 질의됩니다. - 회수 우선순위 계산
각 shrinker의 응답값과seeks힌트를 기반으로 회수 비율을 계산합니다. - scan_objects 호출
계산된 수량만큼scan_objects()가 호출됩니다. 실제 캐시 항목을 LRU에서 제거하고 메모리를 반환합니다. - 회수 결과 집계
반환된 페이지 수를 집계하여 목표 달성 여부를 판단하고, 부족하면 다음 회수 단계(OOM 킬러 등)로 진행합니다.
개요
Linux 커널은 성능을 위해 다양한 캐시를 유지합니다 (dentry, inode, 슬랩, 파일시스템별 캐시). 메모리 압박 시 이 캐시들을 회수해야 하는데, 각 서브시스템이 직접 회수 로직을 구현하면 중복이 발생합니다. Shrinker는 이를 표준화한 콜백 인터페이스입니다.
Shrinker의 역사
Shrinker 메커니즘은 Linux 초기부터 존재했지만, 그 형태는 크게 변화해 왔습니다.
| 커널 버전 | 변경 사항 | 커밋/패치 |
|---|---|---|
| ~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() | 워터마크 이하 시 주기적 회수 |
| 직접 회수 | __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 | 함수 포인터 | 회수 가능 객체 수 반환. 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이면 전역) */
};
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;
}
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 | 회수 중단 (예: 잠금 획득 실패). 이 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 격리 캐시 | 컨테이너별 메모리 제한 준수 |
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);
- 분리된 라이프사이클: 할당(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();
}
레거시 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()이 동적 할당 |
| 등록 실패 처리 | 모든 초기화 후 등록, 실패 시 롤백 복잡 | 할당 후 등록 전에 초기화, 실패 시 shrinker_free() |
| 해제 안전성 | 진행 중인 콜백과 경쟁 가능 | refcount + RCU로 완료 보장 |
| 디버깅 이름 | 선택적 (register_shrinker 인자) | 필수 (shrinker_alloc 인자) |
Shrinker 구현 예제
간단한 캐시 회수 구현
#include <linux/shrinker.h>
#include <linux/list.h>
#include <linux/spinlock.h>
/* 예제 캐시 구조체 */
struct my_cache_entry {
struct list_head lru;
struct rcu_head rcu;
void *data;
unsigned long last_access; /* jiffies 타임스탬프 */
};
static LIST_HEAD(my_lru);
static DEFINE_SPINLOCK(my_lock);
static atomic_long_t my_cache_count = ATOMIC_LONG_INIT(0);
/* count_objects: 현재 회수 가능한 항목 수 반환 */
static unsigned long my_count_objects(struct shrinker *s,
struct shrink_control *sc)
{
unsigned long count = atomic_long_read(&my_cache_count);
/* 캐시가 비어있으면 SHRINK_EMPTY 반환 (memcg 최적화) */
if (count == 0)
return SHRINK_EMPTY;
return count;
}
/* scan_objects: 실제로 객체 회수 */
static unsigned long my_scan_objects(struct shrinker *s,
struct shrink_control *sc)
{
unsigned long freed = 0;
unsigned long nr = sc->nr_to_scan;
struct my_cache_entry *entry, *tmp;
LIST_HEAD(to_free);
if (!spin_trylock(&my_lock))
return SHRINK_STOP; /* 잠금 경합 시 즉시 포기 */
list_for_each_entry_safe(entry, tmp, &my_lru, lru) {
if (freed >= nr)
break;
list_move(&entry->lru, &to_free);
atomic_long_dec(&my_cache_count);
freed++;
}
spin_unlock(&my_lock);
/* 잠금 밖에서 메모리 해제 (RCU 또는 즉시) */
list_for_each_entry_safe(entry, tmp, &to_free, lru) {
list_del(&entry->lru);
kfree(entry->data);
kfree(entry);
}
return freed;
}
static struct shrinker *my_shrinker;
static int __init my_cache_init(void)
{
my_shrinker = shrinker_alloc(0, "my_driver:cache");
if (!my_shrinker)
return -ENOMEM;
my_shrinker->count_objects = my_count_objects;
my_shrinker->scan_objects = my_scan_objects;
my_shrinker->seeks = DEFAULT_SEEKS; /* 기본 재생성 비용 */
shrinker_register(my_shrinker);
return 0;
}
static void __exit my_cache_exit(void)
{
shrinker_free(my_shrinker); /* 등록 해제 + 메모리 해제 */
}
memcg Aware Shrinker
컨테이너 환경에서 cgroup별 메모리 제한을 정확히 준수하려면, shrinker가 SHRINKER_MEMCG_AWARE를 선언하고
sc->memcg에 따라 per-cgroup 캐시를 독립적으로 회수해야 합니다.
#include <linux/shrinker.h>
#include <linux/memcontrol.h>
/*
* per-memcg 캐시 관리 구조체
* 각 cgroup마다 별도의 LRU 리스트와 카운터를 유지
*/
struct my_memcg_cache {
struct list_head lru;
spinlock_t lock;
atomic_long_t count;
};
/* memcg에서 per-memcg 캐시 구조를 가져오는 헬퍼 */
static struct my_memcg_cache *get_memcg_cache(struct mem_cgroup *memcg)
{
if (!memcg)
return &global_cache;
return (struct my_memcg_cache *)mem_cgroup_get_data(memcg, MY_CACHE_ID);
}
/* memcg별 회수를 지원하는 count 콜백 */
static unsigned long my_count_memcg(struct shrinker *s,
struct shrink_control *sc)
{
struct my_memcg_cache *cache = get_memcg_cache(sc->memcg);
unsigned long count;
if (!cache)
return 0;
count = atomic_long_read(&cache->count);
return count ?: SHRINK_EMPTY;
}
/* memcg별 회수를 지원하는 scan 콜백 */
static unsigned long my_scan_memcg(struct shrinker *s,
struct shrink_control *sc)
{
struct my_memcg_cache *cache = get_memcg_cache(sc->memcg);
unsigned long freed = 0, nr = sc->nr_to_scan;
struct my_cache_entry *e, *tmp;
LIST_HEAD(dead);
if (!cache || !spin_trylock(&cache->lock))
return SHRINK_STOP;
list_for_each_entry_safe(e, tmp, &cache->lru, lru) {
if (freed >= nr) break;
list_move(&e->lru, &dead);
atomic_long_dec(&cache->count);
freed++;
}
spin_unlock(&cache->lock);
list_for_each_entry_safe(e, tmp, &dead, lru) {
list_del(&e->lru);
kfree(e);
}
return freed;
}
/* 등록 시 SHRINKER_MEMCG_AWARE 플래그 지정 */
static int __init my_memcg_init(void)
{
my_shrinker = shrinker_alloc(SHRINKER_MEMCG_AWARE,
"my:memcg_cache");
if (!my_shrinker) return -ENOMEM;
my_shrinker->count_objects = my_count_memcg;
my_shrinker->scan_objects = my_scan_memcg;
my_shrinker->seeks = DEFAULT_SEEKS;
shrinker_register(my_shrinker);
return 0;
}
NUMA-aware Shrinker 완전 예제
#include <linux/shrinker.h>
#include <linux/nodemask.h>
/* per-NUMA 노드 캐시 구조 */
struct my_node_cache {
struct list_head lru;
spinlock_t lock;
atomic_long_t count;
} ____cacheline_aligned;
static struct my_node_cache my_caches[MAX_NUMNODES];
static unsigned long my_count_numa(struct shrinker *s,
struct shrink_control *sc)
{
int nid = sc->nid;
if (nid == NUMA_NO_NODE) {
/* 전체 노드 합산 */
unsigned long total = 0;
int i;
for_each_node_state(i, N_NORMAL_MEMORY)
total += atomic_long_read(&my_caches[i].count);
return total;
}
return atomic_long_read(&my_caches[nid].count);
}
static unsigned long my_scan_numa(struct shrinker *s,
struct shrink_control *sc)
{
int nid = (sc->nid == NUMA_NO_NODE) ? numa_node_id() : sc->nid;
struct my_node_cache *cache = &my_caches[nid];
unsigned long freed = 0, nr = sc->nr_to_scan;
struct my_cache_entry *e, *tmp;
LIST_HEAD(dead);
spin_lock(&cache->lock);
list_for_each_entry_safe(e, tmp, &cache->lru, lru) {
if (freed >= nr) break;
list_move(&e->lru, &dead);
atomic_long_dec(&cache->count);
freed++;
}
spin_unlock(&cache->lock);
list_for_each_entry_safe(e, tmp, &dead, lru)
kfree(e);
return freed;
}
static struct shrinker *my_numa_shrinker;
static int __init my_numa_cache_init(void)
{
int i;
for_each_node_state(i, N_NORMAL_MEMORY) {
INIT_LIST_HEAD(&my_caches[i].lru);
spin_lock_init(&my_caches[i].lock);
atomic_long_set(&my_caches[i].count, 0);
}
my_numa_shrinker = shrinker_alloc(SHRINKER_NUMA_AWARE,
"my:numa_cache");
if (!my_numa_shrinker) return -ENOMEM;
my_numa_shrinker->count_objects = my_count_numa;
my_numa_shrinker->scan_objects = my_scan_numa;
my_numa_shrinker->seeks = DEFAULT_SEEKS;
shrinker_register(my_numa_shrinker);
return 0;
}
내부 동작 흐름
shrink_slab()에서 각 shrinker가 호출되는 상세 흐름을 단계별로 살펴봅니다.
shrink_slab 핵심 코드 분석
/* mm/shrinker.c - shrink_slab() 핵심 흐름 (단순화) */
unsigned long shrink_slab(gfp_t gfp_mask, int nid,
struct mem_cgroup *memcg,
int priority)
{
struct shrinker *shrinker;
unsigned long freed = 0;
/* GFP 플래그 검증: __GFP_FS가 없으면 파일시스템 shrinker 건너뜀 */
if (!(gfp_mask & __GFP_FS))
return 0;
/* RCU 보호 하에 shrinker 리스트 순회 */
rcu_read_lock();
list_for_each_entry_rcu(shrinker, &shrinker_list, list) {
struct shrink_control sc = {
.gfp_mask = gfp_mask,
.nid = nid,
.memcg = memcg,
};
/* memcg-aware 필터링 */
if (memcg && !(shrinker->flags & SHRINKER_MEMCG_AWARE))
continue;
/* refcount 획득 (해제 중인 shrinker 건너뜀) */
if (!shrinker_try_get(shrinker))
continue;
rcu_read_unlock();
/* do_shrink_slab: count + scan 실행 */
freed += do_shrink_slab(&sc, shrinker, priority);
shrinker_put(shrinker);
rcu_read_lock();
}
rcu_read_unlock();
return freed;
}
GFP_NOFS를 사용하면
__GFP_FS 플래그가 없으므로 파일시스템 shrinker(dentry, inode 등)가 호출되지 않습니다.
이는 교착 상태를 방지하기 위한 설계입니다.
메모리 압박 전파 메커니즘
메모리 압박이 감지되면 커널은 단계적으로 회수 강도를 높여갑니다. 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);
}
SHRINKER_MEMCG_AWARE로 등록하더라도
sc->memcg가 NULL로 호출되는 경우(전역 회수)가 있습니다.
NULL 체크 없이 per-memcg 데이터에 접근하면 커널 패닉이 발생합니다.
커널 주요 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 버퍼 | fs/xfs/xfs_icache.c | NUMA | MEMCG |
| NFS | dcache/inode | fs/nfs/super.c | 0 |
| GPU (DRM) | GPU 버퍼 객체 | drivers/gpu/drm/*/ | 0 |
| 네트워크 | 연결 추적 항목 | 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 |
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;
}
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;
}
DRM (GPU) Shrinker
GPU 드라이버의 shrinker는 GPU 메모리에서 시스템 메모리로 스왑 가능한 GEM 객체를 회수합니다.
/* drivers/gpu/drm/i915/gem/i915_gem_shrinker.c (단순화) */
static unsigned long
i915_gem_shrink_count(struct shrinker *shrink,
struct shrink_control *sc)
{
struct drm_i915_private *i915 =
container_of(shrink, struct drm_i915_private,
mm.shrinker);
unsigned long count;
/* 페이지 단위로 퍼지 가능(purgeable) + 바인딩 해제 가능 객체 */
count = atomic_long_read(&i915->mm.shrink_count);
return count ?: SHRINK_EMPTY;
}
static unsigned long
i915_gem_shrink_scan(struct shrinker *shrink,
struct shrink_control *sc)
{
/* 1단계: purgeable 객체 (MADV_DONTNEED 마킹) 해제 */
/* 2단계: 비활성 객체의 backing pages 해제 */
/* 3단계: GPU에서 바인딩 해제 후 시스템 메모리 반환 */
return i915_gem_shrink(i915, sc->nr_to_scan, NULL,
I915_SHRINK_BOUND |
I915_SHRINK_UNBOUND |
I915_SHRINK_ACTIVE);
}
NUMA-aware Shrinker 심화
NUMA 시스템에서 shrinker가 올바르게 동작하려면 원격 노드 접근을 최소화하고, 메모리 압박이 있는 노드의 캐시를 우선 회수해야 합니다.
NUMA 토폴로지와 회수 전략
| NUMA 고려 사항 | NUMA_AWARE shrinker | 비-NUMA shrinker |
|---|---|---|
| 회수 범위 | sc->nid에 해당하는 로컬 노드만 | 전역 캐시 전체 |
| 원격 메모리 접근 | 최소화 | 빈번히 발생 가능 |
| nr_deferred | per-node 배열 (각 노드 독립) | 단일 전역 카운터 |
| 캐시 구조 | per-node LRU 필요 | 단일 LRU 충분 |
| 적합한 경우 | 대규모 NUMA 서버, 메모리 집약적 워크로드 | 단일 노드, 소규모 캐시 |
memcg-aware Shrinker 심화
컨테이너 환경에서 각 cgroup의 메모리 한도를 정확히 준수하려면 shrinker가 memcg를 인식해야 합니다.
SHRINKER_MEMCG_AWARE 플래그가 없는 shrinker는 memcg 회수 경로에서 호출되지 않으므로,
특정 cgroup만 메모리 압박을 받는 상황에서 캐시가 회수되지 않을 수 있습니다.
memcg Shrinker 라이프사이클
/*
* memcg-aware shrinker의 내부 동작:
*
* 1. shrinker_alloc(SHRINKER_MEMCG_AWARE, ...) 호출 시:
* - shrinker_idr에서 고유 ID 할당
* - nr_deferred 배열이 per-node * per-memcg 차원으로 할당
*
* 2. 새 memcg 생성 시 (css_online):
* - memcg->shrinker_map에 비트맵 할당
* - 각 shrinker ID에 대응하는 비트로 활성 여부 추적
*
* 3. 캐시 항목 추가 시:
* - list_lru_add()가 해당 memcg의 shrinker 비트 설정
* - 이후 해당 memcg 회수 시 이 shrinker가 호출됨
*
* 4. memcg 삭제 시 (css_offline):
* - reparent: 자식 memcg의 캐시를 부모로 이동
* - shrinker_map 비트 정리
*/
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를 통해
메모리 압박의 심각도를 실시간으로 확인할 수 있습니다. 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 프로세스 증가 | mutex_trylock() 사용, 실패 시 SHRINK_STOP |
| count에서 O(N) 리스트 순회 | 높은 CPU 사용, 잠금 경합 | atomic_long_t 카운터 유지 |
| scan 내 GFP_KERNEL 할당 | 재귀 회수, 교착 상태 | GFP_NOWAIT 또는 사전 할당 |
| count 과소 보고 | nr_deferred 폭주, 갑작스러운 대량 회수 | 정확한 카운트 반환 |
| count 과대 보고 | 과도한 회수, 캐시 히트율 저하 | 실제 회수 가능량만 반환 |
| SHRINK_STOP 남발 | 해당 shrinker 캐시 누적, 메모리 부족 악화 | 일시적 실패에만 사용 |
| 잠금 안에서 kfree() | 잠금 순서 위반, lockdep 경고 | 리스트 분리 후 잠금 밖에서 해제 |
| 해제 중 shrinker_free() 미호출 | 해제 후 사용(UAF), 커널 패닉 | 모듈 exit에서 반드시 shrinker_free() |
Shrinker 테스트 방법
drop_caches를 이용한 기본 테스트
# 1. 캐시를 많이 생성 (파일시스템 탐색)
find / -name "*.c" -exec cat {} > /dev/null 2>&1 \;
# 2. 회수 전 상태 확인
grep -E "Slab|SReclaimable|Cached" /proc/meminfo
echo "=== shrinker count ==="
cat /sys/kernel/debug/shrinker/sb-dentry-cache-*/count 2>/dev/null
# 3. dentry/inode shrinker 트리거
echo 2 | sudo tee /proc/sys/vm/drop_caches
# 4. 회수 후 상태 확인
grep -E "Slab|SReclaimable|Cached" /proc/meminfo
인위적 메모리 압박 테스트
# cgroup v2를 이용한 메모리 압박 테스트
# 1. 테스트용 cgroup 생성
sudo mkdir -p /sys/fs/cgroup/shrinker_test
echo 50M | sudo tee /sys/fs/cgroup/shrinker_test/memory.max
# 2. 현재 셸을 cgroup에 배치
echo $$ | sudo tee /sys/fs/cgroup/shrinker_test/cgroup.procs
# 3. 메모리 할당으로 압박 유발
python3 -c "
import os
data = []
try:
while True:
data.append(bytearray(1024 * 1024)) # 1MB씩 할당
except MemoryError:
print(f'Allocated {len(data)} MB before OOM')
"
# 4. 압박 상태에서 shrinker 동작 확인
cat /sys/fs/cgroup/shrinker_test/memory.stat | grep -E "slab|pgsteal"
cat /sys/fs/cgroup/shrinker_test/memory.pressure
KUnit 기반 shrinker 테스트
#include <kunit/test.h>
#include <linux/shrinker.h>
/* 테스트용 shrinker 콜백 */
static atomic_long_t test_count = ATOMIC_LONG_INIT(100);
static atomic_long_t test_freed = ATOMIC_LONG_INIT(0);
static unsigned long
test_count_objects(struct shrinker *s, struct shrink_control *sc)
{
return atomic_long_read(&test_count);
}
static unsigned long
test_scan_objects(struct shrinker *s, struct shrink_control *sc)
{
unsigned long nr = min(sc->nr_to_scan,
(unsigned long)atomic_long_read(&test_count));
atomic_long_sub(nr, &test_count);
atomic_long_add(nr, &test_freed);
return nr;
}
static void test_shrinker_register_free(struct kunit *test)
{
struct shrinker *s;
s = shrinker_alloc(0, "kunit-test");
KUNIT_ASSERT_NOT_NULL(test, s);
s->count_objects = test_count_objects;
s->scan_objects = test_scan_objects;
s->seeks = DEFAULT_SEEKS;
shrinker_register(s);
/* drop_caches로 회수 트리거 */
drop_caches_sysctl_handler(2);
/* 회수 확인 */
KUNIT_EXPECT_GT(test, atomic_long_read(&test_freed), 0L);
shrinker_free(s);
}
스트레스 테스트 스크립트
#!/bin/bash
# shrinker 스트레스 테스트
# tracepoint 활성화
echo 1 > /sys/kernel/debug/tracing/events/vmscan/mm_shrink_slab_start/enable
echo 1 > /sys/kernel/debug/tracing/events/vmscan/mm_shrink_slab_end/enable
# 메모리 압박 + 파일시스템 부하 동시 실행
stress-ng --vm 4 --vm-bytes 80% --vm-method all -t 30s &
find / -type f -name "*.h" -exec cat {} > /dev/null 2>&1 \; &
# 30초 후 결과 분석
sleep 30
# 트레이스 수집
cat /sys/kernel/debug/tracing/trace | grep shrink_slab > /tmp/shrinker_trace.log
echo "=== 결과 ==="
echo "shrink_slab 호출 횟수:"
wc -l /tmp/shrinker_trace.log
echo "shrinker별 freed 합계:"
grep "freed=" /tmp/shrinker_trace.log | \
sed 's/.*shrink: \([^ ]*\).*freed=\([0-9]*\).*/\1 \2/' | \
awk '{sum[$1]+=$2} END {for (k in sum) print k, sum[k]}' | sort -k2 -rn
# 정리
echo 0 > /sys/kernel/debug/tracing/events/vmscan/mm_shrink_slab_start/enable
echo 0 > /sys/kernel/debug/tracing/events/vmscan/mm_shrink_slab_end/enable
성능 튜닝
관련 커널 파라미터
| 파라미터 | 경로 | 기본값 | 설명 |
|---|---|---|---|
vfs_cache_pressure | /proc/sys/vm/vfs_cache_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 경합 없이 빠르게 추정값 반환 (atomic 카운터 권장)
- scan 안전성: 회수 중 리스트/객체 수명주기 보호 (trylock + 잠금 밖 해제)
- 재귀 회수 방지: 콜백 내부 과도한 할당/슬립 금지 (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 처리 필수 |
완전한 드라이버 예제
NUMA-aware + memcg-aware shrinker를 포함한 완전한 커널 모듈 예제입니다. 실제 프로덕션 수준의 패턴을 따릅니다.
/*
* my_cache_driver.c - NUMA/memcg-aware shrinker 완전 예제
*
* 이 모듈은 per-node LRU 캐시를 관리하며,
* 메모리 압박 시 shrinker를 통해 자동으로 캐시를 회수합니다.
*/
#include <linux/module.h>
#include <linux/shrinker.h>
#include <linux/list_lru.h>
#include <linux/slab.h>
#include <linux/atomic.h>
#include <linux/nodemask.h>
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("NUMA/memcg-aware shrinker example");
/* === 캐시 항목 구조체 === */
struct my_object {
struct list_head lru_link; /* list_lru 연결 */
void *payload; /* 실제 데이터 */
size_t payload_size; /* 페이로드 크기 */
unsigned long last_access; /* 마지막 접근 jiffies */
atomic_t refcount; /* 참조 카운터 */
};
/* === 전역 상태 === */
static struct list_lru my_lru;
static struct shrinker *my_shrinker;
static struct kmem_cache *my_slab;
/* === list_lru 콜백: 회수 가능 여부 판단 === */
static enum lru_status
my_isolate(struct list_head *item,
struct list_lru_one *lru,
spinlock_t *lock, void *cb_arg)
{
struct my_object *obj = container_of(item,
struct my_object, lru_link);
struct list_head *freeable = cb_arg;
/* 참조 중인 객체는 건너뜀 */
if (atomic_read(&obj->refcount) > 0)
return LRU_ROTATE; /* LRU 뒤로 이동 */
/* 최근 접근 항목은 보존 */
if (time_before(jiffies, obj->last_access + HZ * 30))
return LRU_SKIP;
/* 분리 (실제 해제는 콜백 밖에서) */
list_lru_isolate_move(lru, item, freeable);
return LRU_REMOVED;
}
/* === count_objects 콜백 === */
static unsigned long
my_count_objects(struct shrinker *shrink,
struct shrink_control *sc)
{
unsigned long count;
count = list_lru_shrink_count(&my_lru, sc);
return count ?: SHRINK_EMPTY;
}
/* === scan_objects 콜백 === */
static unsigned long
my_scan_objects(struct shrinker *shrink,
struct shrink_control *sc)
{
unsigned long freed;
LIST_HEAD(freeable);
struct my_object *obj, *tmp;
/* list_lru_shrink_walk: NUMA/memcg-aware 자동 처리 */
freed = list_lru_shrink_walk(&my_lru, sc,
my_isolate, &freeable);
/* 잠금 밖에서 실제 해제 */
list_for_each_entry_safe(obj, tmp, &freeable, lru_link) {
list_del(&obj->lru_link);
kfree(obj->payload);
kmem_cache_free(my_slab, obj);
}
return freed;
}
/* === 모듈 초기화 === */
static int __init my_cache_init(void)
{
int err;
/* 슬랩 캐시 생성 */
my_slab = kmem_cache_create("my_objects",
sizeof(struct my_object),
0, SLAB_RECLAIM_ACCOUNT, NULL);
if (!my_slab)
return -ENOMEM;
/* list_lru 초기화 (memcg-aware) */
err = list_lru_init_memcg(&my_lru, NULL);
if (err)
goto err_lru;
/* shrinker 할당 */
my_shrinker = shrinker_alloc(
SHRINKER_NUMA_AWARE | SHRINKER_MEMCG_AWARE,
"my_cache_driver");
if (!my_shrinker) {
err = -ENOMEM;
goto err_shrinker;
}
/* 콜백 설정 */
my_shrinker->count_objects = my_count_objects;
my_shrinker->scan_objects = my_scan_objects;
my_shrinker->seeks = 1; /* 재생성 비용 낮음 */
/* 등록 (이후부터 콜백 호출 가능) */
shrinker_register(my_shrinker);
pr_info("my_cache_driver: initialized\n");
return 0;
err_shrinker:
list_lru_destroy(&my_lru);
err_lru:
kmem_cache_destroy(my_slab);
return err;
}
/* === 모듈 해제 === */
static void __exit my_cache_exit(void)
{
/* 1. shrinker 해제 (진행 중인 콜백 완료 대기) */
shrinker_free(my_shrinker);
/* 2. 남은 캐시 항목 모두 해제 */
/* (shrinker 해제 후이므로 콜백과 경쟁 없음) */
flush_remaining_objects();
/* 3. list_lru 해제 */
list_lru_destroy(&my_lru);
/* 4. 슬랩 캐시 해제 */
kmem_cache_destroy(my_slab);
pr_info("my_cache_driver: cleaned up\n");
}
module_init(my_cache_init);
module_exit(my_cache_exit);
Shrinker와 관련 서브시스템
OOM Killer와의 상호작용
OOM killer가 호출되기 전에 커널은 shrinker를 통한 마지막 회수 시도를 합니다.
/* mm/oom_kill.c (단순화된 OOM 결정 흐름) */
/*
* 1. try_to_free_pages() → shrink_node() → shrink_slab() [priority 감소]
* 2. priority가 0까지 떨어져도 충분히 회수 못하면...
* 3. __alloc_pages_may_oom() 진입
* 4. out_of_memory() → OOM killer 호출
*
* shrinker가 충분히 회수하면 OOM을 피할 수 있음!
* → 따라서 shrinker가 빠르고 효과적으로 회수하는 것이 중요
*/
compaction과의 관계
메모리 단편화 해소(compaction)과 shrinker는 다른 목적이지만 같은 메모리 압박 상황에서 함께 동작합니다.
| 항목 | Shrinker (shrink_slab) | Compaction |
|---|---|---|
| 목적 | 캐시 해제로 free 페이지 수 증가 | 페이지 이동으로 연속 영역 확보 |
| 호출 시점 | free 페이지 부족 시 | high-order 할당 실패 시 |
| 대상 | 슬랩/캐시 메모리 | 이동 가능한(movable) 페이지 |
| 호출 관계 | 먼저 시도 | shrinker 이후 또는 병렬 |
zswap Shrinker
Linux 6.8+에서 zswap도 shrinker를 등록하여, 메모리 압박 시 압축된 스왑 캐시를 디스크 스왑으로 내보냅니다.
/* mm/zswap.c - zswap shrinker (Linux 6.8+) */
static unsigned long
zswap_shrinker_count(struct shrinker *s,
struct shrink_control *sc)
{
struct mem_cgroup *memcg = sc->memcg;
struct lruvec *lruvec;
unsigned long nr_backing, nr_stored;
/* zswap 풀에 저장된 항목 중 writeback 가능한 수 */
nr_backing = memcg_page_state(memcg, MEMCG_ZSWAP_B) / PAGE_SIZE;
nr_stored = memcg_page_state(memcg, MEMCG_ZSWAPPED);
/* writeback 비용을 고려하여 반환 */
return nr_stored;
}
static unsigned long
zswap_shrinker_scan(struct shrinker *s,
struct shrink_control *sc)
{
/* zswap 항목을 디스크 스왑으로 writeback하여 메모리 해제 */
return zswap_writeback_entries(sc);
}
소스 코드 맵
| 파일 | 내용 | 주요 함수/구조체 |
|---|---|---|
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() |
구현 권장 사항
| 항목 | 권장 사항 | 근거 |
|---|---|---|
| count_objects 속도 | 원자적 카운터 또는 list_lru_shrink_count() 사용 | 회수 경로에서 빈번히 호출되므로 O(1) 필수 |
| scan_objects 잠금 | trylock 사용, 실패 시 SHRINK_STOP | 회수 경로에서 잠금 대기는 시스템 스톨 유발 |
| scan_objects 해제 | 리스트 분리 후 잠금 밖에서 kfree() | 잠금 안에서 kfree()는 잠금 순서 위반 가능 |
| batch 설정 | 적절한 최소 단위 설정 (기본 128) | 너무 작으면 오버헤드, 너무 크면 latency spike |
| LRU 활용 | list_lru 인프라 사용 (직접 구현 지양) | NUMA/memcg-aware가 자동 지원됨 |
| memcg 지원 | 가능하면 SHRINKER_MEMCG_AWARE 구현 | cgroup 환경(컨테이너/k8s)에서 정확한 회수 |
| 해제 순서 | shrinker_free() → 캐시 정리 → 자원 해제 | 콜백 경쟁 방지 |
| 디버깅 이름 | "subsystem:cache_type" 형식 | debugfs에서 식별 용이 |
list_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) | 메모리까지 해제됨, 이중 해제 금지 |
| 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— 잠금 해제 후 순회 재시작
GFP 마스크 처리 가이드
sc->gfp_mask는 어떤 종류의 메모리 할당 컨텍스트에서 회수가 요청되었는지를 나타냅니다.
shrinker 콜백은 이 플래그를 확인하여 안전한 동작만 수행해야 합니다.
| GFP 플래그 | 의미 | shrinker 동작 |
|---|---|---|
__GFP_FS 있음 | 파일시스템 작업 허용 | 정상 회수 (dentry/inode 해제 가능) |
__GFP_FS 없음 | 파일시스템 재진입 금지 | 파일시스템 shrinker 건너뜀 (교착 방지) |
__GFP_IO 있음 | I/O 허용 | dirty 캐시 writeback 후 해제 가능 |
__GFP_IO 없음 | I/O 금지 | clean 캐시만 해제 |
__GFP_RECLAIM | 회수 허용 (직접 회수) | 정상 shrinker 동작 |
/* GFP 마스크를 고려한 안전한 scan 구현 */
static unsigned long
safe_scan_objects(struct shrinker *s,
struct shrink_control *sc)
{
/* I/O가 허용되지 않는 컨텍스트에서는 clean 캐시만 */
if (!(sc->gfp_mask & __GFP_IO)) {
return free_clean_entries_only(&my_lru,
sc->nr_to_scan);
}
/* FS가 허용되지 않으면 파일시스템 메타데이터 접근 불가 */
if (!(sc->gfp_mask & __GFP_FS)) {
return free_non_fs_entries(&my_lru,
sc->nr_to_scan);
}
/* 정상: 모든 종류의 캐시 회수 가능 */
return free_all_entries(&my_lru, sc->nr_to_scan);
}
주요 커널 Shrinker 상세 분석
Linux 커널에서 가장 영향력 있는 shrinker 구현체들을 내부 코드 수준에서 분석합니다. 이들은 시스템의 슬랩 메모리 대부분을 관리하므로, 그 동작을 이해하는 것이 메모리 튜닝의 핵심입니다.
dentry_cache shrinker 상세
dentry 캐시 shrinker는 super_block 단위로 등록됩니다.
각 마운트된 파일시스템마다 별도의 shrinker 인스턴스가 존재합니다.
/* fs/super.c - super_block 할당 시 shrinker 설정 */
static struct super_block *alloc_super(
struct file_system_type *type, int flags,
struct user_namespace *user_ns)
{
struct super_block *s;
/* ... 할당 및 초기화 ... */
/* dentry/inode LRU 초기화 (memcg-aware) */
err = list_lru_init_memcg(&s->s_dentry_lru, &s->s_shrink);
err = list_lru_init_memcg(&s->s_inode_lru, &s->s_shrink);
/* shrinker 할당 (NUMA + MEMCG) */
s->s_shrink = shrinker_alloc(
SHRINKER_NUMA_AWARE | SHRINKER_MEMCG_AWARE,
"sb-%s-%p", type->name, s);
s->s_shrink->count_objects = super_cache_count;
s->s_shrink->scan_objects = super_cache_scan;
s->s_shrink->seeks = DEFAULT_SEEKS;
return s;
}
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);
}'
성능 함정과 고급 안티패턴
기본적인 안티패턴 외에, 프로덕션 환경에서 흔히 발생하는 고급 성능 함정들을 분석합니다. 이 패턴들은 단위 테스트에서는 드러나지 않고, 대규모 워크로드에서만 나타나는 경우가 많습니다.
함정 1: nr_deferred 폭발
count_objects()가 실제보다 적은 값을 반환하면, nr_deferred가 지속적으로 누적됩니다.
일정 시점에 total_scan이 폭발적으로 증가하여 한 번의 회수 시도에서 캐시 전체가 날아갑니다.
/* nr_deferred 폭발 시나리오 */
/*
* 시나리오: 실제 캐시 = 10000, count()가 100을 반환
*
* 라운드 1: total_scan = 50 + 0 (deferred) = 50
* freed = 50, deferred += 0
*
* 라운드 2: count=100, scan=50, freed=50, deferred=0
* ... 정상적으로 보임 ...
*
* 하지만 실제 캐시 크기 10000 중 100만 보고하므로:
* - shrink_slab은 이 shrinker가 큰 캐시를 가진 것을 모름
* - LRU/slab 비율 계산이 왜곡됨
* - 메모리 압박이 심해져도 이 캐시는 적게 회수됨
* - 결국 OOM이 발생하여 프로세스가 kill됨
*/
/* 해결: 정확한 카운트 반환 */
static unsigned long correct_count(struct shrinker *s,
struct shrink_control *sc)
{
/* percpu_counter는 약간의 오차가 있지만 O(1) */
long count = percpu_counter_read_positive(&my_counter);
return count ?: SHRINK_EMPTY;
}
함정 2: Thundering Herd (떼몰이 회수)
다수의 CPU가 동시에 메모리 압박을 겪으면, 모든 CPU가 동시에 같은 shrinker를 호출합니다. 잠금 경합이 급증하고 시스템이 일시적으로 멈출 수 있습니다.
/* Thundering Herd 완화 패턴 */
static unsigned long
throttled_scan(struct shrinker *s,
struct shrink_control *sc)
{
static atomic_t active_scanners = ATOMIC_INIT(0);
unsigned long freed;
/* 동시 스캐너 수 제한 (최대 2개) */
if (atomic_read(&active_scanners) >= 2)
return SHRINK_STOP;
atomic_inc(&active_scanners);
/* 실제 회수 수행 */
freed = do_actual_scan(&my_lru, sc->nr_to_scan);
atomic_dec(&active_scanners);
return freed;
}
함정 3: False Positive (거짓 양성 카운트)
캐시 항목이 논리적으로는 회수 불가능하지만 count에는 포함되는 경우입니다.
예를 들어, 참조 카운트가 0보다 큰 항목을 count에 포함하면 scan에서 모두 건너뛰게 되고,
그 차이가 nr_deferred에 누적됩니다.
/* False Positive 방지: count와 scan의 일관성 */
/* 나쁜 예: count에 모든 항목 포함 */
static unsigned long bad_count(struct shrinker *s,
struct shrink_control *sc)
{
/* 전체 항목 수 반환 (참조 중인 것 포함) */
return atomic_long_read(&total_objects); /* 10000 */
/* 하지만 scan에서 9000개는 refcount > 0이라 건너뜀 */
/* → nr_deferred에 9000 누적! */
}
/* 좋은 예: 실제 회수 가능한 수만 반환 */
static unsigned long good_count(struct shrinker *s,
struct shrink_control *sc)
{
/* 회수 가능(unreferenced) 항목 수만 반환 */
return atomic_long_read(&freeable_objects); /* 1000 */
}
함정 4: 잠금 순서 위반
shrinker 콜백은 메모리 회수 경로에서 호출되므로, 커널의 다양한 잠금이 이미 보유된 상태일 수 있습니다. 콜백 내에서 잘못된 순서로 잠금을 획득하면 교착 상태가 발생합니다.
| 위험 패턴 | 이유 | 안전한 대안 |
|---|---|---|
scan 내에서 mmap_lock 획득 | 페이지 폴트 → 회수 → mmap_lock 교착 | mmap_lock이 필요한 해제는 work queue로 지연 |
scan 내에서 sb->s_umount 획득 | umount → shrinker_free → 대기 교착 | trylock_super() 사용 |
| scan 내에서 I/O 대기 | I/O 완료에 메모리 할당 필요 → 재귀 | dirty 캐시는 건너뛰고 clean만 해제 |
count에서 rw_semaphore 획득 | 빈번한 count 호출에서 writer starvation | 원자적 카운터로 대체 |
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 = (4 * sc->nr_to_scan) / shrinker->seeks;
*
* sc->nr_to_scan은 priority에 반비례:
* nr_to_scan = lruvec_lru_size >> priority
*
* 따라서:
* priority=12: nr_to_scan = lru_size / 4096 (매우 적음)
* priority=8: nr_to_scan = lru_size / 256
* priority=4: nr_to_scan = lru_size / 16
* priority=0: nr_to_scan = lru_size (전체)
*
* 예시: lru_size=1048576 (1M 페이지), freeable=50000, seeks=2
*
* priority=12: delta = (4 * 256) / 2 = 512
* scan = 512 * 50000 / 1048577 ≈ 24
*
* priority=8: delta = (4 * 4096) / 2 = 8192
* scan = 8192 * 50000 / 1048577 ≈ 390
*
* priority=4: delta = (4 * 65536) / 2 = 131072
* scan = 131072 * 50000 / 1048577 ≈ 6250
*
* priority=0: delta = (4 * 1048576) / 2 = 2097152
* scan = 2097152 * 50000 / 1048577 ≈ 100000
*/
| 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는
가능한 모든 항목을 회수해야 합니다.
사용자 공간에서의 메모리 압력 감지
/* 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와 상호 보완적으로 동작합니다.
관련 문서
- 메모리 관리 (기초) — 페이지 할당자와 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 할당 메커니즘