페이지 회수 (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 튜닝까지 커널 소스 기반으로 분석합니다.

전제 조건: 메모리 관리(Memory Management) 개요페이지 할당자(Page Allocator) 문서를 먼저 읽으세요. Page Reclaim은 물리 메모리가 부족할 때 작동하므로 Buddy Allocator와 Zone/Node 구조를 이해해야 합니다. 스왑(Swap) 서브시스템도 함께 참고하면 anonymous 페이지 회수 과정을 더 깊이 이해할 수 있습니다.
일상 비유: Page Reclaim은 도서관 서가 정리와 비슷합니다. 서가(물리 메모리)가 가득 차면 새 책(새 페이지)을 놓을 공간이 없습니다. 사서(kswapd)는 대출 기록을 보고 오래 안 읽힌 책을 창고(스왑/디스크)로 옮겨 빈 자리를 만듭니다. 급한 이용자(direct reclaim)는 직접 서가를 뒤져서 공간을 확보하기도 합니다.

핵심 요약

  • 페이지 회수(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 -- 회수된 페이지가 다시 필요해지는 빈도를 추적하여 워킹셋을 보호합니다.

단계별 이해

  1. LRU 리스트 구조 이해
    active/inactive x anon/file 4개 리스트와 lruvec 구조를 파악합니다.
  2. 워터마크 개념 파악
    min/low/high 워터마크가 어떻게 회수를 트리거하는지 이해합니다.
  3. kswapd vs direct reclaim
    백그라운드 회수와 동기 회수의 진입 조건과 동작 차이를 구분합니다.
  4. shrink 경로 추적
    shrink_node → shrink_lruvec → shrink_folio_list 호출 체인을 따라갑니다.
  5. MGLRU 이해
    세대 기반 회수가 클래식 LRU 대비 어떤 장점을 제공하는지 파악합니다.
  6. 모니터링과 튜닝
    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 pagewriteback 후 해제중간수정된 내용을 디스크에 기록한 후 해제
Anonymous pageswap out 후 해제높음스왑 영역(Swap Area)에 기록해야 함
Slab cacheshrinker 호출가변dentry/inode 캐시 등 축소 가능 객체
Kernel stack, page table회수 불가-핀되거나 이동 불가한 커널 페이지

전체 회수 흐름

Page Reclaim 전체 아키텍처 alloc_pages() 실패 워터마크 < low memcg limit 초과 memory.reclaim Direct Reclaim kswapd memcg / proactive shrink_node() shrink_lruvec() shrink_slab() shrink_folio_list() Clean: 해제 Dirty: writeback Anon: swap out Slab: 축소 Free Pages 확보 -> Buddy Allocator 반환
Page Reclaim의 트리거부터 free 페이지 확보까지의 전체 흐름

mm/vmscan.c는 약 7,000줄 이상의 커널 핵심 코드로, 페이지 회수의 모든 정책 결정이 이 파일에 집중되어 있습니다. 최근 커널(6.x)에서는 folio 기반 API로 전환이 진행 중이며, MGLRU가 기본 활성화되면서 클래식 LRU와 공존하는 구조입니다.

LRU 리스트 구조

커널은 메모리 페이지를 4개의 LRU(Least Recently Used) 리스트로 분류합니다. 각 리스트는 struct lruvec에 포함되어 있으며, Zone 또는 memcg 단위로 관리됩니다.

리스트매크로(Macro)대상 페이지회수 우선순위(Priority)
Inactive AnonLRU_INACTIVE_ANON최근 미참조 anonymous 페이지높음 (swappiness에 따라)
Active AnonLRU_ACTIVE_ANON최근 참조된 anonymous 페이지낮음
Inactive FileLRU_INACTIVE_FILE최근 미참조 file-backed 페이지가장 높음
Active FileLRU_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 필드가 세대 기반 관리를 담당합니다.
LRU 4개 리스트 구조 (lruvec) struct lruvec (Node 또는 memcg 단위) Active Anon A1 A2 A3 ... Inactive Anon a1 a2 a3 ... Active File F1 F2 F3 ... Inactive File f1 f2 f3 ... Unevictable (mlock, ramdisk 등 -- 회수 대상 제외) demotion demotion 회수 스캔 방향 1. Inactive File 우선 스캔 (clean page 즉시 해제) 2. Inactive Anon (swappiness > 0 일 때) 3. Active -> Inactive 강등 (참조 비트 확인) 비용 추적 (refault 기반) anon_cost: anon refault 누적 file_cost: file refault 누적 비용이 높은 쪽을 덜 스캔 * memcg 활성화 시 각 cgroup마다 별도 lruvec 존재
LRU 4개 리스트와 demotion/스캔 방향. Active에서 참조 비트가 없는 페이지가 Inactive로 강등됩니다

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 상태를 볼 수 있습니다.
Per-CPU folio_batch → LRU 일괄 삽입 CPU 0 배치 28/31개 CPU 1 배치 12/31개 CPU N 배치 31/31 (full!) drain 트리거: ① 배치 full (31개) ② lru_add_drain() 호출 ③ 회수 시작 전 강제 drain lru_lock 1회 획득 → 배치 전체 일괄 삽입 LRU 리스트 (lruvec -- lru_lock 보호) Active/Inactive × Anon/File 배치 없이: CPU당 최대 31번 lru_lock 경합 → 배치 사용 시: 1번으로 감소
Per-CPU folio_batch 구조. 각 CPU가 독립 배치 버퍼를 채우다가 full이 되거나 drain 요청 시 lru_lock을 한 번만 잡고 일괄 삽입합니다
lru_lock 경합 감소 효과: 배치가 없다면 31개 folio를 LRU에 추가할 때 31번의 lru_lock 획득이 필요합니다. 배치를 사용하면 단 1번으로 줄어듭니다. 단, drain이 지연되면 LRU 통계가 일시적으로 부정확해질 수 있으므로 회수 코드는 항상 drain 이후에 LRU 크기를 읽습니다.
Active/Inactive 분리 이유: 단일 LRU 리스트를 사용하면 한 번만 읽힌 대용량 파일(streaming I/O)이 자주 사용되는 워킹셋을 밀어낼 수 있습니다. Active/Inactive를 분리하여 "두 번 이상 참조된 페이지"를 Active로 승격시키면 워킹셋이 보호됩니다.

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 vs Page: folio_add_lru()는 기존 lru_cache_add()를 대체합니다. Folio는 head page 포인터를 통해 compound page 전체를 한 번에 처리하므로, THP를 LRU에 추가할 때 512번 반복할 필요가 없습니다.

페이지 플래그와 회수 결정

회수 서브시스템은 페이지 플래그를 통해 각 페이지의 상태를 판단합니다. 핵심 플래그들을 정리합니다.

플래그의미회수 시 역할
PG_referenced최근 참조됨inactive에서 이 플래그가 있으면 active로 승격. 회수 시 한 번 클리어 후 재참조 없으면 회수
PG_activeActive LRU에 있음Active 리스트 소속 여부. 회수 대상 선정 시 기본 제외
PG_lruLRU 리스트에 있음LRU에서 분리(isolate) 시 클리어
PG_lockedI/O 진행 중잠겨 있으면 회수 불가, 재시도 대기
PG_dirty수정됨writeback 필요. clean이면 즉시 해제 가능
PG_writebackwriteback 진행 중완료 대기 필요
PG_swapcache스왑 캐시에 있음스왑 슬롯 할당 완료 상태
PG_workingset워킹셋 소속refault distance로 판별. 이 플래그가 있으면 activate 우선
PG_unevictable회수 불가mlock, ramfs 등. LRU_UNEVICTABLE에 배치
PG_mlockedmlock으로 고정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;
PG_referenced 오해: PG_referenced가 설정되었다고 해서 항상 회수에서 제외되는 것은 아닙니다. 참조 비트를 한 번 클리어하고, 다음 스캔에서 다시 확인합니다. 즉 "최소 두 번 참조"되어야 Active로 승격됩니다.

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);
}
kswapd의 목표: kswapd는 Zone의 free pages를 high 워터마크까지 올리는 것을 목표로 합니다. 이미 high 이상이면 다시 sleep합니다. 이는 곧 이어질 할당 요청을 위한 여유분을 미리 확보하는 것입니다.

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() 흐름 kswapd 깨어남 priority = DEF_PRIORITY (12) Zone 순회: highest_zoneidx → 0 zone_balanced()? (free >= high?) Yes 다음 Zone No shrink_node(pgdat, &sc) pgdat_balanced()? (노드 전체 균형?) Yes kswapd sleep No priority-- (스캔 강도 증가) priority < 0 도달 시 회수 실패 → OOM 경로 진입 highest_zoneidx는 kswapd를 깨운 할당 요청의 gfp_mask에서 결정
balance_pgdat() 흐름. Zone 순회와 priority 강화 루프를 반복하며 노드 전체가 균형 잡힐 때까지 shrink_node()를 호출합니다
highest_zoneidx와 Zone 순서: 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()를 호출합니다.
kswapd vs Direct Reclaim 트리거 흐름 alloc_pages(gfp, order) free >= high? Yes 할당 성공 No free >= low? Yes kswapd 깨움 background 회수 No free >= min? Direct Reclaim OOM Kill 가능 할당 프로세스가 직접 회수 high까지 회수
워터마크 수준에 따른 kswapd 기동과 direct reclaim 진입 판단
Direct Reclaim의 성능 영향: Direct reclaim은 할당을 요청한 프로세스 컨텍스트에서 동기적으로 실행됩니다. 이 동안 해당 프로세스는 블록되므로, 지연 민감 애플리케이션(데이터베이스, 실시간(Real-time) 시스템)에서는 심각한 tail latency를 유발할 수 있습니다.

