Memory · Linux 6.18.37 LTS · source + diagram note

reclaim

최신 LTS 기준: kernel.org longterm 6.18.37 기준으로 파일 위치와 링크를 맞춘 원본 코드 분석 노트입니다. LRU, shrinker, kswapd, direct reclaim.

전체 흐름

메모리 관리에서는 주소 변환, 물리 페이지의 소유권, 회수 정책, 할당 지연 시간이 서로 영향을 준다. 이 층에서는 '어느 page가 지금 누구 소유인가'와 '그 소유권을 어떤 lock/refcount가 증명하는가'를 끝까지 추적해야 한다.

핵심 확인 사항

함수의 입력과 처리 결과를 먼저 확인한다. 구조체를 생성하고 연결하는 시점, 다른 코드에서 사용할 수 있게 하는 시점, 더 이상 사용하지 않아 회수하는 시점을 구분한다.

오류 처리 확인

정상 처리와 오류 처리를 함께 확인한다. 오류 복구, 장치의 실행 중 연결·분리(hotplug), 자원 해제 과정에서도 상태가 올바르게 정리되는지 점검한다.

그림 1. reclaim 이 kernel 안에서 놓이는 위치
virtual addressVMA, fault, kernel mapping
→
page tablePTE/PMD/PUD, TLB maintenance
→
struct page/foliorefcount, mapcount, flags
→
allocator/reclaimzone, LRU, compaction, writeback

왼쪽부터 처리 단계를 나열했다. 각 코드 조각에서 바뀌는 상태를 해당 단계와 대조한다.

소스 코드 위치

먼저 확인할 Linux 6.18.37 LTS 소스 파일

원본 코드:
Linux 6.18.37 LTS: mm/vmscan.c
Linux 6.18.37 LTS: mm/workingset.c
Linux 6.18.37 LTS: mm/shrinker.c

설명: 진입 함수가 있는 파일과 구조체 정의, 아키텍처별 연결 코드, 콜백 구현을 함께 확인한다. 여러 파일을 대조해야 호출 관계와 각 코드의 역할을 알 수 있다.

대표 코드

shrink_node — 진입부와 MGLRU 분기

원본 코드: mm/vmscan.c:6083–6106 · v6.18.37 · 사이트 원문
static void shrink_node(pg_data_t *pgdat, struct scan_control *sc)
{
	unsigned long nr_reclaimed, nr_scanned, nr_node_reclaimed;
	struct lruvec *target_lruvec;
	bool reclaimable = false;

	if (lru_gen_enabled() && root_reclaim(sc)) {
		memset(&sc->nr, 0, sizeof(sc->nr));
		lru_gen_shrink_node(pgdat, sc);
		return;
	}

	target_lruvec = mem_cgroup_lruvec(sc->target_mem_cgroup, pgdat);

again:
	memset(&sc->nr, 0, sizeof(sc->nr));

	nr_reclaimed = sc->nr_reclaimed;
	nr_scanned = sc->nr_scanned;

	prepare_scan_control(pgdat, sc);

	shrink_node_memcgs(pgdat, sc);

함수 앞부분만 발췌했다. root reclaim에서 MGLRU가 켜져 있으면 lru_gen_shrink_node()로 돌아가고, 그 외에는 memcg/lruvec 순회를 준비한다. 이 함수가 kswapd를 시작하는 것은 아니다.

kswapd — 대기와 balance_pgdat 호출

원본 코드: mm/vmscan.c:7336–7408 · v6.18.37 · 사이트 원문
static int kswapd(void *p)
{
	unsigned int alloc_order, reclaim_order;
	unsigned int highest_zoneidx = MAX_NR_ZONES - 1;
	pg_data_t *pgdat = (pg_data_t *)p;
	struct task_struct *tsk = current;

	/*
	 * Tell the memory management that we're a "memory allocator",
	 * and that if we need more memory we should get access to it
	 * regardless (see "__alloc_pages()"). "kswapd" should
	 * never get caught in the normal page freeing logic.
	 *
	 * (Kswapd normally doesn't need memory anyway, but sometimes
	 * you need a small amount of memory in order to be able to
	 * page out something else, and this flag essentially protects
	 * us from recursively trying to free more memory as we're
	 * trying to free the first piece of memory in the first place).
	 */
	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);

		/* Read the new order and 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;

		/*
		 * Reclaim begins at the requested order but if a high-order
		 * reclaim fails then kswapd falls back to reclaiming for
		 * order-0. If that happens, kswapd will consider sleeping
		 * for the order it finished reclaiming at (reclaim_order)
		 * but kcompactd is woken to compact for the original
		 * request (alloc_order).
		 */
		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;
	}

	tsk->flags &= ~(PF_MEMALLOC | PF_KSWAPD);

	return 0;
}

