에듀테크 프로젝트 대시보드
Copy-on-Write (쓸 때 복사)
"미리 복사하지 말고, 정말 고쳐 쓸 때가 되면 그때 복사한다" · fork()·컨테이너·스냅샷이 빠르고 가벼운 이유
리눅스에서 프로세스를 새로 만드는 fork()는 부모의 주소 공간을 통째로 복제한 자식을 만든다.
부모가 2 GB짜리 힙을 들고 있었다면 자식도 똑같은 2 GB를 가진다. 순진하게 구현하면 fork()
한 번에 2 GB를 물리 메모리에 그대로 복사해야 한다. 느리고, 메모리도 두 배로 먹는다.
fork() 직후 곧바로 exec()를 불러 완전히 다른 프로그램으로 갈아탄다
(셸이 명령어를 실행할 때가 그렇다). 어렵게 복사한 2 GB는 exec() 순간 전부 버려진다. 100% 낭비다.
Copy-on-Write(COW)는 복사를 게으르게(lazy) 미룬다.
fork()는 페이지 테이블만 만들면 되니 거의 즉시 끝난다. 실제 복사 비용은
나중에 수정되는 페이지에만, 딱 한 번 나눠서 지불된다. 건드리지 않은 페이지는 영원히 공유된다.
부모 프로세스가 4개의 페이지(Code·Data·Heap·Stack)를 물리 프레임에 매핑하고 있다.
fork()를 눌러 자식을 만들고, 부모/자식이 특정 페이지에 쓰게 해보자. 공유 중이던 페이지에 처음 쓸 때만
페이지 폴트 → 복사가 일어난다.
// 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; }
| Eager Copy (미리 복사) | Copy-on-Write | |
|---|---|---|
| fork() 지연 | 주소 공간 크기에 비례 (수십 ms+) | 페이지 테이블 크기에만 비례 (≈즉시) |
| 메모리 | 즉시 2배 | 수정된 페이지만큼만 추가 |
| fork+exec | 복사한 것 전부 버림 | 거의 아무것도 복사 안 함 |
| 쓰기 비용 | 평범한 쓰기 | 첫 쓰기에 페이지 폴트 1회 + 4 KB 복사 |
| 지역성 | — | 쓰기가 몰리면 write amplification 위험 |
libc는 물리 메모리에 한 벌만 올라가고 모든 프로세스가
R/O로 공유한다. 가상 메모리의 페이지 공유가 곧 COW의 사촌.BGSAVE — fork()한 자식이 그 순간의 데이터 스냅샷을 디스크에 저장한다.
부모가 계속 쓰는 키만 COW로 복사되므로 대개 메모리 부담이 작다(쓰기가 폭주하면 커진다).fork() 후 자식이 쓰기를 많이 하면 페이지 폴트가 쏟아져
오히려 느려질 수 있다. Redis가 큰 메모리에서 BGSAVE 중 OOM으로 죽는 사고도 여기서 나온다
(vm.overcommit_memory 튜닝이 권장되는 이유).
Copy-on-Write는 "공유는 공짜, 복사는 미룬다"는 전략이다. 여러 주체가 같은 데이터를 가리키게 해두고,
누군가 처음 고쳐 쓰는 순간에만 그 부분을 몰래 복제해 각자의 사본을 준다. 덕분에 fork(),
스냅샷, 컨테이너가 크기와 상관없이 빠르게 시작된다.