워터마크 시스템

각 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;
    ...
};
Zone 워터마크와 회수 트리거 HIGH kswapd 정지, 모든 것 정상 안전 영역 LOW kswapd 기동 (background reclaim) kswapd 활동 MIN Direct Reclaim 진입 Direct Reclaim 위험 구간 OOM 가능 0 (free=0) free 워터마크 계산 min = vm.min_free_kbytes 기반 low = min + min/4 high = min + min/2 vm.watermark_scale_factor로 low/high 간격 조정 가능 watermark_boost: 단편화 감지 시 high를 일시적으로 올려서 사전 회수량 증가
워터마크 수준별 행동: high 이상 정상, low 미만 kswapd 기동, min 미만 direct reclaim

워터마크 계산

sysctl기본값영향
vm.min_free_kbytes시스템 RAM 의존 (보통 수십 MB)min 워터마크 직접 결정. low/high도 비례 조정
vm.watermark_scale_factor10 (0.1%)low-min, high-min 간격을 managed pages의 비율로 설정
vm.watermark_boost_factor15000 (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;      /* 스캔된 수 */
};
priority(우선순위): priority는 12에서 시작하여 회수 실패 시 0까지 감소합니다. 낮은 priority = 더 공격적 스캔. priority=N일 때 LRU 크기의 1/(2^N)만 스캔합니다. priority=0이면 전체 LRU를 스캔합니다.

shrink_lruvec() / shrink_folio_list() 상세

shrink_lruvec()는 하나의 lruvec에서 페이지를 회수하는 핵심 함수입니다. Anon/File 스캔 비율을 결정하고, Inactive 리스트에서 페이지를 분리(isolate)하여 shrink_folio_list()에 전달합니다.

