한 번의 mmap()으로 만든 범위 가운데 한 page만 munmap()하면 어떻게 될까요. kernel은 처음 요청의 allocation ID를 찾지 않습니다. 현재 주소 범위와 겹치는 VMA를 찾고, 양쪽에 남을 부분을 보존하도록 경계를 나눈 뒤 제거 대상의 translation을 정리합니다.
요청 범위와 VMA 범위는 다릅니다
munmap의 start는 page-aligned여야 하며 length는 양수입니다. length를 page 단위로 올리는 계산과 start + len에는 overflow 검사가 필요합니다. 일부 architecture에는 unmap hook도 있습니다. 범위에 VMA가 전혀 없으면 제거할 것이 없으므로 성공할 수 있습니다. hole을 포함한 요청도 “처음부터 allocation되지 않은 주소가 있다”는 이유로 언제나 실패하지는 않습니다.
첫 겹침을 찾았더라도 start와 end가 기존 VMA 한가운데일 수 있습니다. 이때 남을 왼쪽·오른쪽 부분과 제거할 부분을 분리합니다. 한 VMA 중간을 비우면 결과 VMA 개수가 오히려 늘 수 있어, memory를 해제하는 요청이 VMA metadata allocation이나 max_map_count 제약 때문에 실패할 수 있습니다.
syscall 경계에서는 주소와 lock 상태를 정리합니다
syscall entry의 untagged_addr()는 architecture가 허용하는 address tag를 제거하기 위한 처리입니다. page 정렬을 맞추거나 임의로 잘못된 주소를 정상화하는 함수가 아닙니다. 이어지는 __vm_munmap()은 killable write lock을 얻으므로 대기가 중단되면 EINTR로 끝날 수 있습니다.
RB-tree 계열의 wrapper는 __do_munmap(..., downgrade=true)의 반환 계약에 따라 unlock 방법을 고릅니다. 내부 값 1은 성공하면서 lock이 read로 downgrade되었다는 뜻이므로 read unlock 후 0으로 정규화합니다. 나머지 경로에서는 write unlock을 수행하고, userfaultfd unmap 완료 통지를 처리한 뒤 결과를 반환합니다. 이 내부 값 1을 사용자 성공 값이나 해제한 page 수라고 해석하면 안 됩니다. raw syscall의 음수 error는 libc에서 -1과 errno로 노출되며, 정상 반환 0도 해제한 물리 memory 양이 아닙니다.

