프로젝트
약 16분 분량 학습 프로젝트/토이 커널

Copy-on-Write fork: 복사를 미루는 기술

OSCKernelRISC-Vxv6QEMU
목차

바닥부터 직접 만드는 RISC-V 토이 커널 연재. 이 글은 8편: Copy-on-Write fork(복사를 쓰기 시점까지 미루기).

0. 들어가며

2편에서 만든 fork는 정직하지만 무식합니다.
부모의 주소공간을 통째로 복사합니다. 코드 페이지, 스택 페이지, 힙까지 전부입니다.

이 글은 그 forkCopy-on-Write(COW) 로 다시 짜는 과정을 정리합니다.
COW의 핵심 아이디어는 한 문장입니다. “복사를 쓰기 시점까지 미룬다.”
fork 때는 부모와 자식이 같은 물리 페이지를 읽기 전용으로 공유하고, 누군가 그 페이지에 쓰려고 할 때 비로소 복제합니다.

이걸 구현하는 데 필요한 부품은 딱 두 개입니다. 물리 페이지마다 참조 카운트를 달고, 쓰기 페이지 폴트가 났을 때 그 페이지만 복제하는 것.
그리고 우리 커널만의 재미있는 제약도 하나 나옵니다. (우리 구현에선) 스택은 COW로 공유하지 못한다. (리눅스·xv6 같은 일반 커널은 스택도 COW 하지만, 우리 설계에선 못 합니다. 이유는 4절에서 다룹니다.)
그 제약이 왜 생기고, 그게 어떤 트레이드오프인지까지 정확히 짚어보겠습니다.

왜 지금 COW인가: 이건 5편의 demand paging과 정확히 같은 철학입니다. “필요해질 때까지 일을 미룬다.” 거기선 페이지 할당을 미뤘고, 여기선 페이지 복사를 미룹니다. 그래서 그때 만든 페이지 폴트 핸들러를 거의 그대로 재사용합니다. COW는 새 메커니즘이라기보다 기존 페이지 폴트 메커니즘 위에 얹은 새 정책에 가깝습니다. 페이지 폴트·페이지 테이블·참조 카운트라는 있던 부품의 재조합입니다.

1. Copy-on-Write가 풀어야 하는 진짜 문제

fork는 부모 주소공간을 통째로 복사합니다. 그런데 그 복사본의 대부분은 자식이 한 번도 안 쓰고 버려요. 이 낭비를 어떻게 없앨까요?

현실의 fork는 압도적으로 이 패턴으로 쓰입니다.

if (fork() == 0) {
exec("프로그램"); // 자식: 방금 복사한 주소공간을 곧바로 버리고 새 프로그램으로 교체
}

자식은 fork로 복사한 페이지를 한 줄도 안 읽고 exec로 통째 덮어씁니다.
부모가 힙 1MB, 코드 수십 KB를 들고 있었다면, fork는 그걸 전부 복제하느라 시간과 RAM을 씁니다. 그런데 자식은 exec 한 번에 그 사본을 전부 폐기합니다.
복사한 만큼이 고스란히 낭비인 겁니다.

여기서 핵심 관찰은 이렇습니다.

  • fork 직후 부모와 자식의 페이지 내용은 완전히 똑같다.
  • 둘 중 누군가 그 페이지에 쓰기 전까지는 사본이 따로 필요 없다.
  • 대부분의 페이지는 아무도 안 쓴 채로 자식의 exec/exit에서 사라진다.

그러니 전략은 명확합니다. fork 시점엔 복사하지 말고, 일단 같이 보게 하다가, 쓰는 그 순간에만 그 페이지 하나를 복제한다.
이게 Copy-on-Write입니다.

fork 시 전체 복사 vs Copy-on-Write

핵심 차이는 “비용을 언제 치르느냐” 입니다. 1편의 페이징·5편의 demand paging과 똑같은 구도입니다.

구분fork 시 전체 복사Copy-on-Write
fork의 비용모든 페이지 kalloc + memcpy (무거움)페이지 복사 없이 PTE 권한 수정 + refcount++ (가벼움)
복사 시점fork 그 순간 (선불)누군가 쓰는 순간 (후불, 페이지 단위)
fork 후 곧 exec복사 → 즉시 폐기 (전부 낭비)복사 아예 안 일어남
안 쓰는 페이지그래도 복사됨끝까지 공유, 복사 0회
추가로 필요한 것없음참조 카운트 + 쓰기 폴트 핸들러
비용 청구 시점지금 즉시, 무조건진짜 쓸 때만, 딱 그 페이지만

