LRU 복귀와 마지막 put: list에서 빠지는 세 가지 이유

LRU에서 folio를 제거했다는 기록만으로 physical memory가 free됐다고 판단할 수는 없습니다. reclaim은 검사할 후보를 잠시 isolate하고, compaction은 migration을 위해 후보를 꺼내며, 마지막 reference를 놓는 경로는 object를 소멸시키기 전에 연결을 정리합니다. 같은 list helper가 나타나도 ownership이 달라지는 이유와 그 뒤의 수명은 서로 다릅니다.

여기서는 고전 호출 관계를 version이 정해진 upstream code로 살펴봅니다. v5.18의 move_pages_to_lru()와 v6.1의 folio 전환 후 구현을 구분합니다. 이 identifier들을 v6.18의 API 목록으로 읽어서는 안 됩니다. 아래 code는 해당 함수 전체이며 kernel의 주변 선언과 helper를 필요로 합니다. 별도 사용자 공간 프로그램은 아닙니다.

Local reclaim list를 되돌릴 때

active/inactive shrinking은 원래 lruvec에서 후보를 꺼내 local list에 둡니다. 이후 회수되지 않은 후보를 다시 연결하는 역할이 v5.18의 move_pages_to_lru()입니다. caller는 lruvec lock을 잡고 들어옵니다. folio 전환 전 이름과 달리 이 단계의 반환량은 free한 양이 아니라 LRU에 다시 배치한 base page 수입니다.

evictable이 아니게 된 page는 lock을 잠깐 풀고 putback 경로에 넘깁니다. 정상 후보는 PageLRU를 먼저 세운 뒤 isolation reference를 놓습니다. 그 순서를 뒤집으면 다른 CPU의 마지막 put이 LRU가 아니라고 판단하고 같은 list node를 free list에 연결하는 race가 생길 수 있습니다. reference가 0이 된 후보는 원래 LRU에 다시 연결하지 않습니다. compound page는 별도 destruction 경로로 보내고, 작은 page는 마지막에 caller가 처리할 free list로 돌려줍니다.

v5.18 원본 · 전체 code · GPL-2.0

v5_18_move_pages_to_lru.c

// SPDX-License-Identifier: GPL-2.0
/*
 *  Copyright (C) 1991, 1992, 1993, 1994  Linus Torvalds
 *
 *  Swap reorganised 29.12.95, Stephen Tweedie.
 *  kswapd added: 7.1.96  sct
 *  Removed kswapd_ctl limits, and swap out as many pages as needed
 *  to bring the system back to freepages.high: 2.4.97, Rik van Riel.
 *  Zone aware kswapd started 02/00, Kanoj Sarcar (kanoj@sgi.com).
 *  Multiqueue VM started 5.8.00, Rik van Riel.
 */

/* Extracted unchanged from Linux v5.18, mm/vmscan.c:2235-2297.
 * Full function definition; surrounding kernel context is required.
 * Source: https://github.com/torvalds/linux/blob/v5.18/mm/vmscan.c#L2235
 * License text: GPL-2.0.txt (in this directory).
 */
static unsigned int move_pages_to_lru(struct lruvec *lruvec,
				      struct list_head *list)
{
	int nr_pages, nr_moved = 0;
	LIST_HEAD(pages_to_free);
	struct page *page;

	while (!list_empty(list)) {
		page = lru_to_page(list);
		VM_BUG_ON_PAGE(PageLRU(page), page);
		list_del(&page->lru);
		if (unlikely(!page_evictable(page))) {
			spin_unlock_irq(&lruvec->lru_lock);
			putback_lru_page(page);
			spin_lock_irq(&lruvec->lru_lock);
			continue;
		}

		/*
		 * The SetPageLRU needs to be kept here for list integrity.
		 * Otherwise:
		 *   #0 move_pages_to_lru             #1 release_pages
		 *   if !put_page_testzero
		 *				      if (put_page_testzero())
		 *				        !PageLRU //skip lru_lock
		 *     SetPageLRU()
		 *     list_add(&page->lru,)
		 *                                        list_add(&page->lru,)
		 */
		SetPageLRU(page);

		if (unlikely(put_page_testzero(page))) {
			__clear_page_lru_flags(page);

			if (unlikely(PageCompound(page))) {
				spin_unlock_irq(&lruvec->lru_lock);
				destroy_compound_page(page);
				spin_lock_irq(&lruvec->lru_lock);
			} else
				list_add(&page->lru, &pages_to_free);

			continue;
		}

		/*
		 * All pages were isolated from the same lruvec (and isolation
		 * inhibits memcg migration).
		 */
		VM_BUG_ON_PAGE(!folio_matches_lruvec(page_folio(page), lruvec), page);
		add_page_to_lru_list(page, lruvec);
		nr_pages = thp_nr_pages(page);
		nr_moved += nr_pages;
		if (PageActive(page))
			workingset_age_nonresident(lruvec, nr_pages);
	}