shrink 호출 체인 상세 shrink_node(pgdat, sc) shrink_lruvec(lruvec, sc) get_scan_count() -- anon/file 스캔 수 결정 shrink_active_list() -- demotion shrink_inactive_list() -- 회수 isolate_lru_folios() -- LRU에서 분리 shrink_folio_list() -- 실제 회수 folio별: referenced? -> keep/activate | dirty? -> writeback | anon? -> swap | clean? -> free
회수 경로의 주요 함수 호출 관계. shrink_folio_list()가 각 folio의 운명을 최종 결정합니다
shrink_folio_list() — Folio별 결정 트리 Isolated Folio folio_list에서 꺼냄 ① folio_trylock()? 잠금 획득 시도 실패 KEEP 성공 ② folio_check_references()? PTE Young 비트, 참조 횟수 확인 ACTIVATE ACTIVATE KEEP KEEP RECLAIM ③ folio_test_dirty()? 더티 페이지 여부 더티 pageout() → KEEP 클린 ④ folio_mapped()? PTE 매핑 존재? 매핑됨 try_to_unmap() 실패 → ACTIVATE 매핑 없음 / unmap 성공 ⑤ anon + no swapcache? 익명 + 스왑 캐시 없음? anon add_to_swap() 실패 → ACTIVATE file 또는 swap 준비 완료 FREE! 마름모: 결정 분기점 초록 화살표: 회수 진행 빨간 화살표: 회수 중단 주황: 조건부/중간 단계
shrink_folio_list()의 folio별 결정 트리. ① trylock 실패 시 즉시 KEEP, ② 참조 확인 후 ACTIVATE/KEEP/RECLAIM 분기, ③ 더티이면 writeback 후 KEEP, ④ 매핑된 경우 try_to_unmap() 시도(실패 시 ACTIVATE), ⑤ Anonymous + 스왑 캐시 없으면 add_to_swap() 시도(실패 시 ACTIVATE), 모든 조건 통과 시 FREE

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로 승격됩니다.
folio_check_references() 결정 트리 Inactive 리스트의 folio PTE young 비트? (referenced_ptes) Yes VM_EXEC? (실행 매핑) Yes → ACTIVATE No PG_referenced? (이전 라운드) Yes → ACTIVATE (two-strikes) No KEEP PG_referenced 세트 (1회 유예) No PG_referenced? (folio 플래그만) Yes KEEP No clean file? → RECLAIM_CLEAN RECLAIM
folio_check_references() 결정 트리. PTE young 비트와 PG_referenced 조합으로 4가지 결과(ACTIVATE, KEEP, RECLAIM, RECLAIM_CLEAN)를 반환합니다
세컨드 찬스(Second Chance) 알고리즘: 페이지를 처음 Inactive 리스트 꼬리에서 만났을 때 참조 비트가 있으면 즉시 회수하지 않고 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 리스트로 바로 승격시킵니다.
Refault Distance 개념 시간 (nonresident_age) Eviction shadow entry 저장 age=1000 Refault page fault 발생 age=1350 Refault Distance = 350 거리 <= Inactive 크기 (400) -> 워킹셋! Active로 승격 거리 > Inactive 크기 -> 일시적 참조. Inactive로 유지 Refault distance가 짧을수록 해당 페이지가 워킹셋에 속할 가능성이 높습니다 이 메커니즘이 streaming I/O에 의한 워킹셋 밀림을 방지합니다
Refault distance가 inactive 리스트 크기보다 짧으면 워킹셋으로 판단하여 Active로 승격
PG_workingset의 의미: refault distance 기반으로 워킹셋으로 판별된 페이지는 PG_workingset 플래그가 설정됩니다. 이 플래그가 있으면 shrink_folio_list()에서 참조 한 번만으로도 activate 대상이 됩니다. workingset_refault() 이벤트는 vmstatworkingset_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 스캔 비율 극대화
Anon vs File 스캔 비율 결정 vm.swappiness anon_cost (refault) file_cost (refault) LRU 크기 (anon/file) get_scan_count() nr_scan[ANON] nr_scan[FILE] refault 비용이 높은 쪽의 스캔을 줄여 워킹셋 보호
swappiness, refault 비용, LRU 크기를 종합하여 anon/file 스캔 수를 결정합니다
/* 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의 오해: swappiness=0이라고 anonymous 페이지가 절대 스왑되지 않는 것은 아닙니다. free pages가 min 워터마크 이하로 떨어지면 swappiness=0이어도 anon 페이지를 스캔합니다. "스왑 완전 비활성화"는 swapoff -a를 사용해야 합니다.

MGLRU (Multi-Gen LRU)

MGLRU는 커널 6.1에서 도입된 새로운 페이지 회수 알고리즘으로, 클래식 LRU의 한계(Active/Inactive 2단계만으로는 워킹셋 크기 변화에 느리게 적응)를 극복합니다. 페이지를 세대(generation)로 분류하여 접근 빈도를 더 세밀하게 추적합니다.

핵심 개념: 세대(Generation)

개념클래식 LRUMGLRU
분류 단계2단계 (Active/Inactive)최대 4세대 (gen 0 ~ gen MAX_NR_GENS-1)
접근 추적PG_referenced 1비트PTE young 비트 + 세대 번호
에이징참조 비트 클리어 후 demotionPTE 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 세대 진화 Gen 0 (oldest) min_seq 오래 미참조 페이지 축출 대상 tier 0~3 Gen 1 중간 세대 페이지 tier 0~3 Gen 2 최근 참조 페이지 tier 0~3 Gen 3 (newest) max_seq 방금 참조된 페이지 보호됨 tier 0~3 에이징: PTE young 비트 스캔 -> 세대 승격 Eviction (회수) 에이징 프로세스 (lru_gen_age_node) 1. 프로세스 페이지 테이블을 워크하며 PTE Accessed 비트 확인 2. Accessed=1이면 해당 folio를 최신 세대(max_seq)로 승격 3. max_seq 증가 시 가장 오래된 세대가 밀려나며 min_seq도 증가 새 페이지 세대 수가 MAX_NR_GENS(4)를 초과하면 oldest 세대가 축출 대상이 됩니다 Tier는 접근 유형(exec/read/write)을 세분화하여 회수 우선순위를 조정합니다 MGLRU는 대규모 워킹셋 변화에 클래식 LRU보다 빠르게 적응합니다
MGLRU의 4세대 구조: 새 페이지는 최신 세대에 배치, 에이징으로 세대 승격/축출

MGLRU Tier(접근 유형 분류) 시스템

MGLRU는 세대(Generation)에 더해 Tier(접근 유형)라는 두 번째 차원으로 folio를 분류합니다. 같은 세대 안에서도 접근 방식에 따라 회수 우선순위가 달라지며, Tier가 낮을수록(더 차가울수록) 먼저 회수됩니다.

Tier 번호의미해당 접근 패턴회수 우선순위
Tier 0Cold / 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;
}
MGLRU 세대(Generation) × Tier 2차원 격자 축출은 Gen 0 / Tier 0 (좌하단) 부터 시작하여 오른쪽 → 위쪽 순으로 진행됩니다 세대(Generation) 오래됨 → 새로움 Tier: 차가움(0) → 뜨거움(3) Gen 0 (oldest) 축출 대상 Gen 1 Gen 2 Gen 3 (newest) 보호됨 Tier 0: Cold/Unmapped Tier 1: Exec 접근 Tier 2: Read 접근 Tier 3: Recently Accessed 1순위 축출 nr_pages[0][*][0] 매핑 없음/PTE old 2순위 nr_pages[0][*][1] 3순위 nr_pages[0][*][2] 4순위 nr_pages[0][*][3] nr_pages[1][*][0] 5순위 nr_pages[1][*][1] nr_pages[1][*][2] nr_pages[1][*][3] nr_pages[2][*][0] nr_pages[2][*][1] nr_pages[2][*][2] nr_pages[2][*][3] nr_pages[3][*][0] 최신, 보호됨 nr_pages[3][*][1] nr_pages[3][*][2] nr_pages[3][*][3] 최신 + 최다 접근 가장 마지막 회수 축출 방향: Tier 0 → Tier 3 (같은 세대 내) 축출 방향: Gen0 → Gen3
MGLRU 2차원 격자(세대 × Tier). 축출은 Gen 0, Tier 0(좌상단)에서 시작하여 같은 세대 내 Tier 순서로, 그 다음 Gen 1로 이동합니다. Gen 3(최신 세대)의 folio는 마지막으로 회수됩니다
Tier 기반 축출 순서 요약:
  • 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, ...);
}
MGLRU 활성화 확인: cat /sys/kernel/mm/lru_gen/enabled 값이 0x0007(또는 7)이면 MGLRU가 완전 활성화된 상태입니다. 비트별로 0=core, 1=mm_walk, 2=nonleaf_young을 의미합니다.

MGLRU vs 클래식 LRU 성능 비교

측면클래식 LRUMGLRU
워킹셋 추적 정밀도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% 감소
MGLRU와 클래식 LRU 공존: MGLRU가 활성화되면 클래식 LRU의 active/inactive 리스트 대신 세대 리스트가 사용됩니다. 하지만 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);
}
throttle 발생 시: vmstat에서 pgscan_direct_throttle 카운터가 증가하면 direct reclaim 쓰로틀링이 발생한 것입니다. 이는 I/O 서브시스템이 회수 속도를 따라가지 못하는 징후이며, 스왑 장치를 SSD로 교체하거나 vm.dirty_ratio를 낮추는 것이 해결책입니다.

Writeback과 Dirty Page 회수

Dirty 페이지는 수정된 내용을 디스크에 기록(writeback)한 후에야 해제할 수 있습니다. 회수 경로에서 dirty 페이지를 만나면 즉시 해제하지 않고 writeback을 시작한 후 다음 라운드에서 처리합니다.

Dirty Page Writeback -> Reclaim 파이프라인 Dirty Folio PG_dirty=1 pageout() writeback 시작 Writeback 중 PG_writeback=1 Clean Folio PG_dirty=0 Free! 회수 라운드별 처리: Round 1: dirty folio 발견 -> pageout() 호출 -> writeback 시작 -> keep (LRU 복귀) Round 2: writeback 완료된 clean folio -> try_to_unmap -> free_unref_folios sc->may_writepage가 false이면 dirty 페이지를 건드리지 않고 skip합니다 direct reclaim 초기 라운드에서는 may_writepage=0으로 시작하여 I/O 부하를 줄입니다 priority가 낮아져야(높은 압박) writeback이 허용됩니다
Dirty 페이지는 writeback 후 다음 회수 라운드에서 clean 상태로 해제됩니다

vm.dirty_ratio의 영향

sysctl기본값회수 관점 영향
vm.dirty_ratio20 (%)프로세스가 write 시 throttle되는 dirty 비율. 낮으면 dirty 페이지 적어 회수 빠름
vm.dirty_background_ratio10 (%)flusher가 writeback 시작하는 비율. 낮으면 사전 writeback 증가
vm.dirty_expire_centisecs3000 (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 버퍼 캐시 */
shrinker 목록 확인: /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 계층별 회수 / (root memcg) app-A (max=2G) lruvec: 자체 LRU 사용: 1.5G app-B (max=1G) lruvec: 자체 LRU 사용: 1G (limit!) app-C (max=4G) lruvec: 자체 LRU 사용: 2G limit 초과! memcg reclaim (app-B만) shrink_node(sc->target_mem_cgroup=app-B) app-B의 limit 초과로 인한 회수는 app-A, app-C에 영향을 주지 않습니다 memory.low/memory.min으로 각 cgroup의 최소 보호량을 설정할 수 있습니다
각 memcg는 독립적인 lruvec를 가지며, limit 초과 시 해당 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을 우선 시도합니다.

