안동민 개발노트

본문 시작

요구 페이징과 페이지 폴트

필요한 페이지만 적재하는 요구 페이징의 지역성을 이해하고 페이지 폴트 처리와 Copy-on-Write 흐름을 추적합니다.

가상 메모리의 핵심 아이디어는, 프로세스의 모든 페이지를 미리 메모리에 올리지 않아도 된다는 것입니다.

실제로 필요한 페이지만 메모리에 올리면 됩니다.

이것이 요구 페이징(Demand Paging)이며, 현대 OS 가상 메모리의 기반 전략입니다.


요구 페이징의 원리

왜 모든 페이지를 올리지 않는가

프로세스의 코드와 데이터를 전부 메모리에 올리면:

  • 실행 시작이 느려집니다 (수 MB~수 GB를 디스크에서 읽어야 함).
  • 에러 처리 코드, 드물게 실행되는 함수, 대규모 데이터 배열의 일부분 등 실제로 사용되지 않는 부분까지 메모리를 차지합니다.
  • 동시에 실행할 수 있는 프로세스 수가 줄어듭니다.

요구 페이징은 이 문제를 해결합니다.

프로세스가 시작될 때 아무 페이지도 올리지 않을 수 있습니다(순수 요구 페이징).

프로세스가 특정 페이지에 접근하면 필요한 매핑을 준비합니다. 파일·스왑에서 읽는 경우도 있고, 이미 있는 페이지를 연결하거나 0으로 초기화된 프레임을 마련하는 경우도 있습니다.

지역성이 동작하는 이유

이 게으른(Lazy) 전략이 효율적인 이유는 지역성의 원리(Principle of Locality) 때문입니다.

시간적 지역성: 최근 접근한 메모리 위치를 가까운 미래에 다시 접근할 확률이 높습니다.

루프 변수, 함수 내 지역 변수가 대표적입니다.

공간적 지역성: 접근한 메모리 위치 근처의 메모리를 곧 접근할 확률이 높습니다.

배열 순차 접근, 인접 명령어 실행이 대표적입니다.

이러한 지역성 덕분에, 프로세스가 한동안 주소 공간의 일부에 접근을 집중하는 경우가 많습니다.

이 활발한 부분을 워킹 셋(Working Set)이라고 합니다.

워킹 셋만 메모리에 있으면 프로세스는 거의 페이지 폴트 없이 실행됩니다.

페이지 테이블의 유효 비트

이 절의 단순 모델에서는 PTE의 적재 여부를 valid로 표시합니다. 실제 CPU에서는 present 비트와 접근 권한 등을 구별하며, 적재된 페이지라도 권한 위반으로 폴트가 날 수 있습니다.


페이지 폴트 처리 과정

CPU가 부재한 페이지나 허용되지 않은 접근을 만나면 페이지 폴트(Page Fault) 예외가 발생할 수 있습니다. 아래는 유효한 주소의 미적재 페이지를 복구하는 흐름입니다.

  1. 트랩 발생: CPU가 동기 예외를 발생시키고, OS의 페이지 폴트 핸들러에 제어를 넘깁니다.

  2. 주소 유효성 확인: OS가 주소 공간 매핑과 접근 권한을 확인합니다. 복구 불가능한 접근이면 Linux는 보통 SIGSEGV 같은 신호를 전달하며 기본 동작은 종료입니다. COW처럼 복구 가능한 쓰기 보호는 별도로 처리합니다.

  3. 빈 프레임 확보: 프리 프레임 리스트에서 빈 프레임을 가져옵니다. 빈 프레임이 없으면 페이지 교체 알고리즘으로 기존 페이지를 내보냅니다(다음 절에서 설명).

  4. 내용 준비: 필요한 경우 파일·스왑에서 읽으며 해당 스레드는 I/O를 기다립니다. 디스크 읽기가 필요 없는 경우에는 0 초기화나 기존 페이지 연결로 해결할 수 있습니다.

  5. 페이지 테이블 업데이트: 내용 준비가 성공하면 프레임과 권한을 PTE에 반영하고 필요한 TLB 무효화·갱신을 수행합니다.

  6. 명령어 재실행: 페이지 폴트를 일으킨 명령어를 처음부터 다시 실행합니다. 복구가 성공하고 해당 접근이 허용되면 실행을 계속합니다.

