본문 바로가기

요구 페이징과 페이지 폴트

필요한 페이지만 그때그때 준비하는 요구 페이징과 이를 가능하게 하는 지역성, 페이지 폴트가 처리되는 과정과 그 비용을 살펴봅니다. 같은 버퍼에 두 번 쓰며 페이지 폴트와 최대 RSS를 비교하는 실험, fork에서 쓰는 쓰기 시 복사의 동작, 빈 프레임이 없을 때의 교체와 메모리 매핑 파일을 정리합니다.

1GB짜리 실행 파일을 실행할 때 운영체제가 그 1GB를 모두 메모리에 올린 뒤에야 프로그램을 시작한다면, 시작하는 데만 몇 초가 걸릴 것입니다. 게다가 오류 처리 코드나 거의 쓰이지 않는 기능처럼 한 번도 실행되지 않을 부분까지 메모리를 차지하게 됩니다. 가상 메모리의 핵심은 프로세스의 페이지를 미리 모두 올려 둘 필요가 없다는 데 있습니다. 실제로 접근하는 페이지만, 접근하는 순간에 준비하면 됩니다.

이처럼 필요할 때 페이지를 준비하는 방식을 요구 페이징(demand paging)이라고 하며, 오늘날 가상 메모리의 바탕입니다. 이 글에서는 요구 페이징이 왜 효과가 있는지, 아직 준비되지 않은 페이지에 접근하면 무슨 일이 일어나는지, 그리고 이 구조 위에서 동작하는 쓰기 시 복사와 메모리 매핑 파일을 살펴봅니다.

필요할 때 준비한다

모든 페이지를 미리 올리면 세 가지 문제가 있습니다. 시작이 느리고, 쓰지 않을 부분까지 메모리를 차지하며, 그만큼 동시에 실행할 수 있는 프로세스의 수가 줄어듭니다. 요구 페이징에서는 프로세스가 시작할 때 페이지를 하나도 올리지 않을 수도 있습니다. 프로세스가 어떤 페이지에 처음 접근하는 순간, 그 페이지에 맞는 방법으로 내용을 준비합니다. 실행 파일의 코드라면 파일에서 읽고, 처음 쓰는 힙이나 스택이라면 0으로 채운 프레임을 주고, 다른 프로세스와 같은 라이브러리 페이지라면 이미 메모리에 있는 페이지를 연결합니다. 디스크를 읽는 것은 이 가운데 일부일 뿐입니다.

지역성 덕분에 효과가 있다

필요할 때까지 미루는 이 게으른(lazy) 방식이 효과적인 것은 프로그램의 접근에 지역성(locality)이 있기 때문입니다. 시간 지역성은 최근에 접근한 곳에 곧 다시 접근한다는 성질로, 반복문의 변수나 자주 부르는 함수가 그 예입니다. 공간 지역성은 접근한 곳 근처에 곧 접근한다는 성질로, 배열을 차례로 읽거나 이어진 명령을 실행하는 것이 그 예입니다.

지역성 덕분에 프로세스는 한동안 주소 공간의 일부에 접근을 집중합니다. 일정 기간 동안 프로세스가 실제로 접근하는 페이지들의 집합을 작업 집합(working set)이라고 합니다. 작업 집합이 물리 메모리에 올라와 있으면 프로세스는 페이지 폴트를 거의 일으키지 않고 실행되며, 실행이 진행되면서 작업 집합이 바뀌면 그때 새 페이지들을 준비합니다.

더 깊이: 할당이 성공해도 메모리가 있다는 뜻은 아니다

페이지를 접근할 때 준비한다는 것은, 커널이 할당 요청을 받는 순간에는 아직 물리 메모리를 내주지 않는다는 뜻이기도 합니다. 그래서 커널은 실제 메모리와 스왑을 합친 것보다 많은 주소 공간을 프로세스들에게 약속할 수 있고, 이를 과다 약속(overcommit)이라고 합니다. Linux의 과다 약속 문서에 따르면 기본값인 vm.overcommit_memory = 0은 누가 봐도 지나친 요청만 거절하는 추정 방식이고, 1은 언제나 허락하며, 2는 약속의 합계를 스왑에 물리 메모리의 일정 비율을 더한 값 아래로 묶습니다. 이 비율은 vm.overcommit_ratio로 정하며 기본값은 50%입니다.

