요구 페이징과 페이지 폴트
필요한 페이지만 적재하는 요구 페이징의 지역성을 이해하고 페이지 폴트 처리와 Copy-on-Write 흐름을 추적합니다.
가상 메모리의 핵심 아이디어는, 프로세스의 모든 페이지를 미리 메모리에 올리지 않아도 된다는 것입니다.
실제로 필요한 페이지만 메모리에 올리면 됩니다.
이것이 요구 페이징(Demand Paging)이며, 현대 OS 가상 메모리의 기반 전략입니다.
요구 페이징의 원리
왜 모든 페이지를 올리지 않는가
프로세스의 코드와 데이터를 전부 메모리에 올리면:
- 실행 시작이 느려집니다 (수 MB~수 GB를 디스크에서 읽어야 함).
- 에러 처리 코드, 드물게 실행되는 함수, 대규모 데이터 배열의 일부분 등 실제로 사용되지 않는 부분까지 메모리를 차지합니다.
- 동시에 실행할 수 있는 프로세스 수가 줄어듭니다.
요구 페이징은 이 문제를 해결합니다.
프로세스가 시작될 때 아무 페이지도 올리지 않을 수 있습니다(순수 요구 페이징).
프로세스가 특정 페이지에 접근하면 필요한 매핑을 준비합니다. 파일·스왑에서 읽는 경우도 있고, 이미 있는 페이지를 연결하거나 0으로 초기화된 프레임을 마련하는 경우도 있습니다.
지역성이 동작하는 이유
필요할 때 처리하는 이 지연(Lazy) 전략이 효율적인 이유는 지역성의 원리(Principle of Locality) 때문입니다.
시간적 지역성: 최근 접근한 메모리 위치에 가까운 미래에 다시 접근할 확률이 높습니다.
루프 변수, 함수 내 지역 변수가 대표적입니다.
공간적 지역성: 접근한 메모리 위치 근처의 메모리에 곧 접근할 확률이 높습니다.
배열 순차 접근, 인접 명령어 실행이 대표적입니다.
이러한 지역성 덕분에, 프로세스가 한동안 주소 공간의 일부에 접근을 집중하는 경우가 많습니다.
이 활발한 부분을 워킹 셋(Working Set)이라고 합니다.
워킹 셋만 메모리에 있으면 프로세스는 거의 페이지 폴트 없이 실행됩니다.
페이지 테이블의 유효 비트
이 절의 단순 모델에서는 PTE의 적재 여부를 valid로 표시합니다. 실제 CPU에서는 present 비트와 접근 권한 등을 구별하며, 적재된 페이지라도 권한 위반으로 폴트가 날 수 있습니다.
페이지 폴트 처리 과정
CPU가 부재한 페이지나 허용되지 않은 접근을 만나면 페이지 폴트(Page Fault) 예외가 발생할 수 있습니다. 아래는 유효한 주소의 미적재 페이지를 복구하는 흐름입니다.
-
트랩 발생: CPU가 동기 예외를 발생시키고, OS의 페이지 폴트 핸들러에 제어를 넘깁니다.
-
주소 유효성 확인: OS가 주소 공간 매핑과 접근 권한을 확인합니다. 복구 불가능한 접근이면 Linux는 보통 SIGSEGV 같은 신호를 전달하며 기본 동작은 종료입니다. COW처럼 복구 가능한 쓰기 보호는 별도로 처리합니다.
-
빈 프레임 확보: 프리 프레임 리스트에서 빈 프레임을 가져옵니다. 빈 프레임이 없으면 페이지 교체 알고리즘으로 기존 페이지를 내보냅니다(다음 절에서 설명).
-
내용 준비: 필요한 경우 파일·스왑에서 읽으며 해당 스레드는 I/O를 기다립니다. 디스크 읽기가 필요 없는 경우에는 0 초기화나 기존 페이지 연결로 해결할 수 있습니다.
-
페이지 테이블 업데이트: 내용 준비가 성공하면 프레임과 권한을 PTE에 반영하고 필요한 TLB 무효화·갱신을 수행합니다.
-
명령어 재실행: 페이지 폴트를 일으킨 명령어를 처음부터 다시 실행합니다. 복구가 성공하고 해당 접근이 허용되면 실행을 계속합니다.
페이지 폴트의 성능 영향
페이지 폴트 처리 시간을 , 일반 메모리 접근 시간을 , 페이지 폴트 확률을 라고 하면:
, , (1,000번에 1번)인 가상의 직렬 비용 모델이면:
이 가정에서는 평균 접근 시간이 약 81배가 됩니다. 실제 처리량은 폴트 종류·장치·병렬성에 따라 달라집니다.
독립 확인: 같은 가상 직렬 비용 모델의 t=100ns, t_pf=8ms를 유지하고 p를 0.000001로 낮춥니다. EAT를 계산하고, EAT≤110ns를 만족하는 p의 최댓값을 구하세요. 이 계산으로 아래 C 프로그램의 minor fault 횟수를 예측할 수 있나요?
풀이 확인
8ms는 8,000,000ns이므로 EAT=0.999999×100+0.000001×8,000,000=107.9999ns입니다. 식을 100+7,999,900p로 정리하면 p≤10/7,999,900≈0.000001250016입니다. 이 입력의 p는 접근당 가정한 확률이며, C 프로그램의 프로세스 전체 폴트 횟수·첫 할당 비용·페이지 크기나 폴트 종류를 정한 값이 아닙니다. 따라서 그 횟수를 이 p만으로 예측하지 않습니다.
페이지 폴트율을 극도로 낮추는 것이 가상 메모리 성능의 핵심입니다.
같은 메모리에 처음 쓸 때와 다시 쓸 때 페이지 폴트가 어떻게 달라지는지 비교해 보겠습니다. 아래는 Linux에서 100MiB 버퍼를 할당한 뒤, 같은 프로세스의 같은 버퍼에 두 번 쓰는 예제입니다. 첫 측정은 malloc 전부터 시작하므로 할당과 첫 쓰기를 함께 포함하고, 두 번째 측정은 이미 할당한 버퍼를 다시 쓰는 구간입니다.
sysconf(_SC_PAGESIZE)로 기본 페이지 크기를 바이트 단위로 받아 쓰기 간격에 사용합니다. volatile 접근은 이 예제의 대입이 최적화로 제거되지 않게 하려는 선택입니다. 페이지 폴트 횟수를 고정하거나 스레드를 동기화하는 기능은 아닙니다.
#include <stdio.h>
#include <stdlib.h>
#include <sys/resource.h>
#include <unistd.h>
static int sample_usage(struct rusage *usage) {
if (getrusage(RUSAGE_SELF, usage) != 0) {
perror("getrusage");
return 0;
}
return 1;
}
int main(void) {
const size_t size = 100 * 1024 * 1024; /* 100MiB */
long page_size = sysconf(_SC_PAGESIZE);
if (page_size <= 0) {
fputs("page size lookup failed\n", stderr);
return EXIT_FAILURE;
}
size_t step = (size_t)page_size;
struct rusage before, after_first, after_second;
if (!sample_usage(&before)) return EXIT_FAILURE;
unsigned char *storage = malloc(size);
if (storage == NULL) {
perror("malloc");
return EXIT_FAILURE;
}
volatile unsigned char *buf = storage;
for (size_t i = 0; i < size; i += step) buf[i] = 1;
if (!sample_usage(&after_first)) {
free(storage);
return EXIT_FAILURE;
}
for (size_t i = 0; i < size; i += step) buf[i] = 2;
if (!sample_usage(&after_second)) {
free(storage);
return EXIT_FAILURE;
}
printf("page size: %ld bytes; allocation: %zu MiB\n",
page_size, size / (1024 * 1024));
printf("allocation + first write: minor=%ld major=%ld\n",
after_first.ru_minflt - before.ru_minflt,
after_first.ru_majflt - before.ru_majflt);
printf("second write: minor=%ld major=%ld\n",
after_second.ru_minflt - after_first.ru_minflt,
after_second.ru_majflt - after_first.ru_majflt);
printf("peak resident set: %ld KiB\n", after_second.ru_maxrss);
free(storage);
return EXIT_SUCCESS;
}위 코드를 page_fault_demo.c로 저장하고 Linux의 기존 C 개발 환경에서 다음처럼 실행할 수 있습니다. 출력 호출은 두 측정이 끝난 뒤에 있어 출력 동작이 두 쓰기 구간에 섞이지 않습니다.
cc -std=c11 -O2 -Wall -Wextra page_fault_demo.c -o page_fault_demo
./page_fault_demo출력의 minor는 ru_minflt, major는 ru_majflt의 측정 시점 간 차이입니다. Minor page fault는 I/O 없이 처리한 폴트로, 0으로 초기화된 프레임을 마련하거나 이미 메모리에 있는 페이지를 연결하는 경우가 있습니다. Major page fault는 처리에 I/O가 필요했던 폴트입니다. 저장장치 I/O는 비쌀 수 있지만 폴트 한 번의 비용을 고정된 밀리초로 해석하지 않습니다.
먼저 출력된 페이지 크기와 할당 크기를 읽고, allocation + first write와 second write의 횟수를 비교해 보세요. 기본 페이지 크기가 4KiB라면 루프의 쓰기 위치는 계산상 100 × 1024 × 1024 ÷ 4096 = 25,600개입니다. 이 수는 접근 위치의 수이지 minor의 보장값은 아닙니다. 할당자가 미리 준비한 페이지, 큰 페이지 사용, 페이지 회수와 측정 구간의 다른 활동에 따라 값이 달라집니다. sysconf의 기본 페이지 크기가 실제 매핑의 모든 물리 페이지 크기를 뜻하지도 않습니다.
두 번째 쓰기는 같은 버퍼의 준비된 매핑을 재사용하므로 폴트 증가가 줄어드는지 살펴볼 수 있습니다. 다만 두 번째 값이 반드시 0이 되는 것은 아닙니다. 프로그램을 새로 두 번 실행하면 매번 새 버퍼를 할당하므로 이 비교와 다릅니다. 여기서 처음 익명 메모리를 준비하는 원인과 뒤에서 볼 fork 후 COW 페이지 분리의 원인도 구별해야 합니다.
RUSAGE_SELF는 프로세스의 모든 스레드를 포함합니다. 두 시점의 차이는 그 구간에 발생한 프로세스 전체의 폴트이며 이 버퍼만의 전용 계수가 아닙니다. major가 0이어도 프로그램 전체에 파일 I/O가 없었다는 뜻은 아니며, 증가했다고 이 버퍼나 스왑만 원인이라고 확정할 수도 없습니다. 폴트 횟수로 처리 시간이나 위 EAT 모델의 배수를 실측했다고 말할 수는 없습니다.
ru_maxrss는 Linux에서 KiB 단위의 최대 RSS, 즉 프로세스의 상주 메모리 크기가 과거에 기록한 최댓값입니다. 출력값은 마지막 측정 시점까지의 최대치이며 현재 사용량이나 두 시점의 할당량 차이가 아닙니다. 100MiB와 정확히 같을 필요도 없고, free 뒤에 자동으로 내려가는 값으로 기대해서도 안 됩니다.
값이 예상과 다르면 먼저 호출 실패 메시지가 없는지, 출력된 페이지 크기로 간격을 사용했는지, volatile 쓰기와 같은 버퍼의 재쓰기를 유지했는지 확인합니다. 비교 조건에는 OS·컴파일 옵션·할당 크기·큰 페이지 사용 가능성과 측정 구간을 함께 적어 두세요. 그 뒤 할당자의 동작, 메모리 압력과 다른 활동을 살펴보면 됩니다. 수치를 맞추기 위해 시스템 캐시를 비우거나 전역 설정을 바꿀 필요는 없습니다.
Copy-on-Write
fork()는 부모와 별도로 수정할 수 있는 자식의 주소 공간을 만듭니다. 이를 모든 물리 페이지의 즉시 복사로 구현하면 비용이 큽니다.
4GB 주소 공간을 복사하면 엄청난 시간과 메모리가 소요됩니다.
Copy-on-Write(COW)는 필요한 때에만 복사하여 이 문제를 해결합니다.
COW 대상인 private 매핑은 처음에 물리 페이지를 공유하고 쓰기를 제한합니다. 실제 공유가 남아 있는 페이지에 쓰면 새 프레임으로 분리하고 쓰기를 재시도합니다. MAP_SHARED처럼 쓰기 공유가 목적인 매핑은 이 설명과 구별합니다.
자식의 쓰기 후 private 매핑이 분리됩니다
부모·자식 PTE가 같은 프레임을 가리킨다. 자식의 새 페이지에 200을 기록한다. 각 단계의 값과 전환 이유를 비교합니다.
Copy-on-Write · private 매핑의 분리
자식이 쓰면 새 프레임으로 분리되고 부모의 값 100은 유지됩니다
fork 성공 후 COW 대상 private 페이지가 아직 공유되는 예입니다. 처음에는 쓰기가 제한되며 자식의 보호 폴트를 처리해 복사·매핑을 조정하고 쓰기를 재시도합니다. MAP_SHARED는 이 모형과 구분합니다.
값의 분리를 보여 주는 개념도입니다. 원문의 작은 할당과 같은 페이지에 있는 다른 데이터도 함께 매핑되며, 실제 물리 프레임 번호를 관측한 것은 아닙니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 처음 공유부터 쓰기 재시도까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | 자식의 쓰기 후 private 매핑이 분리됩니다 |
| 관계 설명 | 쓰기 전 부모와 자식의 PTE는 값 100의 같은 프레임을 가리킨다. 자식이 200을 쓰면 성공한 COW 분리 뒤 자식의 새 프레임 값 200과 부모의 기존 값 100을 분리한다. 영역은 시간 상태이며 영역 안 화살표는 PTE 매핑, 영역 사이 강조 화살표는 성공한 쓰기와 분리의 상태 전환이다. 100과 200은 값이며 물리 프레임 번호가 아니다. 실제 fork 관측은 아니다. |
| 관계 설명 | 처음에는 부모와 자식이 값 100의 프레임을 공유한다. 자식이 200을 쓰면 자식은 새 프레임을 가리키고 부모의 값 100은 유지된다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 처음 공유 부모·자식 PTE가 같은 프레임을 가리킨다. |
|
|
| 보호 폴트 쓰기를 곧바로 기존 프레임에 적용하지 않는다. |
|
|
| 복사와 매핑 기존 내용을 복사하고 자식 매핑을 조정한다. |
|
|
| 쓰기 재시도 자식의 새 페이지에 200을 기록한다. |
|
|
자식의 쓰기 후 private 매핑이 분리됩니다
부모·자식 PTE가 같은 프레임을 가리킨다. 자식의 새 페이지에 200을 기록한다. 각 단계의 값과 전환 이유를 비교합니다.
Copy-on-Write · private 매핑의 분리
자식이 쓰면 새 프레임으로 분리되고 부모의 값 100은 유지됩니다
fork 성공 후 COW 대상 private 페이지가 아직 공유되는 예입니다. 처음에는 쓰기가 제한되며 자식의 보호 폴트를 처리해 복사·매핑을 조정하고 쓰기를 재시도합니다. MAP_SHARED는 이 모형과 구분합니다.
값의 분리를 보여 주는 개념도입니다. 원문의 작은 할당과 같은 페이지에 있는 다른 데이터도 함께 매핑되며, 실제 물리 프레임 번호를 관측한 것은 아닙니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 처음 공유부터 쓰기 재시도까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | 자식의 쓰기 후 private 매핑이 분리됩니다 |
| 관계 설명 | 쓰기 전 부모와 자식의 PTE는 값 100의 같은 프레임을 가리킨다. 자식이 200을 쓰면 성공한 COW 분리 뒤 자식의 새 프레임 값 200과 부모의 기존 값 100을 분리한다. 영역은 시간 상태이며 영역 안 화살표는 PTE 매핑, 영역 사이 강조 화살표는 성공한 쓰기와 분리의 상태 전환이다. 100과 200은 값이며 물리 프레임 번호가 아니다. 실제 fork 관측은 아니다. |
| 관계 설명 | 처음에는 부모와 자식이 값 100의 프레임을 공유한다. 자식이 200을 쓰면 자식은 새 프레임을 가리키고 부모의 값 100은 유지된다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 처음 공유 부모·자식 PTE가 같은 프레임을 가리킨다. |
|
|
| 보호 폴트 쓰기를 곧바로 기존 프레임에 적용하지 않는다. |
|
|
| 복사와 매핑 기존 내용을 복사하고 자식 매핑을 조정한다. |
|
|
| 쓰기 재시도 자식의 새 페이지에 200을 기록한다. |
|
|
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
int main() {
int *data = malloc(sizeof(int));
*data = 100;
pid_t pid = fork();
/* fork 직후: parent와 child가 data 페이지를 공유 (COW)
data가 있는 물리 프레임은 하나, 양쪽 페이지 테이블이 같은
프레임을 가리킴. 읽기 전용으로 표시됨. */
if (pid == 0) {
/* 자식: 쓰기 시도 → 보호 위반 트랩 → OS가 페이지 복사 */
*data = 200;
printf("Child: %d\n", *data); /* 200 (복사된 페이지) */
free(data);
_exit(0);
} else {
wait(NULL);
printf("Parent: %d\n", *data); /* 100 (원본 유지) */
free(data);
}
return 0;
}위 코드에서 할당·fork가 성공하면 자식의 값은 200, 부모의 값은 100으로 분리됩니다. 다만 _exit()는 stdio 버퍼를 flush하지 않으므로 출력 리다이렉션 등에서는 자식의 printf 행이 보이지 않을 수 있습니다. 이는 값 분리와 다른 출력 버퍼 문제입니다.
독립 확인: 그림의 한 private 페이지와 부모·자식만 생각합니다. fork 직후 값은 100이고, 자식이 먼저 300을 쓴 뒤 부모가 400을 씁니다. 두 쓰기는 성공하고 다른 공유자나 작업은 없습니다. 마지막 각 값과, 부모의 쓰기가 반드시 세 번째 데이터 프레임을 요구하는지 설명하세요. 자식의 printf 줄이 안 보이면 COW 실패로 판정해도 되나요?
풀이 확인
자식의 첫 쓰기 후 자식의 새 프레임에는 300, 부모가 유지한 기존 프레임에는 100이 있습니다. 이어 부모가 자기 페이지를 수정하면 최종 부모 값은 400, 자식 값은 300입니다. 이미 자식 매핑이 분리되어 기존 프레임을 부모만 사용하는 조건에서는 이를 재사용할 수 있으므로, 부모의 쓰기가 반드시 세 번째 프레임을 만든다고 결론 내리지 않습니다. 실제 구현의 다른 조건과 작업은 이 그림에서 제외했습니다. printf의 stdio 버퍼와 _exit 처리 때문에 출력 줄이 안 보일 수 있어, 그 현상 하나로 데이터 분리 실패를 판정할 수 없습니다.
많은 프로세스가 fork() 후 바로 exec()를 호출하여 새 프로그램을 실행합니다.
exec()가 이전 매핑을 교체하기 전에 쓰지 않은 COW 페이지는 데이터 복사를 피할 수 있습니다. exec() 전 스택·라이브러리 동작까지 포함해 복사가 항상 0이라는 뜻은 아닙니다.
Linux에서는 자식이 곧바로 새 프로그램을 실행할 때 주소 공간과 페이지 테이블을 따로 준비하는 비용을 줄이는 대안으로 vfork()도 사용합니다.
vfork()는 부모를 일시 중단하고 자식이 부모의 주소 공간을 직접 사용합니다.
부모는 자식이 exec 또는 _exit할 때까지 대기합니다. 공유 주소 공간 때문에 자식이 임의로 메모리를 수정하거나 원래 호출자에게 반환하면 안 되는 등 사용 제약이 큽니다.
빈 프레임이 없다면 — 페이지 교체의 필요성
물리 메모리가 가득 차서 빈 프레임이 없는 상태에서 페이지 폴트가 발생하면, 기존에 메모리에 있던 페이지 중 하나를 내보내야(Evict) 합니다.
내보내는 페이지가 수정되었다면(더티 비트 = 1), 디스크에 써야 하므로 추가 I/O가 발생합니다(Page out).
변경되지 않은 파일 캐시처럼 다시 만들 수 있는 깨끗한 페이지는 쓰기 없이 회수할 수 있습니다. 익명 페이지 등은 내용을 복원할 저장소(backing)의 상태와 회수 가능 여부를 별도로 확인합니다.
따라서 더티 비트의 값에 따라 페이지 교체의 비용이 크게 달라집니다.
어떤 페이지를 내보낼지 결정하는 것이 페이지 교체 알고리즘이며, 이것이 가상 메모리 성능의 핵심입니다.
잘못된 페이지를 내보내면 곧바로 다시 필요해져서 또 페이지 폴트가 발생합니다.
메모리 매핑 파일 (Memory-Mapped Files)
페이지 폴트 메커니즘의 응용으로, 메모리 매핑 파일이 있습니다.
mmap() 시스템 콜로 파일을 프로세스의 주소 공간에 직접 매핑합니다.
파일의 내용이 메모리처럼 접근 가능해지며, 실제 데이터는 페이지 폴트를 통해 자동으로 로드됩니다.
아래는 읽을 수 있고 최소 1바이트가 있는 data.bin, 성공한 open·mmap을 가정합니다. 실패 반환값 검사와 파일 길이 확인은 생략되어 있습니다. 첫 접근도 파일 캐시 상태에 따라 디스크 읽기가 필요하지 않을 수 있습니다.
#include <sys/mman.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main() {
int fd = open("data.bin", O_RDONLY);
/* 파일을 메모리에 매핑 */
char *mapped = mmap(NULL, 4096, PROT_READ, MAP_PRIVATE, fd, 0);
close(fd);
/* 첫 접근 때 매핑을 준비하며, 파일 캐시에 없으면 읽기 I/O가 필요할 수 있음 */
printf("첫 번째 바이트: %c\n", mapped[0]);
munmap(mapped, 4096);
return 0;
}다음 절에서 다양한 페이지 교체 알고리즘과 그 성능 비교를 살펴보겠습니다.