흔한 오해 정정: “COW는 fork 때 페이지를 복사한다” 고 생각하기 쉬운데, 정확히 반대입니다. fork 때는 복사를 안 합니다. 같은 물리 페이지를 읽기 전용으로 공유할 뿐입니다. 복사는 누군가 쓰는 시점(write) 에 일어나고, 그래서 이름이 copy-on-write 입니다. fork 직후 exec하는 자식에겐 복사가 단 한 번도 안 일어납니다.

2. 토대: 물리 페이지 참조 카운트

페이지를 공유하려면, 먼저 “이 물리 페이지를 몇 개의 주소공간이 가리키나” 를 알아야 합니다.
부모와 자식이 같은 페이지를 공유하다가 한쪽이 떠나도, 다른 쪽이 아직 쓰고 있으면 그 페이지를 반납하면 안 되기 때문입니다.
“마지막 한 명이 떠날 때만 반납한다”를 알려면 머릿수를 세야 합니다.

핵심 질문

물리 페이지 하나를 여러 주소공간이 공유할 때, 언제 그 페이지를 진짜로 반납해도 안전할까요?

답은 참조 카운트입니다. 물리 페이지마다 “지금 몇 명이 이 페이지를 쓰나”를 세는 카운터를 둡니다.
1편의 할당기에 이미 깔아둔 refcnt 배열이 그것입니다.

// kalloc.c — 물리 페이지마다 참조 카운트. 인덱스 = (pa - RAMBASE) / 4096.
#define RAMBASE 0x80000000UL
#define NPAGES ((PHYSTOP - RAMBASE) / PGSIZE)
static uint8 refcnt[NPAGES];
static int refidx(void *pa) { return (int)(((uint64)pa - RAMBASE) >> 12); }

물리주소를 페이지 번호로 바꾸는 게 refidx입니다. RAM 시작(0x8000_0000)을 빼고 12비트 오른쪽으로 밀면(>>12, 즉 4096으로 나누면) 그 페이지의 배열 인덱스가 나옵니다.
uint8 한 바이트씩, 페이지마다 한 칸입니다.

반납 규칙은 kfree 안에 들어 있습니다.

// kalloc.c — refcount가 1보다 크면 반납하지 않고 카운트만 깎는다.
void kfree(void *pa) {
acquire(&kmem_lock);
int i = refidx(pa);
if (refcnt[i] > 1) { // 아직 다른 주소공간이 공유 중 → 반납하지 않음
refcnt[i]--;
release(&kmem_lock);
return;
}
refcnt[i] = 0; // 마지막 소유자였다 → 진짜로 반납
struct run *r = (struct run *)pa;
r->next = freelist; // free list 머리에 끼운다
freelist = r;
freecnt++;
release(&kmem_lock);
}

세 가지 규칙으로 요약됩니다.

  • kalloc은 새로 떼어 준 페이지의 카운트를 1로 둡니다(소유자 한 명).
  • 공유가 시작되면 kref_inc+1.
  • kfree-1, 그리고 0이 될 때만 진짜로 free list에 되돌립니다.
// kalloc.c — 공유 시작: 이 페이지를 가리키는 주소공간이 하나 늘었다.
void kref_inc(void *pa) {
acquire(&kmem_lock);
refcnt[refidx(pa)]++;
release(&kmem_lock);
}

재밌는 디테일이 하나 있습니다. kinit(부팅 때 빈 RAM을 free list로 미는 초기화)도 이 규칙 위에서 돕니다.

// kalloc.c — 초기화: refcnt를 1로 둔 뒤 kfree(→0, 반납)해서 경로를 하나로 통일.
void kinit(void) {
initlock(&kmem_lock);
uint64 p = PGROUNDUP((uint64)end);
for (; p + PGSIZE <= PHYSTOP; p += PGSIZE) {
refcnt[refidx((void *)p)] = 1; // 1로 두고
kfree((void *)p); // kfree가 1→0으로 보고 진짜 반납
}
}

refcnt를 먼저 1로 세팅한 뒤 kfree를 부르는 게 핵심입니다.
그러면 “반납”이 언제나 kfree라는 한 경로를 통하게 돼서, 초기화든 평소 해제든 코드가 갈라지지 않습니다.