이 차이는 메모리가 정말로 모자랄 때 드러납니다. 0이나 1에서는 malloc이 성공한 뒤에 페이지를 처음 쓰는 순간 메모리를 구하지 못할 수 있고, 그러면 다음 글의 OOM 킬러가 어떤 프로세스를 끝냅니다. 2에서는 대부분의 경우 페이지에 접근하다가 프로세스가 끝나는 대신, 할당을 요청하는 단계에서 오류를 돌려받습니다. 지금 약속한 양과 한도는 /proc/meminfo의 Committed_AS와 CommitLimit에서 볼 수 있습니다.

페이지 폴트가 처리되는 과정

9장 1편의 모델에서는 페이지 테이블 항목의 유효 비트 하나로 페이지가 물리 메모리에 있는지를 나타냈습니다. 실제 CPU는 존재(present) 비트와 접근 권한을 따로 두어, 물리 메모리에 있는 페이지라도 권한에 맞지 않는 접근이면 폴트를 일으킵니다. CPU가 물리 메모리에 없는 페이지나 허락되지 않은 접근을 만나면 페이지 폴트(page fault)라는 예외가 일어나고, 커널의 페이지 폴트 처리 코드가 실행됩니다. 유효한 주소의 아직 준비되지 않은 페이지에 접근한 경우, 처리는 다음과 같이 진행됩니다.

  1. CPU가 예외를 일으키고 커널의 페이지 폴트 처리 코드로 넘어갑니다. 어느 주소에서, 어떤 종류의 접근으로 폴트가 났는지도 함께 전달됩니다.
  2. 커널은 그 주소가 프로세스의 어느 매핑에 속하는지, 그 접근이 매핑의 권한에 맞는지 확인합니다. 어느 매핑에도 속하지 않거나 권한을 어긴 접근이라면 프로세스에 SIGSEGV를 보내며, 기본 동작은 프로세스를 끝내는 것입니다.
  3. 빈 프레임을 하나 마련합니다. 빈 프레임이 없으면 다음 글의 페이지 교체로 기존 페이지 하나를 내보내 자리를 만듭니다.
  4. 프레임에 내용을 준비합니다. 파일이나 스왑에서 읽어야 한다면 입출력을 요청하고, 그동안 이 스레드는 잠들며 CPU는 다른 스레드를 실행합니다. 0으로 채우거나 이미 메모리에 있는 페이지를 연결하는 경우라면 입출력이 필요 없습니다.
  5. 페이지 테이블 항목에 프레임 번호와 권한을 적고, 필요하면 TLB의 옛 항목을 무효화합니다.
  6. 폴트를 일으킨 명령어로 돌아가 처음부터 다시 실행합니다. 이번에는 페이지가 준비되어 있으므로 접근이 성공합니다.

폴트를 일으킨 명령어를 처음부터 다시 실행한다는 점이 중요합니다. 프로그램은 폴트가 있었다는 사실을 모르고, 명령어 하나가 조금 오래 걸린 것처럼 보일 뿐입니다. 이렇게 하려면 CPU가 폴트를 일으킨 명령어를 아무 효과도 남기지 않은 상태로 멈추고 다시 실행할 수 있어야 하며, 오늘날의 CPU는 모두 이를 지원합니다.

페이지 폴트의 비용

페이지 폴트가 성능에 주는 영향은 앞 글의 유효 접근 시간과 같은 방식으로 계산할 수 있습니다. 메모리 접근에 tt, 디스크를 읽는 폴트 처리에 tpft_{pf}가 걸리고, 메모리 접근 가운데 폴트가 나는 비율이 pp라면 다음과 같습니다.

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

다음 코드는 메모리 접근이 100ns, 폴트 처리가 8ms라고 가정하고 계산합니다.

def eat(p, memory_ns=100, fault_ns=8_000_000):
    return (1 - p) * memory_ns + p * fault_ns   # 폴트가 나면 처리 시간 전체를 기다린다

for p in (0.001, 0.000001):
    print(f"폴트 확률 {p}: 평균 {eat(p):,.1f}ns ({eat(p) / 100:.1f}배)")
print(f"평균 110ns 이하가 되는 폴트 확률의 최댓값: {10 / (8_000_000 - 100):.3e}")
폴트 확률 0.001: 평균 8,099.9ns (81.0배)
폴트 확률 1e-06: 평균 108.0ns (1.1배)
평균 110ns 이하가 되는 폴트 확률의 최댓값: 1.250e-06

