파일 offset 하나가 여러 VMA의 PTE를 지울 때

파일을 줄였는데 process가 예전 PTE로 파일 끝 너머를 계속 읽을 수 있다면, inode의 i_size만 바꾸는 것으로는 truncate가 끝나지 않습니다. page cache에서 folio를 제거하는 것과 process의 주소 변환을 제거하는 것은 별개의 작업입니다. 같은 파일을 여러 번 mapping한 process도 있고, 서로 다른 process가 같은 file offset을 가리킬 수도 있습니다.

이 글은 Linux v6.18의 file-mapping unmap 경로를 따라갑니다. 핵심은 byte offset, page index, virtual address라는 세 좌표를 바꿔 가면서도 지워야 할 범위와 남겨야 할 private copy를 구별하는 일입니다.

Byte 범위를 page index로 바꿉니다

unmap_mapping_range()의 holebegin과 holelen은 byte 단위입니다. 내부의 hba와 hlen은 page index 단위입니다. 시작은 아래로, 길이는 위로 반올림합니다. 길이 0은 아무것도 하지 않는 요청이 아니라 끝까지 처리하는 관례로 이어집니다. 큰 byte 범위를 pgoff_t로 표현할 때의 overflow도 검사합니다. 호출자는 이 interface의 정렬 계약에 맞는 시작과 길이를 준비해야 하며, 임의의 비정렬 부분 byte만 PTE에서 제거할 수 있는 것은 아닙니다.

아래는 v6.18 unmap_mapping_range()의 전체입니다.

unmap_mapping_range_v6_18.c

// SPDX-License-Identifier: GPL-2.0-only
/*
 * Complete definition from Linux v6.18 mm/memory.c.
 * Source: https://github.com/torvalds/linux/blob/v6.18/mm/memory.c
 * Copyright (C) 1991, 1992, 1993, 1994 Linus Torvalds
 * Kernel excerpt; not a standalone user-space program.
 * The book's exact source tag is not asserted.
 */

void unmap_mapping_range(struct address_space *mapping,
		loff_t const holebegin, loff_t const holelen, int even_cows)
{
	pgoff_t hba = (pgoff_t)(holebegin) >> PAGE_SHIFT;
	pgoff_t hlen = ((pgoff_t)(holelen) + PAGE_SIZE - 1) >> PAGE_SHIFT;

	/* Check for overflow. */
	if (sizeof(holelen) > sizeof(hlen)) {
		long long holeend =
			(holebegin + holelen + PAGE_SIZE - 1) >> PAGE_SHIFT;
		if (holeend & ~(long long)ULONG_MAX)
			hlen = ULONG_MAX - hba + 1;
	}

	unmap_mapping_pages(mapping, hba, hlen, even_cows);
}

page 단위 helper는 first_index + nr - 1로 inclusive 끝을 계산합니다. 덧셈이 돌아가 끝이 시작보다 작아지면 ULONG_MAX로 제한합니다. nr == 0도 이 규칙에 따라 파일 mapping의 끝까지를 뜻합니다. page cache의 folio를 이 함수에서 찾아 free하는 것은 아닙니다. 이 범위와 겹치는 VMA를 찾아 그 page table을 처리합니다.

unmap_mapping_pages_v6_18.c

// SPDX-License-Identifier: GPL-2.0-only
/*
 * Complete definition from Linux v6.18 mm/memory.c.
 * Source: https://github.com/torvalds/linux/blob/v6.18/mm/memory.c
 * Copyright (C) 1991, 1992, 1993, 1994 Linus Torvalds
 * Kernel excerpt; not a standalone user-space program.
 * The book's exact source tag is not asserted.
 */

void unmap_mapping_pages(struct address_space *mapping, pgoff_t start,
		pgoff_t nr, bool even_cows)
{
	struct zap_details details = { };
	pgoff_t	first_index = start;
	pgoff_t	last_index = start + nr - 1;

	details.even_cows = even_cows;
	if (last_index < first_index)
		last_index = ULONG_MAX;

	i_mmap_lock_read(mapping);
	if (unlikely(!RB_EMPTY_ROOT(&mapping->i_mmap.rb_root)))
		unmap_mapping_range_tree(&mapping->i_mmap, first_index,
					 last_index, &details);
	i_mmap_unlock_read(mapping);
}