흔한 오해 정정: refcnt를 보고 “가비지 컬렉터냐?” 고 묶기 쉬운데, 아닙니다. GC는 “아무도 안 가리키는 객체를 런타임이 추적해서 자동으로 찾아 회수” 하는 것이고, 여기 참조 카운트는 그냥 공유한 사람이 직접 +1, 떠나는 사람이 직접 -1 하는 명시적 카운터입니다. “누가 마지막인지 세는 도구” 그 이상도 이하도 아닙니다.

핵심 교훈: 공유의 전제는 수명 관리입니다. “같이 쓰자”는 쉽지만, “그럼 언제 버리지?”가 진짜 문제고, 참조 카운트가 그 답입니다. 이 한 겹이 깔려야 그 위에 COW를 안전하게 올릴 수 있습니다.

3. fork를 COW로: 공유하고, 표시를 숨겨둔다

이제 토대가 깔렸으니, fork에서 페이지를 복사하는 대신 공유합니다.
요령은 양쪽 모두 읽기 전용으로 만들고, “원래는 쓰기 가능했다”는 표시를 PTE에 숨겨두는 겁니다.

핵심 질문

부모와 자식이 같은 물리 페이지를 공유하는데, 둘 다 그 페이지에 “쓰기”가 허용돼 있었습니다. 누가 쓰려는 순간을 어떻게 하드웨어가 알아채서 우리에게 알려줄까요?

답은 PTE의 쓰기 비트를 일부러 떼는 것입니다.
쓰기 가능 페이지의 PTE_W를 떼면, 그 페이지에 쓰려는 순간 CPU가 쓰기 페이지 폴트를 일으킵니다.
그 폴트가 곧 “지금 누군가 이 공유 페이지에 쓰려 한다”는 하드웨어의 신호입니다.

그런데 PTE_W를 그냥 떼기만 하면, 나중에 “이게 원래 쓰기 가능했던 COW 페이지인지, 아니면 진짜 읽기 전용 코드 페이지인지” 구분을 못 합니다.
그래서 “이건 COW 공유 페이지다” 라는 표시를 따로 남겨야 합니다.
RISC-V는 친절하게도 PTE에 소프트웨어가 마음대로 쓰라고 비워둔 비트(RSW) 를 줍니다.

// vm.h — PTE 플래그. RSW(소프트웨어 예약) 비트 하나를 COW 표시로 쓴다.
#define PTE_V (1L << 0)
#define PTE_R (1L << 1)
#define PTE_W (1L << 2)
#define PTE_X (1L << 3)
#define PTE_U (1L << 4)
#define PTE_COW (1L << 8) // RSW(bit 8): copy-on-write 공유 페이지 표시

PTE_COW = (1 << 8)이 그 표시입니다.
하드웨어는 이 비트를 번역에 전혀 쓰지 않습니다(주소 변환에도, 권한 검사에도 안 봄). 순수하게 우리 커널이 읽으려고 둔 메모입니다.

이제 공유 로직입니다. uvm_cow_share가 부모의 [start, end) 범위 페이지들을 자식과 잇습니다.

// vm.c — 부모의 [start,end) 매핑 페이지를 자식과 읽기전용으로 공유한다(복사 X).
int uvm_cow_share(pagetable_t parent, pagetable_t child, uint64 start, uint64 end) {
for (uint64 a = start; a < end; a += PGSIZE) {
pte_t *pte = walk(parent, a, 0);
if (pte == 0 || !(*pte & PTE_V))
continue; // 아직 폴트 안 난 페이지 → 자식이 따로 demand
uint64 pa = PTE2PA(*pte);
uint64 flags = PTE_FLAGS(*pte);
if (flags & PTE_W) { // 쓰기 가능 → COW로 전환
flags = (flags & ~PTE_W) | PTE_COW;
*pte = PA2PTE(pa) | flags; // 부모도 읽기전용 COW가 된다
}
if (mappages(child, a, PGSIZE, pa, flags) != 0)
return -1;
kref_inc((void *)pa); // 이제 두 주소공간이 공유 → refcount +1
}
sfence_vma();
return 0;
}