1,000번에 한 번만 폴트가 나도 평균 접근 시간이 81배로 늘어납니다. 폴트 처리가 메모리 접근보다 8만 배나 느리기 때문입니다. 느려지는 정도를 10% 안으로 묶으려면, 폴트가 대략 80만 번에 한 번보다 드물어야 합니다. 이 계산은 디스크를 읽는 폴트만 있고 다른 일과 겹치지 않는다는 단순한 가정 위에 있으며, 0으로 채우는 폴트처럼 입출력이 없는 폴트는 훨씬 가볍습니다. 그래도 디스크를 읽는 폴트를 아주 드물게 유지하는 것이 가상 메모리 성능의 핵심이라는 것은 분명합니다.

같은 버퍼에 두 번 쓰기

처음 쓰는 메모리와 다시 쓰는 메모리에서 페이지 폴트가 어떻게 다른지 직접 재 보겠습니다. 다음 Linux용 C 프로그램은 100MiB 버퍼를 할당하고, 같은 버퍼에 페이지마다 한 바이트씩 두 번 씁니다. 첫 측정 구간은 malloc 앞에서 시작하므로 할당과 첫 쓰기를 함께 포함하고, 둘째 측정 구간은 이미 쓴 버퍼에 다시 쓰는 부분만 포함합니다.

page_fault_demo.c
#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;
}
cc -std=c11 -O2 -Wall -Wextra page_fault_demo.c -o page_fault_demo
./page_fault_demo

sysconf(_SC_PAGESIZE)로 기본 페이지 크기를 받아 그 간격으로 쓰므로, 페이지마다 한 번씩 접근하게 됩니다. volatile은 컴파일러가 결과를 쓰지 않는 대입을 최적화로 없애지 못하게 하려는 것입니다. 측정 결과를 출력하는 printf는 두 측정이 모두 끝난 뒤에 있어, 출력하느라 생기는 폴트가 측정에 섞이지 않습니다.

출력의 minor와 major는 getrusage가 알려 주는 마이너 폴트(ru_minflt)와 메이저 폴트(ru_majflt)의 측정 시점 사이의 차이입니다. 마이너 폴트(minor page fault)는 입출력 없이 처리한 폴트로, 0으로 채운 프레임을 주거나 이미 메모리에 있는 페이지를 연결한 경우입니다. 메이저 폴트(major page fault)는 처리하는 데 디스크 입출력이 필요했던 폴트입니다.

먼저 출력된 페이지 크기를 확인하고, 두 구간의 폴트 수를 비교해 보세요. 페이지가 4KiB라면 루프가 접근하는 위치는 100 × 1,024 × 1,024 ÷ 4,096 = 25,600개입니다. 처음 쓰는 버퍼는 아직 준비되지 않은 익명 메모리이므로, 첫 구간에서는 이 수에 가까운 마이너 폴트가 나는 것이 보통입니다. 하지만 이 수가 보장되는 것은 아닙니다. 커널이 9장 1편에서 본 투명한 큰 페이지로 매핑하면 2MiB마다 한 번만 폴트가 나서 수가 크게 줄고, 할당기가 미리 준비해 둔 메모리를 다시 쓰거나 측정 구간에 다른 일이 섞이면 달라집니다. 둘째 구간은 이미 준비된 매핑을 다시 쓰므로 폴트가 크게 줄어드는지 살펴볼 수 있습니다. 다만 0이 된다고 보장되지는 않으며, 프로그램을 새로 실행하면 버퍼를 새로 할당하므로 이 비교와 다른 이야기가 됩니다.

해석할 때 주의할 점이 몇 가지 있습니다. RUSAGE_SELF는 프로세스의 모든 스레드를 합친 값이므로, 두 시점의 차이는 그 구간에 프로세스 전체에서 일어난 폴트이지 이 버퍼만의 폴트가 아닙니다. major가 0이라고 프로그램이 디스크를 전혀 읽지 않았다는 뜻도 아니고, 늘었다고 스왑 때문이라고 단정할 수도 없습니다. 폴트 횟수로 처리 시간을 재었다거나, 앞의 유효 접근 시간 모델을 실측했다고 말할 수도 없습니다.

ru_maxrss는 Linux에서 KiB 단위로, 프로세스가 지금까지 가장 많이 물리 메모리를 차지했던 순간의 크기, 곧 최대 상주 집합 크기(resident set size, RSS)입니다. 지금의 사용량이 아니라 과거의 최댓값이므로, 100MiB와 정확히 같을 필요도 없고 free 뒤에 줄어들지도 않습니다. 값이 예상과 다르다면 오류 메시지가 없는지, 출력된 페이지 크기로 간격을 정했는지, 같은 버퍼에 다시 썼는지부터 확인하고, 운영체제, 컴파일 옵션, 큰 페이지 설정 같은 조건을 함께 적어 두세요. 숫자를 맞추려고 시스템 캐시를 비우거나 시스템 설정을 바꿀 필요는 없습니다.

