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()는 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의 구현은 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()는 이웃에 VM_GROWSDOWN 또는 VM_GROWSUP VMA가 있으면 downgrade를 금지합니다. 당시에는 read lock 아래 stack 경계가 변할 수 있어, 정리 중인 범위와 충돌하는 것을 막아야 했기 때문입니다. 현대의 stack writer 규칙을 이 역사적 조건에 소급해서는 안 됩니다.
실제로 downgrade한 __do_munmap()의 반환값은 1입니다. __vm_munmap()은 이를 “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()은 첫 mapping에서 image 전체 크기를 확보한 뒤 불필요한 나머지를 vm_munmap()으로 제거할 수 있습니다. 이는 segment 사이 hole을 처리하고 randomization 때 다른 image와 겹치지 않게 하기 위한 과정입니다.
exec_mmap()이 얻는 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()은 CLONE_VM이면 mmget()으로 같은 mm을 공유하고, 아니면 dup_mm()으로 복제합니다. CLONE_VM을 사용한다는 사실은 thread group이 같다는 뜻과 동일하지 않습니다. vfork()도 주소 공간 공유를 사용하는 중요한 반례입니다.
MMU build의 dup_mmap()은 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()의 writer와 OOM reaper 배제도 이 teardown의 일부입니다.
fault 분기마다 기다리는 대상이 다릅니다
다음은 구현코드가 아닌 호출관계입니다. 모든 화살표를 무조건 실행하거나 같은 lock을 새로 얻는다는 뜻은 아닙니다.
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 본체는 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 복구는 page lock을 얻은 뒤 MMU notifier 구간 안에서 PTL을 잡고 원래 entry와 같은지 재검사합니다. 경쟁 중 다른 PTE가 설치됐으면 과거 값을 무조건 복원하지 않습니다.
RETRY만으로 unlock을 결정하지 않습니다
__folio_lock_or_retry()의 첫 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()도 trylock 성공과 RETRY_NOWAIT를 먼저 구분합니다. 필요하면 maybe_unlock_mmap_for_io()가 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()는 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에 한정하여 읽어야 합니다.
mlock도 VMA 설정과 page 준비를 나눕니다
do_mlock()은 권한·정렬과 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()는 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, mremap.c: 구조 변경과 역사적 downgrade.
- Linux v6.0 fork.c, exec.c: 공유·복제·mm 전환.
- Linux v6.0 memory.c, filemap.c, gup.c, mlock.c: fault·retry·population 계약.