Skip to content

필사 모드: Linux 가상 메모리 완전 가이드 2025: Page Table, TLB, Huge Pages, Fork, Copy-on-Write 심층 분석

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며: 당신이 보는 주소는 거짓이다

한 가지 실험

#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() {

작성 글자: 0원문 글자: 18,942작성 단락: 0/552