같은 file index를 가리키는 VMA들을 찾습니다

address_space.i_mmap은 이 mapping을 사용하는 VMA들의 interval tree입니다. tree의 key는 process의 virtual address가 아니라 file page offset 범위입니다. 따라서 process A의 낮은 주소와 process B의 높은 주소가 같은 file index와 겹치면 둘 다 순회 대상이 됩니다.

unmap_mapping_pages()는 i_mmap_lock_read()를 얻고 tree가 비지 않았으면 순회한 뒤 lock을 놓습니다. 이 read lock은 VMA 집합을 살피는 동안 구조를 보호합니다. PTE 자체를 수정하는 세부 page-table lock이나 TLB 처리를 대신하는 lock은 아닙니다.

unmap_mapping_range_tree()는 먼저 요청의 [first_index, last_index]와 각 VMA의 file-offset 범위가 겹치는 구간을 구합니다. VMA의 시작 file index vm_pgoff를 뺀 값에 PAGE_SHIFT를 적용하고 vm_start를 더하면 그 VMA에서 지울 virtual address가 나옵니다. 마지막 file index는 inclusive이므로 virtual 끝 주소로 바꿀 때에는 한 page를 더해 exclusive 끝을 만듭니다. 큰 차이를 byte로 바꾸는 연산의 overflow 경고도 코드에 남아 있습니다.

아래 두 함수는 v6.18 mm/memory.c의 전체 정의입니다. 첫 helper가 VMA 한 개를, 두 번째 helper가 겹치는 VMA들을 처리합니다.

unmap_mapping_range_vma_v6_18.c

// SPDX-License-Identifier: GPL-2.0-only
/*
 * Complete definition from Linux v6.18 mm/memory.c.
 * Source: https://github.com/torvalds/linux/blob/v6.18/mm/memory.c
 * Copyright (C) 1991, 1992, 1993, 1994 Linus Torvalds
 * Kernel excerpt; not a standalone user-space program.
 * The book's exact source tag is not asserted.
 */

static void unmap_mapping_range_vma(struct vm_area_struct *vma,
		unsigned long start_addr, unsigned long end_addr,
		struct zap_details *details)
{
	zap_page_range_single(vma, start_addr, end_addr - start_addr, details);
}

unmap_mapping_range_tree_v6_18.c

// SPDX-License-Identifier: GPL-2.0-only
/*
 * Complete definition from Linux v6.18 mm/memory.c.
 * Source: https://github.com/torvalds/linux/blob/v6.18/mm/memory.c
 * Copyright (C) 1991, 1992, 1993, 1994 Linus Torvalds
 * Kernel excerpt; not a standalone user-space program.
 * The book's exact source tag is not asserted.
 */

static inline void unmap_mapping_range_tree(struct rb_root_cached *root,
					    pgoff_t first_index,
					    pgoff_t last_index,
					    struct zap_details *details)
{
	struct vm_area_struct *vma;
	pgoff_t vba, vea, zba, zea;

	vma_interval_tree_foreach(vma, root, first_index, last_index) {
		vba = vma->vm_pgoff;
		vea = vba + vma_pages(vma) - 1;
		zba = max(first_index, vba);
		zea = min(last_index, vea);

		unmap_mapping_range_vma(vma,
			((zba - vba) << PAGE_SHIFT) + vma->vm_start,
			((zea - vba + 1) << PAGE_SHIFT) + vma->vm_start,
				details);
	}
}

예를 들어 VMA가 file index 100부터 107까지를 주소 A부터 mapping하고 있다면 index 102부터 104까지의 제거는 [A + 2P, A + 5P)에 해당합니다. P는 base page 크기입니다. 같은 file index를 주소 B부터 mapping한 다른 VMA에는 그 VMA의 vm_pgoff를 기준으로 다시 계산해야 합니다. file index와 process 주소를 같은 숫자로 취급하지 않는 것이 중요합니다.

