프로그램이 큰 buffer를 할당한 직후에는 조용하다가 실제 값을 쓰기 시작하면 메모리 사용량이 늘어납니다. 이것은 allocator가 크기를 잘못 보고한 현상이 아닙니다. Linux가 주소 범위의 유효성, 그 주소의 translation, data를 담을 physical page를 서로 다른 시점에 준비하기 때문입니다.
이 구분은 '메모리를 할당했다'는 말을 세 가지 질문으로 나누게 합니다. 접근할 주소를 얻었는지, 지금 RAM에 data가 있는지, 앞으로 writable memory를 제공할 부담을 시스템이 얼마나 받아들였는지입니다. VMA, RSS와 commit accounting은 각각 다른 질문에 답합니다.
주소 범위가 먼저 생길 수 있습니다
malloc()은 userland allocator입니다. 이미 확보한 heap에서 작은 요청을 처리할 수도 있고, 필요할 때 brk()나 mmap() 등을 이용할 수도 있습니다. 따라서 malloc() 호출 하나를 kernel allocation 하나와 대응시키면 allocator의 재사용과 batching을 놓칩니다. kernel과의 경계를 볼 때는 mmap()의 mapping 계약을 출발점으로 삼는 편이 명확합니다.
예를 들어 4 KiB base-page 환경에서 private anonymous mapping으로 20,000 byte를 요청했다고 생각해 보겠습니다. 유효한 page-aligned 범위는 20,480 byte, 즉 다섯 page입니다. 아직 data를 한 번도 접근하지 않았고 별도의 population 조건이 없다면, 주소 범위는 존재하지만 data page와 최하위 translation은 준비되지 않았을 수 있습니다.
그 범위를 기술하는 객체가 vm_area_struct, 줄여서 VMA입니다. /proc/PID/maps의 한 줄은 이런 mapping의 성질을 보여 주고, smaps는 해당 mapping의 resident 상태 등을 더 자세히 보여 줍니다. smaps가 단순히 VMA 길이만 출력하는 것은 아닙니다. 각 range의 page-table 상태를 조사하여 RSS 등 여러 값을 산출합니다. VMA가 있다는 사실과 모든 PTE가 있다는 사실을 구별해야 하는 이유입니다.
4 KiB page 다섯 개를 예약한 뒤 첫 page에만 값을 쓴 단순한 base-page 사례에서는 VMA 길이는 여전히 다섯 page이고 private resident data는 한 page가 될 수 있습니다. 이것은 조건을 고정한 사고 예제이며 측정값은 아닙니다. THP, allocator가 미리 건드린 범위, VMA 병합이나 다른 thread의 접근이 있으면 관측은 달라집니다.
작은 프로그램으로 경계를 관찰합니다
아래는 mapping 전후를 관찰하는 독립 예제입니다. mmap()의 실패를 먼저 확인하고, 관찰 뒤에 첫 byte를 씁니다. 첫 관찰 전에 이미 값을 쓰거나, MAP_FAILED를 검사하기 전에 접근하면 서로 다른 실험이 됩니다. munmap() 실패도 allocation 실패와 별도로 보고합니다.
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <unistd.h>
int main(void)
{
const size_t len = 20000;
volatile unsigned char *p;
p = mmap(NULL, len, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) {
perror("mmap");
return EXIT_FAILURE;
}
printf("pid=%ld address=%p length=%zu\n", (long)getpid(),
(const void *)p, len);
puts("Mapped: inspect smaps, then press Enter.");
(void)getchar();
p[0] = 'x';
puts("Written: inspect smaps, then press Enter.");
(void)getchar();
if (munmap((void *)p, len) != 0) {
perror("munmap");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
Linux에서 cc -std=c11 -Wall -Wextra -O0 demand_observe.c -o demand_observe로 빌드할 수 있도록 작성했습니다. 실행·측정하지 않은 예제입니다. 출력된 PID의 /proc/PID/smaps에서 주소를 포함하는 range를 찾아 두 정지 시점을 비교합니다. 가상주소는 ASLR로 달라지며 이웃 mapping과 합쳐질 수도 있습니다. Size: 20 kB, 첫 write 전 Rss: 0 kB, 이후 Rss: 4 kB는 4 KiB page, 병합·population·THP 등의 영향을 제외한 예상 사례이지 이 프로그램의 보장 출력은 아닙니다. pagemap은 binary page-table 관측 인터페이스이며 smaps의 text 출력과 같지 않습니다.
mmap(2)의 반환·해제 계약을 기준으로 실패 검사를 구성했습니다. 예제는 overcommit sysctl을 바꾸거나 메모리 부족을 유발하지 않습니다.
fault는 약속을 실행하는 기회입니다
CPU는 없는 translation이나 허용되지 않은 접근을 만나면 fault를 발생시킵니다. kernel은 해당 주소의 VMA와 접근 종류를 확인합니다. 주소가 range 안에 있고 요청이 허용되며 필요한 자원도 준비할 수 있다면 page를 마련하고 translation을 설치한 뒤 실행을 재개할 수 있습니다. fault 자체가 곧 segmentation fault라는 뜻은 아닙니다.
반대로 VMA가 있어도 원하는 접근이 반드시 성공하는 것은 아닙니다. read-only range의 write, backing file의 범위를 벗어난 접근, allocation 실패와 특수 mapping의 제한은 각각 다른 실패 원인이 됩니다. VMA는 필요조건을 기술하는 객체이지 모든 미래 접근에 대한 무조건적인 성공 보증서가 아닙니다.
anonymous memory를 처음 읽을 때에는 공유 zero page를 read-only로 연결하여 zero-filled 의미를 만족할 수 있습니다. 이후 그 translation에 쓰면 write-protection fault가 발생하고 private backing을 마련합니다. 처음부터 write한 missing-PTE fault는 일반적인 경로에서 zero page를 거쳐야 할 이유 없이 새 zeroed page를 확보합니다. 'anonymous allocation은 항상 zero page를 한 번 거친다'는 설명은 두 경로를 섞습니다.
fork의 CoW도 비슷한 원리를 이용합니다. 부모와 자식이 처음에는 같은 data page를 read-only로 가리키고, 나중의 write를 계기로 각자의 변경을 분리합니다. 실제 write fault에서는 기존 anonymous page를 안전하게 재사용할 수도 있으므로 CoW라는 이름을 모든 fault에서 반드시 memcpy한다는 뜻으로 읽어서는 안 됩니다. 이 동작을 위해 writable VMA와 일시적으로 read-only인 PTE가 함께 존재할 수 있습니다.
resident와 swap-backed는 같은 분류가 아닙니다
anonymous folio는 RAM에 머물 수도 있고 조건이 맞으면 swap backing을 이용해 reclaim될 수도 있습니다. swap으로 내보낸 뒤 PTE에 swap entry가 있으면 다음 접근에서 data를 다시 가져올 수 있습니다. 그러나 swap-out과 동시에 모든 경우에 RAM folio가 사라진다고 단정하면 swap cache와 writeback 중간 상태를 놓칩니다.
PG_swapbacked 계열 상태는 anonymous 및 shmem의 backing 성질과 관련됩니다. 현재 swap에 내려갔다는 단일한 관측값이 아닙니다. swap 가능성 역시 사용 가능한 swap, pinning, mlock, reclaim 정책 등 여러 조건을 받습니다. 반면 일반 file-backed mapping은 원본 파일이 있으므로 clean data를 버렸다가 다시 읽을 수 있고, private write로 생긴 anonymous copy는 원본 file folio와 다른 성격을 갖습니다.
이 때문에 주소 길이, resident page 수, swap 사용량을 하나의 '할당량'으로 합쳐 읽으면 모순처럼 보이는 값들이 생깁니다. 실제로는 서로 다른 상태 축입니다.
overcommit은 별도의 admission check입니다
vm.overcommit_memory는 commit을 받아들일 정책을 고릅니다. upstream v6.18의 __vm_enough_memory()는 요청을 accounting한 뒤 정책에 따라 허용하거나 실패 시 charge를 되돌립니다.
mode 0인 OVERCOMMIT_GUESS는 이 helper에서 현재 요청 page 수가 전체 RAM과 swap의 합보다 큰지 검사합니다. mode 2처럼 모든 기존 commitment와 새 요청의 합을 같은 strict limit에 대조하는 방식은 아닙니다. mode 1인 OVERCOMMIT_ALWAYS는 이 commit check를 통과시킵니다. 그렇다고 주소 범위, resource limit, metadata allocation이나 실제 fault의 실패까지 없애 주지는 않습니다.
mode 2인 OVERCOMMIT_NEVER는 vm_commit_limit()으로 정한 budget과 누적 commitment를 대조합니다. overcommit_kbytes가 0이 아니면 지정한 byte budget을 page 단위로 환산하고, 그렇지 않으면 hugetlb용 RAM을 제외한 RAM에 overcommit_ratio를 적용합니다. 여기에 swap page 수를 더합니다. 두 설정을 동시에 더하는 공식이 아닙니다.
admin 권한이 없는 요청에는 admin_reserve_kbytes를 남기며, mm이 있으면 해당 mm의 total_vm / 32와 user_reserve_kbytes 중 작은 값에 해당하는 reserve도 반영합니다. /proc/meminfo의 CommitLimit을 보았다고 모든 caller가 그 마지막 byte까지 동일하게 사용할 수 있다고 해석하면 안 됩니다. charge의 page 단위와 sysctl의 kB 단위도 구분해야 합니다.
이 계산을 실제 code와 연결하면 charge의 순서가 눈에 들어옵니다. 아래 두 함수는 official v6.18 mm/util.c의 전체 정의입니다. kernel 내부 의존성이 있는 GPL code이며 standalone 실행 프로그램은 아닙니다. 원래 SPDX와 출처 정보는 각 파일, license 본문은 code/GPL-2.0.txt에 있습니다.
// SPDX-License-Identifier: GPL-2.0-only
/*
* Official Linux v6.18: mm/util.c
* Complete function; source lines 875-887.
* Body unchanged. Kernel dependencies and configuration are required.
* Source URL, SHA, commit, and license: provenance.json; GPL-2.0.txt.
*/
unsigned long vm_commit_limit(void)
{
unsigned long allowed;
if (sysctl_overcommit_kbytes)
allowed = sysctl_overcommit_kbytes >> (PAGE_SHIFT - 10);
else
allowed = ((totalram_pages() - hugetlb_total_pages())
* sysctl_overcommit_ratio / 100);
allowed += total_swap_pages;
return allowed;
}
// SPDX-License-Identifier: GPL-2.0-only
/*
* Official Linux v6.18: mm/util.c
* Complete function; source lines 930-974.
* Body unchanged. Kernel dependencies and configuration are required.
* Source URL, SHA, commit, and license: provenance.json; GPL-2.0.txt.
*/
int __vm_enough_memory(const struct mm_struct *mm, long pages, int cap_sys_admin)
{
long allowed;
unsigned long bytes_failed;
vm_acct_memory(pages);
/*
* Sometimes we want to use more memory than we have
*/
if (sysctl_overcommit_memory == OVERCOMMIT_ALWAYS)
return 0;
if (sysctl_overcommit_memory == OVERCOMMIT_GUESS) {
if (pages > totalram_pages() + total_swap_pages)
goto error;
return 0;
}
allowed = vm_commit_limit();
/*
* Reserve some for root
*/
if (!cap_sys_admin)
allowed -= sysctl_admin_reserve_kbytes >> (PAGE_SHIFT - 10);
/*
* Don't let a single process grow so big a user can't recover
*/
if (mm) {
long reserve = sysctl_user_reserve_kbytes >> (PAGE_SHIFT - 10);
allowed -= min_t(long, mm->total_vm / 32, reserve);
}
if (percpu_counter_read_positive(&vm_committed_as) < allowed)
return 0;
error:
bytes_failed = pages << PAGE_SHIFT;
pr_warn_ratelimited("%s: pid: %d, comm: %s, bytes: %lu not enough memory for the allocation\n",
__func__, current->pid, current->comm, bytes_failed);
vm_unacct_memory(pages);
return -ENOMEM;
}
vm_acct_memory()가 먼저 호출되므로 strict 검사의 counter에는 현재 요청도 반영되어 있습니다. 실패 분기는 반드시 vm_unacct_memory()로 되돌립니다. 비교가 < allowed라는 점과 per-CPU counter의 빠른 read를 사용한다는 점도 중요합니다. budget 공식만으로 모든 동시 요청이 byte 단위의 원자적인 reservation을 얻는다고 해석하지 않습니다. LSM의 security_vm_enough_memory_mm() 계층과 private mapping의 charge 판단, shmem 자체 accounting도 이 helper 밖의 책임입니다.
모든 virtual byte가 같은 비용은 아닙니다
private writable mapping은 나중에 각 process의 독립 copy가 필요할 수 있으므로 commit 부담이 큽니다. private read-only mapping은 같은 방식의 write 부담을 만들지 않습니다. writable private file mapping도 CoW로 anonymous copy가 생길 수 있으므로 단순히 '파일이 있으니 비용 0'이라고 할 수 없습니다.
shared file mapping은 file backing을 활용하지만, shared anonymous memory는 shmem의 accounting을 고려해야 합니다. 따라서 VM_SHARED인 VMA가 일반적인 private VM_ACCOUNT 조건에서 제외된다는 사실을 시스템 전체의 shared anonymous commit 비용이 0이라는 결론으로 확장하면 틀립니다. accounting이 적용되는 계층이 다릅니다.
MAP_NORESERVE는 허용되는 조건에서 미리 commitment를 잡지 않는 선택입니다. mode 2에서는 일반적인 이 flag를 무시합니다. hugetlb에는 별도의 reservation 규칙이 있으므로 같은 설명을 그대로 적용하지 않습니다. 또한 no-reserve mapping도 virtual size에는 나타납니다. commit charge가 없다는 사실은 VMA가 없다거나 미래 write가 보장된다는 뜻이 아닙니다.
commit은 mmap()만의 문제도 아닙니다. mprotect()로 writable하게 바꾸거나 mremap()으로 늘리거나 brk()를 늘릴 때, fork로 private copy의 가능성을 추가할 때도 accounting과 실패 조건이 관계됩니다. 반대로 unmap과 teardown은 부담을 돌려줍니다. Committed_AS는 시스템의 commitment이며 특정 process의 현재 RSS와 일대일 대응하지 않습니다.
strict mode에서도 demand paging은 남습니다
미래의 부담을 budget에 반영하는 것과 지금 모든 page를 RAM에 배치하는 것은 별개의 정책입니다. mode 2라도 access 시점에 page를 준비할 수 있습니다. fork마다 모든 data를 즉시 복사하는 것도 아닙니다. demand paging과 CoW를 유지하면서 admission을 더 엄격하게 하는 것입니다.
따라서 memory 사용량을 읽을 때에는 먼저 무엇을 알고 싶은지 정해야 합니다. mapping의 길이는 VMA 범위, 현재 resident data는 RSS, shared page의 분담은 PSS, 전체 시스템의 미래 private backing 부담은 commitment와 관련됩니다. 하나를 다른 것의 결함 있는 근사값으로 보는 대신 각각의 질문에 맞춰 사용하면 큰 sparse allocation도 더 이상 이상한 동작으로 보이지 않습니다.
참고 자료
- Lorenzo Stoakes, The Linux Memory Manager, Early Access 2025, Chapter 4의 demand paging과 Overcommit 설명.
- Linux v6.18 mm/util.c: commit limit과 admission 구현
- Linux v6.18 overcommit accounting 문서
- Linux v6.18 mm/memory.c: anonymous fault와 write-protection 처리
- proc_pid_smaps(5): mapping별 memory 통계