	/*
	 * To save our caller's stack, now use input list for pages to free.
	 */
	list_splice(&pages_to_free, list);

	return nr_moved;
}

v6.1의 이름은 move_folios_to_lru()입니다. lruvec_add_folio()를 호출하는 정상 복귀 branch는 evictable folio를 실제 list에 다시 연결하고 통계를 올립니다. 이를 unevictable 통계만 갱신하는 경로라고 한정하면 잘못입니다. active로 복귀한 page 수는 workingset_age_nonresident()에도 반영됩니다. isolation으로 memcg migration이 억제되어 같은 lruvec으로 복귀한다는 전제도 code의 assertion에서 확인할 수 있습니다.

v6.1 원본 · 전체 code

v6_1_move_folios_to_lru.c

// SPDX-License-Identifier: GPL-2.0
/*
 *  Copyright (C) 1991, 1992, 1993, 1994  Linus Torvalds
 *
 *  Swap reorganised 29.12.95, Stephen Tweedie.
 *  kswapd added: 7.1.96  sct
 *  Removed kswapd_ctl limits, and swap out as many pages as needed
 *  to bring the system back to freepages.high: 2.4.97, Rik van Riel.
 *  Zone aware kswapd started 02/00, Kanoj Sarcar (kanoj@sgi.com).
 *  Multiqueue VM started 5.8.00, Rik van Riel.
 */

/* Extracted unchanged from Linux v6.1, mm/vmscan.c:2365-2427.
 * Full function definition; surrounding kernel context is required.
 * Source: https://github.com/torvalds/linux/blob/v6.1/mm/vmscan.c#L2365
 * License text: GPL-2.0.txt (in this directory).
 */
static unsigned int move_folios_to_lru(struct lruvec *lruvec,
		struct list_head *list)
{
	int nr_pages, nr_moved = 0;
	LIST_HEAD(folios_to_free);

	while (!list_empty(list)) {
		struct folio *folio = lru_to_folio(list);

		VM_BUG_ON_FOLIO(folio_test_lru(folio), folio);
		list_del(&folio->lru);
		if (unlikely(!folio_evictable(folio))) {
			spin_unlock_irq(&lruvec->lru_lock);
			folio_putback_lru(folio);
			spin_lock_irq(&lruvec->lru_lock);
			continue;
		}

		/*
		 * The folio_set_lru needs to be kept here for list integrity.
		 * Otherwise:
		 *   #0 move_folios_to_lru             #1 release_pages
		 *   if (!folio_put_testzero())
		 *				      if (folio_put_testzero())
		 *				        !lru //skip lru_lock
		 *     folio_set_lru()
		 *     list_add(&folio->lru,)
		 *                                        list_add(&folio->lru,)
		 */
		folio_set_lru(folio);

		if (unlikely(folio_put_testzero(folio))) {
			__folio_clear_lru_flags(folio);

			if (unlikely(folio_test_large(folio))) {
				spin_unlock_irq(&lruvec->lru_lock);
				destroy_large_folio(folio);
				spin_lock_irq(&lruvec->lru_lock);
			} else
				list_add(&folio->lru, &folios_to_free);

			continue;
		}

		/*
		 * All pages were isolated from the same lruvec (and isolation
		 * inhibits memcg migration).
		 */
		VM_BUG_ON_FOLIO(!folio_matches_lruvec(folio, lruvec), folio);
		lruvec_add_folio(lruvec, folio);
		nr_pages = folio_nr_pages(folio);
		nr_moved += nr_pages;
		if (folio_test_active(folio))
			workingset_age_nonresident(lruvec, nr_pages);
	}

	/*
	 * To save our caller's stack, now use input list for pages to free.
	 */
	list_splice(&folios_to_free, list);