쓰기 시 복사

3장에서 fork()가 부모의 메모리를 복사한 자식을 만들되, 실제로는 쓰기 시 복사(copy-on-write, COW)로 필요할 때만 복사한다고 했습니다. 그 동작을 페이지 폴트의 관점에서 다시 보겠습니다. fork() 직후 커널은 부모와 자식의 페이지 테이블이 같은 물리 프레임을 가리키게 하고, 이렇게 공유하는 개인용(private) 페이지들을 양쪽 모두에서 쓰기 금지로 표시합니다. 둘 가운데 하나가 쓰려고 하면 쓰기 보호 폴트가 나고, 커널은 그 페이지를 새 프레임에 복사한 뒤 쓰려던 쪽의 매핑만 새 프레임으로 바꾸고 쓰기를 허락합니다. 그리고 쓰기 명령어를 다시 실행합니다.

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

부모·자식 PTE가 같은 프레임을 가리킨다. 자식의 새 페이지에 200을 기록한다. 각 단계의 값과 전환 이유를 비교합니다.

Copy-on-Write · private 매핑의 분리

자식이 쓰면 새 프레임으로 분리되고 부모의 값 100은 유지됩니다

fork 성공 후 COW 대상 private 페이지가 아직 공유되는 예입니다. 처음에는 쓰기가 제한되며 자식의 보호 폴트를 처리해 복사·매핑을 조정하고 쓰기를 재시도합니다. MAP_SHARED는 이 모형과 구분합니다.

같은 private 페이지의 COW 전후 매핑쓰기전부모와자식의PTE는값100의같은프레임을가리킨다. 자식이200을쓰면성공한COW분리뒤자식의새프레임값200과부모의기존값100을분리한다. 영역은시간상태이며영역안화살표는PTE매핑,영역사이강조화살표는성공한쓰기와분리의상태전환이다. 100과200은값이며물리프레임번호가아니다. 실제fork관측은아니다.쓰기 전 · private 공유부모PTE자식PTE공유 프레임값 100화살표는 PTE 매핑자식이 200 쓰기COW 분리·매핑 갱신쓰기 후 · 두 페이지부모PTE자식PTE기존프레임값 100새프레임값 200
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

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

이 예의 조건

  • 본문과 전체 도표의 예를 따라 처음 공유부터 쓰기 재시도까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목자식의 쓰기 후 private 매핑이 분리됩니다
관계 설명쓰기 전 부모와 자식의 PTE는 값 100의 같은 프레임을 가리킨다. 자식이 200을 쓰면 성공한 COW 분리 뒤 자식의 새 프레임 값 200과 부모의 기존 값 100을 분리한다. 영역은 시간 상태이며 영역 안 화살표는 PTE 매핑, 영역 사이 강조 화살표는 성공한 쓰기와 분리의 상태 전환이다. 100과 200은 값이며 물리 프레임 번호가 아니다. 실제 fork 관측은 아니다.
관계 설명처음에는 부모와 자식이 값 100의 프레임을 공유한다. 자식이 200을 쓰면 자식은 새 프레임을 가리키고 부모의 값 100은 유지된다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
처음 공유

부모·자식 PTE가 같은 프레임을 가리킨다.

  • 공유·private 1: 값 100
  • 공유·private 2: COW 쓰기가 제한됨
  • 자식 200 쓰기 시도
보호 폴트

쓰기를 곧바로 기존 프레임에 적용하지 않는다.

  • 공유·private 1: COW 처리 필요
  • 공유·private 2: 부모 100
  • 새 프레임 준비
복사와 매핑

기존 내용을 복사하고 자식 매핑을 조정한다.

  • 공유·private 1: 부모 기존 100
  • 공유·private 2: 자식 새 100
  • 자식 쓰기 허용
쓰기 재시도

자식의 새 페이지에 200을 기록한다.

  • 공유·private 1: 부모 100
  • 공유·private 2: 자식 200
  • private 값 분리; MAP_SHARED는 별도

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

부모·자식 PTE가 같은 프레임을 가리킨다. 자식의 새 페이지에 200을 기록한다. 각 단계의 값과 전환 이유를 비교합니다.

Copy-on-Write · private 매핑의 분리

자식이 쓰면 새 프레임으로 분리되고 부모의 값 100은 유지됩니다

fork 성공 후 COW 대상 private 페이지가 아직 공유되는 예입니다. 처음에는 쓰기가 제한되며 자식의 보호 폴트를 처리해 복사·매핑을 조정하고 쓰기를 재시도합니다. MAP_SHARED는 이 모형과 구분합니다.

