관련 소스:mm/vmscan.c,mm/swap.c,mm/page_alloc.c,mm/shrinker.c,include/linux/mmzone.h
관련 문서: Swap / zswap · OOM Killer · 메모리 Compaction · Folio / Page Cache · SLUB 할당자 · DAMON · 메모리 관리 개요
페이지 회수(Page Reclaim)는 시스템의 가용 메모리가 부족해질 때 커널이 LRU(Least Recently Used) 리스트에서 적극적으로 페이지를 선택하여 해제하거나 스왑으로 내보내는 메커니즘입니다. Linux는 크게 두 가지 경로로 동작합니다: kswapd는 백그라운드에서 watermark 복구를 위해 주기적으로 회수를 수행하고, direct reclaim은 할당 시점에 프로세스가 직접 회수를 수행합니다. 핵심 소스 파일은 mm/vmscan.c이며, LRU 리스트 관리 보조 코드는 mm/swap.c에 위치합니다.
Linux 6.1+에서는 MGLRU(Multi-Gen LRU)가 기본 활성화되어 기존의 active/inactive 2-list 구조를 개선한 세대(generation) 기반 회수로 전환되었습니다. scan_control 구조체가 회수 세션의 모든 파라미터를 담당하며, shrink_lruvec() → shrink_inactive_list() → shrink_folio_list() 체인을 통해 실제 페이지 해제가 이루어집니다.
일상 비유로 보면 페이지 회수는 도서관에서 오래 빌려가지 않은 책부터 서가에서 빼는 작업과 비슷합니다. 파일 기반 페이지는 원본 파일이라는 서고가 있어 필요하면 다시 가져오면 되지만, 익명 페이지는 프로세스의 작업 메모리라서 스왑이라는 창고에 보관한 뒤 비워야 합니다. kswapd는 서가가 비기 전에 미리 정리하는 직원이고, direct reclaim은 방문자가 새 책을 꽂으려는 순간 직접 자리를 만드는 상황입니다.
소스 파일 경로:
mm/vmscan.c — 핵심 회수 로직 (scan_control, shrink_*, kswapd)
mm/swap.c — LRU 리스트 관리 (lru_add, lru_activate, folio_batch)
mm/page_alloc.c — direct reclaim 진입 전후의 할당 slowpath
mm/shrinker.c — shrink_slab()과 shrinker 콜백 실행
mm/swap_state.c — swap-in/out, 페이지 캐시
mm/page_writeback.c — dirty 페이지 writeback 관리
include/linux/swap.h — swap 관련 주요 선언
include/linux/mmzone.h — lruvec, zone 정의
# 1. 시스템 메모리 회수 관련 vmstat 카운터
cat /proc/vmstat | grep -E 'pgscan|pgsteal|pgscan_direct|pgscan_kswapd|pgsteal_direct|pgsteal_kswapd|pgrefill|pgactivate|pgdeactivate|allocstall|pgfault|pgmajfault'
# 2. PSI(Pressure Stall Information) — 메모리 압력 실시간 관찰
cat /proc/pressure/memory
# some avg10=0.00 avg60=0.00 avg300=0.00 total=0
# full avg10=0.00 avg60=0.00 avg300=0.00 total=0
# 3. zone별 회수 가능 페이지 수 확인
cat /proc/zoneinfo | grep -E 'Node|zone|min|low|high|managed|nr_free|inactive|active|scanned|steal|refill|activate|deactivate' | head -40
# 4. memcg별 회수 통계
cat /proc/meminfo | grep -E 'AnonPages|Cached|SwapTotal|SwapFree|Dirty|Writeback|Inactive|Active|Slab|SReclaimable|SUnreclaim|Shmem'
# 5. swappiness 설정값 확인 (0~200)
cat /proc/sys/vm/swappiness
# 6. kswapd 활성 상태 관찰
ps aux | grep kswapd
# 7. 워터마크 관련 설정
cat /proc/sys/vm/min_free_kbytes
cat /proc/sys/vm/watermark_boost_factor
cat /proc/sys/vm/watermark_scale_factor
# 8. MGLRU 활성화 상태와 최소 TTL 설정 확인
cat /sys/kernel/mm/lru_gen/enabled 2>/dev/null || true
cat /sys/kernel/mm/lru_gen/min_ttl_ms 2>/dev/null || true
# 9. 회수 효율: 스캔 대비 회수량 비교
cat /proc/vmstat | grep -E 'pgscan|pgsteal|pgscan_skip|pgrefill|workingset'
# 10. swap, zswap, 메모리 티어링 관련 카운터
cat /proc/vmstat | grep -E 'pswpin|pswpout|zswp|pgdemote|pgpromote'
# 11. cgroup v2 메모리 이벤트와 OOM/pressure 확인
grep -R . /sys/fs/cgroup/*/memory.events 2>/dev/null | head -20
# 12. direct reclaim 지연을 시간 축으로 관찰
vmstat 1 10
vmscan.c:74-182에 정의된 scan_control은 하나의 회수 세션(reclaim session)이 사용하는 모든 파라미터와 상태를 관리합니다. try_to_free_pages()에서 초기화되고, do_try_to_free_pages()의 priority 루프 동안 공유됩니다.
/* mm/vmscan.c:74-182 */
struct scan_control {
/* shrink_list()가 회수해야 할 페이지 수 */
unsigned long nr_to_reclaim;
/*
* 호출자가 허용한 노드의 노드마스크. NULL이면 모든 노드를
* 스캔합니다.
*/
nodemask_t *nodemask;
/*
* 한도에 도달한 메모리 컨트롤러. 따라서 이 회수 호출의
* 주요 대상이 됩니다.
*/
struct mem_cgroup *target_mem_cgroup;
/*
* 익명/파일 LRU 간 스캔 압력 균형
*/
unsigned long anon_cost;
unsigned long file_cost;
/* 능동적 회수를 위한 swappiness 값. 항상 sc_swappiness()를 사용하세요! */
int *proactive_swappiness;
/* 회수 과정에서 활성 folio를 비활성화할 수 있는지? */
#define DEACTIVATE_ANON 1
#define DEACTIVATE_FILE 2
unsigned int may_deactivate:2;
unsigned int force_deactivate:1;
unsigned int skipped_deactivate:1;
/* zone_reclaim_mode, 회수 부스팅 */
unsigned int may_writepage:1;
/* zone_reclaim_mode */
unsigned int may_unmap:1;
/* zone_reclaim_mode, 회수 부스팅, 컨트롤러 제약 */
unsigned int may_swap:1;
/* 회수 과정에서 cache_trim_mode 활성화를 허용하지 않을지? */
unsigned int no_cache_trim_mode:1;
/* cache_trim_mode가 최소 한 번 실패했는지? */
unsigned int cache_trim_mode_failed:1;
/* 사용자 공간에서 호출한 능동적 회수 */
unsigned int proactive:1;
/*
* OOM 위협이 없는 한 memory.low 이하의 컨트롤러 메모리는 보호됩니다.
* 컨트롤러가 memory.low 설정으로 인해 축소된 힘으로 회수되거나
* 완전히 건너뛰어지고 아무것도 회수되지 않으면, OOM을 피하기 위해
* 보호된 메모리를 회수하는 한 사이클을 더 수행합니다.
*/
unsigned int memcg_low_reclaim:1;
unsigned int memcg_low_skipped:1;
/* 공유 컨트롤러 트리 순회 실패, 전체 트리 재스캔 */
unsigned int memcg_full_walk:1;
unsigned int hibernation_mode:1;
/* 하나의 존이 컴팩션 준비 완료 */
unsigned int compaction_ready:1;
/* 현재 노드에 쉽게 회수 가능한 냉장 캐시가 있음 */
unsigned int cache_trim_mode:1;
/* 현재 노드의 파일 folio가 위험할 정도로 적음 */
unsigned int file_is_tiny:1;
/* 하위 티어 메모리로 하향 대신 항상 폐기 */
unsigned int no_demotion:1;
/* 할당 순서 */
s8 order;
/* (total_size >> priority)페이지를 한 번에 스캔 */
s8 priority;
/* 회수를 위해 folio를 격리할 최대 존 */
s8 reclaim_idx;
/* 이 컨텍스트의 GFP 마스크 */
gfp_t gfp_mask;
/* 스캔된 비활성 페이지 수만큼 증가 */
unsigned long nr_scanned;
/* shrink_zones() 호출 중 지금까지 해제된 페이지 수 */
unsigned long nr_reclaimed;
struct {
unsigned int dirty;
unsigned int unqueued_dirty;
unsigned int congested;
unsigned int writeback;
unsigned int immediate;
unsigned int file_taken;
unsigned int taken;
} nr;
/* for recording the reclaimed slab by now */
struct reclaim_state reclaim_state;
};
swap.c:50-66에서 정의된 이 구조체는 LRU 리스트 작업을 per-CPU 로컬 배치로 묶어 락 경합을 줄입니다.
/* mm/swap.c:50-66 */
struct cpu_fbatches {
/*
* The following folio batches are grouped together because they are protected
* by disabling preemption (and interrupts remain enabled).
*/
local_lock_t lock;
struct folio_batch lru_add;
struct folio_batch lru_deactivate_file;
struct folio_batch lru_deactivate;
struct folio_batch lru_lazyfree;
#ifdef CONFIG_SMP
struct folio_batch lru_activate;
#endif
/* 인터럽트 비활성화가 필요한 배치 보호 */
local_lock_t lock_irq;
struct folio_batch lru_move_tail;
};
lruvec
├── lists[LRU_INACTIVE_ANON] ← 비활성 익명 페이지
├── lists[LRU_ACTIVE_ANON] ← 활성 익명 페이지
├── lists[LRU_INACTIVE_FILE] ← 비활성 파일 기반 페이지
├── lists[LRU_ACTIVE_FILE] ← 활성 파일 기반 페이지
├── anon_cost ← 익명 LRU 스캔 비용
├── file_cost ← 파일 LRU 스캔 비용
└── lrugen (MGLRU) ← 세대 기반 회수용 구조
├── max_seq ← 최신 세대 번호
├── min_seq[ANON_AND_FILE] ← 각 타입별 최소 세대
├── nr_pages[gen][type][zone]
└── timestamps[gen]
Linux 7.0의 LRU 리스트 열거값은 include/linux/mmzone.h:316-345에 있으며, 회수 대상 네 리스트와 LRU_UNEVICTABLE을 분리합니다.
/* include/linux/mmzone.h:316-345 */
enum lru_list {
LRU_INACTIVE_ANON = LRU_BASE,
LRU_ACTIVE_ANON = LRU_BASE + LRU_ACTIVE,
LRU_INACTIVE_FILE = LRU_BASE + LRU_FILE,
LRU_ACTIVE_FILE = LRU_BASE + LRU_FILE + LRU_ACTIVE,
LRU_UNEVICTABLE,
NR_LRU_LISTS
};
enum vmscan_throttle_state {
VMSCAN_THROTTLE_WRITEBACK,
VMSCAN_THROTTLE_ISOLATED,
VMSCAN_THROTTLE_NOPROGRESS,
VMSCAN_THROTTLE_CONGESTED,
NR_VMSCAN_THROTTLE,
};
#define for_each_lru(lru) for (lru = 0; lru < NR_LRU_LISTS; lru++)
#define for_each_evictable_lru(lru) for (lru = 0; lru <= LRU_ACTIVE_FILE; lru++)
static inline bool is_file_lru(enum lru_list lru)
{
return (lru == LRU_INACTIVE_FILE || lru == LRU_ACTIVE_FILE);
}
static inline bool is_active_lru(enum lru_list lru)
{
return (lru == LRU_ACTIVE_ANON || lru == LRU_ACTIVE_FILE);
}
struct lruvec는 memcg와 NUMA node 조합별 LRU 단위입니다. 전통 LRU의 lists[], anon/file 회수 비용, refault 기록과 MGLRU의 lrugen이 같은 구조체 안에서 만납니다.
/* include/linux/mmzone.h:669-698 */
struct lruvec {
struct list_head lists[NR_LRU_LISTS];
/* per lruvec lru_lock for memcg */
spinlock_t lru_lock;
/*
* These track the cost of reclaiming one LRU - file or anon -
* over the other. As the observed cost of reclaiming one LRU
* increases, the reclaim scan balance tips toward the other.
*/
unsigned long anon_cost;
unsigned long file_cost;
/* LRU 움직임에 의해 구동되는 비거주 페이지 나이 */
atomic_long_t nonresident_age;
/* 마지막 회수 주기 시점의 재폴트 수 */
unsigned long refaults[ANON_AND_FILE];
/* 다양한 lruvec 상태 플래그 (enum lruvec_flags) */
unsigned long flags;
#ifdef CONFIG_LRU_GEN
/* 세대로 분할된 회수 가능 페이지 */
struct lru_gen_folio lrugen;
#ifdef CONFIG_LRU_GEN_WALKS_MMU
/* lru_gen_mm_list를 동시 순회하기 위해 */
struct lru_gen_mm_state mm_state;
#endif
#endif /* CONFIG_LRU_GEN */
#ifdef CONFIG_MEMCG
struct pglist_data *pgdat;
#endif
struct zswap_lruvec_state zswap_lruvec_state;
};
/* mm/vmscan.c:6566 */
unsigned long try_to_free_pages(struct zonelist *zonelist, int order,
gfp_t gfp_mask, nodemask_t *nodemask)
scan_control을 SWAP_CLUSTER_MAX 회수 목표, DEF_PRIORITY 스캔 우선순위로 설정throttle_direct_reclaim() → do_try_to_free_pages() → 반환try_to_free_pages()는 할당자의 GFP 컨텍스트를 current_gfp_context()로 정리하고, may_writepage, may_unmap, may_swap을 모두 허용한 상태로 시작합니다. throttle_direct_reclaim()이 fatal signal을 감지하면 OOM 판단으로 이어지지 않도록 1을 반환합니다.
/* mm/vmscan.c:6566-6607 */
unsigned long try_to_free_pages(struct zonelist *zonelist, int order,
gfp_t gfp_mask, nodemask_t *nodemask)
{
unsigned long nr_reclaimed;
struct scan_control sc = {
.nr_to_reclaim = SWAP_CLUSTER_MAX,
.gfp_mask = current_gfp_context(gfp_mask),
.reclaim_idx = gfp_zone(gfp_mask),
.order = order,
.nodemask = nodemask,
.priority = DEF_PRIORITY,
.may_writepage = 1,
.may_unmap = 1,
.may_swap = 1,
};
/*
* scan_control uses s8 fields for order, priority, and reclaim_idx.
* Confirm they are large enough for max values.
*/
BUILD_BUG_ON(MAX_PAGE_ORDER >= S8_MAX);
BUILD_BUG_ON(DEF_PRIORITY > S8_MAX);
BUILD_BUG_ON(MAX_NR_ZONES > S8_MAX);
/*
* Do not enter reclaim if fatal signal was delivered while throttled.
* 1 is returned so that the page allocator does not OOM kill at this
* point.
*/
if (throttle_direct_reclaim(sc.gfp_mask, zonelist, nodemask))
return 1;
set_task_reclaim_state(current, &sc.reclaim_state);
trace_mm_vmscan_direct_reclaim_begin(order, sc.gfp_mask);
nr_reclaimed = do_try_to_free_pages(zonelist, &sc);
trace_mm_vmscan_direct_reclaim_end(nr_reclaimed);
set_task_reclaim_state(current, NULL);
return nr_reclaimed;
}
/* mm/vmscan.c:6344 */
static unsigned long do_try_to_free_pages(struct zonelist *zonelist,
struct scan_control *sc)
shrink_zones()를 반복 호출sc->priority는 DEF_PRIORITY(12)에서 시작하여 매 루프마다 감소 - priority가 높으면 소수의 페이지만 스캔 (빠르게 종료)
- priority가 낮으면 전체 LRU를 깊이 스캔 (많은 회수)
- memcg_full_walk: memcg 전체 순회 실패 시 재시도
- skipped_deactivate: 비활성화 건너뛴 경우 강제 비활성화로 재시도
- memcg_low_skipped: memory.low 보호 건너긴 경우 재시도
priority 루프는 회수 목표를 채우거나 compaction 준비가 끝날 때 멈춥니다. 아무 페이지도 회수하지 못하면 memcg partial walk, active list 강제 비활성화, memory.low 보호 해제를 차례로 재시도합니다.
/* mm/vmscan.c:6351-6438 */
retry:
delayacct_freepages_start();
if (!cgroup_reclaim(sc))
__count_zid_vm_events(ALLOCSTALL, sc->reclaim_idx, 1);
do {
if (!sc->proactive)
vmpressure_prio(sc->gfp_mask, sc->target_mem_cgroup,
sc->priority);
sc->nr_scanned = 0;
shrink_zones(zonelist, sc);
if (sc->nr_reclaimed >= sc->nr_to_reclaim)
break;
if (sc->compaction_ready)
break;
} while (--sc->priority >= 0);
last_pgdat = NULL;
for_each_zone_zonelist_nodemask(zone, z, zonelist, sc->reclaim_idx,
sc->nodemask) {
if (zone->zone_pgdat == last_pgdat)
continue;
last_pgdat = zone->zone_pgdat;
snapshot_refaults(sc->target_mem_cgroup, zone->zone_pgdat);
if (cgroup_reclaim(sc)) {
struct lruvec *lruvec;
lruvec = mem_cgroup_lruvec(sc->target_mem_cgroup,
zone->zone_pgdat);
clear_bit(LRUVEC_CGROUP_CONGESTED, &lruvec->flags);
}
}
delayacct_freepages_end();
if (sc->nr_reclaimed)
return sc->nr_reclaimed;
/* 컴팩션 시도를 위해 회수를 중단했는지? OOM하지 않음 */
if (sc->compaction_ready)
return 1;
if (!sc->memcg_full_walk) {
sc->priority = initial_priority;
sc->memcg_full_walk = 1;
goto retry;
}
if (sc->skipped_deactivate) {
sc->priority = initial_priority;
sc->force_deactivate = 1;
sc->skipped_deactivate = 0;
goto retry;
}
/* 미사용 컨트롤러 예약이 있는지? OOM하지 말고 재시도 */
if (sc->memcg_low_skipped) {
sc->priority = initial_priority;
sc->force_deactivate = 0;
sc->memcg_low_reclaim = 1;
sc->memcg_low_skipped = 0;
goto retry;
}
return 0;
/* mm/vmscan.c:6039 */
static void shrink_node(pg_data_t *pgdat, struct scan_control *sc)
lru_gen_enabled() && root_reclaim(sc)이면 lru_gen_shrink_node()로 전환prepare_scan_control() → shrink_node_memcgs() → flush_reclaim_state()/* mm/vmscan.c:5772 */
static void shrink_lruvec(struct lruvec *lruvec, struct scan_control *sc)
lru_gen_enabled() && !root_reclaim(sc)이면 lru_gen_shrink_lruvec()로 전환get_scan_count()에서 anon/file 비율을 swappiness, cost, 메모리 구성을 기반으로 계산proportional_reclaim 플래그로 anon/file 스캔 비율을 균형있게 유지shrink_lruvec()의 내부 루프는 get_scan_count()가 채운 배열을 SWAP_CLUSTER_MAX 단위로 쪼개 shrink_list()에 넘깁니다. active LRU는 조건이 맞을 때 먼저 inactive로 내리고, inactive LRU만 shrink_inactive_list()에서 회수 대상이 됩니다.
/* mm/vmscan.c:2249-2261 */
static unsigned long shrink_list(enum lru_list lru, unsigned long nr_to_scan,
struct lruvec *lruvec, struct scan_control *sc)
{
if (is_active_lru(lru)) {
if (sc->may_deactivate & (1 << is_file_lru(lru)))
shrink_active_list(nr_to_scan, lruvec, sc, lru);
else
sc->skipped_deactivate = 1;
return 0;
}
return shrink_inactive_list(nr_to_scan, lruvec, sc, lru);
}
/* mm/vmscan.c:1977 */
static unsigned long shrink_inactive_list(unsigned long nr_to_scan,
struct lruvec *lruvec, struct scan_control *sc,
enum lru_list lru)
shrink_folio_list()로 실제 회수 수행 1. too_many_isolated() 체크 → 과도 격리 시 throttle
2. lru_add_drain() → per-CPU 배치 비우기
3. isolate_lru_folios() → LRU에서 folio 격리
4. shrink_folio_list() → folio별로 lock/dirty/writeback/references 검사 후 회수
5. move_folios_to_lru() → 미회수 folio를 LRU로 복귀
6. lru_note_cost_unlock_irq() → anon/file cost 갱신
격리된 folio 수는 NR_ISOLATED_ANON/FILE, PGSCAN_, PGSTEAL_ 카운터에 반영됩니다. dirty folio가 모두 I/O 큐에 올라가지 못한 상태라면 flusher를 깨우고, 오래된 cgroup v1 writeback에서는 throttle을 걸어 과도한 OOM 진입을 피합니다.
/* mm/vmscan.c:1991-2039 */
while (unlikely(too_many_isolated(pgdat, file, sc))) {
if (stalled)
return 0;
/* wait a bit for the reclaimer. */
stalled = true;
reclaim_throttle(pgdat, VMSCAN_THROTTLE_ISOLATED);
/* 곧 종료되어 메모리를 해제할 예정. 지금 즉시 반환 */
if (fatal_signal_pending(current))
return SWAP_CLUSTER_MAX;
}
lru_add_drain();
spin_lock_irq(&lruvec->lru_lock);
nr_taken = isolate_lru_folios(nr_to_scan, lruvec, &folio_list,
&nr_scanned, sc, lru);
__mod_node_page_state(pgdat, NR_ISOLATED_ANON + file, nr_taken);
item = PGSCAN_KSWAPD + reclaimer_offset(sc);
if (!cgroup_reclaim(sc))
__count_vm_events(item, nr_scanned);
count_memcg_events(lruvec_memcg(lruvec), item, nr_scanned);
__count_vm_events(PGSCAN_ANON + file, nr_scanned);
spin_unlock_irq(&lruvec->lru_lock);
if (nr_taken == 0)
return 0;
nr_reclaimed = shrink_folio_list(&folio_list, pgdat, sc, &stat, false,
lruvec_memcg(lruvec));
spin_lock_irq(&lruvec->lru_lock);
move_folios_to_lru(lruvec, &folio_list);
mod_lruvec_state(lruvec, PGDEMOTE_KSWAPD + reclaimer_offset(sc),
stat.nr_demoted);
__mod_node_page_state(pgdat, NR_ISOLATED_ANON + file, -nr_taken);
item = PGSTEAL_KSWAPD + reclaimer_offset(sc);
if (!cgroup_reclaim(sc))
__count_vm_events(item, nr_reclaimed);
count_memcg_events(lruvec_memcg(lruvec), item, nr_reclaimed);
__count_vm_events(PGSTEAL_ANON + file, nr_reclaimed);
lru_note_cost_unlock_irq(lruvec, file, stat.nr_pageout,
nr_scanned - nr_reclaimed);
/* mm/vmscan.c:1083 */
static unsigned int shrink_folio_list(struct list_head *folio_list,
struct pglist_data *pgdat, struct scan_control *sc,
struct reclaim_stat *stat, bool ignore_references,
struct mem_cgroup *memcg)
- folio_trylock() 실패 → keep
- writeback 중 + kswapd + PGDAT_WRITEBACK → nr_immediate 카운트, activate
- writeback 중 + legacy memcg → writeback 완료 대기
- folio_check_references() → ACTIVATE / KEEP / RECLAIM 분기
- 익명 페이지 + swap 가능 → folio_alloc_swap() → pageout 또는 activate
- 파일 기반 + clean → __remove_mapping() 으로 즉시 회수
- dirty → pageout()으로 writeback 시작 후 회수 시도
익명 folio가 swapbacked이고 아직 swapcache가 아니면 먼저 swap 슬롯을 확보합니다. 큰 folio는 그대로 swap 슬롯을 잡지 못할 때 분할 후 다시 시도하며, 실패하면 active LRU로 되돌립니다.
/* mm/vmscan.c:1281-1338 */
/*
* Anonymous process memory has backing store?
* Try to allocate it some swap space here.
* Lazyfree folio could be freed directly
*/
if (folio_test_anon(folio) && folio_test_swapbacked(folio) &&
!folio_test_swapcache(folio)) {
if (!(sc->gfp_mask & __GFP_IO))
goto keep_locked;
if (folio_maybe_dma_pinned(folio))
goto keep_locked;
if (folio_test_large(folio)) {
/* cannot split folio, skip it */
if (folio_expected_ref_count(folio) !=
folio_ref_count(folio) - 1)
goto activate_locked;
/*
* Split partially mapped folios right away.
* We can free the unmapped pages without IO.
*/
if (data_race(!list_empty(&folio->_deferred_list) &&
folio_test_partially_mapped(folio)) &&
split_folio_to_list(folio, folio_list))
goto activate_locked;
}
if (folio_alloc_swap(folio)) {
int __maybe_unused order = folio_order(folio);
if (!folio_test_large(folio))
goto activate_locked_split;
/* 일반 페이지로 스왑 대체 */
if (split_folio_to_list(folio, folio_list))
goto activate_locked;
#ifdef CONFIG_TRANSPARENT_HUGEPAGE
if (nr_pages >= HPAGE_PMD_NR) {
count_memcg_folio_events(folio,
THP_SWPOUT_FALLBACK, 1);
count_vm_event(THP_SWPOUT_FALLBACK);
}
#endif
count_mthp_stat(order, MTHP_STAT_SWPOUT_FALLBACK);
if (folio_alloc_swap(folio))
goto activate_locked_split;
}
/*
* Normally the folio will be dirtied in unmap because
* its pte should be dirty. A special case is MADV_FREE
* page. The page's pte could have dirty bit cleared but
* the folio's SwapBacked flag is still set because
* clearing the dirty bit and SwapBacked flag has no
* lock protected. For such folio, unmap will not set
* dirty bit for it, so folio reclaim will not write the
* folio out. This can cause data corruption when the
* folio is swapped in later. Always setting the dirty
* flag for the folio solves the problem.
*/
folio_mark_dirty(folio);
}
dirty folio는 pageout()으로 writeback을 시작합니다. clean 상태가 되거나 이미 clean인 파일 folio는 buffer release 후 __remove_mapping()으로 address_space에서 떼어내고 free_unref_folios() 배치로 반환합니다.
/* mm/vmscan.c:1395-1464 */
mapping = folio_mapping(folio);
if (folio_test_dirty(folio)) {
if (folio_is_file_lru(folio)) {
/*
* Immediately reclaim when written back.
* Similar in principle to folio_deactivate()
* except we already have the folio isolated
* and know it's dirty
*/
node_stat_mod_folio(folio, NR_VMSCAN_IMMEDIATE,
nr_pages);
if (!folio_test_reclaim(folio))
folio_set_reclaim(folio);
goto activate_locked;
}
if (references == FOLIOREF_RECLAIM_CLEAN)
goto keep_locked;
if (!may_enter_fs(folio, sc->gfp_mask))
goto keep_locked;
if (!sc->may_writepage)
goto keep_locked;
/*
* Folio is dirty. Flush the TLB if a writable entry
* potentially exists to avoid CPU writes after I/O
* starts and then write it out here.
*/
try_to_unmap_flush_dirty();
switch (pageout(folio, mapping, &plug, folio_list)) {
case PAGE_KEEP:
goto keep_locked;
case PAGE_ACTIVATE:
/*
* If shmem folio is split when writeback to swap,
* the tail pages will make their own pass through
* this function and be accounted then.
*/
if (nr_pages > 1 && !folio_test_large(folio)) {
sc->nr_scanned -= (nr_pages - 1);
nr_pages = 1;
}
goto activate_locked;
case PAGE_SUCCESS:
if (nr_pages > 1 && !folio_test_large(folio)) {
sc->nr_scanned -= (nr_pages - 1);
nr_pages = 1;
}
stat->nr_pageout += nr_pages;
if (folio_test_writeback(folio))
goto keep;
if (folio_test_dirty(folio))
goto keep;
/*
* A synchronous write - probably a ramdisk. Go
* ahead and try to reclaim the folio.
*/
if (!folio_trylock(folio))
goto keep;
if (folio_test_dirty(folio) ||
folio_test_writeback(folio))
goto keep_locked;
mapping = folio_mapping(folio);
fallthrough;
case PAGE_CLEAN:
; /* try to free the folio below */
}
}
/* mm/vmscan.c:1511-1543 */
if (folio_test_anon(folio) && !folio_test_swapbacked(folio)) {
/* follow __remove_mapping for reference */
if (!folio_ref_freeze(folio, 1))
goto keep_locked;
/*
* The folio has only one reference left, which is
* from the isolation. After the caller puts the
* folio back on the lru and drops the reference, the
* folio will be freed anyway. It doesn't matter
* which lru it goes on. So we don't bother checking
* the dirty flag here.
*/
count_vm_events(PGLAZYFREED, nr_pages);
count_memcg_folio_events(folio, PGLAZYFREED, nr_pages);
} else if (!mapping || !__remove_mapping(mapping, folio, true,
sc->target_mem_cgroup))
goto keep_locked;
folio_unlock(folio);
free_it:
/*
* Folio may get swapped out as a whole, need to account
* all pages in it.
*/
nr_reclaimed += nr_pages;
folio_unqueue_deferred_split(folio);
if (folio_batch_add(&free_folios, folio) == 0) {
mem_cgroup_uncharge_folios(&free_folios);
try_to_unmap_flush();
free_unref_folios(&free_folios);
}
continue;
/* mm/vmscan.c:7280 */
static int kswapd(void *p)
kswapd_try_to_sleep() → balance_pgdat() → 반복prepare_kswapd_sleep()가 모든 존의 free 페이지가 high watermark를 초과할 때 true 반환shrink_node() 호출, compaction 필요 시 kcompactd 깨움kswapd()는 PF_MEMALLOC | PF_KSWAPD를 설정해 회수 중 재귀적인 일반 할당 경로에 갇히지 않도록 합니다. wakeup 쪽에서 전달한 order와 zone index를 읽고, balance_pgdat()으로 node 전체를 high watermark 쪽으로 되돌립니다.
/* mm/vmscan.c:7299-7346 */
tsk->flags |= PF_MEMALLOC | PF_KSWAPD;
set_freezable();
WRITE_ONCE(pgdat->kswapd_order, 0);
WRITE_ONCE(pgdat->kswapd_highest_zoneidx, MAX_NR_ZONES);
atomic_set(&pgdat->nr_writeback_throttled, 0);
for ( ; ; ) {
bool was_frozen;
alloc_order = reclaim_order = READ_ONCE(pgdat->kswapd_order);
highest_zoneidx = kswapd_highest_zoneidx(pgdat,
highest_zoneidx);
kswapd_try_sleep:
kswapd_try_to_sleep(pgdat, alloc_order, reclaim_order,
highest_zoneidx);
/* 새로운 order와 highest_zoneidx 읽기 */
alloc_order = READ_ONCE(pgdat->kswapd_order);
highest_zoneidx = kswapd_highest_zoneidx(pgdat,
highest_zoneidx);
WRITE_ONCE(pgdat->kswapd_order, 0);
WRITE_ONCE(pgdat->kswapd_highest_zoneidx, MAX_NR_ZONES);
if (kthread_freezable_should_stop(&was_frozen))
break;
/*
* We can speed up thawing tasks if we don't call balance_pgdat
* after returning from the refrigerator
*/
if (was_frozen)
continue;
trace_mm_vmscan_kswapd_wake(pgdat->node_id, highest_zoneidx,
alloc_order);
reclaim_order = balance_pgdat(pgdat, alloc_order,
highest_zoneidx);
if (reclaim_order < alloc_order)
goto kswapd_try_sleep;
}
/* mm/vmscan.c:2527 */
static void get_scan_count(struct lruvec *lruvec, struct scan_control *sc,
unsigned long *nr)
- SCAN_FILE: swap 불가 또는 swappiness=0 → 파일만 스캔
- SCAN_ANON: file_is_tiny → 익명만 스캔
- SCAN_EQUAL: priority=0 + swappiness>0 → anon/file 동일 비율
- SCAN_FRACT: 기본값. swappiness와 anon/file cost 비율에 따라 비례 스캔
get_scan_count()는 swap 가능성, memcg swappiness, OOM 근접도, 파일 캐시 부족 여부를 먼저 보며, 기본 경로에서는 최근 회수 비용(anon_cost, file_cost)과 swappiness를 섞어 비율을 계산합니다.
/* mm/vmscan.c:2538-2592 */
/* 스왑 공간이 없으면 익명 folio 스캔을 시도하지 않음 */
if (!sc->may_swap || !can_reclaim_anon_pages(memcg, pgdat->node_id, sc)) {
scan_balance = SCAN_FILE;
goto out;
}
/*
* Global reclaim will swap to prevent OOM even with no
* swappiness, but memcg users want to use this knob to
* disable swapping for individual groups completely when
* using the memory controller's swap limit feature would be
* too expensive.
*/
if (cgroup_reclaim(sc) && !swappiness) {
scan_balance = SCAN_FILE;
goto out;
}
/* 익명 메모리에만 사용자 공간에서 호출한 능동적 회수 */
if (swappiness == SWAPPINESS_ANON_ONLY) {
WARN_ON_ONCE(!sc->proactive);
scan_balance = SCAN_ANON;
goto out;
}
/*
* Do not apply any pressure balancing cleverness when the
* system is close to OOM, scan both anon and file equally
* (unless the swappiness setting disagrees with swapping).
*/
if (!sc->priority && swappiness) {
scan_balance = SCAN_EQUAL;
goto out;
}
/*
* If the system is almost out of file pages, force-scan anon.
*/
if (sc->file_is_tiny) {
scan_balance = SCAN_ANON;
goto out;
}
/*
* If there is enough inactive page cache, we do not reclaim
* anything from the anonymous working right now to make sure
* a streaming file access pattern doesn't cause swapping.
*/
if (sc->cache_trim_mode) {
scan_balance = SCAN_FILE;
goto out;
}
scan_balance = SCAN_FRACT;
calculate_pressure_balance(sc, swappiness, fraction, &denominator);
__alloc_pages() (page_alloc.c)
└─ __alloc_pages_slowpath()
└─ __alloc_pages_direct_reclaim() [page_alloc.c:4437]
└─ try_to_free_pages() [vmscan.c:6566]
├─ throttle_direct_reclaim() [vmscan.c:6486]
└─ do_try_to_free_pages() [vmscan.c:6344]
└─ priority 루프 (12 → 0):
└─ shrink_zones() [vmscan.c:6221]
└─ for_each_zone:
└─ shrink_node() [vmscan.c:6039]
├─ prepare_scan_control() [vmscan.c:2317]
└─ shrink_node_memcgs() [vmscan.c:5960]
└─ for_each_memcg:
├─ shrink_lruvec() [vmscan.c:5772]
│ ├─ get_scan_count() [vmscan.c:2527]
│ └─ for_each_lru:
│ └─ shrink_list() [vmscan.c:2249]
│ ├─ [active] shrink_active_list() [vmscan.c:2098]
│ └─ [inactive] shrink_inactive_list() [vmscan.c:1977]
│ ├─ isolate_lru_folios() [vmscan.c:1710]
│ ├─ shrink_folio_list() [vmscan.c:1083]
│ │ ├─ folio_check_references()
│ │ ├─ folio_alloc_swap() (익명)
│ │ ├─ pageout() (dirty)
│ │ └─ __remove_mapping() (clean)
│ └─ move_folios_to_lru() [vmscan.c:1895]
└─ shrink_slab() [vmscan.c:6022 → mm/shrinker.c:614]
할당 slowpath 쪽에서는 direct reclaim 전후로 PSI memstall을 기록합니다. 회수 후에도 할당이 실패하면 highatomic reserve와 per-CPU page list를 비운 뒤 한 번 더 freelist를 확인합니다.
/* mm/page_alloc.c:4435-4468 */
/* 직접 회수에 진입하는 진짜 느린 할당 경로 */
static inline struct page *
__alloc_pages_direct_reclaim(gfp_t gfp_mask, unsigned int order,
unsigned int alloc_flags, const struct alloc_context *ac,
unsigned long *did_some_progress)
{
struct page *page = NULL;
unsigned long pflags;
bool drained = false;
psi_memstall_enter(&pflags);
*did_some_progress = __perform_reclaim(gfp_mask, order, ac);
if (unlikely(!(*did_some_progress)))
goto out;
retry:
page = get_page_from_freelist(gfp_mask, order, alloc_flags, ac);
/*
* If an allocation failed after direct reclaim, it could be because
* pages are pinned on the per-cpu lists or in high alloc reserves.
* Shrink them and try again
*/
if (!page && !drained) {
unreserve_highatomic_pageblock(ac, false);
drain_all_pages(NULL);
drained = true;
goto retry;
}
out:
psi_memstall_leave(&pflags);
return page;
}
kswapd() [vmscan.c:7280]
└─ for(;;):
├─ kswapd_try_to_sleep() [vmscan.c:7183]
│ └─ prepare_kswapd_sleep() [vmscan.c:6838]
│ └─ zone_watermark_ok() 체크 → sleep/wake
└─ balance_pgdat() [vmscan.c:6950]
└─ priority 루프:
└─ shrink_node() → (위와 동일)
shrink_node() [lru_gen_enabled()]
└─ lru_gen_shrink_node() [vmscan.c:5038]
└─ evict_folios() [vmscan.c:4686]
├─ get_nr_to_scan() [vmscan.c:4811]
├─ try_to_inc_max_seq() [vmscan.c:4021] — page table walk로 세대 에이징
├─ read_ctrl_pos() [vmscan.c:3159] — 세대별 스캔 위치 결정
└─ walk_pmd_range() [vmscan.c:3660] — PTE 스캔
| 항목 | Direct Reclaim | kswapd |
|---|---|---|
| **진입 시점** | 할당 시 watermark 미달 | 백그라운드 스레드 |
| **지연(latency)** | 높음 (할당 지연에 직접 기여) | 낮음 (백그라운드) |
| **PF_memalloc 플래그** | 없음 (일반 프로세스) | 있음 (PF_MEMALLOC) |
| **throttle** | `throttle_direct_reclaim()` | 없음 |
| **partial walk** | 가능 (공정성 고려) | 전체 walk |
| **memcg 보호** | `memory.low` 보호를 잠시 두고 재시도 | `memory.low` 건너뜀 |
| **스캔 우선순위** | DEF_PRIORITY에서 시작 | 점진적 우선순위 감소 |
Zone watermark는 include/linux/mmzone.h:708-714의 WMARK_MIN, WMARK_LOW, WMARK_HIGH, WMARK_PROMO로 정의됩니다. 일반 회수 판단에서는 free 페이지가 low 아래로 내려가면 kswapd를 깨우고, min 아래에서는 할당자가 direct reclaim까지 수행합니다. high를 회복하면 kswapd는 다시 수면 상태로 들어갑니다.
/* include/linux/mmzone.h:708-714 */
enum zone_watermarks {
WMARK_MIN,
WMARK_LOW,
WMARK_HIGH,
WMARK_PROMO,
NR_WMARK
};
| Watermark | 조건 | 주된 동작 | 확인 포인트 |
|---|---|---|---|
| `WMARK_HIGH` | free > high | kswapd가 회수를 멈추고 sleep 가능 | `/proc/zoneinfo`의 `high`와 `nr_free_pages` |
| `WMARK_LOW` | free < low | `wakeup_kswapd()`로 백그라운드 회수 시작 | `pgscan_kswapd`, `pgsteal_kswapd` 증가 |
| `WMARK_MIN` | free < min | 할당 slowpath에서 direct reclaim 가능 | `allocstall_*`, `pgscan_direct` 증가 |
| `WMARK_PROMO` | tiering/NUMA 승격 판단 | 느린 tier에서 빠른 tier로 page promotion 판단에 사용 | `/proc/zoneinfo`의 `promo` 계열 표시 |
| 항목 | Classic LRU | MGLRU (Multi-Gen LRU) |
|---|---|---|
| **리스트 구조** | active/inactive 4개 리스트 | 세대(generation) 기반 다중 리스트 |
| **에이징 메커니즘** | referenced 비트 기반 | page table walk + Bloom filter |
| **CPU 오버헤드** | 낮음 (LRU lock) | 높음 (page walk) BUT 효율적 |
| **정확도** | 최근 접근 추적 부정확 | spatial locality 활용으로 정확 |
| **MGLRU 코드 위치** | - | `vmscan.c:2659-5770` |
| **활성화 조건** | 기본값 | `CONFIG_LRU_GEN_ENABLED` |
| 항목 | 익명 페이지 (Anonymous) | 파일 기반 페이지 (File-backed) |
|---|---|---|
| **회수 방법** | swap으로 내보내기 | 원본 파일에서 다시 읽을 수 있으므로 즉시 해제 |
| **swappiness 영향** | 높음 (0이면 회수 안 함) | 낮음 (기본적으로 우선 회수) |
| **cost 비용** | `lruvec->anon_cost` | `lruvec->file_cost` |
| **writeback** | swap device writeback | filesystem writeback |
| **demotion** | 가능 (NUMA 티어링) | 불가 |
전통 LRU에서 실제 스캔 순서는 get_scan_count()가 계산한 anon/file 비율과 active/inactive 상태에 따라 달라집니다. 운영 관점에서는 비용이 낮은 파일 캐시와 비활성 folio가 먼저 사라지고, 익명 active folio는 swap, demotion, 참조 비트 때문에 더 비싼 경로를 탑니다.
| 대상 | 비용 | 대표 경로 | 특징 |
|---|---|---|---|
| `LRU_INACTIVE_FILE` | 낮음 | clean이면 `__remove_mapping()` 후 free | 원본 파일에서 다시 읽을 수 있어 회수 우선 후보 |
| `LRU_ACTIVE_FILE` | 중간 | `shrink_active_list()`로 inactive 이동 | refault가 많으면 active 유지 경향 |
| `LRU_INACTIVE_ANON` | 높음 | `folio_alloc_swap()` 또는 demotion | swap/zswap, NUMA tiering, DMA pin 여부가 결과를 좌우 |
| `LRU_ACTIVE_ANON` | 가장 높음 | 먼저 inactive로 강등 | 실제 working set일 가능성이 커 공격적 회수 시 지연 증가 |
vmscan은 사용자/파일 페이지뿐 아니라 dentry, inode, filesystem metadata 같은 커널 캐시도 함께 줄입니다. shrink_node_memcgs()는 각 memcg의 shrink_lruvec() 후 shrink_slab()을 호출하므로, 페이지 캐시만 줄어드는 상황과 slab까지 같이 줄어드는 상황을 구분해 봐야 합니다.
| 경로 | 대상 | 대표 함수 | 관찰 지표 |
|---|---|---|---|
| 페이지 LRU | anon/file folio | `shrink_lruvec()` → `shrink_folio_list()` | `pgscan_*`, `pgsteal_*`, `workingset_*` |
| Slab shrinker | dentry/inode 등 shrinker 등록 캐시 | `shrink_slab()` → `do_shrink_slab()` | `/proc/slabinfo`, `SReclaimable`, `KReclaimable` |
| OOM 전 단계 | LRU와 shrinker 모두 부족 | `out_of_memory()`로 이동 | `allocstall_*` 증가 후 `dmesg` OOM 로그 |
| priority | 스캔 비율 | 동작 | 사용 시점 |
|---|---|---|---|
| 12 (DEF_PRIORITY) | total/4096 | 소규모 스캔 | 빠른 종료 목표 |
| 6 | total/64 | 중간 스캔 | 점진적 강화 |
| 0 | total/1 | 전체 LRU 스캔 | 최후 수단 |
Linux 6.1+에서 기본 활성화된 MGLRU는 기존의 active/inactive 2-level 구조를 개선합니다:
max_seq: 최신 세대 번호. 새롭게 접근된 페이지가 이 세대에 속함min_seq[type]: 각 타입(anon/file)별로 가장 오래된 세대. 이 세대의 페이지가 먼저 회수 대상min_type(swappiness): swappiness=0이면 file만, 200이면 anon만 회수MIN_NR_GENS = 2, MAX_NR_GENS = 4BLOOM_FILTER_SHIFT = 15 → 2^15 비트 = 32KB per filterlru_gen_look_around()에서 rmap walk 결과를 feedbackMEMCG_NR_GENS = 4, MEMCG_NR_BINS = 8lru_gen_rotate_memcg()로 memcg 우선순위 동적 조절vm_swappiness (기본값 60)은 anon/file 회수 비율을 제어합니다:
| swappiness | 동작 |
|---|---|
| 0 | 파일 기반만 스캔 (익명 회수 안 함, global reclaim에서는 예외) |
| 1-99 | swappiness 비율에 따라 anon/file 비례 스캔 |
| 100 | anon과 file 동일 비율 스캔 |
| 101-199 | memcg swappiness가 아닌 global swappiness 사용 |
| 200 | `SWAPPINESS_ANON_ONLY` — 익명만 스캔 (proactive reclaim 전용) |
memcg에서는 memory.swap.max, memory.swap.current로 개별 스왑 한도를 설정할 수 있습니다.
| 카운터 | 의미 | 확인 명령 | |
|---|---|---|---|
| `pgscan_kswapd` | kswapd가 스캔한 페이지 수 | `cat /proc/vmstat \ | grep pgscan` |
| `pgscan_direct` | direct reclaim이 스캔한 페이지 수 | `cat /proc/vmstat \ | grep pgscan` |
| `pgsteal_kswapd` | kswapd가 회수한 페이지 수 | `cat /proc/vmstat \ | grep pgsteal` |
| `pgsteal_direct` | direct reclaim이 회수한 페이지 수 | `cat /proc/vmstat \ | grep pgsteal` |
| `allocstall` | direct reclaim 진입 횟수 | `cat /proc/vmstat \ | grep allocstall` |
| `pgrefill` | active→inactive 전환 시 refault | `cat /proc/vmstat \ | grep pgrefill` |
| `pgactivate` | folio가 active로 승격된 횟수 | `cat /proc/vmstat \ | grep pgactivate` |
| `pgdeactivate` | folio가 inactive로 강등된 횟수 | `cat /proc/vmstat \ | grep pgdeactivate` |
| `pgrotated` | inactive에서 active로 회전된 횟수 | `cat /proc/vmstat \ | grep pgrotated` |
| `pgscan_skip` | zone 필터링으로 건너뛴 횟수 | `cat /proc/vmstat \ | grep pgscan_skip` |
단일 카운터만으로 원인을 단정하면 오진하기 쉽습니다. 할당, 회수, swap, slab, compaction 지표를 같은 시간대의 PSI와 함께 맞춰 보면 direct reclaim 지연인지, kswapd가 뒤처진 것인지, working set이 RAM보다 큰 것인지 분리할 수 있습니다.
| 증상 | 우선 확인 | 해석 | 다음 확인 | ||
|---|---|---|---|---|---|
| `allocstall_*` 증가 | `cat /proc/vmstat \ | grep allocstall` | 할당자가 직접 회수에 들어가 latency가 늘어남 | `/proc/pressure/memory`, `vmstat 1` | |
| `pgscan_kswapd`는 큰데 `pgsteal_kswapd`가 낮음 | `pgscan/pgsteal` 비율 | 스캔 효율이 낮거나 hot working set을 건드림 | `workingset_refault`, MGLRU 상태 | ||
| `pgscan_direct` 증가 | `cat /proc/vmstat \ | grep pgscan_direct` | kswapd가 low watermark 회복을 따라가지 못함 | `min_free_kbytes`, `watermark_scale_factor` | |
| `pswpout`와 major fault 증가 | `cat /proc/vmstat \ | grep -E 'pswp\ | pgmajfault'` | 익명 working set이 RAM을 초과하거나 swappiness 영향 | `memory.swap.*`, zswap/zram 설정 |
| `SReclaimable` 급증 | `grep -E 'SReclaimable\ | KReclaimable' /proc/meminfo` | page LRU보다 shrinker 캐시가 압력을 만들 수 있음 | `/proc/slabinfo`, shrinker tracepoint | |
| high-order allocation 실패 | `dmesg \ | grep -i 'page allocation failure'` | free 총량보다 연속 페이지 부족 가능 | `/proc/buddyinfo`, compaction 카운터 |