kswapd는 node별 커널 스레드다. 깨어나면 balance_pgdat()를 호출하고, 그 안에서 kswapd_shrink_node()를 거쳐 shrink_node()에 도달한다. 직접 회수는 별도 진입점에서 합류한다.

함수별 분석

직접 회수와 kswapd는 같은 함수의 연속 단계가 아니다. 각각의 진입점에서 node 회수 함수로 들어온 뒤, scan_control의 조건에 따라 회수량과 쓰기 허용 범위가 달라진다.

직접 회수

try_to_free_pages()가 scan_control을 만들고 do_try_to_free_pages()에 전달한다. 이어 shrink_zones()가 대상 node의 shrink_node()를 호출한다. 할당 문맥의 GFP 제약 때문에 파일시스템 진입이나 writeback이 제한될 수 있다.

근거: mm/vmscan.c:6622–6650, 6393–6412, 6342–6350.

백그라운드 회수

kswapd()는 잠에서 깬 뒤 balance_pgdat()를 부른다. watermark 목표를 판단하는 과정에서 kswapd_shrink_node()가 shrink_node()를 호출한다. scanned와 reclaimed는 별개 카운터다.

근거: mm/vmscan.c:7336–7400, 7118–7135, 6934–6951.

두 회수 경로와 folio의 결과

아래 그림은 가능한 호출 관계를 요약한 개념도다. 실제 실행 trace나 메모리 배치를 나타내지 않는다. shrink_node()의 root reclaim 분기는 MGLRU와 memcg 순회를 가르며, memcg 회수는 shrink_lruvec() 안에서 MGLRU를 다시 선택할 수 있다.

그림 2. 직접 회수와 kswapd의 합류 및 회수 방식 분기
직접 회수는 try_to_free_pages와 shrink_zones를, kswapd는 balance_pgdat와 kswapd_shrink_node를 지나 shrink_node에서 합류한다. 이후 MGLRU와 memcg 순회 경로로 나뉜다.

분기 근거: mm/vmscan.c:6083–6106; memcg의 별도 MGLRU 조건: 5816–5829.

그림 크게 열기

표 1. folio를 스캔한 뒤의 네 대표 결과
결과대표 조건그다음 상태고정 소스
Keep잠금 실패, 참조 확인의 FOLIOREF_KEEP, 또는 dirty folio에 필요한 FS 진입·writepage가 허용되지 않음.ret_folios로 되돌려 LRU에 남긴다. 스캔했다고 곧바로 free되지 않는다.1140–1141, 1282–1293, 1450–1455, 1604–1609
ActivateFOLIOREF_ACTIVATE, DMA pin, 또는 특정 writeback·dirty 조건에서는 즉시 회수하지 않음.active 표시 후 LRU 복귀. writeback folio의 다른 조건은 완료를 기다렸다가 재시도한다.1238–1279, 1285–1289, 1417–1418, 1592–1607
Pageoutdirty folio가 쓰기 조건을 통과해야 pageout()을 시도한다. file folio는 kswapd·reclaim 표시·PGDAT_DIRTY 조건도 검사한다.PAGE_KEEP/PAGE_ACTIVATE/PAGE_SUCCESS/PAGE_CLEAN으로 갈린다. 성공해도 writeback 또는 dirty가 남으면 keep한다.1421–1463, 1463–1502
Freewriteback·dirty 장애가 없고 매핑 제거 또는 참조 동결에 성공해 실제 해제 가능한 folio.free_folios batch에 넣고 uncharge·free한다. pageout 호출만으로 성립하지 않는다.1484–1502, 1549–1580

표는 shrink_folio_list()의 대표 분기를 요약한다. NUMA demotion은 별도 경로이며, 이 네 행이 모든 folio의 최종 상태를 망라하지는 않는다(mm/vmscan.c:1296–1304). 검증에 사용한 로컬 v6.18.37 mm/vmscan.c SHA-256: 9be5764f837b788bedc544b4b7381a3af4084899e34dd2c566e1ada112ad87e8.

구조체와 필드

구조체의 필드를 언제 바꿀 수 있고 다른 코드가 언제 읽을 수 있는지 확인한다. 필드의 의미와 함께 잠금 등 접근을 보호하는 규칙을 살펴본다.