NUMA Memory Tiering과 Demotion Fast Tier (DRAM, Node 0) Hot/Active 페이지 대역폭 높음, 지연 낮음 Slow Tier (PMEM/CXL, Node 1) Cold/Demoted 페이지 용량 크지만 지연 높음 Demotion Promotion 회수 경로에서의 Demotion 결정 1. shrink_folio_list()에서 folio를 회수하려 할 때 2. can_demote()가 true이면 -> demote_folio_list()로 slow tier에 마이그레이션 3. 마이그레이션 실패 또는 slow tier도 부족하면 -> 기존 회수(swap/discard) vm.demote_memory_tier=1 (기본: 프로모션 워터마크 사용) CXL 메모리가 연결된 서버에서 특히 유용합니다
메모리 티어링: 회수 대신 slow tier로 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
Proactive Reclaim 흐름 echo "500M" > memory.reclaim memory_reclaim() sc.proactive=1 try_to_free_mem_cgroup_pages() shrink_node() Proactive Reclaim의 장점 1. 할당 실패 없이 사전에 cold 페이지를 회수하여 direct reclaim 빈도 감소 2. 컨테이너별로 메모리 사용량을 예측하여 적절한 시점에 회수 가능 3. PSI(Pressure Stall Information)와 연계하여 자동 튜닝 가능 4. Meta/Google 등에서 production 환경에서 적극 사용 중
사용자 공간(User Space)에서 memory.reclaim을 통해 회수를 트리거하는 proactive reclaim
PSI와 연계: /proc/pressure/memory 또는 cgroup의 memory.pressure에서 메모리 압박 수준을 모니터링하고, 특정 임계값 초과 시 memory.reclaim을 쓰는 방식으로 자동 proactive reclaim을 구현할 수 있습니다. Meta의 senpai가 대표적인 구현입니다.

