가상 메모리 ② TLB · 페이지 폴트 · mmap
목표 학습 시간: 105분 + 복습 15분 · 전공 수준 정독용
이 챕터를 마치면: (1) TLB가 번역 비용을 어떻게 없애는지와 TLB 도달 범위·플러시를 설명하고, (2) 페이지 폴트 처리 흐름과 마이너/메이저·디맨드 페이징을 설명하고, (3) 교체 정책(LRU/클럭, Belady)을 비교하고, (4) mmap과 copy-on-write fork의 동작을 정확히 그릴 수 있다.
0. 학습 지도 (105분)
| 구간 | 분 | 내용 |
|---|---|---|
| 1 | 8 | 문제 복습: 번역은 비싸다 |
| 2 | 22 | TLB: 구조·히트/미스·도달 범위·플러시 |
| 3 | 20 | 페이지 폴트와 디맨드 페이징 |
| 4 | 20 | 스와핑과 교체 정책 |
| 5 | 22 | mmap과 copy-on-write |
| 6 | 8 | 전체 접근 경로 종합 |
| 복습 | 15 | 인출 + 계산 |
1. 문제 복습 — 번역은 비싸다
세션 5에서 다단계 페이지 테이블은 번역 한 번에 메모리를 여러 번(4단계면 4번) 읽는다고 했다. 그런데 프로그램의 모든 메모리 접근마다 번역이 필요하다. 데이터 한 번 읽으려고 번역에 4번 + 데이터 1번 = 5번 접근하면 5배 느려진다. 가상 메모리가 좋아도 이대로면 쓸 수 없다. 해법은 세션 1·4에서 배운 그 무기 — 캐시다.
2. TLB — 번역 결과를 캐싱한다
2.1 무엇인가
TLB(Translation Lookaside Buffer) 는 "VPN → PFN" 번역 결과를 담는 작고 매우 빠른 캐시다. MMU 안에 있고, 보통 완전 연관(또는 높은 연관도)에 항목 수는 수십~수백 개다. 각 항목엔 VPN, PFN, 그리고 권한 비트(R/W/NX/US)가 함께 들어간다.
2.2 히트와 미스
가상주소 → [TLB 조회]
├─ 히트: PFN 즉시 획득 → 물리주소 완성 (추가 메모리 접근 0)
└─ 미스: 페이지 워크로 PFN을 찾고(메모리 접근 발생),
그 결과를 TLB에 채운 뒤 진행
- TLB 히트: 번역이 사실상 공짜(추가 접근 0). 대부분의 접근이 여기 해당.
- TLB 미스: 페이지 워크 비용이 발생하고, 결과를 TLB에 적재. x86은 하드웨어가 워크하지만, 일부 아키텍처는 OS가 처리(software-managed TLB).
2.3 왜 효과적인가 — 지역성
세션 1의 지역성이 여기서도 핵심이다. 한 페이지(4KB) 안에서 여러 번 접근하면 그 페이지의 번역은 한 번만 미스 나고 이후 계속 히트다. 즉:
- 순차/지역적 접근: 페이지 하나에서 1024개의 int를 쓰면 TLB 미스는 처음 1번뿐 → TLB 히트율 매우 높음.
- 무작위/큰 stride 접근: 매 접근이 다른 페이지로 튀면 TLB 미스가 쏟아진다. pointer chasing이 캐시 미스에 더해 TLB 미스까지 유발해 이중으로 느린 또 다른 이유.
2.4 TLB 도달 범위(reach)와 huge page
TLB reach = (TLB 항목 수) × (페이지 크기). 예: 64항목 × 4KB = 256KB만 동시에 커버한다. 작업 집합이 이보다 크면 TLB 미스가 잦아진다. huge page(2MB) 를 쓰면 같은 항목 수로 reach가 512배 넓어져(64 × 2MB = 128MB) TLB 압박이 급감한다 — DB·가상화에서 huge page를 쓰는 핵심 이유(세션 5의 페이지 크기 트레이드오프와 연결).
2.5 컨텍스트 스위치와 플러시
페이지 테이블은 프로세스마다 다르므로, 프로세스를 전환하면 TLB의 옛 번역은 무효다. 단순히는 전환 시 TLB를 플러시(비움)하는데, 그러면 새 프로세스가 처음엔 TLB 미스를 잔뜩 겪는다. 그래서 현대 CPU는 ASID/PCID 태그를 붙여 프로세스별 항목을 구분, 플러시 없이 공존하게 해 전환 비용을 줄인다.
2.6 멀티코어와 TLB shootdown
TLB는 코어마다 따로 있다(코어의 사적 캐시처럼). 문제: 어떤 코어가 페이지 매핑을 바꾸거나 없애면(munmap, 권한 변경, 페이지 스왑 아웃 등), 다른 코어의 TLB에 남은 옛 번역은 무효다. 그런데 하드웨어는 다른 코어의 TLB를 자동으로 비워주지 않는다.
그래서 OS가 TLB shootdown을 한다: 매핑을 바꾼 코어가 IPI(inter-processor interrupt) 로 관련 코어들에게 "그 항목을 무효화하라"고 알리고, 모두 처리할 때까지 기다린다. 이건 비싸다(IPI + 동기화). 매핑 변경이 잦은 워크로드는 느려질 수 있어, 큰 영역을 한 번에 unmap하는 등 shootdown 횟수를 줄이는 최적화가 중요하다.
세션 10의 캐시 일관성(MESI)은 하드웨어가 자동으로 처리하지만, TLB 일관성은 소프트웨어(OS)가 직접 처리한다는 비대칭을 기억하라.
3. 페이지 폴트와 디맨드 페이징
3.1 Present 비트와 폴트
세션 5의 PTE엔 Present(유효) 비트가 있었다. 이게 꺼져 있으면 "이 페이지는 지금 물리 메모리에 없다"는 뜻이고, 접근하면 하드웨어가 페이지 폴트(page fault) 예외를 일으켜 제어가 OS 핸들러로 넘어간다.
3.2 폴트 처리 흐름
1. CPU가 가상주소 접근 → PTE Present=0 (또는 권한 위반) → 페이지 폴트 트랩
2. OS 핸들러 진입:
- 정당한 접근인가? (매핑된 영역인가, 권한 맞나)
- 아니면 → SIGSEGV (프로세스 종료)
- 맞으면 → 빈 프레임 확보, 내용 채움(파일/스왑/0), PTE 갱신(Present=1)
3. 폴트를 일으킨 명령을 "다시 실행" → 이번엔 정상 진행
핵심: 폴트 후 그 명령을 재시작한다. 프로그램 입장에선 멈췄다 이어진 것일 뿐이다.
3.3 마이너 vs 메이저
- 마이너 폴트(minor): 필요한 페이지가 이미 물리 메모리에 있고(공유 페이지, 페이지 캐시, COW 등) 매핑만 손보면 되는 경우. 디스크 접근 없음 → 빠름.
- 메이저 폴트(major): 페이지 내용이 디스크(스왑/파일)에 있어 실제로 읽어 와야 하는 경우 → 느림(ms 단위 디스크).
- 잘못된 접근: 진짜로 매핑 안 됐거나 권한 없는 접근 → 세그멘테이션 폴트로 종료.
3.4 디맨드 페이징과 demand-zero
핵심 통찰: 페이지 폴트는 흔히 에러가 아니라 정상적인 관리 수단이다. OS는 이를 이용해:
- 디맨드 페이징: 프로그램을 통째로 올리지 않고, 실제로 건드리는 페이지만 그때그때 폴트를 통해 올린다. 실행 시작이 빠르고 안 쓰는 부분은 메모리를 안 먹는다.
- demand-zero: BSS·새 힙 페이지처럼 "0으로 시작해야 하는" 페이지는 미리 0 프레임을 안 만들고, 처음 쓸 때 폴트로 0 프레임을 매핑한다. 여러 0 페이지가 처음엔 같은 0 프레임을 읽기 전용 공유하다가, 쓰는 순간 복사(COW)되기도 한다.
4. 스와핑과 교체 정책
4.1 스와핑
물리 메모리가 부족하면, 당장 안 쓰는 페이지를 디스크의 스왑 공간으로 내보내고(page-out) 그 프레임을 재활용한다. 나중에 그 페이지가 다시 필요하면 메이저 폴트가 나서 도로 읽어 온다(page-in). Dirty 비트가 켜진 페이지만 디스크에 다시 써주면 되고, 안 바뀐 페이지(예: 코드)는 원본 파일에서 다시 읽으면 되니 쓰기 없이 버린다.
4.2 교체 정책 — 무엇을 내보낼까
"어떤 페이지를 희생시킬까"가 성능을 좌우한다.
- OPT(최적): "앞으로 가장 오래 안 쓸 페이지"를 내보낸다. 미래를 알아야 하므로 이론적 상한일 뿐, 실제론 불가능. 다른 정책의 비교 기준으로 쓴다.
- FIFO: 가장 먼저 들어온 페이지를 내보낸다. 단순하지만 Belady의 역설(프레임을 늘렸는데 미스가 오히려 느는 비직관적 현상)이 생길 수 있다.
- LRU(최근 최소 사용): 가장 오래전에 쓴 페이지를 내보낸다. 시간적 지역성에 잘 맞아 효과적이지만, "정확한 LRU"는 매 접근마다 순서를 갱신해야 해 비싸다.
- 클럭(clock / second-chance): LRU의 싼 근사. 페이지들을 원형으로 두고, PTE의 Accessed 비트를 본다. 시곗바늘이 도는 동안 Accessed=1이면 0으로 지우고 한 번 더 기회를 주고(second chance), Accessed=0인 걸 만나면 희생시킨다. 실전 OS가 이 계열을 쓴다.
- 작업 집합 모델: 최근 일정 시간 창에서 쓰인 페이지 집합(working set)을 메모리에 유지하려 한다. 세션 1의 작업 집합 개념이 교체에까지 이어진다.
4.3 스래싱(thrashing)
작업 집합들의 합이 물리 메모리를 넘으면, 페이지를 내보내자마자 다시 필요해져 page-in/out만 반복하는 스래싱에 빠진다. CPU는 디스크만 기다리며 처리량이 폭락한다. 해법은 동시 실행 줄이기(메모리 압력 완화)나 메모리 증설.
5. mmap과 copy-on-write
5.1 mmap — 파일/메모리를 주소 공간에 매핑
mmap은 파일이나 익명 메모리 영역을 프로세스의 가상 주소 공간에 매핑한다. 매핑만 걸어두면 실제 데이터는 그 페이지를 처음 건드릴 때 폴트(디맨드 페이징)로 들어온다.
int fd = open("big.dat", O_RDONLY);
char *data = mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0);
// 이제 data[i]처럼 배열로 파일을 읽는다.
// read()로 일일이 버퍼에 복사하지 않아도, 건드린 페이지만 OS가 가져온다.
munmap(data, len);
종류:
- 파일 기반(file-backed): 파일 내용을 매핑. 페이지 캐시와 연결돼 같은 파일을 매핑한 프로세스들이 메모리를 공유할 수 있다.
- 익명(anonymous): 파일 없이 0으로 초기화된 메모리(큰
malloc이 내부적으로 익명 mmap을 쓰기도 한다). - MAP_PRIVATE vs MAP_SHARED: PRIVATE는 내 수정이 남에게 안 보이고 파일에도 안 써진다(쓰면 COW로 사본). SHARED는 수정이 모두에게 보이고 파일에 반영된다(IPC·공유 메모리).
용도: 대용량 파일을 배열처럼 다루기(복사 없이), 실행 파일·공유 라이브러리 로딩, 프로세스 간 공유 메모리.
5.2 copy-on-write fork — 페이징의 백미
fork()로 자식을 만들 때 부모 메모리를 즉시 복사하면 비싸고 낭비다(곧 exec로 갈아치울 수도 있는데). 그래서:
fork 직후: 부모·자식의 모든 페이지 → 같은 물리 프레임을 "읽기 전용"으로 공유
(PTE를 R/W=읽기전용으로 표시)
누가 쓰기: 읽기 전용 페이지에 쓰기 → 페이지 폴트 →
OS가 그 페이지만 복사 → 쓰는 쪽에 쓰기 가능한 사본을 줌
덕분에 안 바뀌는 페이지는 영원히 공유되어 fork가 싸진다. 바뀌는 페이지만, 바뀌는 순간, 하나씩 복사된다. fork 후 곧장 exec하면 거의 아무것도 복사 안 하고 끝난다. COW는 세션 5의 "페이지 테이블로 공유" + "Present/권한 비트" + "폴트 핸들러"가 합쳐진 결정판이다.
5.3 페이지 캐시 — DRAM이 디스크의 캐시다
세션 1에서 "DRAM은 디스크의 캐시"라 했다. 그 구현이 페이지 캐시(page cache) 다 — OS가 한 번 읽은 파일 페이지를 물리 메모리에 캐싱해, 같은 데이터를 다시 읽을 때 디스크를 건너뛴다.
- 읽기:
read()나 mmap으로 파일을 읽으면 페이지 캐시에 올라오고, 이후 접근은 메모리 속도. - 쓰기(write-back): 쓰기는 일단 페이지 캐시에 반영(dirty 표시)하고 나중에 디스크에 모아 기록한다. 빠르지만,
fsync()없이 크래시가 나면 아직 안 쓰인 dirty 페이지는 유실될 수 있다(DB·저널링이 fsync에 민감한 이유). - mmap의 file-backed 매핑(5.1)이 바로 이 페이지 캐시와 같은 물리 프레임을 가리키는 것이다.
6. 전체 접근 경로 종합
세션 1·4·5·6을 하나로 꿰면, CPU가 가상 주소로 데이터 하나를 읽을 때 이런 일이 벌어진다.
가상 주소
│
▼
[TLB] ── 히트 → 물리 주소 즉시
│ 미스
▼
[페이지 워크] (페이지 테이블을 메모리에서 읽으며 번역)
│ PTE Present=0 ─→ [페이지 폴트] → OS가 프레임 채움(파일/스왑/0) → 재시작
▼
물리 주소 확정 → TLB에 채움
│
▼
[캐시 L1→L2→L3] ── 히트 → 데이터
│ 미스
▼
[메인 메모리(DRAM)]에서 캐시 라인 적재 → 데이터
이 한 장이 메모리 시스템의 "읽기 한 번"의 전모다. TLB·페이지 테이블·캐시·DRAM이 각자 지역성에 베팅하며 협력해, 평균적으로 빠른 접근이라는 착시를 만든다.
정밀 보강(VIPT): 위 그림은 이해를 위해 TLB→캐시를 직렬로 그렸지만, 실제 L1 캐시는 대개 VIPT(virtually-indexed, physically-tagged) 라 가상 주소 하위 비트로 캐시 세트를 인덱싱하는 일과 TLB로 PFN을 얻어 태그를 비교하는 일이 병렬로 진행된다. 그래서 TLB 히트 시 L1 접근에 번역 지연이 거의 더해지지 않는다. (개념 모델은 직렬, 하드웨어 구현은 병렬.)
7. 흔한 오해 바로잡기
- ❌ "TLB는 데이터를 캐싱한다." → TLB는 번역(VPN→PFN) 을 캐싱한다. 데이터는 L1~L3 캐시가 담당. 둘은 다른 캐시다.
- ❌ "페이지 폴트 = 버그/크래시." → 대부분 디맨드 페이징의 정상 동작(마이너/메이저). 진짜 잘못된 접근일 때만 세그폴트.
- ❌ "정확한 LRU를 그냥 쓰면 된다." → 매 접근마다 순서 갱신이 비싸 실전은 클럭 같은 근사를 쓴다.
- ❌ "mmap은 파일을 메모리에 전부 올린다." → 매핑만 걸고 건드린 페이지만 폴트로 올린다(지연 로딩).
- ❌ "fork는 메모리를 통째로 복사한다." → COW로 공유하다가 쓰는 페이지만 복사한다.
8. 한 장 정리
- 번역은 비싸다 → TLB가 VPN→PFN을 캐싱해 대부분을 공짜 번역으로. reach = 항목×페이지크기, huge page로 확장, 컨텍스트 스위치는 PCID로 플러시 회피.
- 페이지 폴트는 흔히 에러가 아니라 디맨드 페이징의 정상 수단(마이너=메모리에 있음, 메이저=디스크에서 읽음).
- 부족하면 스와핑 + 교체 정책: OPT(이론), FIFO(Belady 역설), LRU, 실전은 클럭(Accessed 비트). 넘치면 스래싱.
- mmap은 파일/익명 메모리를 매핑해 지연 로딩·공유, copy-on-write fork는 공유하다 쓰는 페이지만 복사해 fork를 싸게 만든다.
- 읽기 한 번의 전모: TLB → (미스 시) 페이지 워크 → (Present=0 시) 폴트 → 캐시 → DRAM.
9. 복습 (15분) — 답을 가리고
Q1. TLB가 캐싱하는 것은? L1 캐시와 무엇이 다른가?
TLB는 번역 결과(VPN→PFN + 권한)를 캐싱. L1 캐시는 실제 데이터(캐시 라인)를 캐싱. 둘은 다른 목적의 다른 캐시다.
Q2. TLB 히트와 미스의 메모리 접근 횟수 차이는?
히트: 번역에 추가 접근 0, 곧장 데이터 접근. 미스: 페이지 워크로 (예: 4단계면) 여러 번 더 접근한 뒤 데이터.
Q3. TLB reach가 64항목×4KB일 때 커버 범위는? huge page(2MB)면?
64×4KB = 256KB. huge page면 64×2MB = 128MB로 512배 넓어져 TLB 미스 급감.
Q4. 마이너 폴트와 메이저 폴트의 차이는?
마이너: 필요한 페이지가 이미 물리 메모리에 있어 매핑만 손보면 됨(디스크 없음, 빠름). 메이저: 내용이 디스크(스왑/파일)에 있어 읽어 와야 함(느림).
Q5. 클럭(second-chance) 알고리즘이 LRU를 어떻게 근사하나?
페이지를 원형으로 두고 PTE Accessed 비트를 본다. 바늘이 돌며 Accessed=1이면 0으로 지우고 기회를 한 번 더 주고, Accessed=0을 만나면 희생. 정확한 순서 추적 없이 "최근 안 쓴 것"을 싸게 골라낸다.
Q6. copy-on-write fork에서 실제 복사는 언제, 무엇만 일어나나?
fork 직후엔 모든 페이지를 읽기 전용으로 공유(복사 없음). 둘 중 하나가 어떤 페이지에 쓰기를 시도하는 순간, 그 페이지에 대해서만 폴트가 나서 복사본이 생긴다.
Q7. "읽기 한 번"의 전체 경로를 순서대로 말하라.
가상주소 → TLB 조회(히트면 물리주소 즉시) → 미스면 페이지 워크 → PTE Present=0이면 페이지 폴트로 프레임 채우고 재시작 → 물리주소 확정 → L1→L2→L3 캐시 조회 → 미스면 DRAM에서 라인 적재 → 데이터.
다음 배치: 세션 7~10 (동적 할당·메모리 관리 전략·버그/도구·동시성). 이후 전체를 합본 HTML 교재로 묶습니다.