세 가지를 눈여겨봅니다.

  1. 쓰기 가능 페이지만 COW로 바꿉니다. flags & PTE_W가 참일 때만 PTE_W를 떼고 PTE_COW를 답니다. 읽기 전용 페이지(예: 코드의 R|X)는 어차피 쓰기 폴트가 안 나니 그대로 공유합니다.
  2. 부모의 PTE도 같이 고칩니다. *pte를 다시 쓰는 한 줄이 그것입니다. 부모도 이제 그 페이지를 읽기 전용 COW로 보게 됩니다. 안 그러면 부모가 쓸 때 자식 모르게 공유 페이지를 망가뜨립니다.
  3. 물리 페이지는 복사 안 하고 kref_inc만 합니다. 이게 COW의 본질입니다. fork가 memcpy 없이 카운터 증가 한 번으로 끝납니다.

아직 폴트가 안 나서 매핑조차 없는 페이지(!(*pte & PTE_V))는 그냥 건너뜁니다. 그런 페이지는 자식이 나중에 접근하면 demand paging이 알아서 만들어 줍니다.

쓰기 폴트 → 복제

이제 부모도 자식도 그 페이지를 읽을 순 있지만 쓸 수는 없습니다.
누군가 쓰려고 하면 하드웨어가 쓰기(store) 페이지 폴트(RISC-V scause=15)를 일으킵니다.
그게 복제의 방아쇠입니다.

// vm.c — COW 쓰기 폴트: va가 COW 페이지면 새 페이지에 복사하고 쓰기가능으로 되돌린다.
// 1=처리됨(명령 재시도), 0=COW 페이지 아님(호출자가 계속 처리).
int uvm_cow_fault(pagetable_t pt, uint64 va) {
uint64 a = PGROUNDDOWN(va);
pte_t *pte = walk(pt, a, 0);
if (pte == 0 || !(*pte & PTE_V) || !(*pte & PTE_COW))
return 0; // COW 페이지가 아니면 패스
uint64 old = PTE2PA(*pte); // 공유 중인 옛 물리 페이지
char *fresh = (char *)kalloc(); // 새 페이지를 받아
if (fresh == 0)
return 0; // 메모리 부족 → 진짜 폴트로
memcopy(fresh, (void *)old, PGSIZE); // 내용을 복사하고
uint64 flags = (PTE_FLAGS(*pte) & ~PTE_COW) | PTE_W; // COW 표시 제거 + 다시 쓰기 가능
*pte = PA2PTE(fresh) | flags;
sfence_vma(); // 매핑 바꿨으니 TLB flush
kfree((void *)old); // 옛 공유 페이지의 참조 카운트 -1
return 1;
}

순서대로 봅니다. 새 페이지를 kalloc으로 받아서, 옛 공유 페이지 내용을 memcopy로 복사하고, PTE를 새 페이지로 바꾸면서 PTE_COW를 떼고 PTE_W를 복원합니다.
마지막에 kfree(old)로 옛 공유 페이지의 참조 카운트를 깎습니다. 이제 이 주소공간은 옛 페이지를 안 보기 때문입니다.
중간의 sfence_vma()는 TLB를 비우는 것입니다. CPU가 옛 PTE(읽기 전용 COW 매핑)를 TLB에 캐시해 뒀을 수 있어서, 새 PTE(쓰기 가능)가 곧바로 보이도록 하려는 것입니다.

여기서 kfree무조건 반납이 아니라는 점이 빛납니다.
부모와 자식이 공유 중이었다면 옛 페이지의 refcnt는 2였고, kfree는 1로 깎기만 하고 페이지는 살려둡니다. 아직 다른 쪽이 쓰고 있기 때문입니다.
2절의 참조 카운트가 정확히 여기서 일을 합니다.

demand paging보다 먼저 검사해야 한다

페이지 폴트 핸들러에선 이 COW 검사를 demand paging보다 먼저 둬야 합니다.

// proc.c — proc_pagefault: 쓰기 폴트면 COW부터.
// COW 페이지는 이미 매핑돼 있으므로, "빈 페이지 새로 할당"하는 demand 분기보다 우선.
if (store && uvm_cow_fault(p->pagetable, a)) {
uart_puts("[cow] copied a shared page on write at va=");
uart_hex(a); uart_putc('\n');
return 1;
}
if (a >= HEAPBASE && a < p->heap_top) { // --- 힙: 빈 페이지 (demand paging) ---
char *mem = kalloc();
// ...
}