ftrace/tracepoint 기반 vmscan 디버깅

vmscan 서브시스템은 풍부한 tracepoint를 제공합니다. trace-cmdperf로 회수 과정을 실시간 추적할 수 있습니다.

주요 tracepoint

Tracepoint발생 시점핵심 필드
mm_vmscan_direct_reclaim_begindirect reclaim 시작order, gfp_flags
mm_vmscan_direct_reclaim_enddirect reclaim 종료nr_reclaimed
mm_vmscan_kswapd_wakekswapd 깨어남nid, order
mm_vmscan_kswapd_sleepkswapd 수면nid
mm_vmscan_lru_isolateLRU에서 페이지 분리nr_scanned, nr_taken, lru
mm_vmscan_lru_shrink_inactiveinactive 리스트 축소nr_reclaimed, nr_scanned, priority
mm_vmscan_lru_shrink_activeactive 리스트 축소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.swappiness600~200DB 서버: 10~30. 컨테이너/범용: 60. zswap 활성화 시: 100~200
vm.min_free_kbytes자동시스템 의존네트워크 서버: 기본의 2~4배. 너무 높이면 OOM 증가
vm.watermark_scale_factor1010~3000direct reclaim 빈번하면 증가 (100~500). kswapd에 더 많은 버퍼 제공
vm.watermark_boost_factor150000~수만THP 사용 시 기본 유지. 불필요하면 0으로 비활성화
vm.vfs_cache_pressure1000~10000파일 서버: 50 (캐시 보호). 메모리 부족: 200 (적극 회수)
vm.zone_reclaim_mode00~7NUMA: 0 (원격 노드 사용). HPC: 1 (로컬 회수 우선)
vm.dirty_ratio200~100SSD: 40~60. HDD: 10~20. 회수 시 writeback 감소
vm.dirty_background_ratio100~100dirty_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_GENy (6.1+)MGLRU 지원 활성화
CONFIG_LRU_GEN_ENABLEDyMGLRU 기본 활성화 (부팅 시)
CONFIG_LRU_GEN_STATSnMGLRU 디버깅 통계 (/sys/kernel/debug/lru_gen)
CONFIG_MEMCGymemcg 지원 (cgroup 메모리 컨트롤러)
CONFIG_SWAPy스왑 지원 (anonymous 페이지 회수에 필수)
CONFIG_ZSWAPm/y스왑 압축 캐시 (스왑 I/O 감소)
CONFIG_NUMA_BALANCINGy (NUMA)NUMA 자동 밸런싱 (페이지 마이그레이션)
CONFIG_MEMORY_TIERy (6.x)메모리 티어링 / demotion 지원
CONFIG_TRANSPARENT_HUGEPAGEyTHP (회수 시 split 관련)
CONFIG_COMPACTIONy메모리 컴팩션 (회수와 상호 보완)
CONFIG_PSIyPressure Stall Information (메모리 압박 모니터링)
CONFIG_PAGE_REPORTINGy가상화(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)
CONFIG_LRU_GEN_STATS 주의: 이 옵션은 디버깅용으로 성능 오버헤드가 있습니다. production 환경에서는 비활성화하세요. 필요 시 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 후 부분 회수매핑된 부분 보호
대부분 매핑됨전체 회수 또는 skipsplit 비용 대비 이득 없음
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);
}
split_queue 모니터링: /proc/vmstatthp_deferred_split_page 카운터로 deferred split 횟수를 확인할 수 있습니다. 이 값이 지속적으로 증가하면 THP 사용 패턴을 점검해야 합니다.

LRU Lock 경합(Contention)과 최적화

