# Truncate는 cache entry만 지우는 작업이 아닙니다

파일을 줄였는데 이미 mmap한 process가 예전 데이터를 계속 읽을 수 있다면 truncation은 끝난 것이 아닙니다. cache의 index를 지우는 일과 page table에서 접근을 철회하는 일, 경계에 남은 byte를 zero로 만드는 일, filesystem block을 다른 용도로 돌리는 일은 서로 연결되어 있지만 같은 작업은 아닙니다.

이 글은 upstream Linux `v6.18`의 generic page-cache truncation을 기준으로 설명합니다. filesystem별 block 해제와 journal transaction 전체를 generic memory helper 하나가 대신한다고 가정하지 않습니다.

## 먼저 사라져야 할 범위를 정합니다

EOF 축소는 파일 길이를 바꿉니다. 반면 hole punch는 파일 길이를 유지하면서 내부의 특정 범위를 비어 있는 영역으로 만듭니다. inode 파괴 직전에는 mapping 전체를 최종 정리하는 또 다른 문맥이 있습니다. 세 작업이 일부 helper를 공유해도 lock과 private CoW 처리까지 같은 것은 아닙니다.

`truncate_setsize()`는 `i_size_write()`로 새 크기를 반영하고 확장에 필요한 처리와 `truncate_pagecache()`를 연결합니다. caller는 truncate와 write를 직렬화하는 filesystem의 lock 계약을 지켜야 합니다. 일반적인 inode lock만 떠올리고 모든 filesystem에 같은 lock 구현을 강제할 수는 없습니다.