File index와 두 VMA의 virtual address 범위
File index와 두 VMA의 virtual address 범위

그림은 file index 103에서 시작하는 두 번째 VMA와의 교집합도 함께 표시합니다. 두 VMA의 virtual 시작 주소 A와 B는 서로 다르며 숫자 크기는 생략했습니다. present file PTE와 order-0 page를 가정하며 private COW, huge mapping, concurrent fault는 생략했습니다. 제거 뒤에도 VMA 자체는 남습니다.

COW와 특정 folio라는 두 필터가 있습니다

even_cows가 참이면 details.even_cows를 통해 private COW page까지 처리하도록 요청합니다. 거짓일 때에는 원래 file mapping을 제거하는 일과 이미 분리된 private copy를 지우는 일을 구별합니다. 파일 끝을 줄이는 wrapper와 hole-punch wrapper가 이 인자를 다르게 넘기는 이유도 여기에 있습니다. 인자만으로 모든 anonymous page를 전역에서 제거하는 것은 아닙니다. 어디까지나 이 mapping에서 찾은 VMA와 지정 범위 안의 작업입니다.

unmap_mapping_folio()는 더 좁은 목적을 가집니다. locked folio의 mapping과 index, folio_nr_pages()를 사용하고, details.single_folio에 해당 folio를 넣습니다. file range가 같아도 그 사이 교체된 다른 folio까지 무조건 지우려는 요청과는 구별됩니다. even_cows = false이고 ZAP_FLAG_DROP_MARKER도 지정합니다. marker를 실제 present page와 동일시하지 않는 조건입니다.

unmap_mapping_folio_v6_18.c

// SPDX-License-Identifier: GPL-2.0-only
/*
 * Complete definition from Linux v6.18 mm/memory.c.
 * Source: https://github.com/torvalds/linux/blob/v6.18/mm/memory.c
 * Copyright (C) 1991, 1992, 1993, 1994 Linus Torvalds
 * Kernel excerpt; not a standalone user-space program.
 * The book's exact source tag is not asserted.
 */

void unmap_mapping_folio(struct folio *folio)
{
	struct address_space *mapping = folio->mapping;
	struct zap_details details = { };
	pgoff_t	first_index;
	pgoff_t	last_index;

	VM_BUG_ON(!folio_test_locked(folio));

	first_index = folio->index;
	last_index = folio_next_index(folio) - 1;

	details.even_cows = false;
	details.single_folio = folio;
	details.zap_flags = ZAP_FLAG_DROP_MARKER;

	i_mmap_lock_read(mapping);
	if (unlikely(!RB_EMPTY_ROOT(&mapping->i_mmap.rb_root)))
		unmap_mapping_range_tree(&mapping->i_mmap, first_index,
					 last_index, &details);
	i_mmap_unlock_read(mapping);
}

함수는 folio lock을 새로 얻지 않고 이미 잠겼는지 검사합니다. caller가 유효한 file mapping을 가진 locked folio를 넘겨야 하며, 이 함수에는 mapping NULL을 받아 즉시 반환하는 방어 분기가 없습니다. i_mmap read lock을 얻어 VMA들을 찾는 동안 cache XArray의 lock과는 다른 구조를 보호합니다.

PTE 제거는 TLB와 외부 관찰자에게도 알려야 합니다

VMA 한 개로 범위를 좁혀도 PTE만 지우고 끝낼 수는 없습니다. CPU의 TLB에 예전 주소 변환이 남아 있을 수 있고, MMU notifier를 통해 그 mapping을 추적하는 다른 subsystem도 있습니다. v6.18의 zap_page_range_single()은 TLB gather를 시작하고 batched helper를 부른 뒤 gather를 끝냅니다.

zap_page_range_single_v6_18.c