회수 경로에서 가장 큰 성능 병목(Bottleneck) 중 하나가 lru_lock 경합입니다. 모든 LRU 조작(추가, 제거, 이동)이 이 스핀락(Spinlock)을 잡아야 하므로, 대규모 메모리 시스템에서 심각한 contention이 발생할 수 있습니다.

LRU Lock 진화

커널 버전LRU Lock 단위개선 효과
~5.10Zone 단위 (zone->lru_lock)하나의 Zone에서 모든 lruvec가 동일 lock 공유
5.11+lruvec 단위 (lruvec->lru_lock)memcg별로 독립적인 lock. 컨테이너 환경에서 큰 개선
5.14+ (folio batch)per-CPU batch + lruvec locklock 획득 빈도 감소 (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
MGLRU의 lock 이점: MGLRU의 에이징(aging) 단계에서는 페이지 테이블 워크를 사용하므로 LRU lock을 잡지 않습니다. Lock이 필요한 것은 축출(eviction) 단계에서 folio를 LRU에서 분리할 때뿐입니다. 이것이 MGLRU가 대규모 시스템에서 확장성이 좋은 핵심 이유입니다.

회수 우선순위(Priority) 메커니즘

회수 서브시스템은 priority 값(12에서 0까지)으로 스캔 강도를 조절합니다. 각 priority에서 LRU 크기의 1/(2^priority)만큼 스캔하며, 목표만큼 회수되지 않으면 priority를 낮춰 더 공격적으로 스캔합니다.

Priority스캔 비율특성
12 (시작)1/4096매우 보수적. 소수 페이지만 확인
101/1024보수적
81/256일반적인 회수
61/64적극적 회수
41/16공격적 회수
21/4매우 공격적
01/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 에스컬레이션 -- 스캔 강도와 정책 변화 12 1/4096 may_writepage=0, may_deactivate=제한 안전 10 1/1024 may_writepage=0 안전 ▼ priority < DEF_PRIORITY-2: may_writepage = 1 ▼ 8 1/256 may_writepage=1 (dirty writeback 시작) 보통 6 1/64 (적극적 회수, I/O 증가) 보통 4 1/16 (공격적 회수 시작) 주의 2 1/4 (매우 공격적) 위험 ▼ priority = 0: DEACTIVATE_ALL ▼ 0 1/1 (전체 LRU) / DEACTIVATE_ALL / may_writepage=1 OOM 직전 에스컬레이션 안전 보통 (writeback 포함) 위험 (공격적, OOM 접근) priority < 0 도달 → OOM Killer 진입
회수 Priority 에스컬레이션. priority 12(보수적)에서 0(전체 스캔)으로 내려갈수록 스캔 범위와 허용 작업이 확대됩니다

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 페이지 강등 가능 */
priority 모니터링: ftrace의 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_controlfor_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 비율 관리
회수 Writeback의 성능 문제: 회수 경로에서의 writeback은 LRU 순서대로 처리되므로 디스크에 random I/O를 유발합니다. 이는 HDD에서 특히 심각한 성능 저하를 일으킵니다. 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 -> PTEO(1) -- 빠름
Anonymous (fork 후)anon_vma 체인 순회O(N) -- fork 수에 비례
File (단일 매핑)address_space -> interval tree -> PTEO(log N)
File (공유 매핑, 다수 프로세스)interval tree 전체 순회O(N) -- 매핑 수에 비례
KSM 페이지stable_tree -> rmap_item 체인O(N) -- 병합 수에 비례
try_to_unmap() 역방향 매핑(Reverse Mapping) 순회 struct folio try_to_unmap(folio, flags) folio_test_anon() anon_vma folio->mapping rb_root (interval) anon_vma_chain linked VMA list avc->vma struct vm_area_struct (각 VMA 순회) vma->vm_mm mm_struct pgd -> pud -> pmd folio_address() PTE (anon) ptep_get_and_clear() Anonymous 페이지 경로 folio_test_file() address_space folio->mapping i_mmap (interval tree) rb_root (i_mmap) vma_interval_tree vma_interval_tree_foreach() struct vm_area_struct (각 VMA 순회) vma->vm_mm mm_struct 파일 offset → 가상 주소 page_vma_mapped_walk() PTE (file) ptep_get_and_clear() File 페이지 경로 PTE 클리어 → Swap Entry 삽입 (anon) 또는 None (file)
try_to_unmap()의 역방향 매핑 순회 경로. Anonymous 페이지는 anon_vma 체인을 통해 VMA 목록을 찾고, File 페이지는 address_space.i_mmap 인터벌 트리를 통해 매핑된 VMA를 탐색합니다. 두 경로 모두 최종적으로 해당 PTE를 클리어하고 anonymous의 경우 스왑 엔트리(swap entry)로 교체합니다
MGLRU와 rmap: MGLRU의 에이징(aging) 단계에서는 프로세스의 페이지 테이블을 정방향으로 워크하므로 rmap이 필요하지 않습니다. rmap은 축출(eviction) 단계에서 try_to_unmap()을 호출할 때만 사용됩니다. 이것이 MGLRU가 공유 매핑이 많은 환경에서 클래식 LRU보다 효율적인 이유입니다.

memcg Low/Min 보호와 회수 우선순위

cgroup v2에서 memory.lowmemory.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.minmemory.low 값과 비교합니다. 사용량이 min 이하이면 해당 cgroup은 회수에서 완전히 제외됩니다. low 이하이면 스캔 비율이 감소되어 다른 cgroup에서 먼저 회수됩니다.

유효 보호(Effective Protection) 계산

memory.low/memory.min의 실제 보호량은 부모 cgroup의 한계와 형제 cgroup의 보호 요청에 따라 조정됩니다.

/* 유효 보호 계산 (간략화) */
/* 부모의 보호 용량이 자식들의 보호 합계보다 적으면 비례 배분 */
effective_low = min(memory_low,
                    parent_effective_low *
                    usage / siblings_usage);
Kubernetes에서의 활용: Kubernetes의 resources.requestsmemory.low에 매핑되고, resources.limitsmemory.max에 매핑됩니다. 이를 통해 보장 QoS(Guaranteed)와 최선 노력 QoS(BestEffort) Pod 간의 메모리 보호가 구현됩니다.

Page Reclaim 진화 역사

커널 버전주요 변화영향
2.4Andrea Arcangeli VM 재작성기본적인 LRU 기반 회수
2.6.28Split LRU (active/inactive x anon/file)anon/file 독립 관리, swappiness 도입
3.15Workingset detection (Johanbes Weiner)Refault distance 기반 워킹셋 보호
4.8Proportional scanning (anon_cost/file_cost)refault 비용 기반 스캔 비율 조정
5.9Proactive compaction사전 예방적 메모리 관리
5.11Per-lruvec lockmemcg별 독립 lru_lock, contention 감소
5.14folio 도입 (Matthew Wilcox)compound page 자연스러운 처리
5.18memory.reclaim cgroup 인터페이스사용자 공간 proactive reclaim
5.18Memory tiering / NUMA demotion회수 대신 slow tier로 강등
6.1MGLRU (Yu Zhao)세대 기반 회수, 에이징 혁신
6.5memory.reclaim swappiness 지원proactive reclaim 세밀 제어
6.7Large 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_controlnr 구조체는 한 회수 라운드에서 만난 페이지 상태를 기록합니다. 이 정보는 다음 라운드의 전략 결정에 사용됩니다. 예를 들어 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;
}
회수와 Compaction의 협력: 고차(order>0) 할당 요청에 의한 회수에서는 "충분한 free 페이지가 확보되어 compaction으로 연속 블록을 만들 수 있는지"를 판단합니다. 모든 페이지를 회수할 필요 없이, compaction이 가능한 수준의 free 페이지만 확보하면 회수를 중단합니다.

