# fault가 돌아왔을 때 mmap lock은 누구에게 있나요

memory 함수의 반환값을 성공과 실패로만 나누면 중요한 상태를 놓칩니다. page를 얻었어도 VMA 보호가 중간에 끊겼을 수 있고, retry를 요청했어도 lock은 그대로 남아 있을 수 있습니다. 반대로 mapping을 지우는 함수가 끝날 무렵에는 writer가 reader로 바뀌어 있을 수 있습니다.

이 글은 공식 Linux `v6.0`의 경로를 고정하여 이런 전환을 살펴봅니다. 역사적 `__do_munmap()`과 GUP의 `locked` 계약을 현재 API 이름으로 바꾸어 설명하지 않습니다. 책에 사용된 정확한 upstream tag는 확인하지 않았으며, 아래 호출관계는 kernel 구현을 대체하는 C code가 아닙니다.

## mapping을 바꾸는 단계와 population을 나눕니다

`mmap` 계열의 syscall 진입점은 architecture에 따라 달라도 `ksys_mmap_pgoff()`와 `vm_mmap_pgoff()` 쪽으로 모입니다. 실제 lock 경계는 wrapper 이름이 아니라 본체에서 확인해야 합니다. [v6.0 vm_mmap_pgoff()](https://github.com/torvalds/linux/blob/v6.0/mm/util.c#L539-L560)는 security 검사를 통과한 뒤 killable writer를 얻고 `do_mmap()`을 호출합니다. writer 획득이 중단되면 `-EINTR`이며 `do_mmap()`은 실행되지 않습니다.

이 wrapper는 writer를 반환한 뒤 userfaultfd 완료 처리를 하고, 필요하면 `mm_populate()`를 호출합니다. VMA의 범위와 index를 구성하는 단계와 실제 page를 fault-in하는 단계가 같은 critical section이 아닙니다. `brk()`와 `mremap()` 역시 구조를 바꾸는 writer 경로이지만, 하위 unmap helper에 전달하는 downgrade 허용 여부까지 같다고 가정해서는 안 됩니다.

deprecated `remap_file_pages()`는 별도의 사례입니다. [v6.0의 구현](https://github.com/torvalds/linux/blob/v6.0/mm/mmap.c#L2891-L2966)은 writer 아래 기존 shared VMA를 검사하고, 여러 VMA를 가로지르면 hole·file·flags가 맞는지도 확인합니다. file reference를 확보한 상태에서 `MAP_FIXED` 계열의 `do_mmap()`으로 다시 구성하고 writer를 반환한 뒤 population을 처리합니다. 단순히 PTE 몇 개를 read lock 아래 교환하는 전용 빠른 경로로 읽으면 실제 구현과 달라집니다.

## munmap의 downgrade는 조건부입니다

v6.0의 syscall `munmap()`은 `__vm_munmap(..., true)`를 사용하지만, kernel 내부 `vm_munmap()`은 같은 wrapper에 `false`를 넘깁니다. `__vm_munmap()`이 writer를 얻은 뒤 `__do_munmap()`에 그 선택을 전달합니다. 그러므로 같은 하위 함수를 부른다는 사실만으로 두 caller가 모두 downgrade한다고 결론 내릴 수 없습니다.

downstream에서는 VMA를 index에서 분리한 뒤에도 실제 PTE 제거와 VMA 정리가 남습니다. [detach_vmas_to_be_unmapped()](https://github.com/torvalds/linux/blob/v6.0/mm/mmap.c#L2632-L2670)는 이웃에 `VM_GROWSDOWN` 또는 `VM_GROWSUP` VMA가 있으면 downgrade를 금지합니다. 당시에는 read lock 아래 stack 경계가 변할 수 있어, 정리 중인 범위와 충돌하는 것을 막아야 했기 때문입니다. 현대의 stack writer 규칙을 이 역사적 조건에 소급해서는 안 됩니다.

실제로 downgrade한 `__do_munmap()`의 반환값은 1입니다. [__vm_munmap()](https://github.com/torvalds/linux/blob/v6.0/mm/mmap.c#L2850-L2885)은 이를 “read lock을 보유하고 돌아왔다”는 내부 신호로 해석해 read unlock을 하고, user에게는 성공값 0을 돌려줍니다. 다른 결과에서는 writer를 반환합니다. 1을 일반적인 syscall 성공 byte 수나 오류 code로 해석하면 unlock 모드부터 틀어집니다.

## exec와 fork는 서로 다른 mm을 다룹니다

ELF의 `load_elf_binary()`는 `begin_new_exec()`로 새 실행의 mm 전환을 준비하고 segment를 `elf_map()`으로 배치합니다. [elf_map()](https://github.com/torvalds/linux/blob/v6.0/fs/binfmt_elf.c#L365-L402)은 첫 mapping에서 image 전체 크기를 확보한 뒤 불필요한 나머지를 `vm_munmap()`으로 제거할 수 있습니다. 이는 segment 사이 hole을 처리하고 randomization 때 다른 image와 겹치지 않게 하기 위한 과정입니다.

[exec_mmap()](https://github.com/torvalds/linux/blob/v6.0/fs/exec.c#L977-L1039)이 얻는 mmap read lock은 **old mm**의 것입니다. 별도의 `exec_update_lock`과 task 보호 아래 새 `mm`·`active_mm`을 설치한 뒤 old mm의 read lock을 풀고 `mmput()`합니다. 새 image를 만드는 writer와 old mm을 전환하는 reader를 하나의 mm에 대한 중첩 lock처럼 그려서는 안 됩니다.

fork 계열은 `kernel_clone()`에서 `copy_process()`·`copy_mm()`으로 이어집니다. [copy_mm()](https://github.com/torvalds/linux/blob/v6.0/kernel/fork.c#L1547-L1585)은 `CLONE_VM`이면 `mmget()`으로 같은 mm을 공유하고, 아니면 `dup_mm()`으로 복제합니다. `CLONE_VM`을 사용한다는 사실은 thread group이 같다는 뜻과 동일하지 않습니다. `vfork()`도 주소 공간 공유를 사용하는 중요한 반례입니다.

MMU build의 [dup_mmap()](https://github.com/torvalds/linux/blob/v6.0/kernel/fork.c#L580-L725)은 old mm의 killable writer를 먼저, 새 mm의 nested writer를 다음으로 얻습니다. VMA와 page table을 복제한 뒤 새 mm을 unlock하고 old mm의 TLB를 처리한 다음 old writer를 반환합니다. old lock 획득 실패, allocation 실패, 부분 복제의 정리는 정상 완료와 구별됩니다. 마지막 `mm_users`를 내리는 `mmput()`은 `__mmput()`·`exit_mmap()`으로 이어지고, `mmput_async()`는 그 마지막 정리를 workqueue로 넘깁니다. [v6.0 exit_mmap()](https://github.com/torvalds/linux/blob/v6.0/mm/mmap.c#L3075-L3130)의 writer와 OOM reaper 배제도 이 teardown의 일부입니다.

## fault 분기마다 기다리는 대상이 다릅니다

다음은 **구현코드가 아닌 호출관계**입니다. 모든 화살표를 무조건 실행하거나 같은 lock을 새로 얻는다는 뜻은 아닙니다.

```text
do_user_addr_fault -> handle_mm_fault -> __handle_mm_fault
  handle_pte_fault
    do_anonymous_page / do_swap_page / do_numa_page / do_wp_page
    do_fault -> do_read_fault / do_cow_fault / do_shared_fault
      __do_fault -> vma->vm_ops->fault -> filemap_fault
```

missing anonymous PTE의 첫 write와 present read-only PTE의 write-protection fault는 다른 분기입니다. `do_numa_page()`는 NUMA hint용 접근 제한을 다룹니다. [v6.0 본체](https://github.com/torvalds/linux/blob/v6.0/mm/memory.c#L4681-L4777)는 PTL 아래 원래 PTE가 그대로인지 검증하고, migration을 시도할 때 PTL을 풉니다. migration 실패 뒤에도 다시 PTE를 검증합니다. 여기서 풀리는 PTL을 mmap read lock과 혼동해서는 안 됩니다.

`do_swap_page()`가 보는 nonpresent encoding도 모두 disk swap은 아닙니다. device-exclusive entry이면 `remove_device_exclusive_entry()`로 가고, 일반 swap-in page이면 `lock_page_or_retry()`에서 page lock을 기다릴 수 있습니다. [exclusive 복구](https://github.com/torvalds/linux/blob/v6.0/mm/memory.c#L3613-L3636)는 page lock을 얻은 뒤 MMU notifier 구간 안에서 PTL을 잡고 원래 entry와 같은지 재검사합니다. 경쟁 중 다른 PTE가 설치됐으면 과거 값을 무조건 복원하지 않습니다.

## RETRY만으로 unlock을 결정하지 않습니다

[__folio_lock_or_retry()](https://github.com/torvalds/linux/blob/v6.0/mm/filemap.c#L1713-L1744)의 첫 retry 조건은 `ALLOW_RETRY`가 있고 아직 `TRIED`가 없는 경우입니다. 이때 `RETRY_NOWAIT`까지 있으면 false를 돌려주면서 **mmap lock을 유지**합니다. 그렇지 않으면 mmap read lock을 놓고 folio unlock을 기다린 뒤 false를 반환합니다. retry가 허용되지 않은 경우에는 folio lock을 직접 기다리며, killable 획득이 실패하면 mmap lock을 놓고 false를 반환하는 별도 경로도 있습니다.

file fault의 [lock_folio_maybe_drop_mmap()](https://github.com/torvalds/linux/blob/v6.0/mm/filemap.c#L2928-L2959)도 trylock 성공과 `RETRY_NOWAIT`를 먼저 구분합니다. 필요하면 [maybe_unlock_mmap_for_io()](https://github.com/torvalds/linux/blob/v6.0/mm/internal.h#L607-L627)가 file reference를 얻고 mmap read lock을 반환합니다. `fpin`은 이 **file**을 붙잡은 pointer이지 GUP의 folio pin이 아닙니다. 이미 `fpin`이 있으면 다시 reference를 얻거나 같은 mmap lock을 두 번 반환하지 않습니다.

folio의 killable 획득이 실패했는데 `fpin`이 없으면 helper가 mmap read lock을 직접 반환하는 분기도 있습니다. 따라서 false·0·`VM_FAULT_RETRY`를 모두 “같은 unlock 완료 상태”로 축약할 수 없습니다. 상위 caller의 flags와 명시적인 lock 상태 계약이 필요합니다. fault가 lock을 놓고 완료·재시도 상태를 반환한다는 설명은, 모든 retry에서 lock이 없다는 역명제를 뜻하지 않습니다.

## GUP의 locked는 critical section의 연속성도 알립니다

historical [__get_user_pages_locked()](https://github.com/torvalds/linux/blob/v6.0/mm/gup.c#L1387-L1516)는 caller가 `locked` pointer를 주면 초기값이 1인지 확인합니다. retry를 허용하는 이 방식에서는 VMA pointer 배열을 함께 반환할 수 없습니다. 그 pointer들의 보호가 중간에 사라질 수 있기 때문입니다.

하위 fault가 lock을 놓으면 이 helper는 이미 처리한 page 수와 다음 주소를 갱신하고, fatal signal을 확인한 뒤 killable read lock을 재획득합니다. fault를 일으킨 page 하나를 `FOLL_TRIED`로 다시 처리합니다. 재획득 후 일부 작업이 성공했더라도, 과거 critical section이 끊겼다는 사실을 감추지 않기 위해 마지막에 다시 unlock하고 `*locked = 0`으로 caller에게 알릴 수 있습니다. caller가 예전 VMA pointer를 계속 써도 된다는 뜻의 1이 아닙니다.

`get_user_pages_unlocked()`·`get_dump_page()`는 내부에서 read lock을 얻습니다. 하위 호출 뒤에도 `locked`가 참이면 wrapper가 read lock을 해제하고, 이미 0이면 중복 해제하지 않은 채 결과를 반환합니다. `locked`가 참일 때만 함수가 반환한다거나 이 두 wrapper가 caller에게 read lock을 넘긴다는 뜻이 아닙니다. 반면 `__gup_longterm_locked()`는 lock을 넘겨주는 pointer 없이 동작하며 long-term page의 이동 가능성 검사를 포함합니다. v6.0의 `__gup_longterm_unlocked()`는 `FOLL_LONGTERM`이면 별도로 read lock을 얻어 이 경로를 사용합니다. 일반 `get_user_pages_unlocked()`는 그 flag를 `-EINVAL`로 거절하므로 wrapper들을 이름만 비슷한 교환 가능한 API로 볼 수 없습니다.

remote 경로도 `FOLL_LONGTERM`과 non-NULL `locked`의 조합을 v6.0에서 거절합니다. fast GUP는 `FOLL_FAST_ONLY`가 아니면 slow 경로로 내려갈 수 있지만, 얻은 page reference·pin의 수명과 VMA lock 수명은 여전히 다릅니다. `fixup_user_fault()`는 또 별개입니다. retry 뒤 재조회하며, `VM_FAULT_COMPLETED` 때에도 caller의 unlock pairing을 위해 read lock을 다시 얻고 `unlocked`를 알립니다. 이러한 계약은 [v6.0 gup.c](https://github.com/torvalds/linux/blob/v6.0/mm/gup.c)에 한정하여 읽어야 합니다.

## mlock도 VMA 설정과 page 준비를 나눕니다

[do_mlock()](https://github.com/torvalds/linux/blob/v6.0/mm/mlock.c#L568-L613)은 권한·정렬과 `RLIMIT_MEMLOCK`을 확인하고 writer 아래 `VM_LOCKED` 계열의 상태를 적용합니다. writer를 놓은 뒤 `__mm_populate()`로 page 준비를 수행합니다. writer 획득 중 signal, 한도 초과, VMA 변경 실패가 있으면 이 후속 단계에 진입하지 않습니다.

`mlockall()`도 writer를 반환한 뒤 성공하면서 `MCL_CURRENT`가 있는 경우 population을 호출합니다. `MCL_FUTURE`만 있으면 미래 mapping의 policy를 바꾸는 것이고 현재 전체를 즉시 fault-in하는 단계가 아닙니다. `munlock()`·`munlockall()`은 writer 아래 해당 상태를 제거하고 같은 population을 실행하지 않습니다. [__mm_populate()](https://github.com/torvalds/linux/blob/v6.0/mm/gup.c#L1652-L1703)는 read lock을 얻어 다시 VMA를 찾으며, fault가 이를 놓았으면 다음 반복에서 재획득·재조회합니다. `VM_IO`·`VM_PFNMAP`을 건너뛰고, `VM_LOCKONFAULT`의 실제 population 생략은 하위 helper에서 처리합니다.

결국 함수가 돌아왔을 때 물어야 할 것은 두 가지입니다. 현재 어떤 lock을 보유하고 있는지, 그 lock이 처음부터 연속으로 유지되었는지입니다. reference가 확보된 page나 file을 계속 사용할 수 있어도, 그것을 찾을 때 보았던 VMA까지 안전하게 남아 있다는 결론은 따로 증명해야 합니다.

## 참고 자료

- Lorenzo Stoakes, *The Linux Memory Manager*, Early Access 2025, Chapter 4 §4.3.4.6.
- [Linux v6.0 mmap.c](https://github.com/torvalds/linux/blob/v6.0/mm/mmap.c), [mremap.c](https://github.com/torvalds/linux/blob/v6.0/mm/mremap.c): 구조 변경과 역사적 downgrade.
- [Linux v6.0 fork.c](https://github.com/torvalds/linux/blob/v6.0/kernel/fork.c), [exec.c](https://github.com/torvalds/linux/blob/v6.0/fs/exec.c): 공유·복제·mm 전환.
- [Linux v6.0 memory.c](https://github.com/torvalds/linux/blob/v6.0/mm/memory.c), [filemap.c](https://github.com/torvalds/linux/blob/v6.0/mm/filemap.c), [gup.c](https://github.com/torvalds/linux/blob/v6.0/mm/gup.c), [mlock.c](https://github.com/torvalds/linux/blob/v6.0/mm/mlock.c): fault·retry·population 계약.