// SPDX-License-Identifier: GPL-2.0-only
/*
 * Complete definition from Linux v6.18 mm/memory.c.
 * Source: https://github.com/torvalds/linux/blob/v6.18/mm/memory.c
 * Copyright (C) 1991, 1992, 1993, 1994 Linus Torvalds
 * Kernel excerpt; not a standalone user-space program.
 * The book's exact source tag is not asserted.
 */

void zap_page_range_single(struct vm_area_struct *vma, unsigned long address,
		unsigned long size, struct zap_details *details)
{
	struct mmu_gather tlb;

	tlb_gather_mmu(&tlb, vma->vm_mm);
	zap_page_range_single_batched(&tlb, vma, address, size, details);
	tlb_finish_mmu(&tlb);
}

batched helper는 notifier의 invalidate 시작과 끝으로 실제 unmap_single_vma() 호출을 감쌉니다. hugetlb_zap_begin()은 notifier가 다뤄야 하는 범위를 넓힐 수 있지만, 그 확장된 범위를 그대로 unmap_single_vma()의 주소 범위로 바꾸지는 않습니다. 코드가 별도로 보관한 원래 address와 end를 사용합니다. hugetlb 처리가 있었다면 TLB를 마무리하고 hugetlb_zap_end()를 호출한 후 다시 gather를 준비하는 경로도 있습니다.

zap_page_range_single_batched_v6_18.c

// SPDX-License-Identifier: GPL-2.0-only
/*
 * Complete definition from Linux v6.18 mm/memory.c.
 * Source: https://github.com/torvalds/linux/blob/v6.18/mm/memory.c
 * Copyright (C) 1991, 1992, 1993, 1994 Linus Torvalds
 * Kernel excerpt; not a standalone user-space program.
 * The book's exact source tag is not asserted.
 */

void zap_page_range_single_batched(struct mmu_gather *tlb,
		struct vm_area_struct *vma, unsigned long address,
		unsigned long size, struct zap_details *details)
{
	const unsigned long end = address + size;
	struct mmu_notifier_range range;

	VM_WARN_ON_ONCE(!tlb || tlb->mm != vma->vm_mm);

	mmu_notifier_range_init(&range, MMU_NOTIFY_CLEAR, 0, vma->vm_mm,
				address, end);
	hugetlb_zap_begin(vma, &range.start, &range.end);
	update_hiwater_rss(vma->vm_mm);
	mmu_notifier_invalidate_range_start(&range);
	/*
	 * unmap 'address-end' not 'range.start-range.end' as range
	 * could have been expanded for hugetlb pmd sharing.
	 */
	unmap_single_vma(tlb, vma, address, end, details, false);
	mmu_notifier_invalidate_range_end(&range);
	if (is_vm_hugetlb_page(vma)) {
		/*
		 * flush tlb and free resources before hugetlb_zap_end(), to
		 * avoid concurrent page faults' allocation failure.
		 */
		tlb_finish_mmu(tlb);
		hugetlb_zap_end(vma, details);
		tlb_gather_mmu(tlb, vma->vm_mm);
	}
}

여기서 unmap은 file data를 device에 저장했다는 뜻도, 모든 folio의 물리 memory가 반환되었다는 뜻도 아닙니다. PTE mapping에 따르던 참조와 주소 변환을 정리한 뒤에도 page cache, 다른 mapping, private state, 다른 caller의 참조가 남을 수 있습니다. 반대로 truncate의 cache 제거는 새로운 fault가 예전 data를 다시 찾아오지 못하도록 하는 별도의 책임을 맡습니다. 두 작업을 구별해야 truncate 전후의 두 번 unmap도 이해할 수 있습니다.

참고 자료

  • Lorenzo Stoakes, The Linux Memory Manager, Early Access 2025, Chapter 9의 page-cache invalidation과 mapping unmap 설명.
  • Linux v6.18 mm/memory.c: 위에 표시한 일곱 함수의 전체 정의와 zap 경로.
  • Linux v6.18 mm/truncate.c: truncate 및 hole-punch wrapper가 unmap 범위와 even_cows를 선택하는 경로.