페이지 분리(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;
}
NR_ISOLATED 모니터링: /proc/vmstatnr_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);
}
Swap cache의 역할: fork 후 부모/자식이 같은 anonymous 페이지를 공유하는 경우, 한 쪽의 스왑아웃 시 swap cache에 페이지가 남아 있으면 다른 쪽에서 swap-in 없이 접근할 수 있습니다. 또한 스왑아웃 직후 fault가 발생하면 swap cache에서 빠르게 복원됩니다. 자세한 내용은 스왑 서브시스템을 참고하세요.

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
zswap + swappiness 튜닝: zswap이 활성화되면 anonymous 페이지 회수 비용이 크게 줄어듭니다(디스크 I/O 없이 압축만). 따라서 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);
    }
}
THP와 스왑: 현재 커널(6.x)에서 anonymous THP의 스왑아웃은 통째로 이루어집니다. 2MB를 한 번에 스왑하므로 I/O 비용이 크며, SSD에서도 지연이 발생합니다. 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 limitmemory.limit_in_bytesmemory.max
soft limitmemory.soft_limit_in_bytesmemory.high (throttling)
보호없음memory.low / memory.min
proactive reclaim없음memory.reclaim
PSI없음memory.pressure
swappinesscgroup별 설정 가능전역 또는 memory.reclaim으로
OOMcgroup별 OOM계층적 OOM + memory.oom.group
lruvecmemcg별 독립 lruvecmemcg별 독립 lruvec + 계층 순회
soft limit reclaimmem_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으로 대체 */
    ...
}
cgroup v1에서 v2로 마이그레이션: cgroup v1의 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);
        }
    }
}
PSI(Pressure Stall Information) 기반 Proactive Reclaim 피드백 루프 커널 공간 (Kernel Space) 메모리 사용 증가 할당 지연 발생 PSI some/full 상승 /proc/pressure/memory 임계값 초과 PSI 트리거 발화 POLLPRI 이벤트 전달 Proactive Reclaim memory.reclaim 쓰기 PSI avg10 = 0% PSI avg10 > 임계값 poll() 반환 nr_to_reclaim 계산 사용자 공간 에이전트 (oomd / senpai / systemd-oomd) 에이전트 대기 poll() 블록 압박 감지 PSI 수치 읽기 회수량 계산 cgroup 별 정책 적용 회수 성공 free 페이지 증가 PSI 안정 avg10 / avg60 확인 memory.reclaim > 0 PSI some 감소 피드백: PSI 감소 → 에이전트 대기 복귀 예: PSI some avg10 > 10% → 500ms 내 감지 → cgroup당 128MB 회수 → avg10 < 2% → 대기 복귀
PSI 기반 Proactive Reclaim 피드백 루프. 커널이 메모리 압박(PSI some/full)을 계측하고 POLLPRI 이벤트로 사용자 공간 에이전트(oomd, senpai 등)를 깨웁니다. 에이전트는 회수량을 계산하여 memory.reclaim에 쓰고, 회수 성공 후 PSI 수치가 감소하면 다시 대기 상태로 돌아갑니다
실무 구현체: Meta의 oomdsenpai가 PSI 기반 메모리 관리의 대표적인 production 구현체입니다. oomd는 PSI 기반 OOM 결정을, senpai는 PSI 기반 proactive reclaim을 수행합니다. systemd 254+에서는 systemd-oomd로 통합되었습니다.

아키텍처별 회수 차이점

Page Reclaim의 핵심 알고리즘은 아키텍처 독립적이지만, PTE Accessed 비트 확인, TLB flush, cache flush 등에서 아키텍처별 차이가 있습니다.

