- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며: 당신이 보는 주소는 거짓이다
한 가지 실험
#include <stdio.h>
#include <stdlib.h>
int main() {
int *p1 = malloc(sizeof(int));
*p1 = 42;
printf("Address: %p, Value: %d\n", p1, *p1);
return 0;
}
실행하면 어떤 주소가 나올까? 예를 들어 0x7ffeea8b4d60. 똑같은 프로그램을 두 번 실행하면 같은 주소가 나올 수도, 다른 주소가 나올 수도 있다. 게다가 두 프로세스가 같은 주소에 다른 데이터를 저장할 수 있다.
어떻게 가능한가? 답은 하나다: 당신이 보는 주소는 거짓이다. 정확히는 가상 주소(virtual address) 다. 실제 물리 메모리의 주소가 아니다.
Virtual Memory라는 환상
현대 OS는 각 프로세스에게 독립된 주소 공간을 제공한다. 64비트 Linux에서:
- 프로세스는 2^47 바이트 ≈ 128 TB의 주소 공간을 본다.
- 실제 물리 메모리는 16 GB일 수 있다.
- 프로세스는 자기가 유일한 사용자처럼 행동한다.
이 환상을 유지하기 위해 CPU의 MMU (Memory Management Unit) 와 OS의 page table이 매 메모리 접근마다 작동한다.
왜 이걸 알아야 하는가?
- 성능: Huge page 설정으로 수십% 성능 향상 가능.
- 디버깅: 왜 이 포인터가 segfault? 왜 프로세스 간 메모리가 공유되지?
- 컨테이너: 컨테이너의 메모리 제한은 어떻게 동작?
- 데이터베이스: PostgreSQL의 shared_buffers, MySQL innodb_buffer_pool_size 튜닝.
- 보안: ASLR, Kernel isolation, Spectre/Meltdown 이해.
Virtual memory를 모르면 시스템 프로그래머로서 중요한 절반을 모르는 것이다. 이 글은 그 절반을 채운다.
1. 가상 메모리의 기본 개념
Virtual Memory의 세 가지 목표
Virtual memory는 세 가지 문제를 동시에 해결한다:
- 격리: 각 프로세스가 독립된 주소 공간을 봐서 서로 방해 안 됨.
- 추상화: 물리 메모리 크기와 무관하게 프로그램 작성 가능.
- 유연성: 필요한 만큼만 실제 메모리 할당 (lazy allocation, swap).
Address Space 분할
64비트 Linux 프로세스의 전형적인 address space:
0xFFFFFFFFFFFFFFFF ┌─────────────────┐ 상위
│ Kernel Space │ (커널만 접근 가능)
0xFFFF800000000000 ├─────────────────┤
│ │
│ (non-canonical) │ (사용 불가 구간)
│ │
0x00007FFFFFFFFFFF ├─────────────────┤
│ Stack (grow ↓) │
│ │
│ . │
│ . │
│ . │
│ │
│ mmap 영역 │
│ │
│ Heap (grow ↑) │
├─────────────────┤
│ BSS │ (초기화 안 된 전역 변수)
├─────────────────┤
│ Data │ (초기화된 전역 변수)
├─────────────────┤
│ Text (Code) │ (read-only 실행 코드)
0x0000000000400000 ├─────────────────┤
│ (guard page) │
0x0000000000000000 └─────────────────┘ 하위
- Text: 실행 코드. 읽기+실행만 가능.
- Data: 초기화된 전역 변수.
- BSS: 초기화 안 된 전역 변수 (0으로 채워짐).
- Heap:
malloc()이 확장하는 영역. - mmap: 파일 매핑, 공유 메모리, 큰 malloc.
- Stack: 함수 호출 프레임.
- Kernel: 커널 코드/데이터. 사용자 모드에서 접근 불가.
Canonical Address
64비트 주소 전체를 쓰지 않는다. 현재 x86-64는 48비트만 사용 (256 TB). 나머지는 비어 있는 "canonical" 규칙으로 강제:
- 상위 16비트가 47번째 비트와 같아야 함.
- 그렇지 않으면 non-canonical (사용 불가).
이는 CPU가 추후 48비트 이상으로 확장할 여지를 두기 위함이다.
Segmentation vs Paging
x86의 초기 32비트 시절엔 segmentation도 사용되었다. 64비트 x86에선 flat model(segment 없음)을 기본으로 하며, paging이 주 메커니즘이다.
이 글은 paging에 집중한다.
2. Page와 Page Table
Page: 가장 작은 단위
가상 메모리의 기본 단위는 page다. x86-64에서 보통 4 KB.
- 가상 메모리는 가상 페이지로 분할.
- 물리 메모리는 physical page (또는 page frame) 로 분할.
- 둘 다 크기는 같음 (기본 4 KB).
주소 분해
4 KB 페이지일 때, 12비트가 offset (2^12 = 4096):
64비트 가상 주소:
[상위: virtual page number][하위 12비트: offset]
Page number → 어떤 페이지? Offset → 페이지 내 몇 번째 바이트?
단순 Page Table의 문제
가장 단순한 page table: 배열.
page_table[virtual_page_number] = physical_page_number
문제: 64비트 주소 공간 전체를 표현하려면:
- Virtual pages: 2^52개.
- 각 entry 8바이트.
- Total: 36 PB (!).
한 프로세스당 36 PB 테이블? 불가능.
Multi-level Page Table
해결책: 계층 구조. 주소를 여러 부분으로 쪼개고, 각 부분이 한 단계의 인덱스가 된다.
x86-64의 4-level page table:
48-bit virtual address:
[9비트: PML4][9비트: PDPT][9비트: PD][9비트: PT][12비트: offset]
- PML4 (Page Map Level 4): 512개 엔트리.
- PDPT (Page Directory Pointer Table): 512개.
- PD (Page Directory): 512개.
- PT (Page Table): 512개.
- 각 엔트리 8바이트.
모든 레벨이 512개 엔트리인 이유: 4KB 페이지 ÷ 8바이트 엔트리 = 512.
Walk 과정
주소 0x00007fb812345678 → 물리 주소 변환:
1. CR3 레지스터가 현재 프로세스의 PML4 물리 주소를 가리킴.
2. PML4[PML4_index] → PDPT 주소.
3. PDPT[PDPT_index] → PD 주소.
4. PD[PD_index] → PT 주소.
5. PT[PT_index] → physical page 주소.
6. 최종: physical page + offset.
4번의 메모리 접근이 필요하다. 매우 비싸다. 그래서 TLB가 필요하다.
Sparse Allocation
Multi-level의 이점: 필요한 만큼만 할당. 프로세스가 10 MB만 쓰면, 사용하지 않는 PML4/PDPT 엔트리는 NULL이고 하위 테이블도 없다.
전체 page table 메모리: 수십 KB~수 MB 수준.
5-level Page Table
2017년 Intel은 5-level paging을 도입. 57비트 주소 공간 (128 PB). 거대한 메모리 서버용. 아직 대부분 워크로드는 4-level로 충분.
3. TLB: 변환을 빠르게
문제
매 메모리 접근마다 4번의 추가 메모리 접근은 5배 느려진다는 뜻이다. 당연히 견딜 수 없다.
TLB (Translation Lookaside Buffer)
CPU에는 TLB라는 특수 캐시가 있다. 가상 페이지 → 물리 페이지 매핑의 최근 결과를 저장:
TLB entry:
[virtual page number][physical page number][permissions][valid]
메모리 접근 순서:
- TLB hit: 즉시 물리 주소 얻음 (~1 cycle).
- TLB miss: Page table walk (
수십수백 cycles).
TLB hit rate가 99% 넘으면 평균 매우 빠름.
TLB의 크기
현대 x86-64 CPU의 TLB:
- L1 TLB (Instruction): 64~128 entries.
- L1 TLB (Data): 64~128 entries.
- L2 TLB (Unified): 1024~2048 entries.
단순 계산: 4KB × 2048 = 8 MB의 working set만 TLB로 커버.
TLB Miss의 비용
TLB miss는 몇 가지 이유로 비싸다:
- 여러 메모리 접근: 4번의 lookup.
- 캐시 미스 가능성: Page table이 L1/L2 캐시에 없을 수 있음.
- 파이프라인 stall.
실제 TLB miss는 100~500 cycles. 즉 40~200ns. 빈번하면 치명적.
TLB Shootdown
멀티코어에서 TLB는 각 코어에 독립적이다. 프로세스의 페이지 매핑이 변경되면 (예: munmap, mprotect, 페이지 회수), 모든 코어의 TLB를 무효화해야 한다.
이를 TLB shootdown이라 한다:
- 한 코어가 매핑 변경.
- 다른 코어들에게 IPI (Inter-Processor Interrupt) 전송.
- 각 코어가 자기 TLB entry 제거.
- 모두 완료될 때까지 대기.
TLB shootdown은 수천~수만 ns 걸린다. 빈번한 mapping 변경(예: JIT 컴파일러, 공유 메모리)이 성능 문제를 일으킬 수 있다.
ASID (Process Context ID)
context switch마다 TLB를 전부 비워야 할까? 그러면 매번 수백 번의 TLB miss.
해결: ASID (또는 PCID in x86). 각 TLB entry에 프로세스 ID를 함께 저장. Context switch 시:
- TLB 비우지 않음.
- 현재 ASID만 사용.
- 다른 프로세스 entry는 무시.
Linux 4.14+부터 x86 PCID 지원. Context switch 오버헤드 크게 감소.
4. Huge Pages: TLB의 친구
동기
4 KB 페이지의 한계:
- TLB가 2048 entries → 8 MB만 커버.
- 데이터베이스 buffer pool이 GB급이면 TLB miss가 빈번.
해결: 더 큰 페이지.
x86-64의 페이지 크기
x86-64는 세 가지 페이지 크기 지원:
- 4 KB (기본).
- 2 MB (large page).
- 1 GB (huge page, Intel Xeon 이상).
TLB 효율 비교
2048 entries TLB로 커버되는 메모리:
| 페이지 크기 | 커버 범위 |
|---|---|
| 4 KB | 8 MB |
| 2 MB | 4 GB |
| 1 GB | 2 TB |
1 GB 페이지면 2 TB도 TLB로 커버. TLB miss 거의 없음.
Huge Pages 설정
Linux의 huge page 옵션:
- Static Huge Pages (HugeTLB): 부팅 시 또는 런타임에 예약.
- Transparent Huge Pages (THP): 커널이 자동으로 큰 페이지로 합병/분할.
HugeTLB
정적으로 예약:
# 1024개의 2MB 페이지 예약 = 2 GB
echo 1024 > /proc/sys/vm/nr_hugepages
# 또는 부팅 시
# GRUB: hugepages=1024
애플리케이션이 mmap에 MAP_HUGETLB 플래그 사용:
void *p = mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB, -1, 0);
장점: 명시적, 예측 가능. 단점: 사전 예약, 메모리 고정.
Transparent Huge Pages (THP)
커널이 자동으로 4KB 페이지들을 2MB로 합병:
# THP 상태 확인
cat /sys/kernel/mm/transparent_hugepage/enabled
# [always] madvise never
- always: 가능한 모든 곳에서 THP 시도.
- madvise: 애플리케이션이
madvise(MADV_HUGEPAGE)했을 때만. - never: 비활성화.
THP의 함정
THP가 "항상 좋은" 것은 아니다. 문제 사례:
1. Fragmentation 스톨: 큰 연속 영역이 없으면 커널이 memory compaction 실행. 이 중에 애플리케이션이 멈춤. 수백 ms pause 가능.
2. 메모리 낭비: 2 MB 중 1 byte만 사용해도 2 MB 소모. MongoDB, Redis에서 문제 발생 사례 있음.
3. 예측 불가능: 어떤 페이지가 THP인지, 언제 분할되는지 명확하지 않음.
권장 (DB 워크로드): Redis, MongoDB는 THP 비활성화 권장.
echo never > /sys/kernel/mm/transparent_hugepage/enabled
Java, HPC, ML 워크로드는 THP 활성화가 좋을 수 있다. 반드시 벤치마크로 확인.
실측 예시
PostgreSQL 벤치마크 (64 GB buffer pool):
| 설정 | 처리량 |
|---|---|
| 4 KB pages | 100% (기준) |
| Static 2 MB (HugeTLB) | 115~120% |
| THP always | 105~110% (가변) |
5~20% 향상. 대용량 메모리 워크로드에선 무시할 수 없는 차이.
5. Page Fault: Lazy Allocation의 마법
malloc()이 실제로 하는 일
int *p = malloc(1024 * 1024); // 1 MB
이 호출 후 물리 메모리 1 MB가 실제로 할당되었을까?
아니다. Linux의 기본 동작:
malloc은 glibc가 처리.- 큰 요청이면
mmap시스템콜 호출. - 커널은 가상 주소 범위만 할당. 물리 페이지는 아직.
- Page table 엔트리도 아직 없음.
p를 반환.
실제 물리 메모리는 접근할 때 할당된다.
Page Fault
p[0] = 1; // 여기서 실제 할당 발생!
- CPU가
p[0]의 가상 주소 접근. - MMU가 TLB에서 찾음 → miss.
- Page table walk → entry 없음.
- Page fault 예외.
- 커널이 page fault handler 실행:
- 새 물리 페이지 할당.
- 0으로 초기화.
- Page table에 등록.
- 프로세스에 제어권 반환.
- CPU가 명령어 재시도 → 성공.
Lazy Allocation의 이점
int *big = malloc(100 * 1024 * 1024 * 1024); // 100 GB
시스템 메모리가 16 GB여도 성공한다! 실제 접근한 만큼만 할당되기 때문.
이것이 왜 중요한가:
- 프로그램이 "최악의 경우"를 고려한 큰 버퍼를 안전하게 할당 가능.
- 실제 사용하지 않으면 메모리 낭비 없음.
- 희소 데이터 구조(sparse array) 가능.
Minor vs Major Fault
Page fault에는 종류가 있다:
Minor Fault (soft fault):
- 메모리에는 이미 있지만 page table에 매핑 안 됨.
- 예: 처음 접근, COW 후 접근.
- 비용: 낮음 (~1 μs).
Major Fault (hard fault):
- 디스크(swap 또는 파일)에서 읽어야 함.
- 비용: 높음 (~수 ms, 디스크 속도에 따라).
측정
# 프로세스의 page fault 통계
ps -o pid,min_flt,maj_flt,cmd <pid>
# 시스템 전체
vmstat 1
# r b swpd free buff cache si so bi bo in cs us sy id wa
# ^ ^
# swap in/out (major faults)
Major fault가 많으면 메모리 부족 또는 swap 남용. 조사 필요.
6. Fork와 Copy-on-Write
fork()가 진짜 하는 일
fork()는 프로세스의 전체 복사본을 만드는 것처럼 보인다:
pid_t pid = fork();
if (pid == 0) {
// 자식 프로세스
} else {
// 부모 프로세스
}
10 GB 메모리를 쓰는 프로세스를 fork하면 10 GB 복사? 수 초 걸려야 맞다.
그러나 실제론 수 밀리초면 된다. 비밀은 Copy-on-Write (COW) 다.
Copy-on-Write의 작동
- fork 시: 부모의 page table을 복사. 물리 페이지는 그대로 공유.
- 모든 페이지를 read-only로 마킹.
- 부모든 자식이든 쓰기 시도 시 page fault.
- 커널이 실제로 페이지 복사. 복사본을 writable로 설정.
- 원본은 다른 쪽이 여전히 공유.
효과
- Fork 즉시 완료: page table 복사만.
- 읽기 전용 데이터는 영원히 공유 (복사 없음).
- 쓰기가 많은 데이터만 실제로 복사됨.
전형적인 fork() + exec() 패턴에선 거의 아무것도 복사 안 된다.
Redis의 background save
Redis는 SAVE/BGSAVE 할 때 fork() 한다:
- 부모: 계속 요청 처리.
- 자식: 메모리 스냅샷을 디스크로 저장.
- 자식 완료 후 exit.
Copy-on-Write 덕분에 자식이 별도의 스냅샷을 복사할 필요 없이 일관된 뷰를 본다. 부모의 쓰기는 복사로 처리되므로 자식은 fork 시점의 상태를 계속 본다.
함정: 쓰기 트래픽이 높으면 대부분 페이지가 복사되어 메모리 2배 필요. OOM kill 위험.
Linux에서의 fork 최적화
Linux는 fork를 더 최적화한다:
- Page table도 lazy 복사: 4-level의 상위 레벨만 즉시 복사.
- Huge page COW: 2 MB 단위 복사 대신 4 KB로 분할 후 복사.
- Transparent page sharing (KSM): 동일 내용 페이지 공유.
7. Swap: 물리 메모리의 연장
Swap의 기본
메모리가 부족하면 커널이 덜 쓰이는 페이지를 디스크로 내보낸다 (swap out). 필요하면 다시 가져온다 (swap in).
[page_X] [page_X']
↓ ↑
[swap partition] [swap partition]
swap out swap in
Swap의 장단점
장점:
- 물리 메모리보다 큰 working set 가능.
- 오래 안 쓴 페이지 회수.
- OOM kill 방어.
단점:
- 느리다: 디스크 I/O.
- Latency 예측 불가: 언제 swap in 될지 모름.
- SSD 마모: 자주 스왑하면 SSD 수명 감소.
swappiness 파라미터
sysctl vm.swappiness
# vm.swappiness = 60
0~100, 커널이 얼마나 적극적으로 스왑할지:
- 0: 거의 안 함. OOM 직전까지 page cache만 회수.
- 10: 서버 권장.
- 60: 데스크톱 기본.
- 100: 매우 공격적.
DB 서버: 일반적으로 vm.swappiness = 1을 권장. 스왑을 최소화해 레이턴시 일관성 유지.
Zram: 압축 Swap
Zram은 swap 디바이스를 메모리에 만든다 (압축된 상태로):
- 느린 디스크 대신 RAM 사용.
- Lz4/Zstd 압축으로 실효 용량 증가.
- 컨테이너 환경에서 유용.
Swap을 아예 끄기?
"RAM이 충분하면 swap 꺼!"는 나쁜 조언일 수 있다. Swap 없이도:
- 커널이 여전히 page cache 회수.
- 일부 앱은 "거의 안 쓰는" 메모리를 가짐 → swap out이 오히려 유리.
- OOM kill이 즉시 발생 (완충재 없음).
권장: 작은 swap (2~8 GB) 유지 + swappiness 낮게 설정.
8. 컨테이너와 메모리
cgroup의 메모리 제한
Kubernetes/Docker가 memory.limit를 걸면:
resources:
limits:
memory: "512Mi"
내부적으로 cgroup의 memory.max = 512Mi. 이 한도 초과 시:
- 커널이 페이지 회수 시도.
- 회수 불가능하면 OOM Kill.
컨테이너는 Page Cache도 포함
중요한 함정: cgroup의 memory.current는 page cache도 포함한다.
예시:
Process: 200 MB resident
Page cache: 300 MB
cgroup memory.current: 500 MB
memory.max: 512 MB
Page cache 때문에 limit에 근접. 커널이 cache를 회수할 수 있으므로 OOM 안 남. 하지만 모니터링 시 혼란을 줌.
RSS vs PSS vs USS
프로세스 메모리 측정에는 여러 개념이 있다:
- VSZ (Virtual Size): 가상 메모리 전체. 의미 없는 경우 많음 (1 TB VSZ도 가능).
- RSS (Resident Set Size): 현재 물리 메모리에 있는 양. 하지만 공유 페이지도 포함.
- PSS (Proportional Set Size): 공유 페이지를 공유자 수로 나눔. 더 공정.
- USS (Unique Set Size): 이 프로세스만 쓰는 메모리.
# /proc/[pid]/smaps
cat /proc/1234/smaps_rollup
Rss: 102400 kB
Pss: 85000 kB
Uss: 70000 kB
PSS의 합계 = 전체 물리 사용량. RSS의 합계 > 물리 사용량 (공유 때문).
JVM과 컨테이너
Java 프로세스는 특히 까다롭다:
- JVM은 원래 호스트의 전체 메모리를 보려 함.
- JVM 8u131 이전: cgroup 인식 안 함 → 호스트 RAM 기준으로 heap 설정 → OOM.
- JVM 8u191+:
-XX:+UseContainerSupport(기본 on). - JVM 10+: 자동 감지.
권장:
java -XX:MaxRAMPercentage=75 -jar app.jar
컨테이너 메모리의 75%를 heap으로. 나머지는 native (metaspace, stack, direct buffer, GC, JIT).
9. /proc/[pid]/maps: 자신의 주소 공간 들여다보기
전체 매핑 보기
cat /proc/self/maps
55a9b7c00000-55a9b7c01000 r--p 00000000 fd:01 123456 /bin/cat
55a9b7c01000-55a9b7c05000 r-xp 00001000 fd:01 123456 /bin/cat
55a9b7c05000-55a9b7c07000 r--p 00005000 fd:01 123456 /bin/cat
55a9b7c07000-55a9b7c08000 r--p 00006000 fd:01 123456 /bin/cat
55a9b7c08000-55a9b7c09000 rw-p 00007000 fd:01 123456 /bin/cat
55a9b87cc000-55a9b87ed000 rw-p 00000000 00:00 0 [heap]
7f3afc000000-7f3afc021000 rw-p 00000000 00:00 0
7f3afc021000-7f3b00000000 ---p 00000000 00:00 0
7f3b04f6d000-7f3b04f95000 r--p 00000000 fd:01 234567 /lib/libc.so
...
7fffd5f3c000-7fffd5f5d000 rw-p 00000000 00:00 0 [stack]
7fffd5fce000-7fffd5fd2000 r--p 00000000 00:00 0 [vvar]
7fffd5fd2000-7fffd5fd4000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]
각 행은 VMA (Virtual Memory Area):
- 주소 범위.
- 권한 (r/w/x/p=private s=shared).
- 파일 offset (있으면).
- 디바이스:inode.
- 경로 또는 레이블 (
[heap],[stack],[anon]).
주요 영역 해석
- /bin/cat (여러 줄): 실행 파일의 segments (text, rodata, data 등).
- [heap]:
brk로 확장되는 heap. - /lib/libc.so: 공유 라이브러리 매핑.
---p권한: guard page (접근 시 segfault).- [stack]: 스택.
- [vvar], [vdso], [vsyscall]: 커널이 제공하는 특수 매핑 (빠른 시스템콜용).
디버깅
메모리 누수 디버깅 시 유용:
# 특정 프로세스의 모든 매핑
pmap -X <pid>
# 세부 정보
cat /proc/<pid>/smaps | head -50
smaps는 각 매핑의 RSS, PSS, Private Dirty, Shared Clean 등 자세한 통계 제공.
10. 보안과 가상 메모리
ASLR (Address Space Layout Randomization)
매 실행마다 stack, heap, libraries의 주소를 무작위화:
# 기본 활성화
sysctl kernel.randomize_va_space
# kernel.randomize_va_space = 2
- 0: 비활성화.
- 1: Stack, mmap만.
- 2: 모두 (기본).
효과: 버퍼 오버플로우 공격에서 특정 주소로 점프하기 어려워짐. "어디로 return 할지 모름".
NX Bit / DEP (Data Execution Prevention)
Stack과 heap은 실행 불가로 표시. 공격자가 코드를 stack에 주입해도 실행 안 됨.
CPU의 NX bit가 지원. 현대 x86-64는 모두 지원.
KASLR (Kernel ASLR)
커널 이미지의 위치도 무작위화. Meltdown/Spectre 시대에 중요.
KPTI (Kernel Page Table Isolation)
Meltdown 취약점 방어. 사용자 프로세스의 page table에 커널 매핑을 제거. 시스템콜 시 커널 page table로 전환.
대가: 시스템콜 오버헤드 증가 (~5-30%). 특히 syscall-heavy 워크로드에 영향.
# KPTI 상태 확인
grep pti /proc/cpuinfo
11. 실전 튜닝
데이터베이스 서버
PostgreSQL:
# /etc/sysctl.conf
vm.swappiness = 1
vm.overcommit_memory = 2
vm.overcommit_ratio = 80
vm.dirty_background_ratio = 3
vm.dirty_ratio = 10
MySQL/InnoDB:
# Huge pages for buffer pool
echo 1000 > /proc/sys/vm/nr_hugepages
# my.cnf: innodb-buffer-pool-size=12G + large-pages=1
웹 서버 / 응용 서버
# 기본값 괜찮지만
vm.swappiness = 10 # 불필요한 swap 감소
THP: 언어 런타임 벤치마크로 결정. Java는 보통 THP on이 좋다.
컨테이너 호스트
# 많은 컨테이너 = 많은 프로세스 = 많은 page table
# 메모리 fragmentation 방지
vm.min_free_kbytes = 131072 # 128 MB
Redis / MongoDB
# THP 끔 (권장)
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
부팅 시 자동화:
# /etc/rc.local
echo never > /sys/kernel/mm/transparent_hugepage/enabled
HPC / 대용량 메모리
# Static huge pages
echo 10000 > /proc/sys/vm/nr_hugepages
# 10000 × 2MB = 20 GB
애플리케이션이 mmap(..., MAP_HUGETLB, ...) 으로 사용.
12. 디버깅: 가상 메모리 문제들
문제 1: "Out of Memory"
kernel: Out of memory: Killed process 1234 (my_app)
원인:
- 실제 물리 메모리 부족.
- cgroup limit 초과.
진단:
dmesg | grep -i "killed process"
# 어떤 프로세스, 얼마나 썼는지 로그.
# 현재 메모리 상태
free -h
cat /proc/meminfo
해결: 메모리 누수 찾기, limit 조정, swap 확장.
문제 2: 느린 page fault
증상: 프로세스가 간헐적으로 멈춤.
원인: Major fault (디스크 swap in).
진단:
pidstat -r 1 # 프로세스별 page fault
# minflt/s majflt/s 컬럼
Major fault가 높으면 메모리 부족 또는 swap 과도.
문제 3: THP 스톨
증상: 랜덤하게 수백 ms pause.
원인: THP compaction.
진단:
grep -i "compact" /proc/vmstat
grep -i "huge" /proc/vmstat
compact_stall이 증가하면 문제.
해결:
echo madvise > /sys/kernel/mm/transparent_hugepage/defrag- 또는
echo never
문제 4: OOM Killer가 엉뚱한 프로세스 죽임
원인: OOM score 계산으로 대상 선택. 가장 크거나 가장 최근.
해결: 보호할 프로세스의 oom_score_adj 낮추기:
echo -1000 > /proc/$(pidof important_process)/oom_score_adj
문제 5: 공유 라이브러리가 여러 번 매핑됨
증상: RSS는 높지만 실제 메모리 사용은 그만큼 안 됨.
원인: 여러 프로세스가 같은 libc, libssl 등을 공유. Linux는 한 번만 로드.
확인: pmap 또는 smaps의 Shared_Clean 값.
퀴즈로 복습하기
Q1. 4-level page table이 왜 필요한가? 왜 단순 배열로 안 되는가?
A. 메모리 낭비 때문이다. 64비트 주소공간을 단순 배열로 표현하면:
- Virtual pages: 2^(48-12) = 2^36 ≈ 680억 개.
- 각 엔트리 8바이트.
- 전체 크기: 512 GB.
이 테이블을 각 프로세스가 가져야 한다. 물리적으로 불가능하다.
4-level page table의 해결:
- 주소를 4부분(9+9+9+9 bits)으로 분할.
- 각 레벨이 하나의 4KB 페이지에 들어감 (512 entries × 8 bytes).
- 실제로 사용되는 부분만 할당.
프로세스가 1 MB만 쓴다면: PML4 페이지 하나 + PDPT 하나 + PD 하나 + PT 하나 = 16 KB의 page table. 512 GB에서 16 KB로.
대가: TLB miss 시 4번의 메모리 접근. 그래서 TLB가 중요하다. TLB hit rate가 99% 넘으면 4단계 walk의 평균 비용이 무시할 수 있게 된다.
Intel은 최근 5-level paging (57-bit 주소, 128 PB)을 도입해 대용량 메모리 서버에 대응하고 있다. 트리 깊이는 증가하지만 같은 원리다.
Q2. Copy-on-Write가 fork()를 어떻게 빠르게 만드는가?
A. 전통적인 "fork는 전체 메모리 복사"라는 의미대로 구현하면 10 GB 프로세스 fork에 수 초가 걸린다. Linux는 COW로 이를 수 밀리초로 줄인다.
메커니즘:
- fork 시 물리 페이지 복사 안 함. 대신:
- 자식 프로세스 생성.
- 부모의 page table만 복사 (크기 작음, 수 KB).
- 모든 페이지의 page table 엔트리를 read-only로 설정.
- 참조 카운트 증가 (두 프로세스가 공유).
- 읽기: 어느 쪽이든 자유롭게 읽기 가능. 실제 복사 없음.
- 쓰기 시도: Page fault 발생 (read-only 페이지에 쓰기).
- 커널의 page fault handler:
- 실제로 페이지 하나 복사.
- 쓰려는 프로세스에게 private 페이지 할당, writable로.
- 다른 쪽은 원본 공유 계속.
이득:
- Fork 자체는 거의 즉시 (수 ms).
- 읽기 전용 데이터는 영원히 공유: 실행 코드, 정적 데이터, 대부분의 데이터베이스 버퍼.
- 쓰기 위치만 실제로 복사: 전체 복사 대비 수십~수백 배 절약.
비유: 책을 복사하는 대신 "이 책 읽어도 돼, 단 쓰려면 그 페이지만 복사해서 써" 라고 하는 것.
실전 사용: Redis BGSAVE, Nginx worker fork, Python multiprocessing, exec() 전의 일반 fork. 이 모든 곳에서 COW가 작동한다. Linux 성능의 핵심 기능 중 하나다.
Q3. Huge Pages가 성능을 높이는 주 메커니즘은?
A. TLB miss 감소다. 다른 이점도 있지만 이것이 가장 크다.
TLB의 한계:
- 현대 CPU의 L2 TLB는 2048 entries 정도.
- 4 KB 페이지면 TLB는 8 MB만 커버.
- 워킹셋이 100 MB면 TLB miss가 빈번.
Huge page의 해결:
- 2 MB 페이지 사용 시: 2048 × 2 MB = 4 GB 커버.
- 1 GB 페이지 사용 시: 2048 × 1 GB = 2 TB 커버.
결과:
- TLB hit rate 급증.
- TLB miss당 비용(100~500 cycles) 감소.
- 전체 메모리 접근 속도 향상.
추가 이점:
- Page walk 단축: 2 MB 페이지는 3단계 walk (PT 생략), 1 GB는 2단계 walk.
- Memory fragmentation 감소: 큰 단위로 할당되므로.
- Page table 크기 감소: 레벨이 줄어 메모리 절약.
실전 효과 (DB buffer pool 기준):
- PostgreSQL: 5~20% 처리량 증가.
- MongoDB: THP 활성화 시 ~10% (단, MongoDB는 THP 비권장).
- Java heap: 5~15%.
그러나 함정이 있다:
- THP compaction stall: 커널이 2 MB 연속 영역을 만들려고 하다가 애플리케이션 stall. Redis, MongoDB에서 특히 문제.
- 메모리 과다 사용: 2 MB 페이지에 1 KB만 써도 2 MB 소모.
- Fork 시 복사 단위가 커짐: COW가 4 KB가 아닌 2 MB 복사.
권장:
- Static huge pages (HugeTLB): 대용량 DB 버퍼 풀. 예측 가능.
- THP with madvise: Java heap, 일부 HPC.
- THP never: Redis, MongoDB, 짧은 생명주기 프로세스.
반드시 벤치마크로 검증. "Huge page는 항상 좋다"는 맹목적 믿음은 위험하다.
Q4. Linux에서 "10 GB 메모리 할당 성공했는데 시스템 RAM은 8 GB"가 어떻게 가능한가?
A. Linux의 Lazy Allocation + Overcommit 때문이다.
Lazy Allocation:
malloc() 또는 mmap()은 가상 주소 공간만 할당한다. 실제 물리 페이지는 접근 시점에 page fault를 통해 할당된다.
void *p = malloc(100LL * 1024 * 1024 * 1024); // 100 GB
// 여기서 실제 물리 메모리 사용 = 0
p[0] = 1; // 여기서 첫 4 KB 페이지 할당 (실제 메모리 사용 4 KB)
Overcommit:
Linux는 vm.overcommit_memory 설정에 따라 물리 메모리보다 더 많은 가상 메모리 할당을 허용한다:
- 0 (기본): 휴리스틱. 명백히 불가능하면 거부.
- 1: 항상 허용 (overcommit_always).
- 2: Strict.
RAM × ratio + swap만 허용.
이 조합의 효과:
- 희소 데이터 구조 (예: 1 TB 해시테이블 중 1 GB만 사용) 가능.
- 프로그램이 "최악의 경우"에 대비한 큰 버퍼 할당 가능.
- fork() 후 exec() 패턴이 효율적 (전체 메모리 복사 요구 안 함).
위험:
- 여러 프로세스가 실제로 많이 쓰기 시작하면 OOM.
- OOM Killer가 출동해 프로세스 종료.
- 어느 프로세스가 죽을지 예측 불가.
통제:
- Strict overcommit:
vm.overcommit_memory = 2. - cgroup limit: 컨테이너별 강제 제한.
vm.overcommit_ratio: strict 모드에서 허용 비율.
실전:
- 대부분의 Linux 시스템은 기본값 (heuristic)으로 동작.
- 데이터베이스나 메모리 민감 서비스는
vm.overcommit_memory = 2로 예측 가능하게. - 컨테이너 환경은 cgroup이 이미 관리.
이것이 "Linux는 메모리를 게으르게 할당한다"라는 문장의 진짜 의미다. 가상 주소 할당 ≠ 물리 메모리 할당. 이 구분을 이해하면 많은 미스터리가 풀린다.
Q5. TLB shootdown이 무엇이고 왜 성능 문제를 일으키는가?
A. 멀티코어 시스템에서 TLB 일관성을 유지하기 위해 필요한 작업이다.
배경:
- TLB는 각 CPU 코어의 독립적인 캐시다.
- 프로세스 페이지 매핑이 바뀌면 (예:
munmap,mprotect, 페이지 swap out), 모든 코어의 TLB에 캐시된 구버전을 제거해야 한다. - 그렇지 않으면 stale 매핑을 사용해 잘못된 데이터 읽기/쓰기 발생.
Shootdown 과정:
- Core 0이 페이지 매핑 변경 (예:
munmap). - 커널은 해당 매핑을 사용 중일 수 있는 모든 코어에 IPI (Inter-Processor Interrupt) 전송.
- 각 코어가 interrupt를 받고 현재 작업 중단.
- 자기 TLB에서 해당 entry 무효화 (
invlpg명령). - Core 0에 완료 신호.
- Core 0은 모든 완료를 기다린 후 진행.
비용:
- IPI 전송: 수 μs.
- 각 코어의 context switch: 수 μs.
- 동기화 대기: 코어 수 × IPI 시간.
- 총: 수십~수천 μs (코어 수에 따라).
코어 수가 많을수록 비용이 증가한다. 64-core 시스템에선 수 ms까지 갈 수 있다.
문제를 일으키는 상황:
- 빈번한 mmap/munmap: 동적 메모리 할당이 많은 애플리케이션. JVM, v8 (JIT 컴파일).
- 공유 메모리 리매핑: 프로세스 간 공유.
- 페이지 회수: 메모리 부족 시 커널이 페이지 swap out.
- NUMA migration: 페이지를 다른 NUMA 노드로 이동.
- Transparent Huge Pages: 2 MB 페이지 merge/split.
증상:
perf top에smp_call_function_many,flush_tlb_func가 자주 나타남.- CPU 사용률은 낮은데 처리량이 나쁨.
- Latency spike.
완화:
- Mapping 변경 최소화: 큰 버퍼 풀을 사전 할당.
- Huge pages: TLB entry 수가 줄어 shootdown 비용 감소.
- CPU affinity: 프로세스를 특정 코어에 고정 → 다른 코어 무관.
madvise(MADV_DONTNEED)대신MADV_FREE: 지연 회수.- Linux 커널 업그레이드: 최근 버전은 shootdown 최적화 있음.
고성능 서버에서 "왜 확장이 안 되지?"의 원인이 TLB shootdown인 경우가 의외로 많다. 특히 mmap 기반 데이터베이스(MongoDB, LMDB)나 JIT 컴파일러(V8, JVM)에서 나타난다. 이를 측정하고 완화하는 것이 고성능 엔지니어링의 한 부분이다.
마치며: 환상의 공학
핵심 정리
- Virtual Memory는 환상이다. 당신의 포인터는 거짓말이다.
- 4-level page table: 계층 구조로 메모리 낭비 방지.
- TLB: 변환 캐시. Hit rate 99%+가 관건.
- Huge pages: TLB 효율 극대화. 단, 함정 주의.
- Lazy allocation + COW: Fork와 malloc의 마법.
- Page fault: Minor는 빠르고 Major는 느리다.
- Swap: 물리 메모리의 연장, 그러나 신중히.
- ASLR, NX, KPTI: 보안과 메모리의 만남.
왜 이 지식이 가치 있는가
Virtual memory는 운영체제의 가장 복잡하고 가장 영향력 있는 기능 중 하나다. 현대 OS의 거의 모든 성능 최적화, 보안 기능, 프로세스 격리가 이 위에 있다.
이 지식이 없으면:
- "왜 내 프로세스가 이만큼 메모리를 쓰지?" → 답 못함.
- "왜 fork가 빠르지?" → 추측.
- "Huge page를 켜야 할까?" → 감으로 결정.
- "OOM kill이 왜 났지?" → 미스터리.
- "컨테이너 메모리 제한은 어떻게 작동?" → 불명확.
이 지식이 있으면:
- 성능 튜닝에 데이터 기반 결정.
- 이상한 증상의 근본 원인 파악.
- 시스템 설계 시 메모리 효율 고려.
- 보안 기능을 올바르게 활용.
- 더 좋은 엔지니어.
마지막 생각
Virtual memory의 매력은 우아한 추상화다. 수많은 기술(MMU 하드웨어, page table, TLB, COW, lazy allocation, swap)이 조합되어 "모든 프로세스가 독립된 무한 주소 공간을 가진 것처럼" 보이게 한다. 이는 60년 전 Multics 연구에서 시작되어 오늘날 모든 현대 OS의 기반이 되었다.
당신이 다음에 malloc()을 호출할 때, 기억해 보자:
- 이 포인터는 가상 주소다.
- 실제 물리 메모리는 아직 할당되지 않았을 수 있다.
- 첫 접근 시 page fault가 일어날 것이다.
- MMU가 4단계 page table을 탐색해 물리 주소를 찾을 것이다.
- 아니면 TLB가 즉시 답할 것이다.
- 이 모든 것이 나노초 단위에서 일어난다.
그 복잡성이 우리가 쉽게 프로그램을 짤 수 있는 이유다. 환상을 유지하는 것이 진짜 엔지니어링이다.
참고 자료
- What Every Programmer Should Know About Memory (Ulrich Drepper, 2007) - 메모리 바이블
- Understanding the Linux Virtual Memory Manager (Mel Gorman) - 고전 참고서
- Linux Kernel Documentation: Virtual Memory
- Intel 64 and IA-32 Architectures Software Developer's Manual Vol 3A: System Programming Guide
- TLB and Pagewalk Coherence in x86 Processors
- LWN: The current state of kernel page-table isolation (KPTI)
- Transparent Hugepages: measuring the performance impact
- What is Copy-on-Write (CoW)?
- Red Hat: Huge Pages and Transparent Huge Pages
- Brendan Gregg: Linux Performance