같은 private 페이지의 COW 전후 매핑쓰기전부모와자식의PTE는값100의같은프레임을가리킨다. 자식이200을쓰면성공한COW분리뒤자식의새프레임값200과부모의기존값100을분리한다. 영역은시간상태이며영역안화살표는PTE매핑,영역사이강조화살표는성공한쓰기와분리의상태전환이다. 100과200은값이며물리프레임번호가아니다. 실제fork관측은아니다.쓰기 전 · private 공유부모PTE자식PTE공유 프레임값 100화살표는 PTE 매핑자식이 200 쓰기COW 분리·매핑 갱신쓰기 후 · 두 페이지부모PTE자식PTE기존프레임값 100새프레임값 200
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

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

이 예의 조건

  • 본문과 전체 도표의 예를 따라 처음 공유부터 쓰기 재시도까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목자식의 쓰기 후 private 매핑이 분리됩니다
관계 설명쓰기 전 부모와 자식의 PTE는 값 100의 같은 프레임을 가리킨다. 자식이 200을 쓰면 성공한 COW 분리 뒤 자식의 새 프레임 값 200과 부모의 기존 값 100을 분리한다. 영역은 시간 상태이며 영역 안 화살표는 PTE 매핑, 영역 사이 강조 화살표는 성공한 쓰기와 분리의 상태 전환이다. 100과 200은 값이며 물리 프레임 번호가 아니다. 실제 fork 관측은 아니다.
관계 설명처음에는 부모와 자식이 값 100의 프레임을 공유한다. 자식이 200을 쓰면 자식은 새 프레임을 가리키고 부모의 값 100은 유지된다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
처음 공유

부모·자식 PTE가 같은 프레임을 가리킨다.

  • 공유·private 1: 값 100
  • 공유·private 2: COW 쓰기가 제한됨
  • 자식 200 쓰기 시도
보호 폴트

쓰기를 곧바로 기존 프레임에 적용하지 않는다.

  • 공유·private 1: COW 처리 필요
  • 공유·private 2: 부모 100
  • 새 프레임 준비
복사와 매핑

기존 내용을 복사하고 자식 매핑을 조정한다.

  • 공유·private 1: 부모 기존 100
  • 공유·private 2: 자식 새 100
  • 자식 쓰기 허용
쓰기 재시도

자식의 새 페이지에 200을 기록한다.

  • 공유·private 1: 부모 100
  • 공유·private 2: 자식 200
  • private 값 분리; MAP_SHARED는 별도

다음 Python 코드는 이 과정을 모델로 따라갑니다. 프레임마다 그 프레임을 가리키는 매핑의 수를 세어, 쓰기 보호 폴트가 났을 때 아직 함께 쓰는 쪽이 있으면 복사하고, 혼자 남았으면 복사하지 않고 그 프레임에 쓰기를 허락합니다.

class Memory:
    def __init__(self):
        self.frames, self.refs = {}, {}      # 프레임 번호 -> 값, 그 프레임을 가리키는 매핑 수

    def new_frame(self, value):
        n = len(self.frames)
        self.frames[n], self.refs[n] = value, 1
        return n

class Process:
    def __init__(self, name, memory, frame):
        self.name, self.memory, self.frame, self.writable = name, memory, frame, True

    def fork(self, name):
        self.memory.refs[self.frame] += 1
        child = Process(name, self.memory, self.frame)
        self.writable = child.writable = False     # 함께 쓰는 동안 쓰기를 막아 둔다
        return child

    def write(self, value):
        m = self.memory
        if not self.writable:                      # 쓰기 보호 폴트
            if m.refs[self.frame] > 1:             # 아직 함께 쓰는 쪽이 있으면 복사한다
                m.refs[self.frame] -= 1
                self.frame = m.new_frame(m.frames[self.frame])
                how = "새 프레임에 복사"
            else:                                  # 혼자 남았으면 그 프레임을 그대로 쓴다
                how = "기존 프레임 재사용"
            self.writable = True
            print(f"  {self.name}의 쓰기 보호 폴트: {how} (프레임 {self.frame})")
        m.frames[self.frame] = value

    def read(self):
        return self.memory.frames[self.frame]