	return nr_moved;
}

Unevictable에서 다시 회수 후보가 될 때

check_move_unevictable_folios()는 batch의 folio를 다시 검사하여 이제 evictable인 후보를 구제합니다. folio_test_clear_lru()로 이동 중인 상태를 확보한 뒤 lruvec을 잠그고, evictable이면서 unevictable 분류였던 folio만 삭제·flag 해제·재추가합니다. unevictable 쪽 삭제는 일반 list node를 꺼내는 일과 같지 않지만, flag를 지운 뒤의 evictable 추가는 실제 list 연결입니다. PGSCANNED와 PGRESCUED도 folio 개수가 아니라 base page 수로 집계합니다.

v6.1 원본 · 전체 code

v6_1_check_move_unevictable_folios.c

// SPDX-License-Identifier: GPL-2.0
/*
 *  Copyright (C) 1991, 1992, 1993, 1994  Linus Torvalds
 *
 *  Swap reorganised 29.12.95, Stephen Tweedie.
 *  kswapd added: 7.1.96  sct
 *  Removed kswapd_ctl limits, and swap out as many pages as needed
 *  to bring the system back to freepages.high: 2.4.97, Rik van Riel.
 *  Zone aware kswapd started 02/00, Kanoj Sarcar (kanoj@sgi.com).
 *  Multiqueue VM started 5.8.00, Rik van Riel.
 */

/* Extracted unchanged from Linux v6.1, mm/vmscan.c:7729-7763.
 * Full function definition; surrounding kernel context is required.
 * Source: https://github.com/torvalds/linux/blob/v6.1/mm/vmscan.c#L7729
 * License text: GPL-2.0.txt (in this directory).
 */
void check_move_unevictable_folios(struct folio_batch *fbatch)
{
	struct lruvec *lruvec = NULL;
	int pgscanned = 0;
	int pgrescued = 0;
	int i;

	for (i = 0; i < fbatch->nr; i++) {
		struct folio *folio = fbatch->folios[i];
		int nr_pages = folio_nr_pages(folio);

		pgscanned += nr_pages;

		/* block memcg migration while the folio moves between lrus */
		if (!folio_test_clear_lru(folio))
			continue;

		lruvec = folio_lruvec_relock_irq(folio, lruvec);
		if (folio_evictable(folio) && folio_test_unevictable(folio)) {
			lruvec_del_folio(lruvec, folio);
			folio_clear_unevictable(folio);
			lruvec_add_folio(lruvec, folio);
			pgrescued += nr_pages;
		}
		folio_set_lru(folio);
	}

	if (lruvec) {
		__count_vm_events(UNEVICTABLE_PGRESCUED, pgrescued);
		__count_vm_events(UNEVICTABLE_PGSCANNED, pgscanned);
		unlock_page_lruvec_irq(lruvec);
	} else if (pgscanned) {
		count_vm_events(UNEVICTABLE_PGSCANNED, pgscanned);
	}
}

Isolation은 reference를 추가합니다

v6.1의 folio_isolate_lru()는 caller가 이미 유효한 reference를 보유한다는 전제에서 LRU flag를 확보하고 reference를 하나 더 얻습니다. lruvec lock 아래에서 lruvec_del_folio()를 호출한 뒤 성공을 반환합니다. folio를 잠그는 함수가 아니며, 성공한 isolation reference는 이후 putback 또는 최종 처리에서 반드시 정리해야 합니다. reclaim의 batch isolation은 뜨거운 경로라 list와 accounting을 직접 다루기도 하므로 모든 isolation이 이 wrapper를 통과한다고 가정하지 않습니다.

v6.1 원본 · 전체 code

v6_1_folio_isolate_lru.c

// SPDX-License-Identifier: GPL-2.0
/*
 *  Copyright (C) 1991, 1992, 1993, 1994  Linus Torvalds
 *
 *  Swap reorganised 29.12.95, Stephen Tweedie.
 *  kswapd added: 7.1.96  sct
 *  Removed kswapd_ctl limits, and swap out as many pages as needed
 *  to bring the system back to freepages.high: 2.4.97, Rik van Riel.
 *  Zone aware kswapd started 02/00, Kanoj Sarcar (kanoj@sgi.com).
 *  Multiqueue VM started 5.8.00, Rik van Riel.
 */