순서가 왜 중요한지 봅니다. COW 페이지는 이미 물리 페이지가 매핑돼 있는 상태입니다(읽기 전용으로).
그런데 폴트 주소가 힙 영역이기도 해서, COW를 먼저 안 보고 demand 분기로 가면 “빈 페이지를 새로 할당”해서 이미 있던 공유 데이터(42 같은 값)를 0으로 덮어써 버립니다.
“이미 매핑된 페이지의 쓰기 폴트”(=COW)와 “매핑이 아예 없는 페이지의 폴트”(=demand)는 다른 사건이고, COW가 더 구체적인 경우라 먼저 걸러야 합니다.

순서를 뒤집으면(demand를 먼저 검사하면) 어떻게 깨지는지 그림으로 보면:

순서를 뒤집어 demand를 먼저 검사하면, 값 42의 읽기전용 공유 페이지가 store 폴트 후 demand로 오인돼 0으로 덮여 42가 사라진다. 그래서 COW를 먼저 검사해야 한다

핵심 교훈: COW의 전부는 이 흐름입니다. fork는 가벼워지고(복사 0회), 복사는 진짜 쓰는 순간, 쓰는 쪽이 그 페이지의 사본을 하나 만드는 것뿐입니다(쓰기 한 번 = 사본 한 장). fork 직후 곧장 exec하는 자식은 공유 페이지에 쓸 일이 없으니 복사가 아예 안 일어납니다. 1절에서 본 낭비가 정확히 사라집니다.

4. fork가 실제로 무엇을 공유하나: 코드·힙·스택

이제 proc_fork가 세 종류의 페이지를 각각 다르게 다루는 걸 봅니다.
코드는 COW 공유, 힙도 COW 공유, 그런데 스택만은 통째 복사입니다. 여기에 우리 커널만의 트레이드오프가 숨어 있습니다.

// proc.c — proc_fork에서 페이지를 다루는 부분
// 코드: COW 공유 / 힙: COW 공유 / 스택: 사적 복사
char *ustack = kalloc();
copybytes(ustack, parent->ustack, PGSIZE); // 스택만 통째 복사
kref_inc(parent->ucode); // 코드는 공유 → refcount++
child->ucode = parent->ucode;
child->ustack = ustack;
child->pagetable = proc_pagetable((uint64)parent->ucode, (uint64)ustack);
// 힙 페이지: COW로 공유(쓰기 시 비로소 복제). 부모가 이미 건드린 힙만 대상.
uvm_cow_share(parent->pagetable, child->pagetable, HEAPBASE, parent->heap_top);
  • 코드 페이지R|X|U라 쓰기 폴트가 날 일이 없습니다. 그래서 (우리 구현에선) PTE_COW 표시도 필요 없이 그냥 같은 물리 페이지를 자식 테이블에 매핑하고 kref_inc만 합니다. 가장 단순한 공유입니다.
  • 힙 페이지R|W라 쓰기 폴트가 날 수 있습니다. 그래서 uvm_cow_share로 양쪽 다 PTE_W를 떼고 PTE_COW를 달아 3절의 복제 흐름에 태웁니다. HEAPBASE부터 parent->heap_top까지, 부모가 실제로 건드린 힙만 대상입니다(아직 안 만든 힙은 자식이 demand로 따로 받음).
  • 스택 페이지만 옛날 방식 그대로 kalloc + copybytes로 통째 복사합니다. 왜 스택만 특별 취급일까요?

우리만의 제약: 스택은 COW로 공유할 수 없다

코드·힙은 COW로 공유하는데, 왜 스택만 통째 복사할까요?

우리 fork가 자식의 트랩 프레임을 유저 스택 위에서 직접 고쳐 쓰기 때문입니다.

// proc.c — 자식의 fork() 반환값을 0으로, 복귀 지점을 ecall 다음 명령으로
uint64 off = (uint64)f - USERSTACK;
struct regframe *cf = (struct regframe *)(ustack + off);
cf->a0 = 0; // 자식의 fork() 반환값 = 0
cf->sepc = f->sepc + 4; // ecall 다음 명령부터(부모는 자식 pid를 받고, 자식은 0을 받음)

fork는 부모에게 자식 pid를, 자식에게 0을 돌려줘야 합니다.
부모와 자식의 차이가 바로 이 두 줄입니다. 자식의 트랩 프레임에서 a0(반환 레지스터)을 0으로, sepc(복귀 주소)를 ecall 다음으로 고치는 것입니다.
그런데 이 트랩 프레임이 유저 스택 위에 얹혀 있습니다(cf = ustack + off).

