[Daily morning study] Copy-on-Write (CoW) 메커니즘과 메모리 최적화
#daily morning study
Copy-on-Write란?
Copy-on-Write(이하 CoW)는 여러 주체가 동일한 데이터를 공유할 때, 실제로 데이터를 수정하려는 시점까지 복사를 미루는 메모리 최적화 기법이다.
핵심 아이디어는 간단하다. 데이터를 공유하는 동안에는 물리 메모리를 복사하지 않고 같은 페이지를 가리키게 한다. 어느 한쪽이 쓰기를 시도하면 그 시점에 비로소 해당 페이지를 복사하여 독립적인 사본을 만든다.
프로세스 fork()와 CoW
CoW가 가장 잘 드러나는 곳은 fork() 시스템 콜이다.
fork() 이전에는 부모 프로세스가 1GB의 힙 데이터를 가지고 있다고 가정하자. fork()를 호출하면:
- 커널은 자식 프로세스의 페이지 테이블을 생성하되, 부모와 동일한 물리 페이지 프레임을 가리키게 한다.
- 부모와 자식 모두의 페이지 테이블 엔트리를 read-only로 표시한다.
- 물리 메모리는 복사하지 않는다.
이 상태에서 자식이 특정 페이지에 쓰기를 시도하면:
- CPU가 페이지 폴트(Page Fault)를 발생시킨다.
- 커널의 페이지 폴트 핸들러가 이 상황을 CoW 폴트로 인식한다.
- 해당 페이지의 물리 메모리를 새로 할당하고 내용을 복사한다.
- 자식의 페이지 테이블 엔트리를 새 프레임으로 업데이트하고 read-write로 변경한다.
- 자식의 쓰기 작업을 재실행한다.
fork() 직후:
부모 가상 주소 0x1000 → 물리 프레임 #42 (read-only)
자식 가상 주소 0x1000 → 물리 프레임 #42 (read-only) ← 같은 프레임 공유
자식이 0x1000에 쓰기 시도:
페이지 폴트 발생 → 프레임 #99 할당 → #42 내용 복사 → 자식 0x1000 → #99 (read-write)
부모 0x1000 → #42 (read-write로 복원)
이 덕분에 fork() 후 바로 exec()를 호출하는 경우(셸에서 명령어 실행 등)에는 부모의 주소 공간을 거의 복사하지 않아도 된다.
페이지 참조 카운트
CoW를 구현하려면 물리 페이지마다 참조 카운트(reference count)가 필요하다.
- 프레임을 공유하는 프로세스 수를 추적한다.
- 쓰기 폴트 발생 시 참조 카운트가 1이면 이미 단독 소유이므로 복사 없이 write-protect만 해제한다.
- 참조 카운트가 2 이상이면 실제 복사 후 카운트를 감소시킨다.
리눅스 커널에서는 struct page의 _refcount 필드가 이 역할을 한다.
실제 영향: /proc/self/status로 확인
cat /proc/self/status | grep -E 'VmRSS|VmSize|RssAnon|RssShmem'
| 항목 | 의미 |
|---|---|
VmSize | 가상 주소 공간 크기 |
VmRSS | 실제 물리 메모리 사용량 (Resident Set Size) |
RssAnon | 익명 페이지 중 실제 할당된 양 |
RssShmem | 공유 메모리 매핑 양 |
fork() 직후 자식의 VmRSS는 부모와 거의 같아 보이지만, 실제 물리 메모리는 공유 중이다. 쓰기가 발생할수록 RssAnon이 증가한다.
CoW와 메모리 오버커밋
CoW는 리눅스의 메모리 오버커밋을 가능하게 하는 핵심 기전이다.
오버커밋이란 실제 물리 메모리보다 더 많은 가상 메모리를 프로세스들에게 할당하는 것이다. 대부분의 프로세스는 할당받은 메모리의 일부만 실제로 사용하기 때문에 이것이 가능하다.
/proc/sys/vm/overcommit_memory:
0: 휴리스틱 오버커밋 (기본값)1: 항상 오버커밋 허용2: 엄격히 제한 (RAM + 스왑의 일정 비율 이내)
문제는 오버커밋 상황에서 실제로 메모리가 부족해지면 OOM Killer가 개입하여 프로세스를 종료한다는 점이다.
파일 시스템과 CoW
CoW는 파일 시스템에도 적용된다.
Btrfs, ZFS, APFS 같은 CoW 파일 시스템에서는 파일을 수정할 때 기존 블록을 덮어쓰지 않고 새 블록에 기록한 뒤 메타데이터를 업데이트한다. 이 방식의 장점:
- 스냅샷이 거의 공짜: 같은 블록을 참조하는 메타데이터만 복사하면 되므로 스냅샷 생성이 즉각적이다.
- 쓰기 중 일관성 보장: 기존 데이터는 새 블록 쓰기가 완료되기 전까지 유지된다.
- 원자적 갱신: 블록을 쓰고 나서 메타데이터를 업데이트하므로 중간에 크래시가 나도 이전 상태가 보존된다.
반면 쓰기마다 새 블록을 할당하므로 쓰기 증폭(write amplification) 문제가 발생할 수 있고, 파일이 단편화될 가능성이 높다.
Redis와 RDB 스냅샷
Redis의 RDB 스냅샷도 CoW를 활용한다.
BGSAVE 명령을 내리면:
- Redis 부모 프로세스가
fork()로 자식을 생성한다. - 자식은 현재 메모리 상태의 스냅샷을 디스크에 기록한다.
- 그동안 부모는 계속 클라이언트 요청을 처리한다.
- 부모가 데이터를 수정할 때만 CoW에 의해 해당 페이지가 복사된다.
쓰기 부하가 클 때 BGSAVE를 실행하면 CoW 복사가 많아져 메모리 사용량이 일시적으로 두 배 가까이 늘 수 있다. Redis 운영 시 메모리 여유분을 이 점을 감안해 잡아야 한다.
mmap과 MAP_PRIVATE
mmap()의 MAP_PRIVATE 플래그도 CoW 기반이다.
void *addr = mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
파일을 MAP_PRIVATE으로 매핑하면 파일 내용을 물리 메모리에 즉시 복사하지 않는다. 읽을 때는 파일 페이지를 직접 참조하고, 쓸 때 CoW가 발동하여 해당 페이지만 복사한다. 원본 파일에는 어떤 변경도 반영되지 않는다.
MAP_SHARED는 반대로 여러 프로세스가 같은 파일 캐시 페이지를 공유하며, 쓰기는 파일에도 반영된다.
정리
| 상황 | CoW 동작 |
|---|---|
fork() 직후 | 부모·자식이 물리 페이지 공유, 쓰기 시 복사 |
| CoW 파일 시스템 쓰기 | 새 블록에 기록 후 메타데이터 교체 |
Redis BGSAVE | 자식이 스냅샷 기록, 부모 쓰기 시 페이지 복사 |
mmap(MAP_PRIVATE) | 파일 페이지 공유, 쓰기 시 익명 페이지로 복사 |
CoW의 핵심은 “복사는 필요한 순간까지 미룬다”는 지연 평가(lazy evaluation)다. 이 덕분에 fork()가 빠르고, 스냅샷이 가볍고, 메모리 오버커밋이 실용적으로 동작한다.