/* Extracted unchanged from Linux v6.1, mm/vmscan.c:2296-2313.
 * Full function definition; surrounding kernel context is required.
 * Source: https://github.com/torvalds/linux/blob/v6.1/mm/vmscan.c#L2296
 * License text: GPL-2.0.txt (in this directory).
 */
int folio_isolate_lru(struct folio *folio)
{
	int ret = -EBUSY;

	VM_BUG_ON_FOLIO(!folio_ref_count(folio), folio);

	if (folio_test_clear_lru(folio)) {
		struct lruvec *lruvec;

		folio_get(folio);
		lruvec = folio_lruvec_lock_irq(folio);
		lruvec_del_folio(lruvec, folio);
		unlock_page_lruvec_irq(lruvec);
		ret = 0;
	}

	return ret;
}

compaction의 isolate_migratepages_block()도 migration 가능한 후보를 꺼내지만 목적은 free가 아니라 이동입니다. 고전 del_page_from_lru_list()는 page를 folio helper에 연결하는 wrapper이고 caller-held lock을 전제로 합니다. helper가 이름에 del을 포함한다고 reference를 대신 놓거나 data의 folio lock을 대신 잡아 주지는 않습니다.

Batch release와 단일 마지막 put

v6.1의 batch 해제는 folio_batch_release()·pagevec_release() 계열에서 최종적으로 release_pages()로 이어집니다. 배열의 각 reference를 하나씩 놓고 아직 reference가 남은 object는 그대로 둡니다. 마지막 reference인 경우에만 LRU 연결을 정리하고 uncharge·free로 진행합니다. huge zero page, device page, large folio는 각각 별도 branch입니다. lruvec이 바뀌면 lock을 다시 잡고, 같은 lruvec이 이어져도 SWAP_CLUSTER_MAX마다 lock을 풀어 IRQ-off 시간이 지나치게 길어지지 않도록 합니다.

v6.1 원본 · 전체 code

v6_1_release_pages.c

// SPDX-License-Identifier: GPL-2.0-only
/*
 *  linux/mm/swap.c
 *
 *  Copyright (C) 1991, 1992, 1993, 1994  Linus Torvalds
 */

/*
 * This file contains the default values for the operation of the
 * Linux VM subsystem. Fine-tuning documentation can be found in
 * Documentation/admin-guide/sysctl/vm.rst.
 * Started 18.12.91
 * Swap aging added 23.2.95, Stephen Tweedie.
 * Buffermem limits added 12.3.98, Rik van Riel.
 */

/* Extracted unchanged from Linux v6.1, mm/swap.c:977-1056.
 * Full function definition; surrounding kernel context is required.
 * Source: https://github.com/torvalds/linux/blob/v6.1/mm/swap.c#L977
 * License text: GPL-2.0.txt (in this directory).
 */
void release_pages(struct page **pages, int nr)
{
	int i;
	LIST_HEAD(pages_to_free);
	struct lruvec *lruvec = NULL;
	unsigned long flags = 0;
	unsigned int lock_batch;

	for (i = 0; i < nr; i++) {
		struct folio *folio = page_folio(pages[i]);

		/*
		 * Make sure the IRQ-safe lock-holding time does not get
		 * excessive with a continuous string of pages from the
		 * same lruvec. The lock is held only if lruvec != NULL.
		 */
		if (lruvec && ++lock_batch == SWAP_CLUSTER_MAX) {
			unlock_page_lruvec_irqrestore(lruvec, flags);
			lruvec = NULL;
		}

		if (is_huge_zero_page(&folio->page))
			continue;

		if (folio_is_zone_device(folio)) {
			if (lruvec) {
				unlock_page_lruvec_irqrestore(lruvec, flags);
				lruvec = NULL;
			}
			if (put_devmap_managed_page(&folio->page))
				continue;
			if (folio_put_testzero(folio))
				free_zone_device_page(&folio->page);
			continue;
		}

		if (!folio_put_testzero(folio))
			continue;

		if (folio_test_large(folio)) {
			if (lruvec) {
				unlock_page_lruvec_irqrestore(lruvec, flags);
				lruvec = NULL;
			}
			__folio_put_large(folio);
			continue;
		}

		if (folio_test_lru(folio)) {
			struct lruvec *prev_lruvec = lruvec;

			lruvec = folio_lruvec_relock_irqsave(folio, lruvec,
									&flags);
			if (prev_lruvec != lruvec)
				lock_batch = 0;

			lruvec_del_folio(lruvec, folio);
			__folio_clear_lru_flags(folio);
		}

		/*
		 * In rare cases, when truncation or holepunching raced with
		 * munlock after VM_LOCKED was cleared, Mlocked may still be
		 * found set here.  This does not indicate a problem, unless
		 * "unevictable_pgs_cleared" appears worryingly large.
		 */
		if (unlikely(folio_test_mlocked(folio))) {
			__folio_clear_mlocked(folio);
			zone_stat_sub_folio(folio, NR_MLOCK);
			count_vm_event(UNEVICTABLE_PGCLEARED);
		}

		list_add(&folio->lru, &pages_to_free);
	}
	if (lruvec)
		unlock_page_lruvec_irqrestore(lruvec, flags);

	mem_cgroup_uncharge_list(&pages_to_free);
	free_unref_page_list(&pages_to_free);
}

