page를 제거하거나 다른 NUMA node로 옮긴다고 해서 반드시 mmap writer가 필요한 것은 아닙니다. 반대로 실제 data를 옮기지 않는 VMA 이름 변경이 writer를 요구할 수도 있습니다. mmap lock은 모든 memory 변경을 한꺼번에 직렬화하는 lock이 아니라, 주소 공간의 구조와 계약을 보호하는 계층이기 때문입니다.
공식 Linux v6.0의 OOM reaping, prctl()과 NUMA 사례를 비교하겠습니다. 이 글의 historical 함수 이름과 조건은 해당 tag에 고정합니다. 다른 버전에서도 같은 option이 같은 helper·같은 lock mode를 쓴다는 일반화는 하지 않습니다.
OOM reaper는 trylock 실패를 기다림으로 바꾸지 않습니다
OOM reaper는 종료 중인 task가 page를 반환하기만을 기다리지 않고 mm의 일부 mapping을 먼저 걷어낼 수 있습니다. kernel thread의 oom_reaper()에서 oom_reap_task()·oom_reap_task_mm()으로 이어지며, oom_reap_task_mm()은 mmap read trylock을 시도합니다. 실패하면 false이며 상위는 제한된 횟수만큼 잠시 기다렸다 재시도합니다. 무한히 writer를 기다리는 blocking read 획득이 아닙니다.
read lock 아래 MMF_OOM_SKIP을 다시 검사하는 이유는 exit_mmap()의 writer 구간과 순서를 맞추기 위해서입니다. 이미 reaper가 더 이상 다루면 안 되는 상태이면 page 제거를 진행하지 않습니다. __oom_reap_task_mm()은 MMF_UNSTABLE을 표시하고 VMA를 순회하지만 VM_HUGETLB·VM_PFNMAP은 제외합니다. anonymous 또는 shared가 아닌 대상의 PTE를 제거하며, nonblocking MMU notifier 시작이 실패한 구간은 무리하게 진행하지 않습니다. v6.0 본체의 반환 false는 일부 작업을 나중에 다시 해야 한다는 의미를 가질 수 있습니다.
VMA index 자체를 삭제하지 않고 page-table mapping을 걷어내는 작업이므로 mmap read lock과 하위 page-table·notifier·TLB 처리가 함께 존재합니다. “unmap이라는 말이 보이면 mmap writer”라는 규칙은 이 경로에서 성립하지 않습니다. MMF_UNSTABLE도 MMF_OOM_SKIP과 동의어가 아닙니다. 전자는 내용의 안정성을 더 이상 기대할 수 없는 상태를, 후자는 이 mm의 추가 OOM 처리에서 건너뛰어야 하는 상태를 구분합니다.
process_mrelease는 mm lifetime부터 확보합니다
v6.0 process_mrelease()는 pidfd로 task를 찾고, group exit 도중에도 아직 mm을 가진 thread를 선택합니다. task lock 아래 mmgrab()으로 mm 구조체의 수명을 확보한 뒤 task lock을 놓습니다. 이것은 mm_users를 늘려 모든 user mapping을 살아 있게 하는 mmget()과 다른 reference입니다.
대상이 memory를 반환할 상태인지 확인한 뒤 killable mmap read lock을 얻고 MMF_OOM_SKIP을 다시 검사합니다. lock 밖에서 본 값만 믿으면 exit_mmap()과의 경쟁을 놓칩니다. reaping이 아직 끝나지 못하면 -EAGAIN, 획득이 중단되면 -EINTR 등 각 단계의 실패가 다릅니다. 종료하면서 잡아 둔 mm reference도 mmdrop()으로 반환합니다. reaper와 비슷한 page 제거를 한다는 이유로 task 선택과 reference 확보까지 같은 구현이라고 설명해서는 안 됩니다.
prctl의 option이 보호 대상을 바꿉니다
PR_SET_MM은 code·data·brk·stack·arg·env 경계 등 여러 metadata를 바꿉니다. v6.0 prctl_set_mm()의 일반 경계 변경은 mmap reader로 brk()와 VMA 검사를 조율하면서 arg_lock으로 실제 metadata 갱신을 보호합니다. 숫자를 쓴다는 이유만으로 mmap writer를 고르는 경로가 아닙니다.
하지만 모든 option이 그 read lock 구간에 진입하는 것도 아닙니다. PR_SET_MM_MAP·MAP_SIZE는 checkpoint/restore용 helper로, PR_SET_MM_EXE_FILE과 AUXV는 각 전용 helper로 먼저 갈라집니다. prctl_set_mm_map()도 exe file 변경이 필요하면 자신의 mmap read lock을 얻기 전에 처리합니다. 이후 경계 갱신을 위한 reader와 arg_lock을 얻습니다. source가 lock 앞의 분기를 둔 것을 함수 이름 하나의 “read-lock 사용자” 표시로 없애 버리면 실제 중첩을 잘못 추론합니다.
replace_mm_exe_file()은 옛 executable file이 여전히 mapping돼 있는지 read lock으로 확인하고, 있으면 -EBUSY로 거절합니다. 새 file의 write access를 제한하고 reference를 얻은 뒤 실제 exe_file 교환은 xchg()로 수행합니다. 옛 file의 write-access 제한을 해제하는 단계에도 fork와의 경쟁을 위한 별도 read 구간이 있습니다. 하나의 read lock을 처음부터 끝까지 계속 보유하는 교체가 아닙니다.
반면 PR_SET_VMA_ANON_NAME은 지원 build에서 user 문자열을 먼저 검사·복사한 뒤 writer 아래 VMA 이름을 적용합니다. 이름 객체의 reference도 마지막에 반환합니다. PR_SET_THP_DISABLE 역시 이 tag의 syscall switch에서 killable writer를 얻어 mm flag를 바꿉니다. kernel/sys.c의 설정 분기와 THP option을 나누어 보면, prctl() 전체에 하나의 read/write label을 붙일 수 없는 이유가 분명해집니다.
NUMA policy를 읽는 일도 주소 인자에 따라 달라집니다
do_get_mempolicy()는 MPOL_F_ADDR일 때 mmap reader 아래 주소의 VMA와 policy를 찾습니다. 주소를 대상으로 하지 않는 task policy 조회나 MPOL_F_MEMS_ALLOWED 조회는 같은 VMA lookup 구간을 필요로 하지 않습니다. MEMS_ALLOWED에 다른 조회 flag를 섞는 잘못된 조합도 거절합니다.
MPOL_F_ADDR | MPOL_F_NODE는 더 흥미롭습니다. 실제 node를 알아보는 lookup_node()로 가기 전에 policy reference를 얻고 mmap read lock을 놓습니다. 이때 vma = NULL로 바꾸는 것은 이후에 그 pointer를 계속 사용하거나 같은 lock을 다시 unlock하지 않겠다는 제어 흐름입니다. 살아 있는 것은 별도로 붙잡은 policy이지 예전 VMA가 아닙니다. 종료 시 mpol_put()이 그 reference를 반환합니다.
policy를 적용하는 mbind()는 syscall wrapper·kernel_mbind()를 거쳐 do_mbind()로 들어갑니다. v6.0 do_mbind()는 writer 아래 nodemask를 준비하고 VMA policy 범위를 바꿉니다. 이동 옵션에 따른 page 수집·migration과 strict 실패 처리도 이 작업에 연결됩니다. reader로 정보를 조회하는 경로와 VMA를 나누고 policy를 교체하는 경로를 구분해야 합니다.
migration 요청과 node 조회는 같은 syscall 안에서도 갈라집니다
migrate_pages()의 syscall 경로는 kernel_migrate_pages()·do_migrate_pages()로 이어집니다. v6.0 do_migrate_pages()는 mm reader 아래 source·destination node 쌍을 고르고 migrate_to_node()를 호출합니다. 실제 data 이동이 있으므로 writer일 것이라는 추측은 맞지 않습니다. 하위 page isolation과 migration이 자신의 보호를 갖고 VMA 구조는 read-side로 순회합니다.
move_pages()는 nodes 배열의 유무가 작업 자체를 바꿉니다. kernel_move_pages()는 배열이 있으면 이동 요청, 없으면 현재 node의 status 조회를 선택합니다. 이동 경로의 add_page_for_migration()은 각 주소에서 read lock을 얻고 VMA의 migration 가능성과 page를 조사한 뒤 필요한 page를 isolate합니다. 같은 node이거나 special·device page, 공유 상태와 권한 등이 맞지 않는 경우를 구분합니다. 실제 batch migration 함수는 이 짧은 mmap read 구간 밖에서 실행됩니다.
status 경로는 do_pages_stat()·do_pages_stat_array()로 이어져 read lock 아래 page의 node나 주소별 오류를 저장합니다. page reference를 얻은 경우에는 다시 반환합니다. syscall의 전체 반환값과 각 주소의 status 값도 다르므로 일부 주소 실패를 모두 하나의 “migration 실패”로 요약하면 안 됩니다. v6.0 migrate.c가 두 경로를 구체적으로 구분합니다.
set_mempolicy_home_node()는 기존 VMA policy를 복제해 home node를 바꾸므로 writer를 사용합니다. 온라인 node, 정렬·overflow·flags를 검사하고 지원 mode가 아닌 policy를 만나면 거절합니다. 해당 함수의 주석처럼 앞에서 이미 변경한 VMA까지 자동 rollback하는 transaction은 아닙니다. 호출이 실패했으니 앞부분도 전혀 바뀌지 않았다고 가정해서는 안 됩니다.
background scan과 residency도 별도 계약입니다
NUMA balancing의 task_numa_work()는 독립적인 NUMA kernel thread가 아니라 해당 task에 예약되는 task-work입니다. task_tick_numa()가 TWA_RESUME 방식으로 예약하고, 실행 시 read trylock을 얻지 못하면 이번 scan을 건너뜁니다. v6.0 fair.c의 실행 문맥과 OOM reaper의 전용 kernel thread를 혼동하지 않아야 합니다.
세 가지 다른 사례도 같은 원칙을 보여 줍니다. mincore()는 chunk별 reader 아래 residency를 조사하고 unlock한 뒤 user buffer로 복사합니다. 그 결과가 나중의 접근까지 page를 resident로 고정하는 pin은 아닙니다. mprotect()의 do_mprotect_pkey()는 writer 아래 VMA permission·범위를 조정하고 page-table·TLB 처리를 연결합니다. swapoff의 try_to_unuse()가 부르는 unuse_mm()은 reader 아래 anonymous VMA를 순회하며 swap entry 복구를 진행합니다.
어느 함수가 data를 읽거나 쓴다는 표면적인 동사보다, VMA 구조를 바꾸는지·어떤 객체를 계속 보유하는지·하위 lock으로 무엇을 직렬화하는지가 먼저입니다. 이 기준으로 보면 read lock 아래 page를 제거하거나 이동하는 경로도, metadata 변경인데 writer가 필요한 경로도 같은 원리 안에서 설명할 수 있습니다.
참고 자료
- Lorenzo Stoakes, The Linux Memory Manager, Early Access 2025, Chapter 4 §4.3.4.6.
- Linux v6.0 oom_kill.c: reaper와 process_mrelease의 reference·lock·재시도.
- Linux v6.0 sys.c, fork.c: prctl option과 executable file 교체.
- Linux v6.0 mempolicy.c, migrate.c: policy·node 조회·migration 경로.