memory = Memory()
parent = Process("부모", memory, memory.new_frame(100))
child = parent.fork("자식")
print(f"fork 직후: 부모 프레임 {parent.frame}, 자식 프레임 {child.frame}, 값 {parent.read()}")
child.write(200)
print(f"자식이 200을 쓴 뒤: 부모 {parent.read()}, 자식 {child.read()}")
parent.write(400)
print(f"부모가 400을 쓴 뒤: 부모 {parent.read()}, 자식 {child.read()}, 쓰인 프레임 {len(memory.frames)}개")
fork 직후: 부모 프레임 0, 자식 프레임 0, 값 100
  자식의 쓰기 보호 폴트: 새 프레임에 복사 (프레임 1)
자식이 200을 쓴 뒤: 부모 100, 자식 200
  부모의 쓰기 보호 폴트: 기존 프레임 재사용 (프레임 0)
부모가 400을 쓴 뒤: 부모 400, 자식 200, 쓰인 프레임 2개

fork() 직후에는 부모와 자식이 프레임 0을 함께 가리킵니다. 자식이 200을 쓰려 하면 쓰기 보호 폴트가 나고, 프레임 0을 아직 부모도 가리키므로 새 프레임 1에 내용을 복사한 뒤 자식의 매핑만 프레임 1로 바꾸고 200을 씁니다. 부모는 여전히 프레임 0의 100을 봅니다. 그 뒤 부모가 쓸 때도 부모의 매핑은 아직 쓰기 금지로 표시되어 있어 폴트가 나지만, 이제 프레임 0을 가리키는 것은 부모뿐이므로 복사하지 않고 쓰기를 허락합니다. 그래서 프레임은 두 개면 충분합니다. Linux도 쓰기 보호 폴트에서 그 페이지를 쓰는 쪽이 하나뿐이면 복사하지 않고 그대로 씁니다.

같은 일을 C로 쓰면 다음과 같습니다.

cow_example.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void)
{
    int *data = malloc(sizeof *data);
    if (data == NULL)
        return 1;
    *data = 100;

    pid_t pid = fork();          /* 이 시점부터 data가 있는 페이지를 함께 쓴다. */
    if (pid < 0) {
        perror("fork");
        return 1;
    }
    if (pid == 0) {
        *data = 200;             /* 쓰기 보호 폴트가 나고, 자식 쪽에 페이지가 복사된다. */
        printf("자식: %d\n", *data);
        fflush(stdout);
        _exit(0);
    }
    waitpid(pid, NULL, 0);
    printf("부모: %d\n", *data);  /* 부모는 여전히 100을 본다. */
    free(data);
    return 0;
}

할당과 fork()가 성공하면 자식은 200, 부모는 100을 출력합니다. 자식이 _exit()로 끝나기 전에 fflush(stdout)를 부르는 것은, _exit()가 stdio 버퍼를 비우지 않아 출력을 파일로 보낼 때 자식의 줄이 사라지는 일을 막으려는 것입니다. 출력 줄이 보이지 않는 것은 버퍼의 문제이지 쓰기 시 복사가 실패했다는 뜻이 아닙니다.

쓰기 시 복사가 특히 효과적인 것은 fork() 직후 exec()를 부르는 흔한 경우입니다. exec()는 주소 공간을 새 프로그램으로 통째로 바꾸므로, 그 전에 쓰지 않은 페이지는 한 번도 복사되지 않고 버려집니다. 다만 exec()를 부르기 전에도 함수 호출로 스택에 쓰는 등의 일은 있으므로, 복사가 하나도 없다고 할 수는 없습니다.

쓰기 시 복사는 공유하는 개인용 매핑에 대한 이야기입니다. mmap()에 MAP_SHARED를 주어 만든 공유 매핑은 쓰기를 함께 보는 것이 목적이므로 복사하지 않고, 한 프로세스가 쓴 값을 다른 프로세스가 바로 봅니다.

vfork

쓰기 시 복사로도 페이지 테이블은 복사해야 하므로, 메모리가 큰 프로세스는 fork() 자체에 시간이 듭니다. vfork()는 이 비용마저 없애려고 자식이 부모의 주소 공간을 복사 없이 그대로 쓰게 하고, 자식이 exec()를 부르거나 _exit()로 끝날 때까지 부모를 멈춰 둡니다. 자식이 부모의 메모리를 그대로 쓰므로, 그 사이에 자식이 변수를 고치거나 함수에서 반환하면 부모의 상태가 망가집니다. 그래서 vfork() 뒤의 자식은 곧바로 exec()나 _exit()만 해야 하며, 오늘날에는 같은 일을 더 안전하게 하는 posix_spawn()을 주로 씁니다.

빈 프레임이 없을 때