만약 스택을 COW로 공유한다면, 이 두 줄이 부모의 스택까지 망가뜨립니다. 같은 물리 페이지이기 때문입니다.
자식 fork() 반환값을 0으로 쓰는 순간 부모의 반환값도 0이 돼버립니다.
게다가 이 수정은 fork가 끝나기 전, 자식이 한 번도 안 도는 시점에 일어나서 COW 폴트로 잡아낼 기회조차 없습니다.
그래서 스택만큼은 사적 복사를 유지해야 합니다.

단순함 ↔ 최적화 완전함 사이의 선택

이건 xv6와 다른 설계에서 온 비용입니다.

구분xv6우리 커널
트랩 프레임 위치별도의 커널 페이지(trapframe)유저 스택 위
트램폴린 복잡도더 복잡(전용 매핑 관리)더 단순
스택 COW 가능?가능(프레임이 스택 밖이라)불가(프레임이 스택 안이라)

xv6는 트랩 프레임을 스택 밖 별도 커널 페이지에 둬서 스택도 COW로 공유할 수 있습니다.
우리는 트램폴린을 단순화하려고 프레임을 유저 스택 위에 얹었고, 그 단순함의 대가가 “스택은 COW 못 함”입니다. 즉 이건 COW의 보편적 한계가 아니라 우리 설계 선택의 결과입니다. 리눅스나 xv6는 스택도 COW로 공유합니다.

그래도 손해는 작습니다.
스택은 보통 한 페이지고, 정작 덩치 큰 힙과 코드는 COW로 공유하기 때문입니다.
“설계의 단순함 ↔ 최적화의 완전함” 사이에서 저는 단순함을 택했고, 그 경계가 정확히 어디인지를 아는 것이 핵심입니다.

핵심 교훈: 최적화에는 거의 항상 전제 조건이 숨어 있습니다. COW의 전제는 “공유한 페이지를 누가 쓰면 그걸 폴트로 잡을 수 있어야 한다”인데, 스택은 fork 도중 커널이 폴트 없이 직접 고쳐 쓰니 그 전제가 깨집니다. 최적화를 어디까지 적용할 수 있는지는 그 전제가 어디서 깨지는지로 결정됩니다.

5. 눈으로 확인하기

cowtest는 힙에 42를 쓰고 fork한 뒤, 자식이 99를 씁니다.
COW가 맞다면 자식의 쓰기는 부모에게 안 보여야 합니다.

// user/cowtest.c — 핵심 부분
char *p = (char *)sys_sbrk(4096); // 힙 한 페이지(폴트 시 할당)
p[0] = 42; // 여기서 demand-alloc(쓰기 가능)
long pid = sys_fork(); // 이 시점에 힙 페이지가 COW 공유된다
if (pid == 0) { // 자식
/* p[0]을 읽으면 42 (공유) */
p[0] = 99; // 쓰기 → COW 복제 발생
/* p[0]은 이제 99 (자식만의 사본) */
} else { // 부모
sys_wait();
/* p[0]은 여전히 42 (격리 성공) */
}

실제 출력입니다.

cowtest: heap에 42를 쓰고 fork
[pagefault] demand-allocated a heap page at va=0x10000 ← ① 부모가 42 쓸 때 힙 페이지 할당
child: 공유된 p[0]=42 ← ② 자식이 공유값 42를 봄 (복사 안 함)
[cow] copied a shared page on write at va=0x10000 ← ③ 자식이 쓰는 "그 순간" 복제
-> 99 기록 후 p[0]=99 ← ④ 자식만의 사본에 99
parent: p[0]=42 (42여야 격리 성공) ← ⑤ 부모는 여전히 42

다섯 줄이 COW의 생애를 순서대로 보여줍니다.

  • ① demand-allocated: 부모가 p[0]=42를 쓸 때, 5편의 demand paging이 힙 페이지를 처음 할당합니다. (아직 COW와 무관.)
  • ② child: 42: fork 후 자식이 p[0]읽기만 하면 공유값 42가 그대로 보입니다. 복사가 안 일어났다는 증거입니다.
  • ③ [cow] copied … on write: 자식이 p[0]=99를 쓰는 순간, 바로 그때 복제가 일어납니다. 이 줄이 이 글의 핵심입니다.
  • ④ 자식 p[0]=99: 자식은 이제 자기만의 사본을 봅니다.
  • ⑤ 부모 p[0]=42: 부모는 여전히 42. 자식의 쓰기가 부모에게 격리됐습니다.

