한양과학기술고등학교

에듀테크 프로젝트 대시보드

Copy-on-Write (쓸 때 복사)

Copy-on-Write — 쓸 때 복사

"미리 복사하지 말고, 정말 고쳐 쓸 때가 되면 그때 복사한다" · fork()·컨테이너·스냅샷이 빠르고 가벼운 이유

1. fork()의 딜레마

리눅스에서 프로세스를 새로 만드는 fork()는 부모의 주소 공간을 통째로 복제한 자식을 만든다. 부모가 2 GB짜리 힙을 들고 있었다면 자식도 똑같은 2 GB를 가진다. 순진하게 구현하면 fork() 한 번에 2 GB를 물리 메모리에 그대로 복사해야 한다. 느리고, 메모리도 두 배로 먹는다.

게다가 대부분의 프로그램은 fork() 직후 곧바로 exec()를 불러 완전히 다른 프로그램으로 갈아탄다 (셸이 명령어를 실행할 때가 그렇다). 어렵게 복사한 2 GB는 exec() 순간 전부 버려진다. 100% 낭비다.

2. 핵심 아이디어: 일단 공유, 쓸 때만 쪼갠다

Copy-on-Write(COW)는 복사를 게으르게(lazy) 미룬다.

  • fork 시점 — 페이지를 실제로 복사하지 않는다. 자식의 페이지 테이블이 부모와 똑같은 물리 프레임을 가리키게만 하고, 양쪽 모두 그 페이지를 읽기 전용(R/O)으로 표시한다. 각 프레임의 참조 수를 2로 올린다.
  • 읽기 — 둘 다 같은 프레임을 그냥 읽는다. 아무 일도 안 일어난다. 공짜.
  • 쓰기 — 읽기 전용 페이지에 쓰려는 순간 CPU가 페이지 폴트를 일으킨다. 커널이 끼어들어 그 페이지만 새 프레임에 복사하고, 쓴 쪽의 매핑을 새 프레임으로 바꾼 뒤 쓰기 가능(R/W)으로 되돌린다. 원래 프레임의 참조 수는 1로 줄어든다.
결과: fork()는 페이지 테이블만 만들면 되니 거의 즉시 끝난다. 실제 복사 비용은 나중에 수정되는 페이지에만, 딱 한 번 나눠서 지불된다. 건드리지 않은 페이지는 영원히 공유된다.

3. 직접 해보기

부모 프로세스가 4개의 페이지(Code·Data·Heap·Stack)를 물리 프레임에 매핑하고 있다. fork()를 눌러 자식을 만들고, 부모/자식이 특정 페이지에 쓰게 해보자. 공유 중이던 페이지에 처음 쓸 때만 페이지 폴트 → 복사가 일어난다.

부모 프로세스 PID 1000

물리 메모리 (프레임 · 참조 수)

페이지 폴트0
COW 복사 횟수0
사용 중인 물리 프레임4
공유로 절약한 프레임0

4. 커널이 하는 일 (의사 코드)

// fork(): 페이지를 복사하지 않는다 — 매핑만 복제하고 전부 읽기 전용으로
for (page in parent.page_table) {
    child.map(page.vaddr, page.frame);   // 같은 물리 프레임을 가리킴
    page.frame.refcount++;
    page.writable = child.page(page.vaddr).writable = false;   // R/O
}

// 나중에 R/O 페이지에 쓰기 시도 → CPU가 page fault 발생 → 이 핸들러 진입
handle_write_fault(proc, vaddr) {
    old = proc.page(vaddr).frame;
    if (old.refcount == 1) {              // 이미 나 혼자 쓰는 페이지
        proc.page(vaddr).writable = true;   // 복사 없이 권한만 복구
        return;
    }
    new = alloc_frame();
    memcpy(new, old, PAGE_SIZE);          // ← 진짜 복사는 여기, 이 페이지 하나만
    old.refcount--;
    proc.page(vaddr).frame = new;
    proc.page(vaddr).writable = true;
}

5. 미리 복사 vs 쓸 때 복사

Eager Copy (미리 복사)Copy-on-Write
fork() 지연주소 공간 크기에 비례 (수십 ms+)페이지 테이블 크기에만 비례 (≈즉시)
메모리즉시 2배수정된 페이지만큼만 추가
fork+exec복사한 것 전부 버림거의 아무것도 복사 안 함
쓰기 비용평범한 쓰기첫 쓰기에 페이지 폴트 1회 + 4 KB 복사
지역성—쓰기가 몰리면 write amplification 위험

6. 어디에 쓰이나

  • fork() / posix_spawn — 위에서 본 그대로. 프로세스 생성의 기본기.
  • 실행 파일·라이브러리 매핑 — libc는 물리 메모리에 한 벌만 올라가고 모든 프로세스가 R/O로 공유한다. 가상 메모리의 페이지 공유가 곧 COW의 사촌.
  • Redis의 BGSAVE — fork()한 자식이 그 순간의 데이터 스냅샷을 디스크에 저장한다. 부모가 계속 쓰는 키만 COW로 복사되므로 대개 메모리 부담이 작다(쓰기가 폭주하면 커진다).
  • 파일 시스템 스냅샷 — Btrfs·ZFS·APFS는 파일을 수정할 때 원본 블록을 놔두고 새 블록에 쓴다. 스냅샷이 순식간에 만들어지는 이유. LSM 트리의 불변 SSTable도 비슷한 철학.
  • 컨테이너 이미지 — Docker의 오버레이 레이어, 언어의 immutable 문자열, COW 배열/딕셔너리 등 애플리케이션 레벨에도 같은 패턴이 널려 있다.
함정 — 멀티스레드 프로그램에서 fork() 후 자식이 쓰기를 많이 하면 페이지 폴트가 쏟아져 오히려 느려질 수 있다. Redis가 큰 메모리에서 BGSAVE 중 OOM으로 죽는 사고도 여기서 나온다 (vm.overcommit_memory 튜닝이 권장되는 이유).

7. 한 문장 요약

Copy-on-Write는 "공유는 공짜, 복사는 미룬다"는 전략이다. 여러 주체가 같은 데이터를 가리키게 해두고, 누군가 처음 고쳐 쓰는 순간에만 그 부분을 몰래 복제해 각자의 사본을 준다. 덕분에 fork(), 스냅샷, 컨테이너가 크기와 상관없이 빠르게 시작된다.