페이지 폴트를 처리하려는데 빈 프레임이 하나도 없다면, 지금 메모리에 있는 페이지 하나를 내보내고(evict) 그 프레임을 써야 합니다. 이때 비용은 내보낼 페이지의 상태에 따라 크게 다릅니다. 페이지 테이블 항목의 변경(dirty) 비트가 1이라면 그 페이지는 메모리에 올라온 뒤 내용이 바뀐 것이므로, 바뀐 내용을 디스크에 써야 합니다. 파일에서 온 페이지라면 파일에, 익명 페이지라면 스왑에 씁니다. 변경 비트가 0인 파일 페이지라면 파일에 같은 내용이 있으므로 쓰지 않고 바로 버려도 됩니다.

어떤 페이지를 내보낼지는 페이지 교체 알고리즘이 정합니다. 곧 다시 쓸 페이지를 내보내면 바로 다시 폴트가 나서 그 페이지를 다시 읽어 와야 하므로, 무엇을 고르느냐가 가상 메모리의 성능을 좌우합니다. 이 알고리즘들은 다음 글에서 비교합니다.

메모리 매핑 파일

페이지 폴트를 응용한 기능으로 메모리 매핑 파일(memory-mapped file)이 있습니다. mmap() 시스템 콜로 파일을 프로세스의 주소 공간에 매핑하면, 파일의 내용을 read()로 버퍼에 복사하지 않고 메모리처럼 바로 읽을 수 있습니다. 실제 데이터는 그 페이지에 처음 접근할 때 페이지 폴트로 준비됩니다.

mmap_example.c
#include <fcntl.h>
#include <stdio.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>