가장 중요한 줄은 [cow] copied ... on write 입니다.
복제가 fork 시점(②)이 아니라 자식이 쓰는 시점(③) 에 일어났다는 증거입니다.
②와 ③ 사이의 시간차가 바로 “copy-on-write”라는 이름의 의미입니다. 만약 fork 때 복사했다면 ③ 같은 줄은 영원히 안 떴을 것입니다.

누수 없음: 참조 카운트의 마지막 증명

메모리 누수도 확인했습니다.
hello(fork+exec를 반복)를 돌리고 cowtest까지 돌려도, 빈 페이지 수가 32058에서 한 칸도 안 움직입니다.

hobby> mem
free pages: 32058
hobby> cowtest
... (위 출력) ...
hobby> mem
free pages: 32058 ← 정확히 원래대로 복귀

이게 왜 중요한지 봅니다. COW에는 누수가 끼어들 자리가 많습니다.

  • 공유한 페이지를 양쪽이 다 kfree해서 두 번 반납하면? → free list가 깨집니다.
  • 복제한 새 페이지를 아무도 kfree 안 하면? → 누수입니다.
  • 공유 중인 페이지를 한쪽이 kfree했다고 진짜 반납해버리면? → 다른 쪽이 죽은 페이지를 봅니다.

참조 카운트가 이걸 전부 막습니다.
공유한 페이지든 복제한 페이지든, 마지막 소유자가 떠날 때 정확히 한 번 반납됩니다.
32058 → 32058이라는 숫자가 그 불변식이 지켜졌다는 정량적 증거입니다.

흔한 오해 정정: “공유 페이지를 둘 다 kfree하면 더블 프리 아니냐” 는 걱정은 정확히 참조 카운트가 푸는 문제입니다. 첫 kfreerefcnt 2→1로 깎기만 하고 페이지는 살려둡니다. 두 번째 kfree가 와서야 1→0이 되며 진짜 반납됩니다. kfree는 “반납”이 아니라 “참조 하나 놓기” 라고 읽어야 정확합니다.

6. 정리

Copy-on-Write는 작은 코드로 큰 효과를 내는 OS 최적화입니다.
fork가 “주소공간 통째 복사”에서 “포인터 몇 개 공유 + 카운터 증가”로 가벼워졌고, 정작 복사는 진짜 쓸 때 딱 그 페이지만 한 번 일어납니다.

  • 풀려는 문제: fork는 주소공간을 통째 복사하는데, 대부분의 자식은 곧장 exec해 그 복사본을 버립니다. 복사한 만큼이 낭비입니다.
  • 토대(참조 카운트): 물리 페이지마다 “몇 명이 공유하나”를 셉니다. kalloc=1, kref_inc=+1, kfree=−1이고 0일 때만 진짜 반납. GC가 아니라 명시적 카운터입니다.
  • fork를 COW로: 쓰기 가능 페이지의 PTE_W를 떼고 RSW 비트 PTE_COW(1<<8)를 달아 양쪽이 읽기 전용 공유. 쓰기 폴트(scause=15)가 나면 uvm_cow_fault가 그 페이지만 복제하고 쓰기 권한을 복원합니다. 이 검사는 demand paging보다 먼저 와야 합니다.
  • 우리만의 제약: 코드·힙은 COW 공유하지만 스택은 통째 복사합니다. fork가 트랩 프레임을 유저 스택 위에서 직접 고쳐 쓰는데, 공유하면 부모 스택까지 망가지기 때문입니다. 단순한 트램폴린 설계의 대가입니다.
  • 검증: cowtest에서 자식 99 / 부모 42로 격리가 확인되고, 빈 페이지가 32058에서 고정돼 누수가 없습니다.

돌아보면 이번 작업은 이미 가진 부품의 재조합이었습니다.
페이지 폴트 핸들러(5편), 페이지 테이블 조작(1편), 물리 할당기와 참조 카운트(1편), 전부 있던 걸 엮으니 COW가 됐습니다.
“새 기능은 대개 기존 메커니즘의 새로운 조합” 이라는 걸 다시 느꼈습니다. COW는 페이지 복사 알고리즘을 새로 만든 게 아니라 페이지 폴트·페이지 테이블·참조 카운트라는 기존 메커니즘을 조합한 결과입니다.

코드: github.com/dj258255/kernel-hobby
다음 글: 멀티코어 (예정)

참고 (1차 자료 우선)

프로필 사진
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.