이 함수는 보통 size 속성을 바꾸는 filesystem의 setattr 경로에서 사용합니다. filesystem이 관련 block을 해제하기 전에 cache 쪽의 논리적 범위를 먼저 맞춰야 합니다. 다음은 [v6.18의 전체 정의](https://github.com/torvalds/linux/blob/v6.18/mm/truncate.c)이며, 단순 축소만 아니라 확장 때의 `pagecache_isize_extended()` 연결도 그대로 포함합니다. 이 글의 include는 source와 GPL-2.0-only attribution을 보존한 kernel 발췌이며 standalone program은 아닙니다.

<!-- include-code:code/truncate_setsize_v6_18.c -->

storage block을 해제하거나 재배치하는 작업과 cache 정리의 순서도 맞아야 합니다. 오래된 cache를 통해 뒤늦게 제출된 I/O가 이미 다른 용도로 재사용한 block을 덮으면 데이터가 손상됩니다. memory 쪽 cleanup을 끝냈다는 사실만으로 filesystem의 block transaction까지 완성되는 것은 아닙니다.

## 부분 page에는 삭제 대신 zero가 필요합니다

4 KiB page를 사용하는 파일이 16384 byte에서 6000 byte로 줄어든다고 가정하겠습니다. index 0은 그대로 남고, index 1에는 유효한 1904 byte와 제거된 2192 byte가 함께 있습니다. index 2 이후와 달리 경계 page 전체를 버리면 남겨야 할 file data도 잃게 됩니다.

경계의 남는 folio에는 잘린 byte 범위를 zero로 처리합니다. 나중에 파일이 다시 늘거나 해당 cached page에 접근할 때 과거 내용을 노출하지 않도록 하기 위해서입니다. large folio에서는 이 경계가 base page보다 더 큰 객체 안에 들어갈 수 있어 split과 그 실패도 고려해야 합니다.

![EOF 6000 byte에서 남기는 범위와 zero 처리하는 범위, 완전히 제거하는 page를 구별했습니다. 4 KiB order-0 folio와 shared file-backed mapping을 가정하며 private CoW와 실제 storage extent는 제외했습니다. 아래 buffer_head 부분은 별도의 block mapping 예시입니다.](assets/truncate_buffers.png)

이 그림의 경계 page PTE가 항상 present라는 뜻은 아닙니다. page cache의 byte를 zero로 만드는 일과 PTE가 존재하는지를 구분해야 합니다. EOF 밖으로 완전히 나간 page와 마지막 partial page가 같은 fault 결과를 갖는 것도 아닙니다.

## folio 제거에는 lock과 accounting이 함께 필요합니다

`filemap_remove_folio()`의 입력은 locked folio입니다. inode lock과 XArray lock 아래에서 mapping의 accounting을 갱신하고 tree에서 entry를 지웁니다. 실제 삭제를 맡는 내부 계열은 필요한 문맥에서 shadow value로 대체할 수도 있지만, 단순 제거 caller가 shadow를 요구하지 않으면 빈 entry로 만듭니다.

entry를 제거하면 DIRTY·WRITEBACK 등 mark와 `mapping->nrpages`도 같은 상태 변화에 맞추어 정리해야 합니다. large folio를 지울 때 `nrpages` 감소량은 객체 한 개가 아니라 base-page 수입니다. folio의 `mapping`을 끊는 것은 뒤늦게 lock을 얻는 reader가 더 이상 원래 cache에 속하지 않음을 확인하는 표지가 됩니다.

그 뒤 filesystem의 `free_folio` callback과 cache 참조를 정리합니다. 하지만 모든 caller의 참조가 동시에 사라지는 것은 아닙니다. lookup reader가 잡은 참조가 남아 있으면 XArray에서 지워진 뒤에도 folio 객체는 살아 있을 수 있습니다. cache 삭제를 곧 physical memory 반환으로 읽어서는 안 됩니다. [single·batch 제거 구현](https://github.com/torvalds/linux/blob/v6.18/mm/filemap.c)

batch 제거는 여러 folio의 XArray 변경과 accounting을 묶어 처리합니다. batch가 가리키는 대상을 정확히 확인하고, 순회 중 새로 들어간 entry를 무관한 대상으로 잘못 삭제하지 않아야 합니다. index를 유지하는 규칙은 삭제 후 순회를 이어 갈 때도 중요합니다.

[Cache entry 제거와 참조의 전체 구현](15-cache-entry-removal.md)은 이 단계의 일곱 함수를 별도로 다룹니다. 아래 범위 worker가 호출하는 batch 제거 역시 같은 lock·accounting·참조 계약을 사용합니다.

## 두 pass는 빨리 지울 대상과 기다릴 대상을 나눕니다

`truncate_inode_pages_range()`는 byte 범위를 완전히 제거할 page 구간과 양끝의 partial 구간으로 나눕니다. mapping이 비어 있으면 할 일이 없습니다. 그렇지 않으면 folio batch와 index 배열을 준비합니다.

첫 pass는 `find_lock_entries()`를 사용합니다. 즉시 lock할 수 있고 writeback을 기다릴 필요가 없는 대상부터 정리합니다. exceptional value를 실제 folio처럼 처리하지 않도록 걸러 내고, `truncate_cleanup_folio()`로 mapped 상태와 filesystem private state를 정리한 뒤 batch에서 cache entry를 제거합니다.

이 pass가 잠긴 folio를 건너뛴다는 점이 중요합니다. 모든 대상을 당장 기다리지 않고 제거할 수 있는 대상을 먼저 없애면 영향 범위의 불필요한 I/O와 두 번째 검색 비용을 줄일 수 있습니다. 하지만 첫 pass만으로 전체 truncation이 끝났다고 할 수는 없습니다.

양끝에서는 `FGP_LOCK` lookup으로 경계 folio를 잡고 부분 범위를 처리합니다. 시작과 끝이 같은 large folio에 속할 수도 있어 두 번 처리하지 않도록 범위를 조정합니다. 전체 folio를 제거할 수 없으면 zero와 private invalidation을 적용합니다. split이 실패한 large folio에 남은 유효한 부분까지 버리지 않도록 이후 순회 범위를 조정하는 경로도 있습니다.

두 번째 pass는 raw entry를 다시 찾고 folio lock과 writeback 완료를 기다립니다. 첫 pass가 건너뛴 대상을 마저 처리하려는 단계입니다. 순회 중 entry가 사라졌거나 이미 일부 범위를 처리했더라도 필요한 범위를 놓치지 않도록 다시 검색하는 종료 조건이 있습니다. 이 단계에서도 exceptional value는 folio가 아닙니다.

byte 입력 `lend`는 inclusive지만 완전히 제거할 page 구간의 `end`는 exclusive입니다. `lend == -1`은 unsigned 최대 index로 끝까지 처리할 특별한 입력입니다. 현재 lookup들은 `&index`를 갱신하므로 오래된 값 인자 구현의 수동 `indices[last] + 1` 전진을 덧붙이면 안 됩니다. partial lookup도 현재는 `IS_ERR()`로 실패를 검사합니다. `folio_batch_release()`는 stack의 batch 객체를 free하는 함수가 아니라 batch가 가진 folio 참조를 정리합니다.

다음은 [현재 범위 worker 전체](https://github.com/torvalds/linux/blob/v6.18/mm/truncate.c)입니다. 첫 pass의 nonblocking은 folio lock·writeback을 기다리지 않는다는 구별이며, 전체 truncation 함수가 allocation·reschedule까지 없는 atomic 작업이라는 뜻은 아닙니다. 두 번째 pass에서 검색이 끝났더라도 시작 위치가 아니었다면 범위 처음부터 다시 확인합니다.

<!-- include-code:code/truncate_inode_pages_range_v6_18.c -->

끝까지 제거하는 wrapper는 이 함수에 end -1을 전달합니다. caller의 inode·invalidate lock 계약이 필요하며, 반환 순간에도 concurrent 삭제가 진행 중이라 `mapping->nrpages`를 단순한 완료 판정 counter로 사용할 수 없다는 점을 구별해야 합니다.

<!-- include-code:code/truncate_inode_pages_v6_18.c -->

## cleanup과 partial invalidation의 책임이 다릅니다

`truncate_inode_folio()`는 locked folio가 아직 기대한 mapping에 있는지 확인한 뒤 cleanup과 실제 제거를 수행합니다. 다른 thread가 먼저 제거했다면 남은 pointer를 원래 cache entry라고 믿고 계속 진행하지 않습니다.

mapping이 다르면 `-EIO`, cleanup·제거를 수행하면 0입니다. 이 mismatch는 lock 대기 중 reclaim이나 invalidation이 먼저 처리한 경우에도 발생할 수 있습니다. 이미 관련 범위의 의미를 제거하는 truncation 내부 결과를 곧바로 storage read의 media 오류라고 해석할 필요는 없습니다.

<!-- include-code:code/truncate_inode_folio_v6_18.c -->

`truncate_cleanup_folio()`는 mapped folio의 PTE를 철회하고 filesystem의 private state를 무효화합니다. buffer나 extent 관련 상태가 있으면 `invalidate_folio` callback이 해당 범위를 정리합니다. dirty accounting을 취소할 필요도 있지만, 이것은 storage에 성공적으로 써서 dirty 책임을 마친 경우와는 다릅니다.

현재 검사는 `folio_needs_release()`이며 과거 `folio_has_private()`와 같은 표현으로 고정하지 않습니다. 일부 filesystem은 invalidation 중 다시 dirty로 만들 수 있으므로 dirty 취소는 그 뒤에 옵니다. 과거 code 끝의 mapped-to-disk flag clear도 현재 함수 전체에 같은 줄이 남아 있는 것은 아닙니다.

<!-- include-code:code/truncate_cleanup_folio_v6_18.c -->

`truncate_inode_partial_folio()`는 file offset을 folio 안의 offset과 길이로 바꿉니다. writeback이 진행 중이면 기다리고, 요청이 folio 전체를 덮으면 통째 제거할 수 있습니다. 그렇지 않으면 필요한 범위만 zero로 만들고 private state를 invalidate합니다. 이 byte 범위 계약 덕분에 page 정렬되지 않은 hole punch도 경계 내용을 보존할 수 있습니다.

현재 large-folio 처리는 단순한 order-0 알고리즘에 붙인 부가 숫자만은 아닙니다. mapping의 최소 order와 split 또는 unmap 가능성을 함께 고려합니다. split 실패를 무시하고 전체 folio가 제거되었다고 계산하면 잔존 cache와 accounting을 잘못 해석하게 됩니다. [범위 truncation과 partial folio](https://github.com/torvalds/linux/blob/v6.18/mm/truncate.c)

current helper는 `mapping_inaccessible()`인 특별한 mapping에서는 일반 CPU zero를 수행하지 않습니다. 그 외에는 필요한 byte를 먼저 zero하고, release가 필요한 private state를 범위 단위로 invalidate합니다. 큰 folio의 split이 실패해도 clean folio라면 cache에서 통째로 제거하고 남은 유효 내용은 나중에 다시 읽을 수 있습니다. dirty folio는 그렇게 버릴 수 없어 false로 반환하고 caller가 이후 전체 제거 범위를 줄입니다. split 성공 시 두 번째 경계에 남은 큰 folio도 ref·trylock·mapping 재검사를 거쳐 추가 split을 시도합니다.

<!-- include-code:code/truncate_inode_partial_folio_v6_18.c -->

`try_folio_split_or_unmap()`는 split 실패에서 non-shmem folio를 unmap하여 이후 PTE 단위 fault가 EOF/SIGBUS 의미를 지킬 수 있게 합니다. shmem/tmpfs에는 i_size를 가로지르는 PMD 사용의 별도 예외가 있습니다. 이 unmap은 split을 성공으로 바꾸는 것이 아니며 helper는 원래 split 결과를 반환합니다.

<!-- include-code:code/try_folio_split_or_unmap_v6_18.c -->

## process PTE는 i_mmap을 통해 별도로 철회합니다

`unmap_mapping_range()`는 byte 범위를 page 범위로 바꾸며 overflow와 끝까지 처리하는 문맥을 고려합니다. `unmap_mapping_pages()`는 mapping의 `i_mmap` interval tree에서 겹치는 VMA를 찾아 virtual address 범위로 연결합니다. file offset의 범위를 process마다 다른 주소에 대응시키는 과정입니다.

순회에는 `i_mmap_rwsem`이, 실제 PTE 변경에는 page-table lock이 필요합니다. zap 경로는 MMU notifier와 TLB invalidation 등 필요한 절차로 이미 사용하던 translation을 철회합니다. cache에 entry가 없다는 사실만으로 CPU의 기존 접근 경로까지 자동으로 없어지지 않습니다.

[File range를 여러 VMA의 virtual address로 바꾸는 경로](16-file-range-unmapping.md)에서는 좌표 변환과 COW·single-folio 조건, TLB gather를 일곱 함수의 전체 정의로 따로 따라갑니다.

`truncate_pagecache()`가 cache 정리 전후에 두 번 unmap하는 이유 중 하나는 늦게 진행한 private CoW 경쟁까지 처리하는 데 있습니다. 반면 hole-punch용 `truncate_pagecache_range()`는 private CoW page를 같은 방식으로 없애지 않습니다. `even_cows` 조건을 같게 설정하면 원래 달라야 하는 의미를 섞게 됩니다.

EOF 축소 wrapper는 새 i_size가 이미 반영되어 있어야 하며, `round_up(newsize, PAGE_SIZE)`부터 끝까지를 unmap합니다. 첫 unmap은 이후 개별 folio unmap 비용을 줄이고, 마지막 unmap은 중간에 만들어진 private CoW page까지 철회하는 정확성 조건입니다.

<!-- include-code:code/truncate_pagecache_v6_18.c -->

hole-punch wrapper는 완전히 포함된 page 쪽으로 unmap 범위를 안쪽 정렬합니다. byte-range unmap helper 자체가 바깥쪽 정렬을 하는 것과 반대입니다. 여기의 `lend`는 마지막 byte이며 -1/end-of-file와 unmap 길이 0의 특별한 의미도 함께 읽어야 합니다. 이 경로는 한 번만, `even_cows=0`으로 unmap한 뒤 cache 범위를 정리합니다.

<!-- include-code:code/truncate_pagecache_range_v6_18.c -->

이 unmap은 `munmap()`처럼 VMA 자체를 없애는 작업과 다릅니다. VMA는 남고 PTE만 철회될 수 있습니다. 이후 잘린 file offset을 접근하면 다시 fault가 발생하고 현재 EOF에 따라 SIGBUS로 이어질 수 있습니다. [mapping unmap과 zap](https://github.com/torvalds/linux/blob/v6.18/mm/memory.c)

## inode의 마지막 정리는 새 eviction 흔적도 막습니다

`truncate_inode_pages_final()`은 일반적인 파일 크기 변경과 다른 수명 단계에서 호출됩니다. 현재 구현은 mapping을 exiting 상태로 만들고 필요한 XArray lock 직렬화 뒤 전체 내용을 정리합니다. reclaim이 마지막 truncation이 시작된 뒤 새 eviction 정보를 설치하는 경쟁도 막아야 하기 때문입니다.

이 함수 하나가 모든 가능한 외부 caller를 자동으로 중지시키는 것은 아닙니다. inode teardown에 들어갈 수 있도록 상위 수명 계약이 이미 성립해야 합니다. 최종 정리, 일반 EOF 축소와 hole punch의 호출 조건을 나누어 읽으면 같은 truncate라는 이름 뒤에 어떤 보호가 필요한지 드러납니다.

다음 전체 정의에서 exiting 표시 뒤 tree lock을 잠깐 cycle하는 순서를 볼 수 있습니다. exiting 표시를 아직 보지 못한 이전 modifier가 끝난 뒤 마지막 truncation을 시작하려는 직렬화입니다. 이 함수는 정상적인 inode 수명 참조를 얻을 수 없는 reclaim과의 경합도 고려합니다.

<!-- include-code:code/truncate_inode_pages_final_v6_18.c -->

## 참고 자료

- Lorenzo Stoakes, *The Linux Memory Manager*, Early Access 2025, Chapter 9, §9.9.1~9.9.3.
- Linux v6.18: [filemap.c](https://github.com/torvalds/linux/blob/v6.18/mm/filemap.c), [truncate.c](https://github.com/torvalds/linux/blob/v6.18/mm/truncate.c), [memory.c](https://github.com/torvalds/linux/blob/v6.18/mm/memory.c).
