페이지 회수 (Page Reclaim)
리눅스 커널의 Page Reclaim(페이지 회수) 서브시스템을 심층 분석합니다. 물리 메모리(Physical Memory)가 부족할 때 사용 빈도가 낮은 페이지를 식별하여 해제하는 mm/vmscan.c 핵심 로직, LRU 리스트(active/inactive x anon/file)와 lruvec 구조, folio 기반 회수 경로, kswapd 백그라운드 데몬과 direct reclaim 동기 경로, 워터마크(min/low/high) 시스템, shrink_node/shrink_lruvec/shrink_folio_list 호출 체인, Refault Distance 기반 워킹셋 보호, MGLRU(Multi-Gen LRU) 세대 기반 회수, memcg 계층별 회수, NUMA demotion, slab shrinker 연동, proactive reclaim, ftrace 디버깅(Debugging), sysctl 튜닝까지 커널 소스 기반으로 분석합니다.
핵심 요약
- 페이지 회수(Page Reclaim) -- 물리 메모리가 부족할 때 사용 빈도가 낮은 페이지를 해제하여 free 페이지를 확보하는 메커니즘입니다.
- LRU 리스트 -- 페이지를 active/inactive x anon/file 4개 리스트로 분류하여 최근 미사용 페이지를 우선 회수합니다.
- 2가지 회수 경로 -- kswapd(백그라운드 데몬, 워터마크 기반)와 direct reclaim(할당 실패 시 동기 회수)이 있습니다.
- 워터마크 -- 각 Zone의 min/low/high 워터마크가 kswapd 기동과 direct reclaim 진입을 결정합니다.
- MGLRU -- 클래식 LRU의 한계를 극복하기 위해 세대(generation) 기반으로 페이지 접근 빈도를 추적하는 최신 알고리즘입니다.
- Refault Distance -- 회수된 페이지가 다시 필요해지는 빈도를 추적하여 워킹셋을 보호합니다.
단계별 이해
- LRU 리스트 구조 이해
active/inactive x anon/file 4개 리스트와 lruvec 구조를 파악합니다. - 워터마크 개념 파악
min/low/high 워터마크가 어떻게 회수를 트리거하는지 이해합니다. - kswapd vs direct reclaim
백그라운드 회수와 동기 회수의 진입 조건과 동작 차이를 구분합니다. - shrink 경로 추적
shrink_node → shrink_lruvec → shrink_folio_list 호출 체인을 따라갑니다. - MGLRU 이해
세대 기반 회수가 클래식 LRU 대비 어떤 장점을 제공하는지 파악합니다. - 모니터링과 튜닝
vmstat, ftrace, sysctl로 회수 상태를 진단하고 최적화합니다.
mm/vmscan.c (핵심 회수 로직), mm/workingset.c (refault distance), mm/swap.c (LRU 관리), include/linux/mmzone.h (lruvec, zone 구조), mm/page_alloc.c (워터마크 검사, 회수 트리거).
초기 구현은 2.4 시대 Andrea Arcangeli의 VM 재작성에서 시작되었으며, MGLRU는 Yu Zhao의 커밋 시리즈(v6.1)로 도입되었습니다.
Page Reclaim 개요
리눅스 커널은 물리 메모리를 최대한 활용하기 위해 여유 메모리를 파일 캐시(page cache)나 프로세스(Process)의 anonymous 메모리로 적극 사용합니다. 시간이 지나면 할당된 페이지가 늘어나 free 페이지가 줄어들고, 새로운 할당 요청을 처리하기 어려워집니다. 이때 Page Reclaim 서브시스템이 작동하여 "덜 중요한" 페이지를 식별하고 해제합니다.
회수 대상 페이지 유형
| 페이지 유형 | 회수 방법 | 비용 | 설명 |
|---|---|---|---|
| Clean file page | 즉시 해제 | 낮음 | 디스크에 원본이 있으므로 바로 해제 가능 |
| Dirty file page | writeback 후 해제 | 중간 | 수정된 내용을 디스크에 기록한 후 해제 |
| Anonymous page | swap out 후 해제 | 높음 | 스왑 영역(Swap Area)에 기록해야 함 |
| Slab cache | shrinker 호출 | 가변 | dentry/inode 캐시 등 축소 가능 객체 |
| Kernel stack, page table | 회수 불가 | - | 핀되거나 이동 불가한 커널 페이지 |
전체 회수 흐름
mm/vmscan.c는 약 7,000줄 이상의 커널 핵심 코드로, 페이지 회수의 모든 정책 결정이 이 파일에 집중되어 있습니다. 최근 커널(6.x)에서는 folio 기반 API로 전환이 진행 중이며, MGLRU가 기본 활성화되면서 클래식 LRU와 공존하는 구조입니다.
LRU 리스트 구조
커널은 메모리 페이지를 4개의 LRU(Least Recently Used) 리스트로 분류합니다. 각 리스트는 struct lruvec에 포함되어 있으며, Zone 또는 memcg 단위로 관리됩니다.
| 리스트 | 매크로(Macro) | 대상 페이지 | 회수 우선순위(Priority) |
|---|---|---|---|
| Inactive Anon | LRU_INACTIVE_ANON | 최근 미참조 anonymous 페이지 | 높음 (swappiness에 따라) |
| Active Anon | LRU_ACTIVE_ANON | 최근 참조된 anonymous 페이지 | 낮음 |
| Inactive File | LRU_INACTIVE_FILE | 최근 미참조 file-backed 페이지 | 가장 높음 |
| Active File | LRU_ACTIVE_FILE | 최근 참조된 file-backed 페이지 | 낮음 |
enum lru_list {
LRU_INACTIVE_ANON = 0,
LRU_ACTIVE_ANON = LRU_INACTIVE_ANON + LRU_ACTIVE,
LRU_INACTIVE_FILE = LRU_INACTIVE_ANON + LRU_FILE,
LRU_ACTIVE_FILE = LRU_INACTIVE_FILE + LRU_ACTIVE,
LRU_UNEVICTABLE,
NR_LRU_LISTS
};
struct lruvec {
struct list_head lists[NR_LRU_LISTS];
unsigned long anon_cost;
unsigned long file_cost;
/* Aging 관련 */
atomic_long_t nonresident_age;
unsigned long refaults[ANON_AND_FILE];
/* MGLRU 지원 */
struct lru_gen_folio lrugen;
...
};
코드 설명
lruvec는 LRU 리스트의 컨테이너(Container)입니다. lists[] 배열에 4개 LRU + unevictable 리스트가 포함됩니다. anon_cost/file_cost는 anon/file 페이지의 refault 비용을 추적하여 스캔 비율을 조정합니다. MGLRU 활성화 시 lrugen 필드가 세대 기반 관리를 담당합니다.
Per-CPU Folio 배치 처리
LRU 리스트는 lru_lock 스핀락(Spinlock)으로 보호됩니다. 매 페이지 추가/제거 시마다 락을 획득하면 CPU 수가 많을수록 경합이 심해집니다. 커널은 Per-CPU folio 배치(Batch)를 통해 락 획득 횟수를 대폭 줄입니다.
/* include/linux/pagevec.h - folio_batch 구조 */
struct folio_batch {
unsigned char nr; /* 현재 배치 내 folio 수 */
struct folio *folios[PAGEVEC_SIZE]; /* 최대 31개 */
};
/* mm/swap.c - cpu_fbatches: per-CPU 배치 집합 */
struct cpu_fbatches {
local_lock_t lock;
struct folio_batch lru_add; /* LRU 추가 배치 */
struct folio_batch lru_deactivate; /* 비활성화 배치 */
struct folio_batch activate; /* Active 승격 배치 */
struct folio_batch lru_lazyfree; /* MADV_FREE 대상 */
};
DEFINE_PER_CPU(struct cpu_fbatches, cpu_fbatches);
/* lru_add_drain(): per-CPU 배치를 LRU에 flush */
void lru_add_drain(void)
{
local_lock(&cpu_fbatches.lock);
lru_add_drain_cpu(smp_processor_id());
local_unlock(&cpu_fbatches.lock);
}
코드 설명
folio_batch는 최대 31개의 folio 포인터를 담는 per-CPU 버퍼입니다. 배치가 가득 차거나 lru_add_drain()이 호출될 때 lru_lock을 한 번만 잡고 전체 배치를 LRU에 일괄 삽입합니다. 회수 시작 전에는 반드시 모든 CPU의 배치를 drain해야 정확한 LRU 상태를 볼 수 있습니다.
lru_lock 획득이 필요합니다. 배치를 사용하면 단 1번으로 줄어듭니다. 단, drain이 지연되면 LRU 통계가 일시적으로 부정확해질 수 있으므로 회수 코드는 항상 drain 이후에 LRU 크기를 읽습니다.
Folio 기반 LRU 관리
커널 5.16 이후 LRU 관리가 struct page에서 struct folio로 전환되고 있습니다. Folio는 compound page를 자연스럽게 표현하며, THP(Transparent Huge Page)도 단일 folio로 LRU에서 관리됩니다.
folio_batch와 지연(Latency) LRU 추가
페이지를 LRU에 추가할 때마다 lru_lock을 잡으면 성능이 저하됩니다. 커널은 folio_batch(per-CPU 배치 버퍼(Buffer))를 사용하여 여러 folio를 모았다가 한 번에 LRU에 추가합니다.
/* mm/swap.c */
void folio_add_lru(struct folio *folio)
{
struct folio_batch *fbatch;
/* folio에 LRU 플래그 설정 */
folio_get(folio);
local_lock(&cpu_fbatches.lock);
fbatch = this_cpu_ptr(&cpu_fbatches.lru_add);
/* 배치 버퍼에 추가, 가득 차면 drain */
if (!folio_batch_add(fbatch, folio))
folio_batch_move_lru(fbatch);
local_unlock(&cpu_fbatches.lock);
}
코드 설명
folio_add_lru()는 folio를 per-CPU folio_batch에 추가합니다. 배치가 가득 차면(기본 31개) folio_batch_move_lru()를 호출하여 실제 LRU 리스트에 일괄 삽입합니다. 이렇게 하면 lru_lock 획득 횟수가 크게 줄어듭니다.
Folio 활성화/비활성화
/* Active로 승격 */
void folio_activate(struct folio *folio)
{
if (!folio_test_active(folio) &&
!folio_test_unevictable(folio)) {
struct folio_batch *fbatch;
fbatch = this_cpu_ptr(&cpu_fbatches.activate);
folio_get(folio);
if (!folio_batch_add(fbatch, folio))
folio_batch_move_lru(fbatch);
}
}
/* Inactive로 강등 */
void folio_deactivate(struct folio *folio)
{
if (folio_test_lru(folio) &&
folio_test_active(folio) &&
!folio_test_unevictable(folio)) {
struct folio_batch *fbatch;
fbatch = this_cpu_ptr(&cpu_fbatches.lru_deactivate);
folio_get(folio);
if (!folio_batch_add(fbatch, folio))
folio_batch_move_lru(fbatch);
}
}
folio_add_lru()는 기존 lru_cache_add()를 대체합니다. Folio는 head page 포인터를 통해 compound page 전체를 한 번에 처리하므로, THP를 LRU에 추가할 때 512번 반복할 필요가 없습니다.
페이지 플래그와 회수 결정
회수 서브시스템은 페이지 플래그를 통해 각 페이지의 상태를 판단합니다. 핵심 플래그들을 정리합니다.
| 플래그 | 의미 | 회수 시 역할 |
|---|---|---|
PG_referenced | 최근 참조됨 | inactive에서 이 플래그가 있으면 active로 승격. 회수 시 한 번 클리어 후 재참조 없으면 회수 |
PG_active | Active LRU에 있음 | Active 리스트 소속 여부. 회수 대상 선정 시 기본 제외 |
PG_lru | LRU 리스트에 있음 | LRU에서 분리(isolate) 시 클리어 |
PG_locked | I/O 진행 중 | 잠겨 있으면 회수 불가, 재시도 대기 |
PG_dirty | 수정됨 | writeback 필요. clean이면 즉시 해제 가능 |
PG_writeback | writeback 진행 중 | 완료 대기 필요 |
PG_swapcache | 스왑 캐시에 있음 | 스왑 슬롯 할당 완료 상태 |
PG_workingset | 워킹셋 소속 | refault distance로 판별. 이 플래그가 있으면 activate 우선 |
PG_unevictable | 회수 불가 | mlock, ramfs 등. LRU_UNEVICTABLE에 배치 |
PG_mlocked | mlock으로 고정 | unevictable 리스트로 이동 |
Second Chance 알고리즘
클래식 LRU에서 회수 결정의 핵심은 Second Chance(두 번째 기회) 알고리즘입니다.
/* 간략화된 회수 결정 로직 */
if (folio_test_referenced(folio)) {
/* 참조됨 -> 한 번 더 기회 부여 */
folio_clear_referenced(folio);
if (folio_test_active(folio) || folio_test_workingset(folio))
goto activate; /* Active로 승격 */
goto keep; /* Inactive 유지, 다음 라운드에 재검토 */
}
/* 미참조 -> 회수 진행 */
goto reclaim;
kswapd 데몬
kswapd는 각 NUMA 노드당 하나씩 존재하는 커널 데몬으로, 워터마크 기반으로 백그라운드에서 페이지를 회수합니다. 대부분의 정상 상황에서 메모리 회수(Memory Reclaim)는 kswapd가 담당합니다.
/* mm/vmscan.c - kswapd 메인 루프 */
static int kswapd(void *p)
{
struct pglist_data *pgdat = (struct pglist_data *)p;
unsigned int alloc_order, reclaim_order;
for ( ; ; ) {
/* 워터마크 이상이면 sleep */
prepare_to_wait(&pgdat->kswapd_wait,
&wait, TASK_INTERRUPTIBLE);
if (!kswapd_shrink_node(pgdat, ...))
schedule(); /* 할 일 없으면 대기 */
finish_wait(&pgdat->kswapd_wait, &wait);
/* 깨어나면 워터마크 high까지 회수 */
alloc_order = reclaim_order = pgdat->kswapd_order;
balance_pgdat(pgdat, alloc_order, ...);
}
return 0;
}
코드 설명
kswapd는 무한 루프에서 동작합니다. 워터마크가 충분하면schedule()로 대기하고, wakeup_kswapd() 호출로 깨어나면 balance_pgdat()를 통해 해당 노드의 모든 Zone에서 워터마크 high까지 페이지를 회수합니다.
kswapd 기동 조건
/* mm/page_alloc.c - 할당 경로에서 kswapd 깨우기 */
static void wakeup_kswapd(struct zone *zone,
gfp_t gfp_flags,
int order)
{
struct pglist_data *pgdat = zone->zone_pgdat;
/* Zone의 free pages가 low 워터마크 미만이면 */
if (!managed_zone(zone))
return;
if (!waitqueue_active(&pgdat->kswapd_wait))
return;
/* kswapd에 회수할 order 전달 */
if (pgdat->kswapd_order < order)
pgdat->kswapd_order = order;
wake_up_interruptible(&pgdat->kswapd_wait);
}
balance_pgdat() 상세 흐름
balance_pgdat()는 kswapd의 핵심 작업 루프입니다. NUMA 노드(Node) 내 모든 Zone을 순회하며 워터마크(Watermark) 상태를 확인하고, 필요하면 shrink_node()를 반복 호출합니다.
/* mm/vmscan.c - balance_pgdat() 핵심 흐름 (간략화) */
static unsigned long
balance_pgdat(struct pglist_data *pgdat,
int order,
int highest_zoneidx)
{
struct scan_control sc = {
.gfp_mask = GFP_KERNEL,
.order = order,
.may_writepage = !laptop_mode,
.may_unmap = 1,
.may_swap = 1,
};
/* priority를 DEF_PRIORITY(12)에서 0까지 낮추며 반복 */
do {
bool raise_priority = true;
sc.priority = priority;
/* highest_zoneidx에서 ZONE_NORMAL 방향으로 검사 */
for (i = highest_zoneidx; i >= 0; i--) {
zone = pgdat->node_zones + i;
/* 이미 high 워터마크 이상이면 skip */
if (zone_balanced(zone, order, highest_zoneidx))
continue;
/* 실제 회수 수행 */
shrink_node(pgdat, &sc);
raise_priority = false;
}
if (raise_priority || !nr_reclaimed)
priority--;
/* 노드 전체가 균형 잡혔으면 종료 */
if (pgdat_balanced(pgdat, order, highest_zoneidx))
break;
} while (priority >= 0);
return sc.nr_reclaimed;
}
코드 설명
balance_pgdat()는 priority 12부터 시작하여 0까지 낮춰가며 회수를 시도합니다. 각 라운드에서 highest_zoneidx부터 ZONE_NORMAL 방향으로 Zone을 순회하며, 워터마크 미달 Zone에 대해 shrink_node()를 호출합니다. 노드 전체가 균형(high 워터마크 이상)이 되면 kswapd는 다시 sleep합니다.
balance_pgdat()는 가장 높은 Zone부터 낮은 방향으로 회수합니다. 이 순서가 중요한 이유는 높은 Zone이 부족할 때 낮은 Zone의 페이지를 회수해도 높은 Zone의 할당 요청을 해결할 수 없기 때문입니다. highest_zoneidx는 kswapd를 깨운 할당 요청의 gfp_mask에서 결정됩니다.
Direct Reclaim
kswapd가 충분히 회수하지 못한 상태에서 페이지 할당이 요청되면, 할당을 요청한 프로세스가 직접 페이지를 회수합니다. 이것이 Direct Reclaim이며, 할당 지연의 주요 원인입니다.
/* mm/page_alloc.c - __alloc_pages_slowpath() 내부 */
static struct page *
__alloc_pages_slowpath(gfp_t gfp, unsigned int order,
struct alloc_context *ac)
{
...
/* 1단계: kswapd 깨우기 */
if (gfpflags_allow_blocking(gfp))
wake_all_kswapds(order, gfp, ac);
/* 2단계: 워터마크 낮춰서 재시도 */
page = get_page_from_freelist(gfp, order, ALLOC_WMARK_MIN, ac);
if (page)
goto got_pg;
/* 3단계: Direct Reclaim 진입 */
page = __perform_reclaim(gfp, order, ac);
if (page)
goto got_pg;
/* 4단계: Direct Compaction */
page = __alloc_pages_direct_compact(gfp, order, ...);
if (page)
goto got_pg;
/* 5단계: OOM Killer */
page = __alloc_pages_may_oom(gfp, order, ac, ...);
...
}
코드 설명
페이지 할당 slowpath에서 1) kswapd 깨우기(Wakeup), 2) 워터마크 완화 재시도, 3) direct reclaim, 4) compaction, 5) OOM kill 순서로 진행합니다. Direct reclaim은__perform_reclaim()을 통해 try_to_free_pages()를 호출합니다.
워터마크 시스템
각 Zone은 세 개의 워터마크(min, low, high)를 가지며, 이 값들이 메모리 회수 정책의 핵심 임계값입니다.
/* include/linux/mmzone.h */
enum zone_watermarks {
WMARK_MIN,
WMARK_LOW,
WMARK_HIGH,
WMARK_PROMO, /* NUMA promotion 워터마크 (6.x) */
NR_WMARK
};
struct zone {
unsigned long _watermark[NR_WMARK];
unsigned long watermark_boost;
...
};
워터마크 계산
| sysctl | 기본값 | 영향 |
|---|---|---|
vm.min_free_kbytes | 시스템 RAM 의존 (보통 수십 MB) | min 워터마크 직접 결정. low/high도 비례 조정 |
vm.watermark_scale_factor | 10 (0.1%) | low-min, high-min 간격을 managed pages의 비율로 설정 |
vm.watermark_boost_factor | 15000 (150%) | 단편화(Fragmentation) 감지 시 high 워터마크 boost 비율 |
/* 워터마크 계산 (간략화) */
min_watermark = min_free_kbytes / zone_proportion;
low_watermark = min_watermark + max(min_watermark / 4,
managed_pages * watermark_scale_factor / 10000);
high_watermark = min_watermark + max(min_watermark / 2,
managed_pages * watermark_scale_factor / 10000 * 2);
shrink_node() 경로 분석
shrink_node()는 하나의 NUMA 노드에서 페이지를 회수하는 최상위 함수입니다. kswapd와 direct reclaim 모두 이 함수를 통해 실제 회수를 수행합니다.
/* mm/vmscan.c - shrink_node() 핵심 흐름 */
static void shrink_node(struct pglist_data *pgdat,
struct scan_control *sc)
{
struct lruvec *target_lruvec;
target_lruvec = mem_cgroup_lruvec(sc->target_mem_cgroup,
pgdat);
/* 1. memcg 계층 순회하며 lruvec 회수 */
do {
struct mem_cgroup *memcg;
memcg = mem_cgroup_iter(sc->target_mem_cgroup,
NULL, &reclaim);
do {
struct lruvec *lruvec;
lruvec = mem_cgroup_lruvec(memcg, pgdat);
/* LRU 페이지 회수 */
shrink_lruvec(lruvec, sc);
/* Slab 캐시 회수 */
shrink_slab(sc->gfp_mask, pgdat->node_id,
memcg, sc->priority);
} while ((memcg = mem_cgroup_iter(...)));
} while (should_continue_reclaim(pgdat, sc));
}
코드 설명
shrink_node()는 대상 memcg와 그 자식 cgroup들을 순회하면서 각 lruvec에 대해 shrink_lruvec()(LRU 페이지 회수)와 shrink_slab()(slab 캐시 회수)를 호출합니다. scan_control 구조체(Struct)가 회수 목표, 우선순위, GFP 마스크 등 정책 파라미터를 전달합니다.
scan_control 구조체
struct scan_control {
unsigned long nr_to_reclaim; /* 회수 목표 페이지 수 */
gfp_t gfp_mask; /* 할당 플래그 */
int priority; /* 스캔 우선순위 (12~0) */
unsigned int may_writepage:1; /* dirty 페이지 writeback 허용 */
unsigned int may_unmap:1; /* 매핑된 페이지 unmap 허용 */
unsigned int may_swap:1; /* 스왑 허용 */
unsigned int proactive:1; /* proactive reclaim 여부 */
struct mem_cgroup *target_mem_cgroup;
unsigned long nr_reclaimed; /* 실제 회수된 수 */
unsigned long nr_scanned; /* 스캔된 수 */
};
1/(2^N)만 스캔합니다. priority=0이면 전체 LRU를 스캔합니다.
shrink_lruvec() / shrink_folio_list() 상세
shrink_lruvec()는 하나의 lruvec에서 페이지를 회수하는 핵심 함수입니다. Anon/File 스캔 비율을 결정하고, Inactive 리스트에서 페이지를 분리(isolate)하여 shrink_folio_list()에 전달합니다.
shrink_folio_list() 내부 결정 로직
/* mm/vmscan.c - shrink_folio_list() 핵심 결정 흐름 (간략화) */
static unsigned int
shrink_folio_list(struct list_head *folio_list,
struct pglist_data *pgdat,
struct scan_control *sc)
{
LIST_HEAD(free_folios);
while (!list_empty(folio_list)) {
struct folio *folio = lru_to_folio(folio_list);
list_del(&folio->lru);
/* 1. trylock 실패 -> skip */
if (!folio_trylock(folio))
goto keep;
/* 2. 참조 검사 (second chance) */
references = folio_check_references(folio, sc);
switch (references) {
case FOLIOREF_ACTIVATE:
goto activate_locked;
case FOLIOREF_KEEP:
goto keep_locked;
case FOLIOREF_RECLAIM:
case FOLIOREF_RECLAIM_CLEAN:
; /* 회수 진행 */
}
/* 3. Dirty 검사 -> writeback 또는 skip */
if (folio_test_dirty(folio)) {
if (!sc->may_writepage)
goto keep_locked;
folio_try_writeback(folio);
goto keep_locked; /* writeback 시작 후 다음 라운드 */
}
/* 4. Mapped 페이지 -> try_to_unmap */
if (folio_mapped(folio)) {
if (!try_to_unmap(folio, ...))
goto activate_locked;
}
/* 5. Anonymous -> 스왑 캐시에 추가 */
if (folio_test_anon(folio) &&
!folio_test_swapcache(folio)) {
if (!add_to_swap(folio))
goto activate_locked;
}
/* 6. 해제! */
list_add(&folio->lru, &free_folios);
continue;
activate_locked:
folio_set_active(folio);
keep_locked:
folio_unlock(folio);
keep:
list_add(&folio->lru, &ret_folios);
}
free_unref_folios(&free_folios);
return nr_reclaimed;
}
코드 설명
shrink_folio_list()는 분리된 folio 리스트를 순회하며 각 folio의 운명을 결정합니다. 1) lock 획득, 2) 참조 검사(second chance), 3) dirty면 writeback 시작, 4) 매핑(Mapping)된 PTE를 unmap, 5) anonymous면 스왑 캐시 추가, 6) 모든 조건 통과 시 해제. 각 단계에서 실패하면 keep(유지) 또는 activate(승격)로 분기합니다.
folio_check_references() 결정 로직
folio_check_references()는 세컨드 찬스(Second Chance) 알고리즘의 핵심 판단 함수입니다. Inactive 리스트에서 꺼낸 folio의 참조(Reference) 상태를 검사하여 회수, 유지, 또는 Active 승격 중 하나를 결정합니다.
/* mm/vmscan.c - folio_check_references() 반환값 정의 */
enum folio_references {
FOLIOREF_RECLAIM, /* 회수 진행 (참조 없음, 즉시 해제 가능) */
FOLIOREF_RECLAIM_CLEAN, /* clean 파일 페이지 회수 (writeback 불필요) */
FOLIOREF_KEEP, /* Inactive에 유지 (soft 참조 있음) */
FOLIOREF_ACTIVATE, /* Active로 승격 (strong 참조 있음) */
};
static enum folio_references
folio_check_references(struct folio *folio,
struct scan_control *sc)
{
int referenced_ptes, referenced_folio;
unsigned long vm_flags;
/* 1단계: PTE young 비트를 sweep하여 참조 수 집계 */
referenced_ptes = folio_referenced(folio, 1,
sc->target_mem_cgroup,
&vm_flags);
/* 2단계: PG_referenced 플래그 확인 (이전 라운드 참조) */
referenced_folio = folio_test_clear_referenced(folio);
/* 3단계: mapped 페이지에서 PTE young 비트가 있었다면 */
if (referenced_ptes) {
/* executable 매핑은 즉시 Active 승격 */
if (vm_flags & VM_EXEC)
return FOLIOREF_ACTIVATE;
/* 이전에도 참조됐으면 (two-strikes) -> Active 승격 */
if (referenced_folio)
return FOLIOREF_ACTIVATE;
/* 첫 번째 참조: PG_referenced 세트, Inactive 유지 */
return FOLIOREF_KEEP;
}
/* 4단계: PTE young 없음 */
if (referenced_folio)
return FOLIOREF_KEEP; /* folio 플래그만 있으면 유지 */
/* 5단계: 참조 완전 없음 -> 회수 */
if (folio_test_file(folio) && !folio_test_dirty(folio))
return FOLIOREF_RECLAIM_CLEAN; /* clean 파일: 즉시 해제 */
return FOLIOREF_RECLAIM;
}
코드 설명
folio_check_references()는 두 가지 참조 소스를 확인합니다. 첫째로 folio_referenced()를 통해 모든 매핑된 PTE의 young 비트(하드웨어 접근 비트)를 확인합니다. 둘째로 PG_referenced 플래그(이전 스캔 라운드에서 설정됨)를 확인합니다. 두 번 모두 참조된 경우(two-strikes 규칙)에만 Active로 승격됩니다. VM_EXEC 매핑(실행 파일)은 즉시 Active로 승격됩니다.
PG_referenced 플래그를 설정한 뒤 Inactive 리스트의 앞쪽으로 되돌립니다. 다음 라운드에서 다시 꼬리에 도달했을 때 또 참조 비트가 있으면 그때 Active로 승격합니다. 이 "두 번 참조(two-strikes)" 규칙이 세컨드 찬스의 핵심이며, 한 번만 읽힌 대용량 파일 페이지가 워킹셋을 밀어내는 것을 방지합니다.
Refault Distance와 워킹셋 보호
Refault Distance는 회수된 페이지가 다시 fault로 읽히는 시점까지의 "거리"를 측정하여, 해당 페이지가 워킹셋에 속하는지 판단하는 메커니즘입니다. mm/workingset.c에 구현되어 있습니다.
개념: Shadow Entry
페이지가 LRU에서 회수(evict)되면 page cache의 radix tree(xarray)에 shadow entry가 남겨집니다. 이 shadow entry에는 회수 시점의 "비거주 나이(nonresident age)"가 기록됩니다.
/* mm/workingset.c */
void workingset_eviction(struct folio *folio,
struct mem_cgroup *target_memcg)
{
unsigned long eviction;
/* 비거주 나이 = 현재까지 evict + activate된 총 횟수 */
eviction = atomic_long_read(
&lruvec->nonresident_age);
/* shadow entry로 저장 */
workingset_age_nonresident(lruvec, folio_nr_pages(folio));
pack_shadow(memcgid, pgdat, eviction, workingset);
}
Refault 감지
/* 회수된 페이지가 다시 fault되었을 때 */
bool workingset_test_recent(void *shadow,
bool file,
bool *workingset)
{
unsigned long eviction, refault_distance;
unsigned long inactive, nr_active;
/* shadow에서 eviction 시점 복원 */
unpack_shadow(shadow, &memcgid, &pgdat,
&eviction, workingset);
/* refault distance = 현재 나이 - eviction 시점 나이 */
refault_distance = current_nonresident_age - eviction;
/* inactive 리스트 크기보다 거리가 짧으면 -> 워킹셋 */
inactive = lruvec_page_state(lruvec, NR_INACTIVE_FILE);
return refault_distance <= inactive;
}
코드 설명
페이지가 회수된 후 다시 fault되면 shadow entry의 eviction 시점과 현재 나이의 차이(refault distance)를 계산합니다. 이 거리가 현재 inactive 리스트 크기 이하이면 "곧 다시 필요한 페이지"로 판단하여PG_workingset 플래그를 설정하고 Active 리스트로 바로 승격시킵니다.
PG_workingset 플래그가 설정됩니다. 이 플래그가 있으면 shrink_folio_list()에서 참조 한 번만으로도 activate 대상이 됩니다. workingset_refault() 이벤트는 vmstat의 workingset_refault_file/workingset_refault_anon 카운터로 확인할 수 있습니다.
Anon vs File 스캔 비율
회수할 때 anonymous 페이지와 file 페이지를 어떤 비율로 스캔할지는 get_scan_count()에서 결정합니다. 핵심 파라미터는 vm.swappiness입니다.
| swappiness 값 | 의미 | 스캔 비율 |
|---|---|---|
0 | 스왑 최소화 | File 페이지만 스캔 (free pages 충분할 때) |
1~99 | 혼합 | swappiness 비율에 따라 anon/file 배분 |
100 (기본) | 공정 스캔 | anon과 file을 동등하게 스캔 |
200 | 스왑 적극 | anon 스캔 비율 극대화 |
/* mm/vmscan.c - get_scan_count() 핵심 로직 (간략화) */
static void get_scan_count(struct lruvec *lruvec,
struct scan_control *sc,
unsigned long *nr)
{
unsigned long anon_cost, file_cost, total_cost;
unsigned long ap, fp; /* anon/file pressure */
u64 fraction[ANON_AND_FILE];
/* swappiness=0 이고 free 충분하면 file만 */
if (!sc->may_swap || !swappiness) {
scan_balance = SCAN_FILE;
goto out;
}
/* refault 비용 반영 */
anon_cost = lruvec->anon_cost + 1;
file_cost = lruvec->file_cost + 1;
total_cost = anon_cost + file_cost;
/* swappiness로 기본 비율 설정 */
ap = swappiness * (total_cost + 1);
ap /= anon_cost + 1;
fp = (200 - swappiness) * (total_cost + 1);
fp /= file_cost + 1;
/* 비율에 따라 각 LRU의 스캔 수 계산 */
fraction[0] = ap;
fraction[1] = fp;
...
}
swappiness=0이라고 anonymous 페이지가 절대 스왑되지 않는 것은 아닙니다. free pages가 min 워터마크 이하로 떨어지면 swappiness=0이어도 anon 페이지를 스캔합니다. "스왑 완전 비활성화"는 swapoff -a를 사용해야 합니다.
MGLRU (Multi-Gen LRU)
MGLRU는 커널 6.1에서 도입된 새로운 페이지 회수 알고리즘으로, 클래식 LRU의 한계(Active/Inactive 2단계만으로는 워킹셋 크기 변화에 느리게 적응)를 극복합니다. 페이지를 세대(generation)로 분류하여 접근 빈도를 더 세밀하게 추적합니다.
핵심 개념: 세대(Generation)
| 개념 | 클래식 LRU | MGLRU |
|---|---|---|
| 분류 단계 | 2단계 (Active/Inactive) | 최대 4세대 (gen 0 ~ gen MAX_NR_GENS-1) |
| 접근 추적 | PG_referenced 1비트 | PTE young 비트 + 세대 번호 |
| 에이징 | 참조 비트 클리어 후 demotion | PTE young 비트 스캔으로 세대 승격 |
| 축출(Eviction) 대상 | Inactive 리스트 꼬리 | 가장 오래된 세대(oldest gen) |
| 스캔 범위 | 전체 LRU 순회 | 프로세스 페이지 테이블(Page Table) 워크 |
/* include/linux/mmzone.h - MGLRU 구조 */
#define MAX_NR_GENS 4
#define MAX_NR_TIERS 4
struct lru_gen_folio {
unsigned long max_seq; /* 최신 세대 번호 */
unsigned long min_seq[ANON_AND_FILE]; /* 가장 오래된 세대 */
unsigned long timestamps[MAX_NR_GENS]; /* 세대별 생성 시각 */
struct list_head folios[MAX_NR_GENS][ANON_AND_FILE][MAX_NR_TIERS];
unsigned long nr_pages[MAX_NR_GENS][ANON_AND_FILE][MAX_NR_TIERS];
int enabled;
...
};
코드 설명
MGLRU는 최대 4세대(MAX_NR_GENS)를 유지합니다. 각 세대는 anon/file로 나뉘고, 다시 tier(접근 유형: 최근 접근, 실행, 읽기 등)로 세분화됩니다. max_seq는 가장 최신 세대이고 min_seq는 가장 오래된 세대입니다. 회수는 항상 min_seq 세대에서 시작합니다.
MGLRU Tier(접근 유형 분류) 시스템
MGLRU는 세대(Generation)에 더해 Tier(접근 유형)라는 두 번째 차원으로 folio를 분류합니다. 같은 세대 안에서도 접근 방식에 따라 회수 우선순위가 달라지며, Tier가 낮을수록(더 차가울수록) 먼저 회수됩니다.
| Tier 번호 | 의미 | 해당 접근 패턴 | 회수 우선순위 |
|---|---|---|---|
| Tier 0 | Cold / Unmapped | 매핑 해제됨 또는 전혀 접근 없음 (PTE Accessed=0) | 최우선 회수 |
| Tier 1 | 실행(Exec) 접근 | 코드 실행 중 접근 (text segment, exec mapping) | 높음 |
| Tier 2 | 읽기(Read) 접근 | 일반 읽기 접근 (read-only mapping, page cache read) | 중간 |
| Tier 3 | 최근 접근(Recently accessed) | 최근 쓰기 포함 다중 접근 (dirty page, write access) | 마지막 회수 |
Tier는 lru_gen_folio 구조체 내부에서 세대별로 Tier 히스토그램 형태로 관리됩니다.
folio를 세대에 배치할 때 folio_update_gen()이 Tier를 결정하고 원자적 카운터를 업데이트합니다.
/* include/linux/mm_inline.h - MGLRU Tier 결정 */
#define MIN_NR_GENS 2
#define MAX_NR_GENS 4
#define MIN_NR_TIERS 2
#define MAX_NR_TIERS 4
struct lru_gen_folio {
/* 세대별 nr_pages[gen][type] 카운터 */
atomic_long_t nr_pages[MAX_NR_GENS][ANON_AND_FILE][MAX_NR_TIERS];
/* 세대 시퀀스 번호 */
unsigned long min_seq[ANON_AND_FILE];
unsigned long max_seq;
};
/* mm/vmscan.c - folio의 Tier를 결정하여 세대에 배치 */
static int folio_lru_tier(struct folio *folio)
{
int refs;
/* referenced 횟수로 Tier 결정 */
refs = folio_lru_refs(folio);
/* refs == 0 -> Tier 0 (cold, 미접근)
refs == 1 -> Tier 1 (exec 또는 1회 접근)
refs == 2 -> Tier 2 (read, 2회 접근)
refs >= 3 -> Tier 3 (recently accessed) */
return min(refs, MAX_NR_TIERS - 1);
}
/* folio를 지정 세대로 이동하면서 Tier 카운터 갱신 */
static bool folio_update_gen(struct folio *folio,
struct lruvec *lruvec,
int new_gen)
{
int old_gen = folio_lru_gen(folio);
int tier = folio_lru_tier(folio);
int type = folio_is_file_lru(folio) ? LRU_GEN_FILE : LRU_GEN_ANON;
if (old_gen == new_gen)
return false;
/* 이전 세대/Tier 카운터 감소 */
atomic_long_sub(folio_nr_pages(folio),
&lruvec->lrugen.nr_pages[old_gen][type][tier]);
/* 새 세대/Tier 카운터 증가 */
atomic_long_add(folio_nr_pages(folio),
&lruvec->lrugen.nr_pages[new_gen][type][tier]);
return true;
}
- Gen 0 / Tier 0가 가장 먼저 축출됩니다. 이 folio들은 오래된 세대이면서 매핑도 해제되어 있어 아무런 가치가 없습니다.
- 같은 세대 내에서 Tier 0 → Tier 1 → Tier 2 → Tier 3 순서로 축출됩니다.
- 한 세대의 모든 Tier를 소진한 뒤에야 다음 세대(Gen 1)로 넘어갑니다.
- Gen 3(최신 세대)의 Tier 3 folio는 워킹셋의 핵심이므로 가장 마지막에 회수됩니다.
- Tier 정보는
lru_gen_folio.nr_pages[gen][type][tier]원자적 카운터로 O(1) 접근이 가능합니다.
MGLRU 내부: 세대 카운터, 에이징, 축출
에이징(Aging): PTE 워크
MGLRU의 에이징은 프로세스의 페이지 테이블을 직접 워크하여 PTE의 Accessed(young) 비트를 확인합니다. 이는 클래식 LRU가 LRU 리스트를 순차적으로 스캔하는 것과 근본적으로 다릅니다.
/* mm/vmscan.c - MGLRU 에이징 진입점 */
static void lru_gen_age_node(struct pglist_data *pgdat,
struct scan_control *sc)
{
struct lruvec *lruvec;
unsigned long max_seq;
lruvec = mem_cgroup_lruvec(NULL, pgdat);
max_seq = READ_ONCE(lruvec->lrugen.max_seq);
/* 에이징 필요 여부 판단 */
if (should_run_aging(lruvec, max_seq, sc, ...)) {
/* 프로세스 페이지 테이블 워크 */
try_to_inc_max_seq(lruvec, max_seq, sc, ...);
}
}
/* 페이지 테이블 워크: mm_struct 리스트 순회 */
static void walk_mm(... struct mm_struct *mm, ...)
{
/* VMA 순회 */
for (vma = mm->mmap; vma; vma = vma->vm_next) {
/* PTE 워크: Accessed 비트 확인 */
walk_pte_range(...);
}
}
축출(Eviction)
/* oldest 세대에서 folio 축출 */
static int evict_folios(struct lruvec *lruvec,
struct scan_control *sc,
int swappiness)
{
int type; /* ANON or FILE */
int min_seq[ANON_AND_FILE];
/* anon/file 중 어느 쪽을 축출할지 결정 */
type = get_type_to_scan(lruvec, swappiness, ...);
/* oldest 세대의 folio들을 분리 */
isolate_folios(lruvec, sc, type, &list);
/* 클래식 shrink_folio_list()로 실제 회수 */
return shrink_folio_list(&list, pgdat, sc, ...);
}
cat /sys/kernel/mm/lru_gen/enabled 값이 0x0007(또는 7)이면 MGLRU가 완전 활성화된 상태입니다. 비트별로 0=core, 1=mm_walk, 2=nonleaf_young을 의미합니다.
MGLRU vs 클래식 LRU 성능 비교
| 측면 | 클래식 LRU | MGLRU |
|---|---|---|
| 워킹셋 추적 정밀도 | 2단계 (Active/Inactive) | 4세대 + 4 Tier = 16단계 |
| 스캔 방식 | LRU 리스트 순차 순회 | 페이지 테이블 워크 (rmap 불필요) |
| 대규모 메모리 시 오버헤드(Overhead) | LRU lock contention 증가 | 병렬 PTE 워크로 분산 |
| 워킹셋 변화 적응 속도 | 느림 (여러 라운드 필요) | 빠름 (한 번의 에이징으로 세대 재배치(Relocation)) |
| File streaming I/O | 워킹셋 밀림 위험 | 세대 분리로 보호 |
| 벤치마크 (memcached) | 기준 | 처리량(Throughput) 5~10% 향상 (Google 벤치마크) |
| 벤치마크 (MySQL) | 기준 | TPS 2~5% 향상 |
| 벤치마크 (Chrome OS) | 기준 | OOM kill 50% 감소 |
shrink_folio_list() 같은 하위 함수는 공유합니다. /sys/kernel/mm/lru_gen/enabled에 0을 쓰면 런타임에 클래식 LRU로 전환할 수 있습니다.
회수 쓰로틀링
여러 프로세스가 동시에 direct reclaim에 진입하면 과도한 I/O와 CPU 소모가 발생합니다. 커널은 reclaim_throttle()로 동시 회수를 제어합니다.
/* mm/vmscan.c */
enum vmscan_throttle_state {
VMSCAN_THROTTLE_WRITEBACK, /* writeback I/O 대기 */
VMSCAN_THROTTLE_ISOLATED, /* 분리된 페이지 과다 */
VMSCAN_THROTTLE_NOPROGRESS, /* 회수 진행 없음 */
VMSCAN_THROTTLE_CONGESTED, /* backing store 혼잡 */
NR_VMSCAN_THROTTLE,
};
static void reclaim_throttle(struct pglist_data *pgdat,
enum vmscan_throttle_state reason)
{
wait_queue_head_t *wqh = &pgdat->reclaim_wait[reason];
/* kswapd는 쓰로틀하지 않음 */
if (current_is_kswapd())
return;
/* 최대 100ms 대기 */
wait_event_interruptible_timeout(*wqh,
atomic_read(&pgdat->nr_writeback_throttled) == 0,
HZ/10);
}
vmstat에서 pgscan_direct_throttle 카운터가 증가하면 direct reclaim 쓰로틀링이 발생한 것입니다. 이는 I/O 서브시스템이 회수 속도를 따라가지 못하는 징후이며, 스왑 장치를 SSD로 교체하거나 vm.dirty_ratio를 낮추는 것이 해결책입니다.
Writeback과 Dirty Page 회수
Dirty 페이지는 수정된 내용을 디스크에 기록(writeback)한 후에야 해제할 수 있습니다. 회수 경로에서 dirty 페이지를 만나면 즉시 해제하지 않고 writeback을 시작한 후 다음 라운드에서 처리합니다.
vm.dirty_ratio의 영향
| sysctl | 기본값 | 회수 관점 영향 |
|---|---|---|
vm.dirty_ratio | 20 (%) | 프로세스가 write 시 throttle되는 dirty 비율. 낮으면 dirty 페이지 적어 회수 빠름 |
vm.dirty_background_ratio | 10 (%) | flusher가 writeback 시작하는 비율. 낮으면 사전 writeback 증가 |
vm.dirty_expire_centisecs | 3000 (30초) | dirty 페이지 유지 최대 시간. 짧으면 writeback 빈번 |
Slab Shrinker 연동
LRU 페이지 외에도 커널의 slab 캐시(dentry, inode, buffer_head 등)도 회수 대상입니다. shrink_slab()는 등록된 모든 shrinker를 호출하여 축소 가능한 객체를 해제합니다.
/* mm/shrinker.c */
struct shrinker {
unsigned long (*count_objects)(struct shrinker *,
struct shrink_control *);
unsigned long (*scan_objects)(struct shrinker *,
struct shrink_control *);
long batch;
int seeks; /* 캐시 재생성 비용 */
unsigned int flags;
struct list_head list;
int id;
...
};
/* 대표적인 shrinker 등록 예 */
/* - super_cache_scan(): dentry + inode 캐시 */
/* - slab_objects_scan(): workingset shadow node */
/* - drm_gem_shrinker(): GPU 버퍼 캐시 */
/sys/kernel/debug/shrinker/에서 등록된 shrinker 목록과 각 shrinker의 캐시 크기를 확인할 수 있습니다 (커널 6.6+).
LRU vs Slab 스캔 비율
LRU 페이지와 slab 캐시의 스캔 비율은 각각의 크기에 비례하여 결정됩니다. shrink_node()에서 LRU 스캔 수와 slab shrinker의 count_objects() 반환값을 비교하여 균형을 맞춥니다.
memcg 계층별 회수
cgroup v2의 memory 컨트롤러는 각 cgroup에 대해 독립적인 메모리 회수를 수행합니다. 각 memcg는 자체 lruvec를 가지며, 메모리 사용량이 memory.max에 도달하면 해당 cgroup 내에서만 회수가 진행됩니다.
memcg 관련 인터페이스
| 인터페이스 | 용도 |
|---|---|
memory.max | 하드 리밋. 초과 시 cgroup 내 회수, 실패 시 OOM kill |
memory.high | 소프트 리밋. 초과 시 회수 압박 + 할당 쓰로틀링 |
memory.low | 최소 보호. 전역 회수 시 이 양까지 보호 |
memory.min | 절대 보호. 어떤 상황에서도 이 양은 회수하지 않음 |
memory.reclaim | 사용자가 직접 회수(Direct Reclaim)를 트리거 (proactive reclaim) |
memory.stat | 회수 통계 (pgfault, pgmajfault, workingset_* 등) |
NUMA Reclaim과 Demotion
NUMA 시스템에서는 페이지를 회수하는 대신 느린 메모리 티어(예: PMEM, CXL 메모리)로 강등(demotion)하는 것이 더 효율적일 수 있습니다. 커널 5.18+에서 도입된 memory tiering은 회수 경로에서 demotion을 우선 시도합니다.
/* mm/vmscan.c - demotion 가능 여부 확인 */
static bool can_demote(int nid, struct scan_control *sc)
{
if (!numa_demotion_enabled)
return false;
if (sc->memcg_low_reclaim)
return false;
/* 느린 티어 노드가 존재하는지 확인 */
return next_demotion_node(nid) != NUMA_NO_NODE;
}
Proactive Reclaim
커널 5.18+에서 도입된 Proactive Reclaim은 사용자가 cgroup의 memory.reclaim 인터페이스를 통해 명시적으로 회수를 트리거할 수 있게 합니다. 이는 컨테이너 오케스트레이터(Kubernetes 등)가 메모리 압박 전에 사전 회수하는 데 유용합니다.
# cgroup에서 500MB 사전 회수
echo "500M" > /sys/fs/cgroup/app/memory.reclaim
# swappiness 지정 가능 (커널 6.5+)
echo "500M swappiness=0" > /sys/fs/cgroup/app/memory.reclaim
/proc/pressure/memory 또는 cgroup의 memory.pressure에서 메모리 압박 수준을 모니터링하고, 특정 임계값 초과 시 memory.reclaim을 쓰는 방식으로 자동 proactive reclaim을 구현할 수 있습니다. Meta의 senpai가 대표적인 구현입니다.
ftrace/tracepoint 기반 vmscan 디버깅
vmscan 서브시스템은 풍부한 tracepoint를 제공합니다. trace-cmd나 perf로 회수 과정을 실시간 추적할 수 있습니다.
주요 tracepoint
| Tracepoint | 발생 시점 | 핵심 필드 |
|---|---|---|
mm_vmscan_direct_reclaim_begin | direct reclaim 시작 | order, gfp_flags |
mm_vmscan_direct_reclaim_end | direct reclaim 종료 | nr_reclaimed |
mm_vmscan_kswapd_wake | kswapd 깨어남 | nid, order |
mm_vmscan_kswapd_sleep | kswapd 수면 | nid |
mm_vmscan_lru_isolate | LRU에서 페이지 분리 | nr_scanned, nr_taken, lru |
mm_vmscan_lru_shrink_inactive | inactive 리스트 축소 | nr_reclaimed, nr_scanned, priority |
mm_vmscan_lru_shrink_active | active 리스트 축소 | nr_deactivated, nr_referenced |
mm_vmscan_writepage | 회수 중 writeback 발생 | folio, reclaim_flags |
mm_vmscan_throttled | 회수 쓰로틀링 | nid, reason, timeout |
추적 예시
# direct reclaim 이벤트 실시간 추적
trace-cmd record -e vmscan:mm_vmscan_direct_reclaim_begin \
-e vmscan:mm_vmscan_direct_reclaim_end
# kswapd 활동 추적
trace-cmd record -e vmscan:mm_vmscan_kswapd_wake \
-e vmscan:mm_vmscan_kswapd_sleep
# perf로 reclaim latency 히스토그램
perf trace -e 'vmscan:*' --duration 10
# bpftrace로 direct reclaim 시간 측정
bpftrace -e '
tracepoint:vmscan:mm_vmscan_direct_reclaim_begin {
@start[tid] = nsecs;
}
tracepoint:vmscan:mm_vmscan_direct_reclaim_end /@start[tid]/ {
@latency_us = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}'
vmstat 카운터
# 주요 vmscan 관련 카운터
grep -E 'pgsteal|pgscan|pgrefill|pgactivate|pgdeactivate|pglazyfreed|workingset' /proc/vmstat
# 출력 예:
# pgscan_kswapd 12345678 -- kswapd가 스캔한 페이지 수
# pgscan_direct 234567 -- direct reclaim이 스캔한 페이지 수
# pgsteal_kswapd 11000000 -- kswapd가 회수한 페이지 수
# pgsteal_direct 200000 -- direct reclaim이 회수한 페이지 수
# pgrefill 5678901 -- active->inactive 강등된 페이지 수
# pgactivate 4567890 -- inactive->active 승격된 페이지 수
# workingset_refault_file 1234 -- file 워킹셋 refault 수
# workingset_refault_anon 567 -- anon 워킹셋 refault 수
# workingset_activate_file 890 -- refault 후 activate된 file 수
pgsteal/pgscan 비율이 회수 효율입니다. 이 값이 낮으면(예: <0.1) 많은 페이지를 스캔하지만 적게 회수하고 있어 워킹셋이 커서 회수할 것이 없는 상태입니다. swappiness 조정이나 메모리 증설을 고려하세요.
sysctl 튜닝 가이드
| sysctl | 기본값 | 범위 | 튜닝 가이드 |
|---|---|---|---|
vm.swappiness | 60 | 0~200 | DB 서버: 10~30. 컨테이너/범용: 60. zswap 활성화 시: 100~200 |
vm.min_free_kbytes | 자동 | 시스템 의존 | 네트워크 서버: 기본의 2~4배. 너무 높이면 OOM 증가 |
vm.watermark_scale_factor | 10 | 10~3000 | direct reclaim 빈번하면 증가 (100~500). kswapd에 더 많은 버퍼 제공 |
vm.watermark_boost_factor | 15000 | 0~수만 | THP 사용 시 기본 유지. 불필요하면 0으로 비활성화 |
vm.vfs_cache_pressure | 100 | 0~10000 | 파일 서버: 50 (캐시 보호). 메모리 부족: 200 (적극 회수) |
vm.zone_reclaim_mode | 0 | 0~7 | NUMA: 0 (원격 노드 사용). HPC: 1 (로컬 회수 우선) |
vm.dirty_ratio | 20 | 0~100 | SSD: 40~60. HDD: 10~20. 회수 시 writeback 감소 |
vm.dirty_background_ratio | 10 | 0~100 | dirty_ratio의 절반 이하. 사전 writeback 촉진 |
시나리오별 튜닝
데이터베이스 서버 (PostgreSQL/MySQL)
# Anonymous 페이지 보호 (DB 버퍼 = anon)
sysctl vm.swappiness=10
# Direct reclaim 최소화
sysctl vm.watermark_scale_factor=200
sysctl vm.min_free_kbytes=524288 # 512MB
# 파일 캐시보다 slab 적극 회수
sysctl vm.vfs_cache_pressure=150
컨테이너 환경 (Kubernetes)
# 호스트 레벨
sysctl vm.watermark_scale_factor=500
sysctl vm.min_free_kbytes=1048576 # 1GB
# Pod별 memcg 설정
# memory.max로 hard limit
# memory.high로 soft limit (throttling)
# memory.low로 워킹셋 보호
대용량 파일 서버 (NFS/Samba)
# 파일 캐시 최대 활용
sysctl vm.swappiness=10
sysctl vm.vfs_cache_pressure=50
sysctl vm.dirty_ratio=40
sysctl vm.dirty_background_ratio=10
회수 문제 진단 플레이북
문제: Direct Reclaim 빈번 (지연 증가)
# 1. 확인: pgscan_direct vs pgscan_kswapd 비율
awk '/pgscan_direct|pgscan_kswapd/ {print}' /proc/vmstat
# 2. 원인 분석: 워터마크 확인
cat /proc/zoneinfo | grep -A5 "Normal"
# 3. 해결: kswapd에 더 많은 여유 제공
sysctl vm.watermark_scale_factor=300
sysctl vm.min_free_kbytes=$(($(awk '/MemTotal/{print $2}' /proc/meminfo) / 32))
# 4. 효과 확인
vmstat 1 10 # si/so, bi/bo 칼럼 확인
문제: 워킹셋 쓰래싱 (refault 과다)
# 1. 확인: workingset refault 증가 추세
watch -d 'grep workingset /proc/vmstat'
# 2. PSI로 메모리 압박 확인
cat /proc/pressure/memory
# 3. 원인 판별: anon refault vs file refault
grep workingset_refault /proc/vmstat
# anon 높으면: 스왑 I/O 병목 -> zswap 활성화 또는 메모리 증설
# file 높으면: 파일 캐시 부족 -> swappiness 낮추거나 메모리 증설
# 4. cgroup별 확인
cat /sys/fs/cgroup/*/memory.stat | grep workingset
문제: kswapd CPU 사용률 높음
# 1. kswapd CPU 확인
top -p $(pgrep -d, kswapd)
# 2. 스캔 효율 확인 (steal/scan 비율)
awk '/pgsteal_kswapd|pgscan_kswapd/ {print $1, $2}' /proc/vmstat
# 3. 단편화로 인한 compaction 루프 확인
grep compact /proc/vmstat
# 4. 해결 방안
# - THP defrag 완화: echo madvise > /sys/kernel/mm/transparent_hugepage/defrag
# - watermark_boost_factor 줄이기: sysctl vm.watermark_boost_factor=0
# - MGLRU 활성화 확인: cat /sys/kernel/mm/lru_gen/enabled
문제: 예상치 못한 OOM Kill
# 1. OOM 로그 확인
dmesg | grep -i "out of memory\|oom"
# 2. 회수 실패 원인 분석
# - /proc/buddyinfo 확인 (단편화?)
# - /proc/meminfo 확인 (Slab 캐시 과다?)
# - slabtop 확인 (특정 slab 객체 과다?)
# 3. memcg OOM인 경우
journalctl -k | grep "memory cgroup out of memory"
# -> memory.max 조정 필요
커널 빌드 옵션
| 옵션 | 기본 | 설명 |
|---|---|---|
CONFIG_LRU_GEN | y (6.1+) | MGLRU 지원 활성화 |
CONFIG_LRU_GEN_ENABLED | y | MGLRU 기본 활성화 (부팅 시) |
CONFIG_LRU_GEN_STATS | n | MGLRU 디버깅 통계 (/sys/kernel/debug/lru_gen) |
CONFIG_MEMCG | y | memcg 지원 (cgroup 메모리 컨트롤러) |
CONFIG_SWAP | y | 스왑 지원 (anonymous 페이지 회수에 필수) |
CONFIG_ZSWAP | m/y | 스왑 압축 캐시 (스왑 I/O 감소) |
CONFIG_NUMA_BALANCING | y (NUMA) | NUMA 자동 밸런싱 (페이지 마이그레이션) |
CONFIG_MEMORY_TIER | y (6.x) | 메모리 티어링 / demotion 지원 |
CONFIG_TRANSPARENT_HUGEPAGE | y | THP (회수 시 split 관련) |
CONFIG_COMPACTION | y | 메모리 컴팩션 (회수와 상호 보완) |
CONFIG_PSI | y | Pressure Stall Information (메모리 압박 모니터링) |
CONFIG_PAGE_REPORTING | y | 가상화(Virtualization) 환경에서 free page reporting |
# 현재 커널의 설정 확인
zcat /proc/config.gz | grep -E 'LRU_GEN|MEMCG|SWAP|ZSWAP|COMPACTION|PSI'
# 또는
grep -E 'LRU_GEN|MEMCG|SWAP|ZSWAP|COMPACTION|PSI' /boot/config-$(uname -r)
debugfs에서 MGLRU 세대별 통계를 확인할 수 있습니다.
Folio Split과 대규모 Folio 회수
THP(Transparent Huge Page) 환경에서 회수 시 중요한 문제가 folio split입니다. 2MB folio(order-9) 전체를 회수하는 것이 비효율적일 때, 4KB 단위로 분할하여 일부만 회수할 수 있습니다.
Split 결정 기준
/* mm/vmscan.c - 대규모 folio 처리 */
static unsigned int
shrink_folio_list(struct list_head *folio_list, ...)
{
...
if (folio_test_large(folio)) {
/* 대규모 folio: 부분 unmap 확인 */
if (folio_entire_mapcount(folio) == 0 &&
folio_nr_pages_mapped(folio) <
folio_nr_pages(folio) / 2) {
/* 절반 이상 미매핑 -> split 시도 */
if (split_folio(folio) == 0) {
/* split 성공: 개별 페이지로 재처리 */
list_move(&folio->lru, folio_list);
continue;
}
}
}
...
}
코드 설명
대규모 folio(THP 등)가 회수 대상이 되면 먼저 부분 매핑 상태를 확인합니다. 전체 페이지의 절반 이상이 이미 unmap된 경우 split을 시도하여 unmapped 페이지만 해제합니다. 이를 통해 불필요하게 매핑된 페이지까지 회수하는 것을 방지합니다.| 조건 | 처리 | 이유 |
|---|---|---|
| 전체 folio 미매핑 | 통째로 회수 | split 오버헤드 불필요 |
| 절반 이상 미매핑 | split 후 부분 회수 | 매핑된 부분 보호 |
| 대부분 매핑됨 | 전체 회수 또는 skip | split 비용 대비 이득 없음 |
| Dirty folio | 통째로 writeback | 부분 writeback 불가 |
Deferred Split 큐
split이 즉시 불가능한 경우(lock contention 등), folio는 deferred split 큐에 추가됩니다. shrinker가 나중에 이 큐를 처리합니다.
/* mm/huge_memory.c */
void deferred_split_folio(struct folio *folio)
{
struct pglist_data *pgdat = folio_pgdat(folio);
if (!folio_test_partially_mapped(folio))
return;
spin_lock(&pgdat->split_queue_lock);
if (list_empty(&folio->_deferred_list)) {
list_add_tail(&folio->_deferred_list,
&pgdat->split_queue);
pgdat->split_queue_len++;
}
spin_unlock(&pgdat->split_queue_lock);
}
/proc/vmstat의 thp_deferred_split_page 카운터로 deferred split 횟수를 확인할 수 있습니다. 이 값이 지속적으로 증가하면 THP 사용 패턴을 점검해야 합니다.
LRU Lock 경합(Contention)과 최적화
회수 경로에서 가장 큰 성능 병목(Bottleneck) 중 하나가 lru_lock 경합입니다. 모든 LRU 조작(추가, 제거, 이동)이 이 스핀락(Spinlock)을 잡아야 하므로, 대규모 메모리 시스템에서 심각한 contention이 발생할 수 있습니다.
LRU Lock 진화
| 커널 버전 | LRU Lock 단위 | 개선 효과 |
|---|---|---|
| ~5.10 | Zone 단위 (zone->lru_lock) | 하나의 Zone에서 모든 lruvec가 동일 lock 공유 |
| 5.11+ | lruvec 단위 (lruvec->lru_lock) | memcg별로 독립적인 lock. 컨테이너 환경에서 큰 개선 |
| 5.14+ (folio batch) | per-CPU batch + lruvec lock | lock 획득 빈도 감소 (batch로 모아서 한 번에) |
| 6.1+ (MGLRU) | MGLRU 자체 locking | 에이징은 PTE 워크로 LRU lock 불필요 |
/* 5.11+ lruvec 단위 lock */
static void lru_add_fn(struct lruvec *lruvec,
struct folio *folio)
{
int was_unevictable = folio_test_unevictable(folio);
int lru;
/* lruvec->lru_lock은 이미 호출자가 보유 */
folio_set_lru(folio);
lru = folio_lru_list(folio);
list_add(&folio->lru, &lruvec->lists[lru]);
update_lru_size(lruvec, lru, folio_zonenum(folio),
folio_nr_pages(folio));
}
Lock Contention 진단
# perf로 lru_lock contention 프로파일링
perf lock record -- sleep 30
perf lock report
# 또는 bpftrace로 lru_lock 대기 시간 측정
bpftrace -e '
kprobe:folio_lruvec_lock_irqsave {
@start[tid] = nsecs;
}
kretprobe:folio_lruvec_lock_irqsave /@start[tid]/ {
@lock_us = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}'
# /proc/lock_stat (CONFIG_LOCK_STAT 필요)
grep lru_lock /proc/lock_stat
회수 우선순위(Priority) 메커니즘
회수 서브시스템은 priority 값(12에서 0까지)으로 스캔 강도를 조절합니다. 각 priority에서 LRU 크기의 1/(2^priority)만큼 스캔하며, 목표만큼 회수되지 않으면 priority를 낮춰 더 공격적으로 스캔합니다.
| Priority | 스캔 비율 | 특성 |
|---|---|---|
| 12 (시작) | 1/4096 | 매우 보수적. 소수 페이지만 확인 |
| 10 | 1/1024 | 보수적 |
| 8 | 1/256 | 일반적인 회수 |
| 6 | 1/64 | 적극적 회수 |
| 4 | 1/16 | 공격적 회수 |
| 2 | 1/4 | 매우 공격적 |
| 0 | 1/1 (전체) | 전체 LRU 스캔. 이래도 실패하면 OOM |
/* mm/vmscan.c - priority 기반 스캔 수 계산 */
static void get_scan_count(struct lruvec *lruvec,
struct scan_control *sc,
unsigned long *nr)
{
unsigned long lru_pages;
int priority = sc->priority;
/* 각 LRU 리스트별 스캔 수 */
for_each_evictable_lru(lru) {
lru_pages = lruvec_lru_size(lruvec, lru, ...);
/* priority가 높을수록(숫자 큼) 적게 스캔 */
nr[lru] = lru_pages >> priority;
}
}
/* try_to_free_pages() - priority 루프 */
do {
sc.priority = priority;
shrink_zones(zonelist, &sc);
if (sc.nr_reclaimed >= sc.nr_to_reclaim)
break;
} while (--priority >= 0);
코드 설명
try_to_free_pages()는 priority=12에서 시작하여 충분한 페이지가 회수될 때까지 priority를 낮춥니다. 각 라운드에서 스캔할 페이지 수는 LRU 크기를 priority만큼 right-shift한 값입니다. priority=0에서도 목표에 미달하면 회수 실패로 OOM 경로에 진입합니다.
Priority와 may_writepage 관계
/* priority가 낮아질수록 더 많은 작업 허용 */
if (sc->priority < DEF_PRIORITY - 2)
sc->may_writepage = 1; /* dirty page writeback 허용 */
if (sc->priority == 0)
sc->may_deactivate = DEACTIVATE_ALL; /* 모든 active 페이지 강등 가능 */
mm_vmscan_lru_shrink_inactive 이벤트에서 현재 priority 값을 확인할 수 있습니다. priority가 자주 0에 도달하면 심각한 메모리 부족 상태이며, 메모리 증설이나 워크로드 조정이 필요합니다.
페이지 아웃(pageout) 메커니즘
Dirty 페이지를 회수하려면 먼저 디스크에 기록해야 합니다. 이 과정을 pageout이라 하며, shrink_folio_list() 내부에서 pageout() 함수가 처리합니다.
/* mm/vmscan.c - pageout() */
static pageout_t pageout(struct folio *folio,
struct address_space *mapping)
{
/* writeback이 이미 진행 중이면 skip */
if (folio_test_writeback(folio))
return PAGE_KEEP;
/* 매핑이 없으면 (truncate 등) */
if (!mapping) {
folio_set_clean(folio);
return PAGE_CLEAN;
}
/* a_ops->writepage() 호출 */
if (mapping->a_ops->writepage) {
struct writeback_control wbc = {
.sync_mode = WB_SYNC_NONE,
.nr_to_write = SWAP_CLUSTER_MAX,
.range_start = 0,
.range_end = LLONG_MAX,
.for_reclaim = 1, /* 회수를 위한 writeback */
};
folio_set_reclaim(folio);
int res = mapping->a_ops->writepage(
&folio->page, &wbc);
if (res == 0)
return PAGE_SUCCESS;
}
return PAGE_ACTIVATE;
}
코드 설명
pageout()는 dirty folio를 디스크에 기록합니다. writeback_control의 for_reclaim=1은 이것이 회수를 위한 writeback임을 표시합니다. writeback이 시작되면 PG_writeback 플래그가 설정되고, folio는 LRU에 다시 넣어집니다(keep). 다음 회수 라운드에서 writeback이 완료되었으면 clean 상태로 해제됩니다.
회수 Writeback vs Flusher 스레드(Thread)
| 특성 | 회수 Writeback (pageout) | Flusher 스레드 (flush-x:y) |
|---|---|---|
| 트리거 | shrink_folio_list()에서 dirty 페이지 만남 | dirty_background_ratio 초과 또는 주기적 |
| 동기/비동기 | 비동기 시작, 다음 라운드에서 확인 | 비동기 백그라운드 |
| I/O 순서 | LRU 순서 (비최적) | Address space 순서 (순차적, 최적) |
| 성능 영향 | 높음 (random I/O 유발) | 낮음 (순차 I/O) |
| 목적 | free 페이지 확보 | dirty 비율 관리 |
vm.dirty_background_ratio를 낮게 설정하여 flusher가 미리 writeback하도록 유도하면 회수 시 dirty 페이지를 만날 확률이 줄어듭니다.
try_to_unmap() -- Reverse Mapping과 회수
매핑된 페이지를 회수하려면 해당 페이지를 참조하는 모든 PTE를 찾아서 제거해야 합니다. 이 과정을 reverse mapping(rmap)이라 하며, try_to_unmap()이 담당합니다.
/* mm/rmap.c */
bool try_to_unmap(struct folio *folio,
enum ttu_flags flags)
{
struct rmap_walk_control rwc = {
.rmap_one = try_to_unmap_one,
.done = folio_not_mapped,
.arg = &flags,
};
if (folio_test_anon(folio))
rmap_walk_anon(folio, &rwc, false);
else
rmap_walk_file(folio, &rwc, false);
return !folio_mapped(folio);
}
/* 개별 PTE 해제 */
static bool try_to_unmap_one(struct folio *folio,
struct vm_area_struct *vma,
unsigned long address, ...)
{
struct mm_struct *mm = vma->vm_mm;
pte_t *pvmw;
/* 1. PTE 찾기 */
pvmw = page_vma_mapped_walk(folio, vma, address, ...);
if (!pvmw)
return true;
/* 2. PTE 클리어 */
ptep_clear_flush(vma, address, pvmw);
/* 3. 스왑 엔트리로 교체 (anon의 경우) */
if (folio_test_anon(folio))
set_pte_at(mm, address, pvmw,
swp_entry_to_pte(entry));
/* 4. mapcount 감소 */
folio_remove_rmap_pte(folio, subpage, vma);
return true;
}
코드 설명
try_to_unmap()은 reverse mapping을 사용하여 folio를 참조하는 모든 VMA/PTE를 찾습니다. Anonymous 페이지는 anon_vma 체인으로, file 페이지는 address_space의 interval tree로 역방향 탐색합니다. 각 PTE를 클리어하고, anonymous 페이지의 경우 스왑 엔트리로 교체합니다.
Reverse Mapping 비용
| 페이지 유형 | rmap 탐색 구조 | 비용 |
|---|---|---|
| Anonymous (단일 매핑) | anon_vma -> vma -> PTE | O(1) -- 빠름 |
| Anonymous (fork 후) | anon_vma 체인 순회 | O(N) -- fork 수에 비례 |
| File (단일 매핑) | address_space -> interval tree -> PTE | O(log N) |
| File (공유 매핑, 다수 프로세스) | interval tree 전체 순회 | O(N) -- 매핑 수에 비례 |
| KSM 페이지 | stable_tree -> rmap_item 체인 | O(N) -- 병합 수에 비례 |
anon_vma 체인을 통해 VMA 목록을 찾고, File 페이지는 address_space.i_mmap 인터벌 트리를 통해 매핑된 VMA를 탐색합니다. 두 경로 모두 최종적으로 해당 PTE를 클리어하고 anonymous의 경우 스왑 엔트리(swap entry)로 교체합니다try_to_unmap()을 호출할 때만 사용됩니다. 이것이 MGLRU가 공유 매핑이 많은 환경에서 클래식 LRU보다 효율적인 이유입니다.
memcg Low/Min 보호와 회수 우선순위
cgroup v2에서 memory.low와 memory.min은 전역 메모리 회수 시 특정 cgroup의 메모리를 보호합니다. 이 보호 메커니즘은 회수 스캔 비율에 직접 영향을 미칩니다.
/* mm/vmscan.c - memcg 보호 적용 */
static unsigned long
mem_cgroup_protection(struct mem_cgroup *root,
struct mem_cgroup *memcg)
{
unsigned long usage, parent_usage;
unsigned long elow, emin;
usage = page_counter_read(&memcg->memory);
/* memory.min: 절대 보호 (전혀 회수 안 함) */
emin = READ_ONCE(memcg->memory.emin);
if (usage <= emin)
return MEMCG_PROT_MIN; /* 완전 보호 */
/* memory.low: 비례 보호 (스캔 비율 감소) */
elow = READ_ONCE(memcg->memory.elow);
if (usage <= elow)
return MEMCG_PROT_LOW; /* 스캔 비율 감소 */
return MEMCG_PROT_NONE;
}
코드 설명
회수 시 각 memcg의 사용량을memory.min과 memory.low 값과 비교합니다. 사용량이 min 이하이면 해당 cgroup은 회수에서 완전히 제외됩니다. low 이하이면 스캔 비율이 감소되어 다른 cgroup에서 먼저 회수됩니다.
유효 보호(Effective Protection) 계산
memory.low/memory.min의 실제 보호량은 부모 cgroup의 한계와 형제 cgroup의 보호 요청에 따라 조정됩니다.
/* 유효 보호 계산 (간략화) */
/* 부모의 보호 용량이 자식들의 보호 합계보다 적으면 비례 배분 */
effective_low = min(memory_low,
parent_effective_low *
usage / siblings_usage);
resources.requests는 memory.low에 매핑되고, resources.limits는 memory.max에 매핑됩니다. 이를 통해 보장 QoS(Guaranteed)와 최선 노력 QoS(BestEffort) Pod 간의 메모리 보호가 구현됩니다.
Page Reclaim 진화 역사
| 커널 버전 | 주요 변화 | 영향 |
|---|---|---|
| 2.4 | Andrea Arcangeli VM 재작성 | 기본적인 LRU 기반 회수 |
| 2.6.28 | Split LRU (active/inactive x anon/file) | anon/file 독립 관리, swappiness 도입 |
| 3.15 | Workingset detection (Johanbes Weiner) | Refault distance 기반 워킹셋 보호 |
| 4.8 | Proportional scanning (anon_cost/file_cost) | refault 비용 기반 스캔 비율 조정 |
| 5.9 | Proactive compaction | 사전 예방적 메모리 관리 |
| 5.11 | Per-lruvec lock | memcg별 독립 lru_lock, contention 감소 |
| 5.14 | folio 도입 (Matthew Wilcox) | compound page 자연스러운 처리 |
| 5.18 | memory.reclaim cgroup 인터페이스 | 사용자 공간 proactive reclaim |
| 5.18 | Memory tiering / NUMA demotion | 회수 대신 slow tier로 강등 |
| 6.1 | MGLRU (Yu Zhao) | 세대 기반 회수, 에이징 혁신 |
| 6.5 | memory.reclaim swappiness 지원 | proactive reclaim 세밀 제어 |
| 6.7 | Large folio 지원 확대 | mTHP 회수 최적화 |
| 6.9+ | folio writeback 개선 | 회수 경로 I/O 효율화 |
scan_control 상세 분석
struct scan_control은 하나의 회수 세션의 모든 정책 파라미터와 결과를 담는 핵심 구조체입니다. 회수 경로의 모든 함수가 이 구조체를 참조합니다.
/* mm/vmscan.c - scan_control 전체 필드 */
struct scan_control {
/* 입력 파라미터 */
unsigned long nr_to_reclaim; /* 회수 목표 (보통 SWAP_CLUSTER_MAX=32) */
gfp_t gfp_mask; /* 할당 요청의 GFP 플래그 */
int order; /* 할당 요청 order */
nodemask_t *nodemask; /* 대상 노드 마스크 */
struct mem_cgroup *target_mem_cgroup; /* 대상 memcg */
/* 정책 플래그 */
unsigned int may_writepage:1; /* dirty writeback 허용 */
unsigned int may_unmap:1; /* mapped 페이지 unmap 허용 */
unsigned int may_swap:1; /* 스왑 허용 */
unsigned int proactive:1; /* proactive 회수 */
unsigned int memcg_low_reclaim:1; /* memcg low 회수 */
unsigned int memcg_full_walk:1; /* memcg 전체 순회 */
unsigned int hibernation_mode:1; /* 하이버네이션 모드 */
/* 동적 상태 */
int priority; /* 현재 우선순위 (12~0) */
int reclaim_idx; /* 최고 zone index */
/* 결과 */
unsigned long nr_scanned; /* 총 스캔 수 */
unsigned long nr_reclaimed; /* 총 회수 수 */
struct {
unsigned int dirty; /* dirty 페이지 수 */
unsigned int unqueued_dirty; /* writeback 큐 미등록 dirty */
unsigned int congested; /* 혼잡한 페이지 수 */
unsigned int writeback; /* writeback 중인 수 */
unsigned int immediate; /* writeback 즉시 완료 가능 수 */
unsigned int file_taken; /* 분리된 file 수 */
unsigned int taken; /* 총 분리된 수 */
} nr;
};
코드 설명
scan_control의 nr 구조체는 한 회수 라운드에서 만난 페이지 상태를 기록합니다. 이 정보는 다음 라운드의 전략 결정에 사용됩니다. 예를 들어 dirty가 많으면 다음 라운드에서 may_writepage를 활성화하고, congested가 많으면 쓰로틀링을 적용합니다.
should_continue_reclaim() -- 회수 계속 여부 결정
shrink_node()는 한 번의 회수 라운드 후 should_continue_reclaim()을 호출하여 추가 회수가 필요한지 판단합니다.
/* mm/vmscan.c */
static bool should_continue_reclaim(struct pglist_data *pgdat,
unsigned long nr_reclaimed,
struct scan_control *sc)
{
/* 1. 목표량 달성? */
if (sc->nr_reclaimed >= sc->nr_to_reclaim)
return false;
/* 2. 이번 라운드에서 하나도 못 회수? */
if (!nr_reclaimed)
return false;
/* 3. compaction을 위한 회수라면 */
if (sc->order > 0) {
/* 충분한 free 페이지가 확보되었는지 확인 */
if (compaction_ready(zone, sc))
return false;
}
/* 4. 계속 회수 */
return true;
}
페이지 분리(Isolate) 과정
LRU에서 페이지를 회수하려면 먼저 LRU 리스트에서 분리(isolate)해야 합니다. 분리된 페이지는 lru_lock 밖에서 처리되므로 lock contention을 줄일 수 있습니다.
/* mm/vmscan.c - isolate_lru_folios() */
static unsigned long
isolate_lru_folios(unsigned long nr_to_scan,
struct lruvec *lruvec,
struct list_head *dst,
unsigned long *nr_scanned,
struct scan_control *sc,
enum lru_list lru)
{
struct list_head *src = &lruvec->lists[lru];
unsigned long nr_taken = 0;
unsigned long scan;
for (scan = 0; scan < nr_to_scan &&
!list_empty(src); scan++) {
struct folio *folio = lru_to_folio(src);
/* 분리 가능한지 확인 */
if (!folio_test_lru(folio))
continue;
/* LRU에서 제거하고 dst 리스트로 이동 */
folio_clear_lru(folio);
list_move(&folio->lru, dst);
nr_taken += folio_nr_pages(folio);
}
*nr_scanned = scan;
return nr_taken;
}
과다 분리 방지
/* 분리된 페이지가 너무 많으면 다른 회수 스레드를 쓰로틀 */
static bool too_many_isolated(struct pglist_data *pgdat,
int file,
struct scan_control *sc)
{
unsigned long inactive, isolated;
inactive = node_page_state(pgdat,
file ? NR_INACTIVE_FILE : NR_INACTIVE_ANON);
isolated = node_page_state(pgdat,
file ? NR_ISOLATED_FILE : NR_ISOLATED_ANON);
/* 분리된 페이지가 inactive의 절반 초과하면 대기 */
return isolated > inactive >> 1;
}
/proc/vmstat의 nr_isolated_anon/nr_isolated_file 값이 비정상적으로 높으면 회수 경쟁이 심한 것입니다. 이 경우 VMSCAN_THROTTLE_ISOLATED 쓰로틀링이 발생합니다.
Swap Cache와 회수의 상호작용
Anonymous 페이지를 스왑아웃할 때, 먼저 swap cache에 추가한 후 writeback합니다. Swap cache는 동일한 페이지가 여러 프로세스에 의해 공유될 때 중복 스왑 I/O를 방지합니다.
/* mm/vmscan.c - anonymous 페이지 스왑아웃 흐름 */
/* shrink_folio_list() 내부 */
if (folio_test_anon(folio) &&
!folio_test_swapcache(folio)) {
/* 1. 스왑 슬롯 할당 + swap cache 추가 */
if (!add_to_swap(folio))
goto activate_locked; /* 스왑 공간 없음 */
}
/* 2. PTE를 스왑 엔트리로 교체 */
try_to_unmap(folio, TTU_BATCH_FLUSH);
/* 3. pageout()으로 스왑 장치에 기록 */
pageout(folio, mapping);
/* 4. writeback 완료 후 다음 라운드에서 */
/* swap cache에서 제거하고 페이지 해제 */
if (!folio_test_writeback(folio) &&
folio_ref_freeze(folio, 1)) {
/* swap cache에서 제거 */
delete_from_swap_cache(folio);
/* 페이지 해제! */
list_add(&folio->lru, &free_folios);
}
zswap과 회수의 상호작용
zswap이 활성화된 환경에서 anonymous 페이지 회수 경로가 변경됩니다. 스왑아웃할 페이지를 디스크에 기록하기 전에 먼저 압축하여 메모리 내 zswap 풀에 저장합니다. 이는 스왑 I/O를 크게 줄여 회수 성능을 향상시킵니다.
zswap 회수 경로
/* mm/page_io.c - zswap frontswap 경로 */
int swap_writepage(struct page *page,
struct writeback_control *wbc)
{
/* 1. zswap 저장 시도 */
if (zswap_store(folio)) {
/* 압축 성공 -> 디스크 I/O 불필요 */
folio_set_writeback(folio);
folio_end_writeback(folio);
return 0;
}
/* 2. zswap 실패 -> 디스크 스왑 */
__swap_writepage(page, wbc);
return 0;
}
코드 설명
swap_writepage()에서 먼저 zswap_store()를 호출합니다. 페이지를 압축하여 zswap 풀에 저장할 수 있으면 디스크 I/O 없이 회수가 완료됩니다. zswap 풀이 가득 차거나 압축률이 나쁘면 디스크 스왑으로 fallback합니다.
zswap Writeback (LRU 기반)
zswap 풀이 가득 차면 오래된 항목을 디스크 스왑으로 방출합니다. 커널 6.5+에서는 zswap 자체 LRU를 사용하여 cold 페이지를 선별합니다.
/* zswap 풀 크기 설정 */
# echo 20 > /sys/module/zswap/parameters/max_pool_percent
# 전체 메모리의 20%까지 zswap 풀 사용
# zswap 상태 확인
# cat /sys/kernel/debug/zswap/pool_total_size
# cat /sys/kernel/debug/zswap/stored_pages
# cat /sys/kernel/debug/zswap/written_back_pages
vm.swappiness를 100~200으로 올려서 anon 페이지도 적극 회수하면 file cache를 더 많이 보존할 수 있습니다. 자세한 내용은 zswap을 참고하세요.
THP와 회수의 상호작용
Transparent Huge Pages(THP)는 회수 경로에서 특별한 처리가 필요합니다. 2MB folio 전체를 회수하면 512개 base page를 한 번에 확보할 수 있지만, 워킹셋의 일부만 THP에 매핑되어 있으면 불필요한 데이터까지 스왑아웃될 수 있습니다.
THP 회수 결정 트리
| 조건 | 처리 | 비용 |
|---|---|---|
| 전체 folio unmapped + clean | 통째로 해제 | 매우 낮음 (512 페이지 한 번에) |
| 전체 folio unmapped + dirty | 통째로 writeback 후 해제 | 중간 (2MB 연속 I/O) |
| 부분 mapped (<50%) | split 후 unmapped 부분만 해제 | 중간 (split 오버헤드) |
| 대부분 mapped (>50%) | skip 또는 통째로 처리 | 높음 (워킹셋 밀림 위험) |
| Anonymous THP + swap | 통째로 스왑아웃 (SWP_SYNCHRONOUS_IO 미지원) | 높음 (2MB 스왑 I/O) |
/* mm/vmscan.c - THP 회수 시 split 판단 */
if (folio_test_large(folio)) {
/* 매핑 비율 확인 */
unsigned int nr_mapped = folio_nr_pages_mapped(folio);
unsigned int nr_pages = folio_nr_pages(folio);
/* partially mapped THP: split 시도 */
if (nr_mapped < nr_pages &&
nr_mapped < nr_pages / 2) {
if (!folio_trylock(folio))
goto keep;
if (split_folio(folio) == 0) {
/* split 성공: base page들이 각각 LRU에 */
folio_unlock(folio);
continue;
}
folio_unlock(folio);
}
}
mTHP(multi-size THP, 커널 6.8+)를 사용하면 64KB 등 중간 크기 folio로 스왑 I/O를 줄일 수 있습니다.
cgroup v1 vs v2 회수 차이
cgroup v1과 v2에서 메모리 회수 메커니즘은 상당히 다릅니다. 특히 계층적 회수와 보호 메커니즘에서 차이가 큽니다.
| 특성 | cgroup v1 (memory) | cgroup v2 (memory) |
|---|---|---|
| 계층적 회수 | use_hierarchy=1 필요 (기본 0) | 항상 계층적 |
| hard limit | memory.limit_in_bytes | memory.max |
| soft limit | memory.soft_limit_in_bytes | memory.high (throttling) |
| 보호 | 없음 | memory.low / memory.min |
| proactive reclaim | 없음 | memory.reclaim |
| PSI | 없음 | memory.pressure |
| swappiness | cgroup별 설정 가능 | 전역 또는 memory.reclaim으로 |
| OOM | cgroup별 OOM | 계층적 OOM + memory.oom.group |
| lruvec | memcg별 독립 lruvec | memcg별 독립 lruvec + 계층 순회 |
| soft limit reclaim | mem_cgroup_soft_limit_reclaim() | 제거됨 (high throttling으로 대체) |
cgroup v1 Soft Limit Reclaim의 문제점
/* mm/memcontrol.c - v1 soft limit reclaim */
unsigned long
mem_cgroup_soft_limit_reclaim(struct pglist_data *pgdat,
int order,
gfp_t gfp_mask,
unsigned long *total_scanned)
{
/* soft limit 초과 cgroup들을 RB-tree에서 선별 */
/* 문제: O(N) 순회, 비효율적 */
/* cgroup v2에서는 memory.high + throttling으로 대체 */
...
}
memory.soft_limit_in_bytes는 v2에서 memory.high로 대체되었습니다. v1의 soft limit은 전역 회수 시에만 작동하여 예측이 어렵지만, v2의 memory.high는 할당 시점에서 즉시 throttling과 회수를 트리거하여 더 예측 가능합니다. Kubernetes 환경에서는 cgroup v2 사용을 강력히 권장합니다.
PSI(Pressure Stall Information)와 회수 연동
커널 4.20+에서 도입된 PSI는 메모리 압박(memory pressure)을 정량적으로 측정합니다. 회수 서브시스템의 동작이 PSI 카운터에 직접 반영되며, 사용자 공간 에이전트가 이를 기반으로 proactive reclaim 등의 조치를 취할 수 있습니다.
# 전역 메모리 압박
cat /proc/pressure/memory
# some avg10=0.50 avg60=0.30 avg300=0.10 total=123456
# full avg10=0.00 avg60=0.00 avg300=0.00 total=789
#
# some: 하나 이상의 태스크가 메모리 대기
# full: 모든 태스크가 메모리 대기 (CPU idle)
# cgroup별 메모리 압박
cat /sys/fs/cgroup/app/memory.pressure
# PSI 트리거 설정 (사용자 공간 이벤트)
# 10초 동안 some이 20% 초과하면 알림
echo "some 200000 10000000" > /proc/pressure/memory
PSI 기반 자동 회수 에이전트
/* PSI 트리거로 proactive reclaim 자동화 (의사코드) */
int psi_fd = open("/proc/pressure/memory", O_RDWR);
/* 500ms 동안 some이 10% 초과하면 트리거 */
char *trigger = "some 100000 500000";
write(psi_fd, trigger, strlen(trigger));
struct pollfd fds = { .fd = psi_fd, .events = POLLPRI };
while (1) {
int ret = poll(&fds, 1, -1);
if (ret > 0) {
/* 메모리 압박 감지 -> proactive reclaim */
for (cg in cgroups) {
unsigned long reclaim_bytes = calculate_reclaim(cg);
write_to(cg->memory_reclaim, reclaim_bytes);
}
}
}
memory.reclaim에 쓰고, 회수 성공 후 PSI 수치가 감소하면 다시 대기 상태로 돌아갑니다systemd-oomd로 통합되었습니다.
아키텍처별 회수 차이점
Page Reclaim의 핵심 알고리즘은 아키텍처 독립적이지만, PTE Accessed 비트 확인, TLB flush, cache flush 등에서 아키텍처별 차이가 있습니다.
| 요소 | x86_64 | ARM64 | RISC-V |
|---|---|---|---|
| PTE Accessed 비트 | 하드웨어 자동 설정 | 하드웨어 자동 (AF bit) | 하드웨어 자동 (A bit) |
| PTE Dirty 비트 | 하드웨어 자동 설정 | 하드웨어 자동 (DBM) | 하드웨어 자동 (D bit) |
| TLB flush 범위 | INVLPG (단일 페이지) | TLBI VAE1IS (Inner Shareable) | SFENCE.VMA |
| Batch TLB flush | INVLPGB (AMD) / IPI | TLBI VALE1IS (range) | SFENCE.VMA (asid) |
| MGLRU PTE 워크 | 일반 PTE 워크 | FEAT_AF 활용 | Svadu 확장 (있으면) |
| Young 비트 클리어 비용 | 낮음 (atomic PTE 업데이트) | 낮음 (non-shareable) | 가변 (Svadu 유무) |
| 대형 페이지 회수 | 2MB/1GB PTE split | 2MB/64KB (contiguous) split | 2MB (Sv39) split |
ARM64 Access Flag(AF) 관리
/* arch/arm64/mm/fault.c */
/* ARM64에서는 하드웨어가 AF 비트를 자동 설정 (FEAT_AF) */
/* MGLRU가 PTE 워크 시 AF 비트를 확인하고 클리어 */
static int ptep_test_and_clear_young(struct vm_area_struct *vma,
unsigned long addr,
pte_t *ptep)
{
pte_t pte = *ptep;
if (!pte_young(pte))
return 0;
/* AF 비트 클리어 (원자적) */
set_pte_at(vma->vm_mm, addr, ptep,
pte_mkold(pte));
return 1;
}
하이버네이션과 회수
시스템 하이버네이션(suspend-to-disk) 시에도 회수가 활용됩니다. 하이버네이션 이미지를 디스크에 저장하려면 충분한 free 메모리가 필요하므로, 사전에 대규모 회수를 수행합니다.
/* kernel/power/snapshot.c */
int hibernate_preallocate_memory(void)
{
unsigned long saveable = count_data_pages();
unsigned long pages_needed = saveable / 2;
/* 메모리의 절반을 free로 만들어야 함 */
shrink_all_memory(pages_needed);
...
}
/* mm/vmscan.c */
unsigned long shrink_all_memory(unsigned long nr_to_reclaim)
{
struct scan_control sc = {
.nr_to_reclaim = nr_to_reclaim,
.gfp_mask = GFP_HIGHUSER_MOVABLE,
.hibernation_mode = 1, /* 하이버네이션 모드 */
.may_writepage = 1,
.may_unmap = 1,
.may_swap = 1,
};
/* 모든 노드에서 공격적 회수 */
return do_try_to_free_pages(zonelist, &sc);
}
hibernation_mode=1에서는 모든 제한이 해제되어 가장 공격적인 회수가 수행됩니다. may_writepage, may_unmap, may_swap이 모두 활성화되며, memcg 보호(memory.low/memory.min)도 무시됩니다.
가상화 환경에서의 회수
가상 머신(VM)과 컨테이너 환경에서는 회수에 추가적인 고려사항이 있습니다. 게스트 OS와 호스트 OS의 메모리 관리가 상호 작용하여 예상치 못한 동작이 발생할 수 있습니다.
Memory Ballooning과 회수
/* drivers/virtio/virtio_balloon.c */
/* 하이퍼바이저가 게스트의 메모리를 회수하는 메커니즘 */
/*
* 1. 하이퍼바이저가 balloon inflate 요청
* 2. 게스트의 balloon 드라이버가 페이지 할당
* 3. 할당된 페이지를 하이퍼바이저에 반환
* 4. 게스트의 free 메모리 감소 -> 회수 트리거
*/
/* Free page reporting (커널 5.6+) */
/* 게스트가 free 페이지를 자발적으로 보고 */
static void page_reporting_process(struct page_reporting_dev_info *prdev)
{
/* 각 zone의 high-order free 페이지 중 */
/* 보고 대상인 것을 하이퍼바이저에 알림 */
for_each_zone(zone) {
page_reporting_drain_per_cpu_zones(zone);
err = page_reporting_cycle(prdev, zone, ...);
}
}
| 기술 | 메커니즘 | 게스트 회수 영향 |
|---|---|---|
| Memory Ballooning | 게스트에서 페이지 할당 후 호스트에 반환 | 간접적 회수 트리거 (free 감소) |
| Free Page Reporting | 게스트가 free 페이지를 호스트에 보고 | 직접 영향 없음 (free 페이지만 보고) |
| KSM (Kernel Samepage Merging) | 동일 내용 페이지 병합 | 실질 free 증가, rmap 비용 증가 |
| Memory Overcommit | VM 합계 > 물리 메모리 | 호스트/게스트 이중 회수 위험 |
이중 회수(Double Reclaim) 문제
memory.low로 보호, 3) balloon 크기 제한 등의 조치가 필요합니다.
컨테이너 환경 특수 고려사항
# Kubernetes Pod의 메모리 회수 최적화
# 1. Guaranteed QoS: requests == limits
# -> memory.min = memory.max = requests
# -> 다른 Pod의 회수로부터 완전 보호
# 2. Burstable QoS: requests < limits
# -> memory.low = requests, memory.max = limits
# -> requests까지 보호, limits까지 사용 가능
# 3. BestEffort QoS: requests/limits 없음
# -> 보호 없음, 가장 먼저 회수 대상
# kubelet의 eviction threshold와 연동
# --eviction-hard=memory.available<100Mi
# --eviction-soft=memory.available<300Mi
# PSI 기반 eviction (1.26+):
# --feature-gates=PodAndContainerStatsFromCRI=true
vm_event 카운터 전체 참조
회수 관련 /proc/vmstat 카운터를 카테고리별로 정리합니다.
| 카테고리 | 카운터 | 의미 |
|---|---|---|
| 스캔 | pgscan_kswapd | kswapd가 스캔한 페이지 수 |
pgscan_direct | direct reclaim이 스캔한 페이지 수 | |
pgscan_khugepaged | khugepaged가 스캔한 수 | |
pgscan_direct_throttle | direct reclaim이 쓰로틀된 횟수 | |
| 회수 | pgsteal_kswapd | kswapd가 회수한 페이지 수 |
pgsteal_direct | direct reclaim이 회수한 페이지 수 | |
pgsteal_anon | 회수된 anonymous 페이지 수 (6.x) | |
pgsteal_file | 회수된 file 페이지 수 (6.x) | |
| LRU 이동 | pgactivate | inactive -> active 승격 수 |
pgdeactivate | active -> inactive 강등 수 | |
pgrefill | active 리스트 스캔(참조 비트 클리어) 수 | |
| 워킹셋 | workingset_refault_anon | anon 워킹셋 refault 수 |
workingset_refault_file | file 워킹셋 refault 수 | |
workingset_activate_anon | anon refault 후 activate 수 | |
workingset_activate_file | file refault 후 activate 수 | |
| 쓰로틀 | nr_writeback_throttled | writeback 대기 쓰로틀 수 |
pgscan_direct_throttle | direct reclaim 쓰로틀 횟수 |
카운터 분석 실전 예시
# 상황 1: kswapd가 충분히 일하고 있는지 확인
$ awk '/pgscan_kswapd|pgsteal_kswapd/ {print}' /proc/vmstat
pgscan_kswapd 15234567
pgsteal_kswapd 14890123
# 효율 = 14890123 / 15234567 = 97.7% -> 매우 양호
# 상황 2: direct reclaim이 문제인 경우
$ awk '/pgscan_direct|pgsteal_direct/ {print}' /proc/vmstat
pgscan_direct 5678901
pgsteal_direct 567890
# 효율 = 567890 / 5678901 = 10% -> 매우 나쁨
# -> 대부분 페이지가 active이고 회수할 게 없음
# -> 해결: 메모리 증설 또는 워크로드 최적화
# 상황 3: 워킹셋 쓰래싱 확인
$ awk '/workingset/ {print}' /proc/vmstat
workingset_refault_anon 123456
workingset_refault_file 7890123
workingset_activate_file 6500000
workingset_restore_anon 100000
# file refault가 높지만 activate도 높음
# -> file cache가 반복적으로 회수되고 다시 읽힘
# -> swappiness를 낮추어 file 보호 또는 캐시 증설
cgroup별 회수 통계 분석
# cgroup별 회수 카운터
cat /sys/fs/cgroup/app/memory.stat
# 핵심 필드:
# pgfault -- 페이지 폴트 수 (minor + major)
# pgmajfault -- 메이저 폴트 (디스크 I/O 발생)
# pgscan -- 이 cgroup에서 스캔된 페이지 수
# pgsteal -- 이 cgroup에서 회수된 페이지 수
# workingset_refault_anon -- anon 워킹셋 refault
# workingset_refault_file -- file 워킹셋 refault
# pgscan_kswapd -- kswapd에 의한 스캔
# pgscan_direct -- direct reclaim에 의한 스캔
# 여러 cgroup 비교 스크립트
for cg in /sys/fs/cgroup/*/; do
name=$(basename "$cg")
pgscan=$(grep "^pgscan " "$cg/memory.stat" 2>/dev/null | awk '{print $2}')
pgsteal=$(grep "^pgsteal " "$cg/memory.stat" 2>/dev/null | awk '{print $2}')
if [ -n "$pgscan" ] && [ "$pgscan" -gt 0 ]; then
eff=$((pgsteal * 100 / pgscan))
printf "%-30s scan=%-10s steal=%-10s eff=%d%%\n" \
"$name" "$pgscan" "$pgsteal" "$eff"
fi
done | sort -t= -k4 -n
/proc/vmstat과 cgroup의 memory.stat 카운터를 node_exporter(Prometheus) 또는 cAdvisor로 수집하면 회수 상태를 시각화할 수 있습니다. 핵심 지표는 1) pgscan_direct 증가율, 2) pgsteal/pgscan 비율, 3) workingset_refault 증가율, 4) PSI some avg10 값입니다.
회수 관련 흔한 실수와 해결
실수 1: swappiness=0으로 스왑 완전 비활성화 기대
vm.swappiness=0은 스왑을 비활성화하지 않습니다. 메모리 압박이 심하면 여전히 anonymous 페이지가 스왑됩니다. 스왑을 완전히 끄려면 swapoff -a를 사용하세요. 다만 스왑 없이 anonymous 페이지는 회수 불가하므로 OOM 위험이 증가합니다.
실수 2: vm.min_free_kbytes를 과도하게 높게 설정
# 잘못된 설정: 메모리의 50%를 min_free로
sysctl vm.min_free_kbytes=$(($(awk '/MemTotal/{print $2}' /proc/meminfo) / 2))
# -> 절반의 메모리가 항상 비어 있어야 함 -> 사실상 메모리 반토막
# 올바른 설정: 메모리의 1~3% 수준
sysctl vm.min_free_kbytes=$(($(awk '/MemTotal/{print $2}' /proc/meminfo) / 64))
# 64GB 시스템이면 약 1GB
실수 3: drop_caches를 정기적으로 실행
echo 3 > /proc/sys/vm/drop_caches를 cron으로 정기 실행하는 것은 올바른 메모리 관리가 아닙니다. 이는 워킹셋을 포함한 모든 캐시를 강제로 비우므로, 이후 대규모 page fault(major fault)가 발생하여 성능이 일시적으로 크게 저하됩니다. 커널의 LRU 알고리즘이 자동으로 필요한 캐시를 보존하므로 수동 개입은 불필요합니다.
실수 4: NUMA에서 zone_reclaim_mode=1 남용
# zone_reclaim_mode=1: 원격 노드 사용 전에 로컬 노드를 먼저 회수
# 문제: 로컬 노드의 파일 캐시를 적극적으로 버려서
# I/O 증가 및 성능 저하 가능
# 대부분의 워크로드에서는 기본값(0)이 최적
sysctl vm.zone_reclaim_mode=0
# zone_reclaim_mode=1이 유용한 경우:
# - HPC 등 NUMA 지역성이 매우 중요한 경우
# - 파일 I/O가 거의 없는 계산 집약적 워크로드
실수 5: overcommit 설정 오류
| vm.overcommit_memory | 의미 | 회수 영향 |
|---|---|---|
| 0 (기본) | 휴리스틱 overcommit | 정상적인 회수 동작 |
| 1 | 항상 허용 | 실제 사용 시 회수 압박 증가, OOM 위험 |
| 2 | 엄격 제한 (swap + RAM*ratio) | 할당 단계에서 거부, 회수 부담 감소 |
실시간 모니터링 스크립트
#!/bin/bash
# vmscan 핵심 카운터 실시간 모니터링
# 사용법: ./vmscan-monitor.sh [간격_초]
INTERVAL=${1:-1}
prev_scan_d=0; prev_scan_k=0
prev_steal_d=0; prev_steal_k=0
prev_refault_f=0; prev_refault_a=0
while true; do
read_vmstat() { awk "/$1/ {print \$2}" /proc/vmstat; }
scan_d=$(read_vmstat pgscan_direct)
scan_k=$(read_vmstat pgscan_kswapd)
steal_d=$(read_vmstat pgsteal_direct)
steal_k=$(read_vmstat pgsteal_kswapd)
refault_f=$(read_vmstat workingset_refault_file)
refault_a=$(read_vmstat workingset_refault_anon)
d_scan=$((scan_d - prev_scan_d))
d_kscan=$((scan_k - prev_scan_k))
d_steal=$((steal_d - prev_steal_d))
d_ksteal=$((steal_k - prev_steal_k))
d_ref_f=$((refault_f - prev_refault_f))
d_ref_a=$((refault_a - prev_refault_a))
# 효율 계산
efficiency="N/A"
total_scan=$((d_scan + d_kscan))
total_steal=$((d_steal + d_ksteal))
if [ $total_scan -gt 0 ]; then
efficiency=$((total_steal * 100 / total_scan))"%"
fi
printf "%s | direct_scan=%-8d kswapd_scan=%-8d | " \
"$(date +%H:%M:%S)" "$d_scan" "$d_kscan"
printf "steal_d=%-6d steal_k=%-6d | " "$d_steal" "$d_ksteal"
printf "refault_f=%-5d refault_a=%-5d | eff=%s\n" \
"$d_ref_f" "$d_ref_a" "$efficiency"
prev_scan_d=$scan_d; prev_scan_k=$scan_k
prev_steal_d=$steal_d; prev_steal_k=$steal_k
prev_refault_f=$refault_f; prev_refault_a=$refault_a
sleep $INTERVAL
done
Folio 생애주기(Lifecycle) 종합
하나의 Folio(폴리오)는 할당 시점부터 최종 해제(또는 스왑 아웃)까지 여러 상태 전이를 거칩니다.
물리 메모리 할당에서 LRU 추가, 접근으로 인한 활성화, 워킹셋(Working Set) 편입, 에이징으로 인한 비활성화,
shrink_folio_list()에서의 최종 결정, 그리고 Shadow Entry를 통한 Refault 감지까지
전체 경로를 이해하면 회수 동작을 직관적으로 파악할 수 있습니다.
| 조건 (우선순위 순) | 결과 | 관련 함수 | 다음 상태 |
|---|---|---|---|
| trylock 실패 (다른 코드가 락 보유) | 유지(Keep) | folio_trylock() | LRU 복귀 |
| 참조 있음 (PTE young, swap cache hit) | 재활성화(Activate) | folio_check_references() | Active LRU |
| Dirty + 쓰기 가능 파일 페이지 | Writeback 시작 | pageout() | writeback 완료 후 재회수 |
| 매핑됨 → try_to_unmap 실패 | 재활성화 | try_to_unmap() | Active LRU |
| Anonymous + 스왑 공간 없음 | 유지(Keep) | add_to_swap() 실패 | LRU 복귀 |
| Anonymous + 스왑 캐시 추가 성공 | Swap Out | swap_writepage() | 스왑 슬롯 할당 후 해제 |
| Clean file 페이지 (unmapped) | 즉시 해제 | free_pages_and_swap_cache() | Free 페이지 → Buddy |
참고자료
커널 문서
- Memory Management Concepts -- 페이지 회수를 포함한 메모리 관리 개념을 설명합니다
- Page Reclaim -- 페이지 회수 메커니즘의 개요입니다
- Multi-Gen LRU (MGLRU) -- 다세대 LRU 프레임워크 문서입니다
LWN 기사
- Page replacement for huge memory (2008) -- 대용량 메모리 환경의 페이지 교체 문제를 다룹니다
- Better active/inactive list balancing (2012) -- active/inactive 리스트 균형 조정을 개선하는 방법입니다
- Shrinker and page reclaim (2013) -- shrinker와 페이지 회수의 관계를 설명합니다
- Multi-generational LRU (2021) -- MGLRU의 설계와 구현을 소개합니다
- MGLRU lands in 6.1 (2022) -- MGLRU가 커널 6.1에 병합된 과정을 다룹니다
커널 소스
- mm/vmscan.c -- kswapd와 direct reclaim을 포함하는 vmscan 핵심 구현입니다
- mm/workingset.c -- 워킹셋 감지 및 refault 거리 계산 구현입니다
- include/linux/mmzone.h -- LRU 리스트와 존 구조체 정의입니다
관련 문서
mm/vmscan.c-- 페이지 회수 핵심 로직 (shrink_node, shrink_lruvec, shrink_folio_list, kswapd, MGLRU)mm/workingset.c-- Refault distance 기반 워킹셋 감지mm/swap.c-- LRU 관리 (folio_add_lru, activate/deactivate, per-CPU batch)mm/memcontrol.c-- memcg 회수 정책, memory.high/low/min/max 처리mm/rmap.c-- Reverse mapping, try_to_unmapmm/page_io.c-- 스왑 I/O, zswap 연동include/linux/mmzone.h-- lruvec, zone, watermark, lru_gen_folio 구조체include/linux/mm_types.h-- folio/page 구조체의 LRU 관련 필드
- 메모리 관리 개요로 전체 구조 파악
- 페이지 할당자에서 워터마크와 할당 실패 경로 이해
- 본 문서(vmscan)에서 회수 경로 상세 학습
- 스왑 서브시스템에서 anonymous 페이지 스왑아웃 과정 확인
- Memory Compaction에서 회수 후 연속 블록 확보 이해
- OOM Killer에서 회수 실패 시 최후 처리 확인