페이지 폴트의 성능 영향

페이지 폴트 처리 시간을 tpft_{pf}, 일반 메모리 접근 시간을 tt, 페이지 폴트 확률을 pp라고 하면:

EAT=(1−p)×t+p×tpfEAT = (1 - p) \times t + p \times t_{pf}

t=100nst = 100\text{ns}, tpf=8mst_{pf} = 8\text{ms}, p=0.001p = 0.001 (1,000번에 1번)인 가상의 직렬 비용 모델이면:

EAT=0.999×100+0.001×8000000=99.9+8000=8099.9nsEAT = 0.999 \times 100 + 0.001 \times 8000000 = 99.9 + 8000 = 8099.9\text{ns}

이 가정에서는 평균 접근 시간이 약 81배가 됩니다. 실제 처리량은 폴트 종류·장치·병렬성에 따라 달라집니다.

페이지 폴트율을 극도로 낮추는 것이 가상 메모리 성능의 핵심입니다.

다음 C 예제의 주석 수치는 4KiB 단위로 100MiB를 접근한다는 계산상 기대입니다. malloc·getrusage 오류 처리를 생략했으며, 최적화로 관측되지 않는 쓰기가 제거되거나 대규모 페이지가 사용될 수 있어 실제 폴트 수는 달라집니다. 여기서는 실행하지 않았습니다.

page_fault_demo.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/resource.h>

int main() {
    struct rusage before, after;
    getrusage(RUSAGE_SELF, &before);

    /* 대량 메모리 할당 및 접근 — 페이지 폴트 유발 */
    size_t size = 100 * 1024 * 1024;  /* 100MB */
    char *buf = malloc(size);
    for (size_t i = 0; i < size; i += 4096) {
        buf[i] = 1;  /* 각 페이지의 첫 바이트에 접근 */
    }

    getrusage(RUSAGE_SELF, &after);
    long minor = after.ru_minflt - before.ru_minflt;
    long major = after.ru_majflt - before.ru_majflt;
    printf("Minor page faults: %ld\n", minor);  /* ~25600 */
    printf("Major page faults: %ld\n", major);   /* 0 (메모리 충분시) */

    free(buf);
    return 0;
}

Minor page fault: 디스크 I/O 없이 해결되는 페이지 폴트.

예: 0으로 초기화된 빈 프레임을 할당하거나, 이미 메모리에 있는 공유 페이지를 매핑.

Major page fault: 디스크에서 페이지를 읽어야 하는 페이지 폴트.

저장장치 I/O가 포함되어 일반 메모리 접근보다 훨씬 비쌀 수 있으나 고정된 밀리초 비용은 아닙니다.


Copy-on-Write

fork()는 부모와 별도로 수정할 수 있는 자식의 주소 공간을 만듭니다. 이를 모든 물리 페이지의 즉시 복사로 구현하면 비용이 큽니다.

4GB 주소 공간을 복사하면 엄청난 시간과 메모리가 소요됩니다.

Copy-on-Write(COW)는 이 문제를 우아하게 해결합니다.

COW 대상인 private 매핑은 처음에 물리 페이지를 공유하고 쓰기를 제한합니다. 실제 공유가 남아 있는 페이지에 쓰면 새 프레임으로 분리하고 쓰기를 재시도합니다. MAP_SHARED처럼 쓰기 공유가 목적인 매핑은 이 설명과 구별합니다.

자식의 쓰기 후 private 매핑이 분리됩니다

자식의 쓰기 후 private 매핑이 분리됩니다