주소 index에서 분리한 뒤에도 작업이 남습니다
RB-tree 기반 구현의 detach_vmas_to_be_unmapped()는 제거 대상을 VMA list와 RB-tree에서 떼어 별도 연결로 만듭니다. 이전 VMA의 next 또는 mm의 list head를 수정하고, 뒤 VMA와 연결을 복구하며, 각 node 제거에 따라 map_count와 gap 요약을 고칩니다. vmacache_invalidate()는 옛 VMA를 cache에서 다시 얻지 않도록 하는 단계입니다.
이 시점은 data page가 모두 free된 시점이 아닙니다. unmap_region()은 PTE 제거, MMU-gather, TLB invalidation과 reference release를 수행합니다. 옛 PTE만 지우고 data page를 즉시 재사용하면 다른 CPU의 stale TLB가 그 page를 계속 쓸 수 있습니다. VMA index 변경과 hardware translation 수명 종료는 반드시 구분해야 합니다.
lock을 downgrade할 수 없는 이웃
제거 대상을 주소 공간에서 떼면 일부 후속 처리는 mmap_lock을 read로 낮추어 진행할 수 있습니다. 그러나 인접 VMA가 제거한 범위 쪽으로 성장할 수 있는 stack이면 이야기가 달라집니다. old translation을 아직 invalidation하지 않았는데 stack이 그 범위를 다시 차지하면 충돌할 수 있습니다. RB-tree 기반 경로는 앞의 upward stack과 뒤의 downward stack을 이러한 downgrade 제한으로 설명합니다.
이는 모든 unmap에서 언제나 같은 lock 전환이 일어난다는 뜻은 아닙니다. v6.18의 do_vmi_munmap() 계열은 VMA iterator와 Maple Tree의 분리·완료 경계를 사용합니다. lock이 해제되었거나 낮아졌다는 반환 계약은 helper별로 확인해야 하며, 이전 VMA pointer를 무조건 계속 사용하는 방식으로 일반화할 수 없습니다.
VMA의 마지막 정리와 page의 마지막 정리
remove_vma_list()는 total virtual usage를 줄이기 전에 high-water 통계를 보존하고, exec·stack·data 등의 통계와 accountable page 양을 정리합니다. VM_ACCOUNT가 없는 VMA까지 같은 commit 양으로 차감하면 안 됩니다. remove_vma()는 filesystem의 close callback, file reference와 NUMA policy reference를 처리한 뒤 VMA object를 해제합니다.
이 close는 application의 file descriptor를 닫는 close(fd)와 다른 callback입니다. 또한 이 VMA의 page reference를 내렸어도 다른 process의 mapping, page cache나 pin이 남아 있으면 folio는 살아 있습니다. munmap 성공은 “이 주소 범위의 mapping이 사라졌다”는 계약이며 “그만큼의 RAM이 즉시 buddy에 반환되었다”는 측정값이 아닙니다.
양쪽 split은 object identity도 보존합니다
시작이 첫 VMA 안에 걸치면 __split_vma(..., start, 0)으로 위쪽 조각을 새 object로 만듭니다. 끝이 마지막 VMA 안에 걸치면 __split_vma(..., end, 1)로 아래쪽 조각을 새 object로 만듭니다. 양쪽 모두 경계만 맞추는 것처럼 보여도, 부분 unmap 후 원래 VMA object가 남아 있어야 한다는 mremap()의 VM_ACCOUNT 처리 전제를 보존하는 선택입니다.
한 VMA 가운데를 비우면 최종적으로 왼쪽·오른쪽 두 VMA가 남으므로 map count가 하나 늘어납니다. 이 경우에는 split 전부터 한계를 검사합니다. 반면 여러 VMA를 제거하는 과정의 일시적 count 증가는 내부 helper가 허용할 수 있습니다. 모든 split에 wrapper의 동일한 검사를 기계적으로 넣으면 오히려 자원을 해제할 요청을 막을 수 있습니다.
첫 split이 성공한 뒤 두 번째 split이나 userfaultfd 준비가 실패하면 원래 하나였던 VMA가 나뉜 상태로 남을 수 있습니다. translation을 제거하기 전의 실패라 하더라도 metadata 분할까지 반드시 원상 복구한다는 계약은 아닙니다. 성공·실패를 allocation transaction 하나로 단순화할 수 없는 이유입니다.
고전 unmap 경로의 전체 함수
다음 코드는 Linux v5.18 mm/mmap.c의 실제 함수 전체이며 GPL-2.0-only입니다. 각 block은 표시한 함수의 시작부터 끝까지 포함합니다. kernel build context와 그 안의 helper가 필요하므로 독립 실행 C 프로그램은 아닙니다. license 원문을 함께 두었으며 tab 표시 외에 분기나 주석을 줄이지 않았습니다. 이 tag에서는 userfaultfd 준비가 경계 split 뒤, detach 전에 놓인다는 순서도 함께 보실 수 있습니다.
우선 syscall entry 전체입니다. address tag 처리 뒤 downgrade를 허용하여 wrapper에 넘깁니다.
// SPDX-License-Identifier: GPL-2.0-only
// Linux v5.18, mm/mmap.c
SYSCALL_DEFINE2(munmap, unsigned long, addr, size_t, len)
{
addr = untagged_addr(addr);
return __vm_munmap(addr, len, true);
}
// SPDX-License-Identifier: GPL-2.0-only
// Linux v5.18, mm/mmap.c. Written by obz.
// Address space accounting code: <alan@lxorguk.ukuu.org.uk>
int __do_munmap(struct mm_struct *mm, unsigned long start, size_t len,
struct list_head *uf, bool downgrade)
{
unsigned long end;
struct vm_area_struct *vma, *prev, *last;
if ((offset_in_page(start)) || start > TASK_SIZE || len > TASK_SIZE-start)
return -EINVAL;
len = PAGE_ALIGN(len);
end = start + len;
if (len == 0)
return -EINVAL;
/*
* arch_unmap() might do unmaps itself. It must be called
* and finish any rbtree manipulation before this code
* runs and also starts to manipulate the rbtree.
*/
arch_unmap(mm, start, end);
/* Find the first overlapping VMA where start < vma->vm_end */
vma = find_vma_intersection(mm, start, end);
if (!vma)
return 0;
prev = vma->vm_prev;
/*
* If we need to split any vma, do it now to save pain later.
*
* Note: mremap's move_vma VM_ACCOUNT handling assumes a partially
* unmapped vm_area_struct will remain in use: so lower split_vma
* places tmp vma above, and higher split_vma places tmp vma below.
*/
if (start > vma->vm_start) {
int error;
/*
* Make sure that map_count on return from munmap() will
* not exceed its limit; but let map_count go just above
* its limit temporarily, to help free resources as expected.
*/
if (end < vma->vm_end && mm->map_count >= sysctl_max_map_count)
return -ENOMEM;
error = __split_vma(mm, vma, start, 0);
if (error)
return error;
prev = vma;
}
/* Does it split the last one? */
last = find_vma(mm, end);
if (last && end > last->vm_start) {
int error = __split_vma(mm, last, end, 1);
if (error)
return error;
}
vma = vma_next(mm, prev);
if (unlikely(uf)) {
/*
* If userfaultfd_unmap_prep returns an error the vmas
* will remain split, but userland will get a
* highly unexpected error anyway. This is no
* different than the case where the first of the two
* __split_vma fails, but we don't undo the first
* split, despite we could. This is unlikely enough
* failure that it's not worth optimizing it for.
*/
int error = userfaultfd_unmap_prep(vma, start, end, uf);
if (error)
return error;
}
/* Detach vmas from rbtree */
if (!detach_vmas_to_be_unmapped(mm, vma, prev, end))
downgrade = false;
if (downgrade)
mmap_write_downgrade(mm);
unmap_region(mm, vma, prev, start, end);
/* Fix up all other VM information */
remove_vma_list(mm, vma);
return downgrade ? 1 : 0;
}
arch_unmap()은 해당 architecture가 먼저 수행해야 할 index 변경을 공통 RB-tree 조작보다 앞에 둡니다. 겹치는 VMA가 전혀 없으면 0으로 돌아옵니다. detach 후의 downgrade 판단은 caller가 downgrade를 허용했는지와 이웃 stack 조건을 모두 반영합니다. helper의 반환 값 1은 그 결과인 lock 상태를 전달합니다. userspace에 1을 반환하기 위한 값이 아닙니다.
// SPDX-License-Identifier: GPL-2.0-only
// Linux v5.18, mm/mmap.c. Written by obz.
// Address space accounting code: <alan@lxorguk.ukuu.org.uk>
static int __vm_munmap(unsigned long start, size_t len, bool downgrade)
{
int ret;
struct mm_struct *mm = current->mm;
LIST_HEAD(uf);
if (mmap_write_lock_killable(mm))
return -EINTR;
ret = __do_munmap(mm, start, len, &uf, downgrade);
/*
* Returning 1 indicates mmap_lock is downgraded.
* But 1 is not legal return value of vm_munmap() and munmap(), reset
* it to 0 before return.
*/
if (ret == 1) {
mmap_read_unlock(mm);
ret = 0;
} else
mmap_write_unlock(mm);
userfaultfd_unmap_complete(mm, &uf);
return ret;
}
wrapper가 userfaultfd 완료 처리를 unlock 뒤에 수행하는 점과, ret 1일 때 read unlock을 선택한 다음 0으로 바꾸는 점을 함께 보실 수 있습니다. 아직 write lock인 경로에서 read unlock을 하거나 이미 낮아진 lock에 write unlock을 하는 것은 같은 의미가 아닙니다.
분리한 list와 남겨 둔 주소 공간
detach는 시작 VMA의 vm_prev와 마지막 VMA의 vm_next를 NULL로 만들어 제거 대상 list를 독립시킵니다. 남는 주소 공간 쪽에서는 prev->vm_next 또는 mm->mmap을 첫 생존 VMA로 연결합니다. 다음 VMA가 없을 때는 highest_vm_end를 생존 predecessor의 guard-adjusted 끝으로 바꿉니다. locked VMA의 locked_vm도 여기서 줄이므로 뒤의 object free 때 같은 양을 또 빼면 안 됩니다.
// SPDX-License-Identifier: GPL-2.0-only
// Linux v5.18, mm/mmap.c. Written by obz.
// Address space accounting code: <alan@lxorguk.ukuu.org.uk>
static bool
detach_vmas_to_be_unmapped(struct mm_struct *mm, struct vm_area_struct *vma,
struct vm_area_struct *prev, unsigned long end)
{
struct vm_area_struct **insertion_point;
struct vm_area_struct *tail_vma = NULL;
insertion_point = (prev ? &prev->vm_next : &mm->mmap);
vma->vm_prev = NULL;
do {
vma_rb_erase(vma, &mm->mm_rb);
if (vma->vm_flags & VM_LOCKED)
mm->locked_vm -= vma_pages(vma);
mm->map_count--;
tail_vma = vma;
vma = vma->vm_next;
} while (vma && vma->vm_start < end);
*insertion_point = vma;
if (vma) {
vma->vm_prev = prev;
vma_gap_update(vma);
} else
mm->highest_vm_end = prev ? vm_end_gap(prev) : 0;
tail_vma->vm_next = NULL;
/* Kill the cache */
vmacache_invalidate(mm);
/*
* Do not downgrade mmap_lock if we are next to VM_GROWSDOWN or
* VM_GROWSUP VMA. Such VMAs can change their size under
* down_read(mmap_lock) and collide with the VMA we are about to unmap.
*/
if (vma && (vma->vm_flags & VM_GROWSDOWN))
return false;
if (prev && (prev->vm_flags & VM_GROWSUP))
return false;
return true;
}
오른쪽 이웃의 GROWSDOWN 또는 왼쪽 이웃의 GROWSUP은 이 tag에서 read lock 아래 성장할 수 있습니다. detach했다고 주소가 곧 재사용에 안전해지는 것이 아니므로, 이런 이웃이 있으면 write lock을 유지합니다. cache invalidation은 stale VMA pointer 검색을 막는 것이며 CPU의 TLB invalidation과는 다른 cache입니다.
// SPDX-License-Identifier: GPL-2.0-only
// Linux v5.18, mm/mmap.c. Written by obz.
// Address space accounting code: <alan@lxorguk.ukuu.org.uk>
static void unmap_region(struct mm_struct *mm,
struct vm_area_struct *vma, struct vm_area_struct *prev,
unsigned long start, unsigned long end)
{
struct vm_area_struct *next = vma_next(mm, prev);
struct mmu_gather tlb;
lru_add_drain();
tlb_gather_mmu(&tlb, mm);
update_hiwater_rss(mm);
unmap_vmas(&tlb, vma, start, end);
free_pgtables(&tlb, vma, prev ? prev->vm_end : FIRST_USER_ADDRESS,
next ? next->vm_start : USER_PGTABLES_CEILING);
tlb_finish_mmu(&tlb);
}
lru_add_drain()과 RSS high-water 보존 뒤 unmap_vmas()가 PTE 범위를 정리하고, free_pgtables()가 생존 이웃의 경계 안에서 page-table page를 정리합니다. tlb_finish_mmu()까지가 MMU-gather 수명의 일부입니다. 이 block을 모든 folio가 마지막 reference를 잃는 시점이라고 해석해서는 안 됩니다.
statistics와 object를 마지막으로 반납합니다
// SPDX-License-Identifier: GPL-2.0-only
// Linux v5.18, mm/mmap.c. Written by obz.
// Address space accounting code: <alan@lxorguk.ukuu.org.uk>
static void remove_vma_list(struct mm_struct *mm, struct vm_area_struct *vma)
{
unsigned long nr_accounted = 0;
/* Update high watermark before we lower total_vm */
update_hiwater_vm(mm);
do {
long nrpages = vma_pages(vma);
if (vma->vm_flags & VM_ACCOUNT)
nr_accounted += nrpages;
vm_stat_account(mm, vma->vm_flags, -nrpages);
vma = remove_vma(vma);
} while (vma);
vm_unacct_memory(nr_accounted);
validate_mm(mm);
}
static struct vm_area_struct *remove_vma(struct vm_area_struct *vma)
{
struct vm_area_struct *next = vma->vm_next;
might_sleep();
if (vma->vm_ops && vma->vm_ops->close)
vma->vm_ops->close(vma);
if (vma->vm_file)
fput(vma->vm_file);
mpol_put(vma_policy(vma));
vm_area_free(vma);
return next;
}
remove_vma_list()는 total virtual usage를 낮추기 전에 high-water를 보존합니다. 각 VMA의 virtual usage는 모두 정리하지만 commit 반납량은 VM_ACCOUNT가 있었던 page만 누적합니다. remove_vma()는 다음 pointer를 미리 저장하고, sleep 가능한 context에서 close callback·file·NUMA policy를 정리한 뒤 VMA object를 해제합니다. 일반 unmap의 close callback과, close가 없는 VMA만 허용하는 merge의 cleanup 경로도 구별해야 합니다.
v6.18의 do_vmi_munmap() 계열은 VMA iterator와 Maple Tree를 사용하며 위의 RB-tree/list helper 묶음과 동일한 구현이 아닙니다. 특히 현재 stack-growth 동기화에 고전 read-lock 규칙을 일반화하지 않습니다. 현대 코드와 비교할 때에는 v6.18 mm/vma.c의 gather·clear·complete 경계부터 다시 확인해야 합니다.
본문에 수록한 v5.18 정의의 원 파일 전체는 다운로드 가능한 source 목록에서 읽으실 수 있습니다. 원 header·license를 보존한 파일과 본문의 표시용 code block을 구분하며, 이 파일 목록이 kernel의 모든 build dependency를 제공하는 것은 아닙니다.
참고 자료
Lorenzo Stoakes, The Linux Memory Manager, Chapter 5, §5.0.6. 현재 unmap의 준비·완료는 Linux v6.18 mm/vma.c, PTE와 page-table 정리는 mm/memory.c, API는 munmap(2)에 있습니다.