그림 3. reclaim 핵심 필드 연결
lruvecmemcg와 NUMA node별 LRU
→
scan_controlreclaim 정책과 압박 수준
→
folio flagsdirty/writeback/referenced 상태
→
shrinkerslab/object cache 회수 hook

각 단계에서 읽고 쓰는 필드를 구조체 정의와 대조한다.

어떤 코드가 필드를 변경하고 어떤 방식으로 접근을 보호하는지, 객체를 언제 해제하는지 원본 코드에서 확인한다.

필드읽는 법
lruvecmemcg와 NUMA node별 LRU
scan_controlreclaim 정책과 압박 수준
folio flagsdirty/writeback/referenced 상태
shrinkerslab/object cache 회수 hook

실행 단계와 상태 변화

memory pressure가 있다고 모든 folio가 같은 네 단계를 통과하지는 않는다. 회수 진입 후 scan_control의 목표·GFP 제약과 folio의 참조·dirty·writeback 상태를 검사한다. nr_scanned는 살핀 양이고 nr_reclaimed는 회수로 집계된 양(강등 포함)이다(mm/vmscan.c:1613–1616).

특히 writeback을 요청한 folio는 I/O 완료 전까지 free로 진행할 수 없다. 참조가 있으면 활성화하고, 진행 중인 writeback은 조건에 따라 활성화하거나 기다린 뒤 재시도한다. 가능한 결과와 소스 분기는 위의 그림과 folio 결과표에서 확인할 수 있다.

불변 조건과 실패 사례

lifetime

reclaim의 핵심 객체가 외부에 공개된 뒤에는 마지막 참조가 사라질 때까지 해제 경로가 callback, timer, IRQ, worker와 경합하지 않아야 한다.

ordering

다른 CPU나 하위 계층이 변경된 필드를 읽는다면 잠금과 메모리 배리어가 접근 순서를 어떻게 보장하는지 확인한다. 참조 횟수와 RCU로 관리하는 객체 수명도 별도로 확인한다.

오류 복구

중간 단계에서 실패하면 이미 확보한 자원을 의존 관계에 맞춰 해제해야 한다. goto 레이블이 많다면 각 분기에 도달했을 때 확보한 자원을 표로 정리한다.

bring-up symptom

임베디드 보드를 처음 구동할 때는 패닉 대신 로그 중단, 시간 초과, 장치 초기화 지연, 과도한 인터럽트 같은 증상으로 문제가 드러날 수 있다.

주의

direct reclaim이 sleep/IO를 유발할 수 있다는 점을 놓침

주의

dirty/writeback page를 즉시 free 가능하다고 봄

주의

memcg reclaim과 global reclaim을 섞음

계측과 검증

로그를 무작정 추가하지 말고 확인할 가설을 먼저 세운다. 상태표에서 처리가 멈춘 단계를 찾은 뒤, 상태를 바꾸는 함수와 그 결과를 사용하는 함수를 함께 살펴본다.

로그가 출력되었다고 처리가 끝난 것으로 판단하지 않는다. 원본 코드에서 로그 앞뒤의 필드 변경을 확인한다.

도구보는 것
trace_vmscan_*reclaim 실행이 어느 단계에서 멈추는지 확인
vmpressurereclaim 실행이 어느 단계에서 멈추는지 확인
psi memoryreclaim 실행이 어느 단계에서 멈추는지 확인
cat /proc/vmstatreclaim 실행이 어느 단계에서 멈추는지 확인
기본 추적 경로:
# 예시: tracefs가 켜진 보드에서 토픽별 event를 좁혀 본다.
mount -t tracefs nodev /sys/kernel/tracing
echo function_graph > /sys/kernel/tracing/current_tracer
echo ':mod:*' > /sys/kernel/tracing/set_ftrace_filter
cat /sys/kernel/tracing/trace_pipe

추가 확인 사항

  • reclaim 의 state machine을 네 단계로 줄였을 때, 실제 코드에서 빠지는 intermediate state는 무엇인가?
  • reclaim 의 fast path가 생략한 검사는 어느 init path 또는 slow path에서 보증되는가?
  • 실험으로 확인한다면 'direct reclaim이 sleep/IO를 유발할 수 있다는 점을 놓침' 문제를 어떤 tracepoint와 counter로 분리할 수 있는가?
  • 실험으로 확인한다면 'dirty/writeback page를 즉시 free 가능하다고 봄' 문제를 어떤 tracepoint와 counter로 분리할 수 있는가?