들어가며: 당신이 보는 주소는 거짓이다
한 가지 실험
#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는 세 가지 문제를 동시에 해결한다:
1. **격리**: 각 프로세스가 독립된 주소 공간을 봐서 서로 방해 안 됨.
2. **추상화**: 물리 메모리 크기와 무관하게 프로그램 작성 가능.
3. **유연성**: 필요한 만큼만 실제 메모리 할당 (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]
메모리 접근 순서:
1. **TLB hit**: 즉시 물리 주소 얻음 (~1 cycle).
2. **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는 몇 가지 이유로 비싸다:
1. **여러 메모리 접근**: 4번의 lookup.
2. **캐시 미스 가능성**: Page table이 L1/L2 캐시에 없을 수 있음.
3. **파이프라인 stall**.
실제 TLB miss는 **100~500 cycles**. 즉 **40~200ns**. 빈번하면 치명적.
TLB Shootdown
멀티코어에서 TLB는 각 코어에 독립적이다. 프로세스의 페이지 매핑이 변경되면 (예: `munmap`, `mprotect`, 페이지 회수), **모든 코어의 TLB를 무효화**해야 한다.
이를 **TLB shootdown**이라 한다:
1. 한 코어가 매핑 변경.
2. 다른 코어들에게 IPI (Inter-Processor Interrupt) 전송.
3. 각 코어가 자기 TLB entry 제거.
4. 모두 완료될 때까지 대기.
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 옵션**:
1. **Static Huge Pages (HugeTLB)**: 부팅 시 또는 런타임에 예약.
2. **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의 기본 동작:
1. `malloc`은 glibc가 처리.
2. 큰 요청이면 `mmap` 시스템콜 호출.
3. 커널은 **가상 주소 범위만 할당**. 물리 페이지는 아직.
4. Page table 엔트리도 아직 없음.
5. `p`를 반환.
**실제 물리 메모리는 접근할 때 할당된다**.
Page Fault
p[0] = 1; // 여기서 실제 할당 발생!
1. CPU가 `p[0]`의 가상 주소 접근.
2. MMU가 TLB에서 찾음 → miss.
3. Page table walk → entry 없음.
4. **Page fault 예외**.
5. 커널이 page fault handler 실행:
- 새 물리 페이지 할당.
- 0으로 초기화.
- Page table에 등록.
- 프로세스에 제어권 반환.
6. 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의 작동
1. **fork 시**: 부모의 page table을 **복사**. 물리 페이지는 그대로 공유.
2. 모든 페이지를 **read-only**로 마킹.
3. 부모든 자식이든 **쓰기 시도 시 page fault**.
4. 커널이 **실제로 페이지 복사**. 복사본을 writable로 설정.
5. 원본은 다른 쪽이 여전히 공유.
효과
- **Fork 즉시 완료**: page table 복사만.
- **읽기 전용 데이터**는 영원히 공유 (복사 없음).
- **쓰기가 많은 데이터**만 실제로 복사됨.
전형적인 `fork() + exec()` 패턴에선 거의 아무것도 복사 안 된다.
Redis의 background save
Redis는 `SAVE`/`BGSAVE` 할 때 `fork()` 한다:
1. 부모: 계속 요청 처리.
2. 자식: 메모리 스냅샷을 디스크로 저장.
3. 자식 완료 후 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`. 이 한도 초과 시:
1. 커널이 페이지 회수 시도.
2. 회수 불가능하면 **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` 값.
퀴즈로 복습하기
**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)을 도입해 대용량 메모리 서버에 대응하고 있다. 트리 깊이는 증가하지만 같은 원리다.
**A.** 전통적인 "fork는 전체 메모리 복사"라는 의미대로 구현하면 10 GB 프로세스 fork에 수 초가 걸린다. Linux는 **COW로 이를 수 밀리초로** 줄인다.
**메커니즘**:
1. **fork 시 물리 페이지 복사 안 함**. 대신:
- 자식 프로세스 생성.
- 부모의 **page table만 복사** (크기 작음, 수 KB).
- 모든 페이지의 page table 엔트리를 **read-only**로 설정.
- 참조 카운트 증가 (두 프로세스가 공유).
2. **읽기**: 어느 쪽이든 자유롭게 읽기 가능. 실제 복사 없음.
3. **쓰기 시도**: Page fault 발생 (read-only 페이지에 쓰기).
4. **커널의 page fault handler**:
- 실제로 페이지 하나 복사.
- 쓰려는 프로세스에게 private 페이지 할당, writable로.
- 다른 쪽은 원본 공유 계속.
**이득**:
- **Fork 자체는 거의 즉시** (수 ms).
- **읽기 전용 데이터는 영원히 공유**: 실행 코드, 정적 데이터, 대부분의 데이터베이스 버퍼.
- **쓰기 위치만 실제로 복사**: 전체 복사 대비 수십~수백 배 절약.
**비유**: 책을 복사하는 대신 "이 책 읽어도 돼, 단 쓰려면 그 페이지만 복사해서 써" 라고 하는 것.
**실전 사용**: Redis BGSAVE, Nginx worker fork, Python multiprocessing, exec() 전의 일반 fork. 이 모든 곳에서 COW가 작동한다. Linux 성능의 핵심 기능 중 하나다.
**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) 감소.
- 전체 메모리 접근 속도 향상.
**추가 이점**:
1. **Page walk 단축**: 2 MB 페이지는 3단계 walk (PT 생략), 1 GB는 2단계 walk.
2. **Memory fragmentation 감소**: 큰 단위로 할당되므로.
3. **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는 항상 좋다"는 맹목적 믿음은 위험하다.
**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. 희소 데이터 구조 (예: 1 TB 해시테이블 중 1 GB만 사용) 가능.
2. 프로그램이 "최악의 경우"에 대비한 큰 버퍼 할당 가능.
3. 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는 메모리를 게으르게 할당한다"라는 문장의 진짜 의미다. **가상 주소 할당 ≠ 물리 메모리 할당**. 이 구분을 이해하면 많은 미스터리가 풀린다.
**A.** **멀티코어 시스템에서 TLB 일관성을 유지**하기 위해 필요한 작업이다.
**배경**:
- TLB는 **각 CPU 코어의 독립적인 캐시**다.
- 프로세스 페이지 매핑이 바뀌면 (예: `munmap`, `mprotect`, 페이지 swap out), 모든 코어의 TLB에 캐시된 구버전을 **제거**해야 한다.
- 그렇지 않으면 stale 매핑을 사용해 잘못된 데이터 읽기/쓰기 발생.
**Shootdown 과정**:
1. Core 0이 페이지 매핑 변경 (예: `munmap`).
2. 커널은 해당 매핑을 사용 중일 수 있는 **모든 코어에 IPI** (Inter-Processor Interrupt) 전송.
3. 각 코어가 interrupt를 받고 현재 작업 중단.
4. 자기 TLB에서 해당 entry 무효화 (`invlpg` 명령).
5. Core 0에 완료 신호.
6. Core 0은 **모든 완료를 기다린** 후 진행.
**비용**:
- IPI 전송: 수 μs.
- 각 코어의 context switch: 수 μs.
- 동기화 대기: 코어 수 × IPI 시간.
- **총: 수십~수천 μs** (코어 수에 따라).
코어 수가 많을수록 비용이 증가한다. 64-core 시스템에선 수 ms까지 갈 수 있다.
**문제를 일으키는 상황**:
1. **빈번한 mmap/munmap**: 동적 메모리 할당이 많은 애플리케이션. JVM, v8 (JIT 컴파일).
2. **공유 메모리 리매핑**: 프로세스 간 공유.
3. **페이지 회수**: 메모리 부족 시 커널이 페이지 swap out.
4. **NUMA migration**: 페이지를 다른 NUMA 노드로 이동.
5. **Transparent Huge Pages**: 2 MB 페이지 merge/split.
**증상**:
- `perf top`에 `smp_call_function_many`, `flush_tlb_func`가 자주 나타남.
- CPU 사용률은 낮은데 처리량이 나쁨.
- Latency spike.
**완화**:
1. **Mapping 변경 최소화**: 큰 버퍼 풀을 사전 할당.
2. **Huge pages**: TLB entry 수가 줄어 shootdown 비용 감소.
3. **CPU affinity**: 프로세스를 특정 코어에 고정 → 다른 코어 무관.
4. **`madvise(MADV_DONTNEED)` 대신 `MADV_FREE`**: 지연 회수.
5. **Linux 커널 업그레이드**: 최근 버전은 shootdown 최적화 있음.
고성능 서버에서 "왜 확장이 안 되지?"의 원인이 TLB shootdown인 경우가 의외로 많다. 특히 mmap 기반 데이터베이스(MongoDB, LMDB)나 JIT 컴파일러(V8, JVM)에서 나타난다. 이를 측정하고 완화하는 것이 고성능 엔지니어링의 한 부분이다.
마치며: 환상의 공학
핵심 정리
1. **Virtual Memory는 환상**이다. 당신의 포인터는 거짓말이다.
2. **4-level page table**: 계층 구조로 메모리 낭비 방지.
3. **TLB**: 변환 캐시. Hit rate 99%+가 관건.
4. **Huge pages**: TLB 효율 극대화. 단, 함정 주의.
5. **Lazy allocation + COW**: Fork와 malloc의 마법.
6. **Page fault**: Minor는 빠르고 Major는 느리다.
7. **Swap**: 물리 메모리의 연장, 그러나 신중히.
8. **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)](https://akkadia.org/drepper/cpumemory.pdf) - 메모리 바이블
- [Understanding the Linux Virtual Memory Manager (Mel Gorman)](https://www.kernel.org/doc/gorman/) - 고전 참고서
- [Linux Kernel Documentation: Virtual Memory](https://www.kernel.org/doc/html/latest/admin-guide/mm/index.html)
- [Intel 64 and IA-32 Architectures Software Developer's Manual Vol 3A: System Programming Guide](https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html)
- [TLB and Pagewalk Coherence in x86 Processors](https://www.usenix.org/conference/atc14/technical-sessions/presentation/bhattacharjee)
- [LWN: The current state of kernel page-table isolation (KPTI)](https://lwn.net/Articles/741878/)
- [Transparent Hugepages: measuring the performance impact](https://www.percona.com/blog/transparent-hugepages/)
- [What is Copy-on-Write (CoW)?](https://en.wikipedia.org/wiki/Copy-on-write)
- [Red Hat: Huge Pages and Transparent Huge Pages](https://access.redhat.com/solutions/46111)
- [Brendan Gregg: Linux Performance](https://www.brendangregg.com/linuxperf.html)
현재 단락 (1/552)
int main() {