단일 folio_put()·put_page()의 마지막 reference 경로에서는 page 종류에 맞는 put helper를 거쳐 __page_cache_release()가 아직 남은 LRU·mlock 상태를 정리할 수 있습니다. 이 helper의 본문은 LRU 정리이며 allocator에 page를 반환하는 전체 call chain 자체는 아닙니다. batch release와 단일 release가 둘 다 삭제 helper에 도달한다는 사실을 모든 put이 무조건 물리적 free를 한다는 뜻으로 바꾸면 안 됩니다.

v6.1 원본 · 전체 code

v6_1_page_cache_release.c

// SPDX-License-Identifier: GPL-2.0-only
/*
 *  linux/mm/swap.c
 *
 *  Copyright (C) 1991, 1992, 1993, 1994  Linus Torvalds
 */

/*
 * This file contains the default values for the operation of the
 * Linux VM subsystem. Fine-tuning documentation can be found in
 * Documentation/admin-guide/sysctl/vm.rst.
 * Started 18.12.91
 * Swap aging added 23.2.95, Stephen Tweedie.
 * Buffermem limits added 12.3.98, Rik van Riel.
 */

/* Extracted unchanged from Linux v6.1, mm/swap.c:80-99.
 * Full function definition; surrounding kernel context is required.
 * Source: https://github.com/torvalds/linux/blob/v6.1/mm/swap.c#L80
 * License text: GPL-2.0.txt (in this directory).
 */
static void __page_cache_release(struct folio *folio)
{
	if (folio_test_lru(folio)) {
		struct lruvec *lruvec;
		unsigned long flags;

		lruvec = folio_lruvec_lock_irqsave(folio, &flags);
		lruvec_del_folio(lruvec, folio);
		__folio_clear_lru_flags(folio);
		unlock_page_lruvec_irqrestore(lruvec, flags);
	}
	/* See comment on folio_test_mlocked in release_pages() */
	if (unlikely(folio_test_mlocked(folio))) {
		long nr_pages = folio_nr_pages(folio);

		__folio_clear_mlocked(folio);
		zone_stat_mod_folio(folio, NR_MLOCK, -nr_pages);
		count_vm_events(UNEVICTABLE_PGCLEARED, nr_pages);
	}
}

결국 list membership, isolation reference, 마지막 put은 함께 읽어야 합니다. list에서 빠졌어도 살아 있는 folio가 있고, 마지막 reference가 사라지는 순간에는 남은 list·accounting을 정리해야 합니다. 이 구분이 없으면 reclaim의 scan량과 실제 free량도 잘못 연결하게 됩니다.

v6.18에서는 free 대상의 전달 방식도 달라집니다

v6.18의 move_folios_to_lru()는 마지막 reference가 사라진 folio를 folio_batch에 모으고, lock을 풀어 uncharge와 free_unref_folios()를 수행합니다. v6.1처럼 입력 list를 작은 free 대상의 반환 통로로 쓰는 구현과 같지 않습니다. 반면 unevictable 재검사는 evictable 전환 시 삭제·flag 해제·재추가한다는 순서를 유지합니다. version 비교에서는 같은 함수 이름이 남아 있는지뿐 아니라 free 대상의 ownership을 누가 넘겨받는지까지 보아야 합니다.

참고 자료