process가 종료했다고 그 주소 공간을 설명하던 객체가 반드시 같은 순간 사라지는 것은 아닙니다. 반대로 mm_struct pointer를 살아 있게 붙잡았다고 user mapping까지 보존되는 것도 아닙니다. Linux가 mm_users와 mm_count를 나누는 이유는 이 두 수명이 다르기 때문입니다.
이 구분을 놓치면 다른 task의 memory를 읽는 코드, kernel thread의 context switch, teardown 순서를 모두 잘못 이해하기 쉽습니다. 먼저 객체들이 어떤 관계인지 정리한 다음 reference가 실제로 무엇을 보장하는지 살펴보겠습니다. 구현 기준은 upstream v6.18입니다.
task가 주소 공간을 공유할 수 있습니다
task_struct.mm은 task의 user address space를 가리킵니다. 같은 address space를 공유하는 thread들은 각자 task_struct를 가지면서 같은 mm_struct를 참조합니다. 일반적인 fork는 새로운 mm을 만들고, CLONE_VM이 지정된 생성 경로는 기존 mm을 공유합니다. 따라서 task 수, process 수, mm 객체 수를 같은 숫자로 취급할 수 없습니다.
mm은 VMA index, page-table root인 pgd, lock, 각종 통계와 실행 이미지의 경계를 담습니다. v6.18의 VMA index는 mm_mt인 Maple Tree입니다. 예전의 mmap, mm_rb, vmacache_seqnum을 현재 field 목록으로 가져와서는 안 됩니다. map_count는 VMA 개수이고 pgtables_bytes는 page-table metadata의 byte 비용을 추적하므로 data RSS와 다릅니다.
def_flags는 새 VMA에 적용할 기본 조건을 담습니다. 예를 들어 mlockall(MCL_FUTURE)는 앞으로 만드는 mapping에도 locking 의도가 적용되게 합니다. 이 field를 바꾸는 일과 이미 존재하는 모든 VMA의 flag를 즉시 같은 값으로 덮는 일은 다릅니다. historical highest_vm_end 같은 배치 보조 field도 해당 구현의 index와 함께 읽어야 하며 current mm layout의 필수 field라고 외우지 않습니다.
그 아래에는 두 종류의 관계가 있습니다. VA에서 VMA를 찾고 page table을 통해 data로 가는 정방향 관계, folio에서 자신을 mapping한 VMA들을 찾는 reverse mapping입니다. anonymous 쪽은 anon_vma와 anon_vma_chain이, file 쪽은 address_space.i_mmap과 VMA의 file 관계가 중요한 연결부입니다. folio와 VMA를 서로 하나씩 직접 소유하는 구조가 아닙니다.
하나의 file folio가 여러 mm에 mapping될 수 있고 하나의 VMA도 여러 folio를 포함합니다. fork와 split은 이 관계를 더 복잡하게 만듭니다. data page, page-table page, VMA와 mm 자체의 수명을 따로 세는 것이 번거로워 보이지만, 바로 이 공유 때문에 필요합니다.
mm_users는 address space 이용을 붙잡습니다
mmget()은 이미 유효한 이용 관계를 전제로 mm_users를 증가시키고, mmput()은 그 이용을 반환합니다. 종료와 경쟁하여 이미 0일 수 있는 경우에는 mmget_not_zero()가 중요합니다. 0이 된 counter를 임의로 되살려 teardown 중인 address space를 다시 이용해서는 안 됩니다.
mm_users는 단순한 userland thread 개수만이 아닙니다. kernel이 일시적으로 user address space를 사용할 때 얻는 reference도 포함할 수 있습니다. 다른 task의 memory에 접근하는 경로가 reference를 확보하는 이유입니다. 다만 그 reference는 mm의 전체 user teardown을 막는 것이지 특정 VMA가 unmap되지 않도록 얼리는 lock이 아닙니다. 다른 thread의 munmap()과 경쟁하지 않으려면 별도의 올바른 locking이 필요합니다.
마지막 mmput()은 __mmput() 경로를 통해 user mapping과 관련 자원들을 정리합니다. 마지막에 user 이용자 전체가 보유하던 mm 객체 reference도 내려놓습니다. 정리 작업은 sleep할 수 있고, 문맥에 따라 mmput_async() 같은 별도 helper가 쓰이는 이유도 여기에 있습니다. 단순한 atomic 감소만 보고 호출 가능한 context를 판단하면 안 됩니다.
실제 reference helper부터 보면 두 counter가 섞이지 않습니다. 아래는 official v6.18 sched/mm.h의 전체 함수 정의입니다. kernel 의존성이 있는 GPL code이며 원래 SPDX와 출처를 파일에, license 본문을 code/GPL-2.0.txt에 보존했습니다.
/* SPDX-License-Identifier: GPL-2.0 */
/*
* Official Linux v6.18: include/linux/sched/mm.h
* Complete function; source lines 131-134.
* Body unchanged. Kernel dependencies and configuration are required.
* Source URL, SHA, commit, and license: provenance.json; GPL-2.0.txt.
*/
static inline void mmget(struct mm_struct *mm)
{
atomic_inc(&mm->mm_users);
}
/* SPDX-License-Identifier: GPL-2.0 */
/*
* Official Linux v6.18: include/linux/sched/mm.h
* Complete function; source lines 136-139.
* Body unchanged. Kernel dependencies and configuration are required.
* Source URL, SHA, commit, and license: provenance.json; GPL-2.0.txt.
*/
static inline bool mmget_not_zero(struct mm_struct *mm)
{
return atomic_inc_not_zero(&mm->mm_users);
}
/* SPDX-License-Identifier: GPL-2.0 */
/*
* Official Linux v6.18: include/linux/sched/mm.h
* Complete function; source lines 35-38.
* Body unchanged. Kernel dependencies and configuration are required.
* Source URL, SHA, commit, and license: provenance.json; GPL-2.0.txt.
*/
static inline void mmgrab(struct mm_struct *mm)
{
atomic_inc(&mm->mm_count);
}
/* SPDX-License-Identifier: GPL-2.0 */
/*
* Official Linux v6.18: include/linux/sched/mm.h
* Complete function; source lines 47-56.
* Body unchanged. Kernel dependencies and configuration are required.
* Source URL, SHA, commit, and license: provenance.json; GPL-2.0.txt.
*/
static inline void mmdrop(struct mm_struct *mm)
{
/*
* The implicit full barrier implied by atomic_dec_and_test() is
* required by the membarrier system call before returning to
* user-space, after storing to rq->curr.
*/
if (unlikely(atomic_dec_and_test(&mm->mm_count)))
__mmdrop(mm);
}
mm_count는 객체를 붙잡습니다
mmgrab()과 mmdrop()은 mm_count를 관리합니다. 이것은 mm 객체 자체가 free되지 않게 하는 reference입니다. mm_users가 하나 이상 있는 상태 전체는 mm_count에서 하나의 reference를 가집니다. user reference를 열 개 추가했다고 mm_count도 자동으로 열 개 증가하는 모델이 아닙니다.