int main(void)
{
    int fd = open("data.bin", O_RDONLY);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    struct stat st;
    if (fstat(fd, &st) != 0 || st.st_size == 0) {
        fputs("파일이 비어 있거나 크기를 알 수 없습니다\n", stderr);
        close(fd);
        return 1;
    }

    char *mapped = mmap(NULL, (size_t) st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
    close(fd);                              /* 매핑은 디스크립터를 닫아도 남는다. */
    if (mapped == MAP_FAILED) {
        perror("mmap");
        return 1;
    }

    printf("첫 바이트: %d\n", mapped[0]);   /* 이 접근에서 페이지 폴트로 내용이 준비된다. */
    munmap(mapped, (size_t) st.st_size);
    return 0;
}

mmap()이 성공하면 파일의 내용이 주소 공간에 매핑되지만, 그 순간에 데이터를 읽어 오지는 않습니다. mapped[0]에 처음 접근할 때 페이지 폴트가 나고, 커널이 파일의 첫 페이지를 준비합니다. 그 파일이 이미 페이지 캐시에 있다면 디스크를 읽지 않고 캐시의 페이지를 연결하므로 마이너 폴트가 되고, 캐시에 없다면 디스크에서 읽어야 하므로 메이저 폴트가 됩니다. 매핑을 만든 뒤에는 파일 디스크립터를 닫아도 매핑이 남는다는 점도 알아 둘 만합니다. 데이터베이스나 검색 엔진처럼 큰 파일의 일부를 여기저기 읽는 프로그램이 이 방식을 많이 씁니다.

정리

  • 요구 페이징은 페이지를 미리 올리지 않고, 접근하는 순간에 파일에서 읽거나, 0으로 채우거나, 이미 있는 페이지를 연결해 준비합니다. 지역성 덕분에 작업 집합만 메모리에 있으면 폴트가 드뭅니다.
  • 페이지 폴트가 나면 커널은 주소와 권한을 확인하고, 프레임을 마련하고, 내용을 준비해 페이지 테이블을 고친 뒤 폴트를 일으킨 명령어를 다시 실행합니다. 유효하지 않은 접근이면 SIGSEGV를 보냅니다.
  • 디스크를 읽는 폴트는 메모리 접근보다 수만 배 느려, 아주 드물게만 일어나도 평균 접근 시간이 크게 늘어납니다.
  • 마이너 폴트는 입출력 없이, 메이저 폴트는 입출력과 함께 처리된 폴트입니다. getrusage로 구간별 폴트 수와 최대 RSS를 재어 볼 수 있지만, 수치는 큰 페이지와 할당기, 다른 활동에 따라 달라집니다.
  • 쓰기 시 복사는 fork() 뒤 공유하는 페이지를 쓰기 금지로 두었다가, 쓰는 순간 그 페이지만 복사합니다. 혼자 남은 쪽은 복사 없이 씁니다.
  • 빈 프레임이 없으면 페이지를 내보내야 하며, 바뀐 페이지는 디스크에 써야 하므로 비용이 큽니다. 메모리 매핑 파일은 파일을 주소 공간에 매핑해 페이지 폴트로 읽습니다.

연습 문제

1. 폴트 확률과 평균 접근 시간

메모리 접근이 100ns, 디스크를 읽는 폴트 처리가 8ms라고 할 때, 폴트 확률이 0.000001이면 유효 접근 시간은 얼마인가요? 평균이 110ns 이하가 되려면 폴트 확률이 얼마보다 작아야 하나요? 이 확률로 본문의 C 프로그램이 낼 마이너 폴트 수를 예측할 수 있을까요?

해설 보기

8ms는 8,000,000ns이므로 유효 접근 시간은 0.999999 × 100 + 0.000001 × 8,000,000 = 99.9999 + 8 = 107.9999ns, 약 108ns입니다. 식을 정리하면 100 + 7,999,900p이므로 110ns 이하가 되려면 p ≤ 10 ÷ 7,999,900 ≈ 0.00000125여야 합니다. 이 확률은 메모리 접근마다 디스크를 읽는 폴트가 날 가능성을 가정한 값입니다. 본문의 C 프로그램이 내는 마이너 폴트는 처음 쓰는 익명 페이지를 0으로 채워 준비하는 폴트로 디스크를 읽지 않으며, 그 수는 접근 확률이 아니라 페이지 수와 큰 페이지 사용 여부로 정해지므로 이 확률로 예측할 수 없습니다.

2. 둘 다 쓰는 쓰기 시 복사

fork() 직후 공유하는 개인용 페이지의 값이 100입니다. 자식이 먼저 300을 쓰고, 그다음 부모가 400을 씁니다. 다른 프로세스는 없습니다. 마지막에 부모와 자식이 보는 값은 각각 얼마이고, 부모가 쓸 때 세 번째 프레임이 필요할까요? 자식이 출력한 줄이 화면에 보이지 않는다면 쓰기 시 복사가 실패한 것일까요?

해설 보기

자식이 300을 쓰면 쓰기 보호 폴트가 나고, 원래 프레임을 부모도 가리키므로 새 프레임에 복사한 뒤 자식 쪽에 300을 씁니다. 부모는 원래 프레임의 100을 봅니다. 그다음 부모가 400을 쓰면 다시 쓰기 보호 폴트가 나지만, 원래 프레임을 가리키는 것은 이제 부모뿐이므로 복사하지 않고 원래 프레임에 400을 씁니다. 그래서 마지막에 부모는 400, 자식은 300을 보며, 프레임은 두 개면 되고 세 번째 프레임은 필요 없습니다. 본문의 Python 모델에서 값만 바꾸어 같은 결과를 확인할 수 있습니다. 자식의 출력 줄이 보이지 않는 것은 _exit()가 stdio 버퍼를 비우지 않아 생기는 일일 수 있으므로, 그것만으로 쓰기 시 복사가 실패했다고 판단할 수는 없습니다.

3. 같은 프로그램을 두 번 실행하기

본문의 page_fault_demo를 두 번 연달아 실행했더니, 두 번 모두 첫 구간의 마이너 폴트가 비슷하게 많았습니다. 동료가 "두 번째 실행에서는 첫 실행이 준비해 둔 페이지를 다시 쓰니까 폴트가 줄어야 하는 것 아니냐"고 물었습니다. 어떻게 설명할 수 있을까요?

해설 보기

본문의 둘째 구간이 폴트가 적은 것은 같은 프로세스 안에서 이미 준비한 버퍼의 매핑을 다시 쓰기 때문입니다. 프로그램을 새로 실행하면 새 프로세스가 만들어지고, malloc으로 받은 100MiB 버퍼는 새로 매핑한 익명 메모리입니다. 첫 실행의 버퍼는 그 프로세스가 끝날 때 모두 회수되었고, 익명 메모리는 파일처럼 페이지 캐시에 남아 다음 실행이 다시 쓰는 것이 아니므로, 두 번째 실행에서도 처음 쓰는 페이지마다 0으로 채운 프레임을 준비하는 마이너 폴트가 납니다. 반면 실행 파일의 코드나 공유 라이브러리처럼 파일에서 오는 페이지는 페이지 캐시에 남아 있어, 두 번째 실행에서는 디스크를 읽는 메이저 폴트 대신 캐시를 연결하는 마이너 폴트로 바뀌는 경우가 많습니다.

다음 글 페이지 교체 알고리즘에서는 빈 프레임이 없을 때 어떤 페이지를 내보낼지 정하는 FIFO, 최적, LRU, 클록 알고리즘을 비교하고, 스래싱과 작업 집합을 살펴봅니다.