COW 대상 private 페이지의 두 상태처음에는 부모와 자식이 값100의 프레임을 공유한다. 자식이200을 쓰면 자식은새프레임을가리키고부모의값100은유지된다.쓰기 전자식이 200을 쓴 뒤부모 PTE자식 PTE공유 프레임값 100부모 PTE기존 값 100자식 PTE새 값 200
COW 대상 private 페이지의 두 상태처음에는 부모와 자식이 값100의 프레임을 공유한다. 자식이200을 쓰면 자식은새프레임을가리키고부모의값100은유지된다.쓰기 전부모 PTE자식 PTE공유 프레임값 100자식이 200을 쓴 뒤부모 PTE기존 값 100자식 PTE새 값 200

값의 분리를 보여 주는 개념도입니다. 원문의 작은 할당과 같은 페이지에 있는 다른 데이터도 함께 매핑되며, 실제 물리 프레임 번호를 관측한 것은 아닙니다.

cow_example.c
#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 행이 보이지 않을 수 있습니다. 이는 값 분리와 다른 출력 버퍼 문제입니다.

많은 프로세스가 fork() 후 바로 exec()를 호출하여 새 프로그램을 실행합니다.

exec()가 이전 매핑을 교체하기 전에 쓰지 않은 COW 페이지는 데이터 복사를 피할 수 있습니다. exec() 전 스택·라이브러리 동작까지 포함해 복사가 항상 0이라는 뜻은 아닙니다.

Linux에서는 fork() 대안으로 COW를 개선한 vfork() 도 있습니다.

vfork()는 부모를 일시 중단하고 자식이 부모의 주소 공간을 직접 사용합니다.

부모는 자식이 exec 또는 _exit할 때까지 대기합니다. 공유 주소 공간 때문에 자식이 임의로 메모리를 수정하거나 원래 호출자에게 반환하면 안 되는 등 사용 제약이 큽니다.


빈 프레임이 없다면 — 페이지 교체의 필요성

물리 메모리가 가득 차서 빈 프레임이 없는 상태에서 페이지 폴트가 발생하면, 기존에 메모리에 있던 페이지 중 하나를 내보내야(Evict) 합니다.

내보내는 페이지가 수정되었다면(더티 비트 = 1), 디스크에 써야 하므로 추가 I/O가 발생합니다(Page out).

변경되지 않은 파일 캐시처럼 다시 만들 수 있는 깨끗한 페이지는 쓰기 없이 회수할 수 있습니다. 익명 페이지 등은 backing 상태와 회수 가능 여부를 별도로 확인합니다.

따라서 더티 비트의 값에 따라 페이지 교체의 비용이 크게 달라집니다.

어떤 페이지를 내보낼지 결정하는 것이 페이지 교체 알고리즘이며, 이것이 가상 메모리 성능의 핵심입니다.

잘못된 페이지를 내보내면 곧바로 다시 필요해져서 또 페이지 폴트가 발생합니다.

메모리 매핑 파일 (Memory-Mapped Files)

페이지 폴트 메커니즘의 응용으로, 메모리 매핑 파일이 있습니다.

mmap() 시스템 콜로 파일을 프로세스의 주소 공간에 직접 매핑합니다.

파일의 내용이 메모리처럼 접근 가능해지며, 실제 데이터는 페이지 폴트를 통해 자동으로 로드됩니다.

아래는 읽을 수 있고 최소 1바이트가 있는 data.bin, 성공한 open·mmap을 가정합니다. 실패 반환값 검사와 파일 길이 확인은 생략되어 있습니다. 첫 접근도 파일 캐시 상태에 따라 디스크 읽기가 필요하지 않을 수 있습니다.

mmap_example.c
#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);

    /* 메모리처럼 접근 — 첫 접근 시 page fault → OS가 파일에서 읽음 */
    printf("첫 번째 바이트: %c\n", mapped[0]);

    munmap(mapped, 4096);
    return 0;
}

다음 절에서 다양한 페이지 교체 알고리즘과 그 성능 비교를 살펴보겠습니다.