요소x86_64ARM64RISC-V
PTE Accessed 비트하드웨어 자동 설정하드웨어 자동 (AF bit)하드웨어 자동 (A bit)
PTE Dirty 비트하드웨어 자동 설정하드웨어 자동 (DBM)하드웨어 자동 (D bit)
TLB flush 범위INVLPG (단일 페이지)TLBI VAE1IS (Inner Shareable)SFENCE.VMA
Batch TLB flushINVLPGB (AMD) / IPITLBI VALE1IS (range)SFENCE.VMA (asid)
MGLRU PTE 워크일반 PTE 워크FEAT_AF 활용Svadu 확장 (있으면)
Young 비트 클리어 비용낮음 (atomic PTE 업데이트)낮음 (non-shareable)가변 (Svadu 유무)
대형 페이지 회수2MB/1GB PTE split2MB/64KB (contiguous) split2MB (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;
}
RISC-V Svadu: RISC-V의 Svadu(Supervisor Virtual Address translation and protection Update) 확장이 있으면 하드웨어가 자동으로 A/D 비트를 업데이트합니다. 없는 CPU에서는 소프트웨어 에뮬레이션이 필요하여 MGLRU 에이징 성능에 영향을 줄 수 있습니다.

하이버네이션과 회수

시스템 하이버네이션(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 OvercommitVM 합계 > 물리 메모리호스트/게스트 이중 회수 위험

이중 회수(Double Reclaim) 문제

이중 회수 위험: 호스트가 메모리를 회수하면 게스트의 물리 메모리가 줄어들어 게스트도 회수를 시작합니다. 양쪽에서 동시에 회수가 진행되면 성능이 급격히 저하됩니다. 이를 방지하려면 1) VM에 적절한 메모리 보장량 설정, 2) 호스트에서 VM 프로세스를 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_kswapdkswapd가 스캔한 페이지 수
pgscan_directdirect reclaim이 스캔한 페이지 수
pgscan_khugepagedkhugepaged가 스캔한 수
pgscan_direct_throttledirect reclaim이 쓰로틀된 횟수
회수pgsteal_kswapdkswapd가 회수한 페이지 수
pgsteal_directdirect reclaim이 회수한 페이지 수
pgsteal_anon회수된 anonymous 페이지 수 (6.x)
pgsteal_file회수된 file 페이지 수 (6.x)
LRU 이동pgactivateinactive -> active 승격 수
pgdeactivateactive -> inactive 강등 수
pgrefillactive 리스트 스캔(참조 비트 클리어) 수
워킹셋workingset_refault_anonanon 워킹셋 refault 수
workingset_refault_filefile 워킹셋 refault 수
workingset_activate_anonanon refault 후 activate 수
workingset_activate_filefile refault 후 activate 수
쓰로틀nr_writeback_throttledwriteback 대기 쓰로틀 수
pgscan_direct_throttledirect 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
Grafana 대시보드 구성: /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 감지까지 전체 경로를 이해하면 회수 동작을 직관적으로 파악할 수 있습니다.

페이지 할당 __alloc_pages() folio_add_lru() Inactive LRU 비활성 리스트 접근 감지 folio_mark_accessed() Active LRU 워킹셋 (활성) 지속 접근 Working Set 보호 영역 deactivate (메모리 압박) 에이징 isolate_lru_folios() LRU에서 임시 분리 → folio_list로 이동 shrink_folio_list() — 최종 결정 folio별 운명 결정: free / writeback / swap / re-activate clean file 즉시 해제 free_pages_and_ swap_cache() dirty file Writeback pageout() 호출 → 다음 회수 때 free anon Swap Out add_to_swap() try_to_unmap() → 스왑 referenced 재활성화 folio_activate() Second Chance Shadow Entry workingset_eviction() 재접근 Refault 감지 workingset_refault() 호출 distance 짧음 즉시 활성화 workingset_activate() MGLRU 경로 (CONFIG_LRU_GEN=y 시) Gen N (신규) Gen N-1 Gen N-2 Gen 0 (최고령) 축출 (Evict) 에이징: PTE Accessed 비트 클리어 → Tier/Generation 업데이트 → min_seq 증가 클래식 LRU와 달리 per-CPU batch 없이 세대 카운터만 원자적 업데이트 활성화/보호 회수/스왑 Shadow/Refault writeback/대기 재활성화
Folio 생애주기 종합. 할당 → Inactive LRU → 접근 시 Active LRU → 에이징 후 isolate → shrink_folio_list() 결정 → (clean: 즉시 해제 / dirty: writeback / anon: 스왑 / referenced: 재활성화). 해제된 folio의 Shadow Entry는 재접근(Refault) 시 워킹셋으로 즉시 활성화됩니다. MGLRU 경로에서는 세대 카운터 기반으로 에이징이 이루어집니다
shrink_folio_list() 주요 결정 분기점 요약
조건 (우선순위 순)결과관련 함수다음 상태
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 Outswap_writepage()스왑 슬롯 할당 후 해제
Clean file 페이지 (unmapped)즉시 해제free_pages_and_swap_cache()Free 페이지 → Buddy

참고자료

커널 문서

LWN 기사

커널 소스

커널 소스 핵심 파일:
  • 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_unmap
  • mm/page_io.c -- 스왑 I/O, zswap 연동
  • include/linux/mmzone.h -- lruvec, zone, watermark, lru_gen_folio 구조체
  • include/linux/mm_types.h -- folio/page 구조체의 LRU 관련 필드
추천 학습 순서:
  1. 메모리 관리 개요로 전체 구조 파악
  2. 페이지 할당자에서 워터마크와 할당 실패 경로 이해
  3. 본 문서(vmscan)에서 회수 경로 상세 학습
  4. 스왑 서브시스템에서 anonymous 페이지 스왑아웃 과정 확인
  5. Memory Compaction에서 회수 후 연속 블록 확보 이해
  6. OOM Killer에서 회수 실패 시 최후 처리 확인