그림은 user mapping teardown과 mm 객체 해제를 구분합니다. CPU별 lazy TLB 정리와 RCU 등 세부 지연 경로는 생략했습니다.
예를 들어 kernel의 장기 bookkeeping이 mm을 mmgrab()으로 붙잡고 있어도 마지막 mm_users가 사라지면 user VMA와 mapping은 이미 해제될 수 있습니다. 나중에 memory를 읽으려면 mmget_not_zero() 등으로 user 이용이 여전히 가능한지 확인하고, 성공했을 때에도 VMA와 page table의 접근 규칙을 따라야 합니다.
마지막 mmdrop()은 __mmdrop()으로 이어집니다. v6.18에서는 lazy TLB 이용 CPU의 정리, PGD 해제, architecture context와 notifier bookkeeping 정리, RSS counter 해제 등을 거쳐 mm 객체를 반환합니다. 이 단계는 정상적인 user mapping teardown 뒤입니다. mm 객체가 남았다는 이유로 그 안의 과거 VMA pointer까지 유효하다고 판단해서는 안 됩니다.
완전한 helper 정의와 수명 계약은 include/linux/sched/mm.h, 실제 teardown은 kernel/fork.c에 있습니다.
mmput()은 sleep 가능한 context를 요구합니다. 다음 세 정의는 v6.18 kernel/fork.c의 해당 함수 전체입니다. __mmput()의 subsystem 종료 순서, 마지막 mmdrop(), __mmdrop()의 PGD·counter 자원 반환을 서로 다른 단계로 읽을 수 있습니다.
// SPDX-License-Identifier: GPL-2.0-only
/*
* linux/kernel/fork.c
*
* Copyright (C) 1991, 1992 Linus Torvalds
*/
/*
* 'fork.c' contains the help-routines for the 'fork' system call
* (see also entry.S and others).
* Fork is rather simple, once you get the hang of it, but the memory
* management can be a bitch. See 'mm/memory.c': 'copy_page_range()'
*/
/*
* Official Linux v6.18: kernel/fork.c
* Complete function; source lines 1151-1157.
* Body unchanged. Kernel dependencies and configuration are required.
* Source URL, SHA, commit, and license: provenance.json; GPL-2.0.txt.
*/
void mmput(struct mm_struct *mm)
{
might_sleep();
if (atomic_dec_and_test(&mm->mm_users))
__mmput(mm);
}
// SPDX-License-Identifier: GPL-2.0-only
/*
* linux/kernel/fork.c
*
* Copyright (C) 1991, 1992 Linus Torvalds
*/
/*
* 'fork.c' contains the help-routines for the 'fork' system call
* (see also entry.S and others).
* Fork is rather simple, once you get the hang of it, but the memory
* management can be a bitch. See 'mm/memory.c': 'copy_page_range()'
*/
/*
* Official Linux v6.18: kernel/fork.c
* Complete function; source lines 1125-1146.
* Body unchanged. Kernel dependencies and configuration are required.
* Source URL, SHA, commit, and license: provenance.json; GPL-2.0.txt.
*/
static inline void __mmput(struct mm_struct *mm)
{
VM_BUG_ON(atomic_read(&mm->mm_users));
uprobe_clear_state(mm);
exit_aio(mm);
ksm_exit(mm);
khugepaged_exit(mm); /* must run before exit_mmap */
exit_mmap(mm);
mm_put_huge_zero_folio(mm);
set_mm_exe_file(mm, NULL);
if (!list_empty(&mm->mmlist)) {
spin_lock(&mmlist_lock);
list_del(&mm->mmlist);
spin_unlock(&mmlist_lock);
}
if (mm->binfmt)
module_put(mm->binfmt->module);
lru_gen_del_mm(mm);
futex_hash_free(mm);
mmdrop(mm);
}
// SPDX-License-Identifier: GPL-2.0-only
/*
* linux/kernel/fork.c
*
* Copyright (C) 1991, 1992 Linus Torvalds
*/
/*
* 'fork.c' contains the help-routines for the 'fork' system call
* (see also entry.S and others).
* Fork is rather simple, once you get the hang of it, but the memory
* management can be a bitch. See 'mm/memory.c': 'copy_page_range()'
*/
/*
* Official Linux v6.18: kernel/fork.c
* Complete function; source lines 674-694.
* Body unchanged. Kernel dependencies and configuration are required.
* Source URL, SHA, commit, and license: provenance.json; GPL-2.0.txt.
*/
void __mmdrop(struct mm_struct *mm)
{
BUG_ON(mm == &init_mm);
WARN_ON_ONCE(mm == current->mm);
/* Ensure no CPUs are using this as their lazy tlb mm */
cleanup_lazy_tlbs(mm);
WARN_ON_ONCE(mm == current->active_mm);
mm_free_pgd(mm);
mm_free_id(mm);
destroy_context(mm);
mmu_notifier_subscriptions_destroy(mm);
check_mm(mm);
put_user_ns(mm->user_ns);
mm_pasid_drop(mm);
mm_destroy_cid(mm);
percpu_counter_destroy_many(mm->rss_stat, NR_MM_COUNTERS);
free_mm(mm);
}
kernel thread는 active_mm을 빌릴 수 있습니다
일반적인 kernel thread에는 고유 user address space가 필요하지 않으므로 mm이 NULL입니다. 그러나 CPU는 실행할 page-table context가 필요합니다. context switch 때 이전 address space를 active_mm으로 빌려 쓰면 불필요한 switch 비용을 줄일 수 있습니다. 이것이 lazy TLB라는 설명의 출발점입니다.
active_mm을 빌렸다는 사실은 kernel thread가 그 process의 arbitrary user pointer를 자유롭게 사용할 권한을 얻었다는 뜻이 아닙니다. scheduler와 architecture가 관리하는 translation context의 수명 문제입니다. 다른 이용이 필요하면 그 API가 정한 방식으로 mm과 접근 권한을 확보해야 합니다.
reference 처리도 버전과 config를 구별해야 합니다. v6.18 scheduler는 mmgrab_lazy_tlb()와 대응 drop helper를 사용하며, CONFIG_MMU_LAZY_TLB_REFCOUNT에 따라 실제 mm_count reference를 올리는 경로와 그렇지 않은 경로가 갈립니다. 따라서 'lazy TLB 진입은 모든 kernel에서 반드시 mm_count를 1 증가시킨다'는 규칙은 지나칩니다. reference를 생략하는 구성도 최종 해제 전에 CPU 이용을 정리하고 필요한 memory barrier를 제공해야 합니다.
또한 PCID와 TLB generation이 있는 x86에서는 PGD를 바꿀 때 모든 translation을 늘 통째로 폐기하지는 않습니다. lazy context 유지의 의미와 PCID에 따른 invalidation 최적화는 관련되지만 같은 기법이 아닙니다. mm_cpumask()도 mm의 translation을 다뤄야 할 CPU를 추적하는 자료이지 task의 일반적인 CPU affinity mask와 같은 field가 아닙니다.
이를 네 가지 전환으로 나누면 borrowing reference를 왜 이전하거나 반환해야 하는지 분명해집니다. kernel→kernel에서는 같은 active_mm 이용을 다음 task로 이전합니다. user→kernel에서는 lazy 이용을 시작합니다. kernel→user에서는 새 user context로 전환하고 빌렸던 mm을 rq->prev_mm에 넘겨 switch 뒤 정리합니다. user→user에서는 다음 mm으로 전환합니다. 같은 mm끼리 전환하는 경우도 memory ordering 요구가 사라지지는 않습니다.
다음은 v6.18 context_switch()의 전체 정의입니다. register·stack switch와 mm switch가 같은 사건이 아니라는 점도 확인할 수 있습니다.
// SPDX-License-Identifier: GPL-2.0-only
/*
* kernel/sched/core.c
*
* Core kernel CPU scheduler code
*
* Copyright (C) 1991-2002 Linus Torvalds
* Copyright (C) 1998-2024 Ingo Molnar, Red Hat
*/
/*
* Official Linux v6.18: kernel/sched/core.c
* Complete function; source lines 5268-5329.
* Body unchanged. Kernel dependencies and configuration are required.
* Source URL, SHA, commit, and license: provenance.json; GPL-2.0.txt.
*/
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next, struct rq_flags *rf)
{
prepare_task_switch(rq, prev, next);
/*
* For paravirt, this is coupled with an exit in switch_to to
* combine the page table reload and the switch backend into
* one hypercall.
*/
arch_start_context_switch(prev);
/*
* kernel -> kernel lazy + transfer active
* user -> kernel lazy + mmgrab_lazy_tlb() active
*
* kernel -> user switch + mmdrop_lazy_tlb() active
* user -> user switch
*
* switch_mm_cid() needs to be updated if the barriers provided
* by context_switch() are modified.
*/
if (!next->mm) { // to kernel
enter_lazy_tlb(prev->active_mm, next);
next->active_mm = prev->active_mm;
if (prev->mm) // from user
mmgrab_lazy_tlb(prev->active_mm);
else
prev->active_mm = NULL;
} else { // to user
membarrier_switch_mm(rq, prev->active_mm, next->mm);
/*
* sys_membarrier() requires an smp_mb() between setting
* rq->curr / membarrier_switch_mm() and returning to userspace.
*
* The below provides this either through switch_mm(), or in
* case 'prev->active_mm == next->mm' through
* finish_task_switch()'s mmdrop().
*/
switch_mm_irqs_off(prev->active_mm, next->mm, next);
lru_gen_use_mm(next->mm);
if (!prev->mm) { // from kernel
/* will mmdrop_lazy_tlb() in finish_task_switch(). */
rq->prev_mm = prev->active_mm;
prev->active_mm = NULL;
}
}
/* switch_mm_cid() requires the memory barriers above. */
switch_mm_cid(rq, prev, next);
prepare_lock_switch(rq, next, rf);
/* Here we just switch the register state and the stack. */
switch_to(prev, next, prev);
barrier();
return finish_task_switch(prev);
}
lazy helper 자체에도 config 분기를 남겨 보겠습니다. drop이 실제 refcount 감소를 생략하는 구성에서도 full barrier를 제공하는 이유는 membarrier() 계약과 관련됩니다.
/* SPDX-License-Identifier: GPL-2.0 */
/*
* Official Linux v6.18: include/linux/sched/mm.h
* Complete function; source lines 88-92.
* Body unchanged. Kernel dependencies and configuration are required.
* Source URL, SHA, commit, and license: provenance.json; GPL-2.0.txt.
*/
static inline void mmgrab_lazy_tlb(struct mm_struct *mm)
{
if (IS_ENABLED(CONFIG_MMU_LAZY_TLB_REFCOUNT))
mmgrab(mm);
}
/* SPDX-License-Identifier: GPL-2.0 */
/*
* Official Linux v6.18: include/linux/sched/mm.h
* Complete function; source lines 94-105.
* Body unchanged. Kernel dependencies and configuration are required.
* Source URL, SHA, commit, and license: provenance.json; GPL-2.0.txt.
*/
static inline void mmdrop_lazy_tlb(struct mm_struct *mm)
{
if (IS_ENABLED(CONFIG_MMU_LAZY_TLB_REFCOUNT)) {
mmdrop(mm);
} else {
/*
* mmdrop_lazy_tlb must provide a full memory barrier, see the
* membarrier comment finish_task_switch which relies on this.
*/
smp_mb();
}
}
mm == NULL은 일반적인 kernel thread의 출발 상태입니다. 명시적으로 user address space를 사용하는 kthread_use_mm() 같은 예외 API도 있습니다. 이는 scheduler가 단순히 active_mm만 빌리는 경우와 다릅니다. v6.18 sched/mm.h의 선언과 계약을 기준으로 두 경우를 구별해야 합니다.
init_mm은 초기 kernel 주소 공간입니다
init_mm은 동적으로 생성한 평범한 process mm이 아니라 정적으로 준비한 kernel address-space 객체입니다. pgd는 swapper_pg_dir를 가리키고 page-table helper는 이를 kernel mapping의 기준 root로 이용합니다. boot 중 mapping 구성과 global state의 기준이 필요하므로 일반 user process보다 이른 시점부터 존재합니다.
v6.18 mm/init-mm.c는 mm_users를 2, mm_count를 1로 초기화합니다. 이를 실제 user thread 두 개가 init_mm을 사용한다는 뜻으로 읽으면 안 됩니다. 정적 초기 객체의 유지 조건입니다. idle task와 CPU 초기 context, kernel page-table helper의 기준이 되는 용도도 일반 user mapping의 소유권과 다릅니다.
init_mm.mmlist는 swap 관련 mm 연결의 head로도 쓰입니다. kernel TLB 처리에서 init_mm으로 context를 떠나거나 임시 mm을 사용하는 경로도 있으며, kernel fault가 오래된 translation 때문인지 조사할 때 kernel root를 참조할 수도 있습니다. 이것은 kernel thread가 모두 새 user mm을 할당받는다는 모델과 맞지 않습니다.
현재 initializer 전체도 확인할 수 있습니다. v6.18 mm/init-mm.c는 historical mm_rb 대신 mm_mt를 초기화하고, per-VMA lock 구성의 sequence와 wait 상태도 준비합니다. 정적 init_mm의 CPU bitmap은 동적 mm과 달리 최대 CPU 수를 기준으로 공간을 잡습니다.
// SPDX-License-Identifier: GPL-2.0
/*
* Official Linux v6.18: mm/init-mm.c
* Complete initializer; source lines 32-49.
* Body unchanged. Kernel dependencies and configuration are required.
* Source URL, SHA, commit, and license: provenance.json; GPL-2.0.txt.
*/
struct mm_struct init_mm = {
.mm_mt = MTREE_INIT_EXT(mm_mt, MM_MT_FLAGS, init_mm.mmap_lock),
.pgd = swapper_pg_dir,
.mm_users = ATOMIC_INIT(2),
.mm_count = ATOMIC_INIT(1),
.write_protect_seq = SEQCNT_ZERO(init_mm.write_protect_seq),
MMAP_LOCK_INITIALIZER(init_mm)
.page_table_lock = __SPIN_LOCK_UNLOCKED(init_mm.page_table_lock),
.arg_lock = __SPIN_LOCK_UNLOCKED(init_mm.arg_lock),
.mmlist = LIST_HEAD_INIT(init_mm.mmlist),
#ifdef CONFIG_PER_VMA_LOCK
.vma_writer_wait = __RCUWAIT_INITIALIZER(init_mm.vma_writer_wait),
.mm_lock_seq = SEQCNT_ZERO(init_mm.mm_lock_seq),
#endif
.user_ns = &init_user_ns,
.cpu_bitmap = CPU_BITS_NONE,
INIT_MM_CONTEXT(init_mm)
};
init_mm은 boot의 kernel table 생성, idle CPU의 초기 context, kernel PTE helper의 기준 root에 쓰입니다. swap 대상 mm의 list head와 temporary-mm 전환의 기준 객체 역할도 구분됩니다. 예를 들어 x86 kernel fault가 mapping 변경 뒤의 stale TLB 때문인지 조사할 때는 kernel root를 기준으로 permission을 다시 확인할 수 있습니다. 이 용도들을 '일반 process 하나가 모든 kernel memory를 소유한다'는 해석으로 합치지 않습니다.
reference와 lock은 서로 대신할 수 없습니다
mm의 장기 소유권, user address space의 이용권, 특정 VMA의 안정성, PTE 변경의 직렬화는 네 가지 질문입니다. mmgrab()은 첫 번째, mmget()은 두 번째에 해당하고, mmap/VMA/rmap lock 및 page-table lock은 나머지 동시성 문제를 각 계약에 맞게 담당합니다.
owner가 있는 memcg 구성에서도 그 pointer를 유일한 address-space 이용자라고 생각해서는 안 됩니다. 여러 task가 같은 mm을 공유할 수 있고 owner는 종료 시 바뀔 수 있습니다. write_protect_seq 역시 일반적인 VMA 수명 lock이 아니라 fork 등의 write-protection 진행을 GUP 같은 observer와 조율하기 위한 sequence입니다. mm에 중요한 field가 모여 있다고 해서 하나의 reference나 lock으로 모두 보호되는 것은 아닙니다.
수명 버그를 검토할 때는 'pointer가 NULL이 아니다'보다 '무슨 reference를 누가 가지고 있으며 그 reference가 무엇까지 살려 두는가'를 먼저 물어야 합니다. 이 질문 하나로 process 종료 뒤에도 안전한 bookkeeping과, 이미 사라진 mapping을 향한 접근을 구별할 수 있습니다.
참고 자료
- Lorenzo Stoakes, The Linux Memory Manager, Early Access 2025, Chapter 4의 userland memory 구조와 mm_struct reference counting 설명.
- Linux v6.18 sched/mm.h: mmget, mmgrab과 lazy TLB helper 계약
- Linux v6.18 fork.c: mm 초기화와 teardown
- Linux v6.18 scheduler context_switch
- Linux v6.18 init_mm 초기화
- Linux v6.18 mm_types.h: mm_struct field와 reference 주석