Copy-on-Write — fork 최적화와 메모리 공유 전략
fork()를 하면 프로세스 전체를 복사해야 할까요? Copy-on-Write는 "쓰기가 발생할 때까지 복사를 미루는" 최적화 기법으로, 리눅스의 프로세스 생성을 효율적으로 만드는 핵심입니다.
Copy-on-Write란
Copy-on-Write(COW)는 자원 공유 상태에서 수정이 발생할 때만 실제 복사를 수행하는 전략 입니다.
fork() 없이 복사한다면:
fork() 호출 시 (COW 없이):
부모 프로세스 메모리 (수백 MB) → 전체 복사 → 자식 프로세스 메모리
→ 시간도 오래 걸리고, 메모리도 2배 사용
→ exec()으로 다른 프로그램을 실행하면 복사한 메모리 전부 폐기 (낭비!)
COW의 동작 과정
1단계: fork() 직후
부모와 자식이 같은 물리 페이지를 공유 합니다. 페이지 테이블만 복사하고, 모든 공유 페이지를 읽기 전용 으로 설정합니다.
fork() 직후:
부모 페이지 테이블 물리 메모리 자식 페이지 테이블
┌──────────────┐ ┌──────────┐ ┌──────────────┐
│ 페이지0→프레임A│────→│ 프레임A │←────│ 페이지0→프레임A│
│ (읽기 전용) │ │ 데이터X │ │ (읽기 전용) │
├──────────────┤ ├──────────┤ ├──────────────┤
│ 페이지1→프레임B│────→│ 프레임B │←────│ 페이지1→프레임B│
│ (읽기 전용) │ │ 데이터Y │ │ (읽기 전용) │
└──────────────┘ └──────────┘ └──────────────┘
물리 메모리 추가 사용: 거의 없음 (페이지 테이블만 복사)
2단계: 쓰기 발생 시
어느 한쪽이 페이지를 수정하려 하면 페이지 폴트 발생 → OS가 해당 페이지만 복사합니다.
자식이 페이지 0에 쓰기 시도:
1. 읽기 전용 페이지에 쓰기 → 페이지 폴트 (Protection Fault)
2. OS가 COW 폴트임을 인식
3. 새 프레임 C를 할당하고 프레임 A의 내용을 복사
4. 자식의 페이지 테이블을 프레임 C로 업데이트 (쓰기 가능)
5. 부모의 페이지 0도 쓰기 가능으로 복원 (참조 카운트가 1이면)
부모 페이지 테이블 물리 메모리 자식 페이지 테이블
┌──────────────┐ ┌──────────┐ ┌──────────────┐
│ 페이지0→프레임A│────→│ 프레임A │ │ 페이지0→프레임C│
│ (쓰기 가능) │ │ 데이터X │ │ (쓰기 가능) │
├──────────────┤ ├──────────┤ ├──────────────┤
│ 페이지1→프레임B│────→│ 프레임B │←────│ 페이지1→프레임B│
│ (읽기 전용) │ │ 데이터Y │ │ (읽기 전용) │
└──────────────┘ ├──────────┤ └──────────────┘
│ 프레임C │←──── 자식 전용 복사본
│데이터X(수정)│
└──────────┘
참조 카운트 (Reference Count)
OS는 각 물리 페이지의 참조 카운트 를 관리합니다.
fork() 전: 프레임 A 참조 카운트 = 1
fork() 후: 프레임 A 참조 카운트 = 2 (부모 + 자식)
COW 복사 후: 프레임 A 참조 카운트 = 1 (부모만)
프레임 C 참조 카운트 = 1 (자식만)
참조 카운트가 1이 되면 읽기 전용 표시를 해제하고, 0이 되면 프레임을 해제합니다.
COW가 효율적인 이유
fork() + exec() 패턴
Unix에서 새 프로그램을 실행하는 표준 방식입니다.
pid_t pid = fork();
if (pid == 0) {
// 자식 프로세스: exec()으로 새 프로그램 실행
exec("/bin/ls"); // → 기존 메모리를 모두 새 프로그램으로 교체
}
COW 없이:
- fork() → 부모 메모리 전체 복사 (수백 MB)
- exec() → 복사한 메모리 전부 폐기하고 새 프로그램 로드
- 복사 비용 100% 낭비
COW 있으면:
- fork() → 페이지 테이블만 복사 (수 KB)
- exec() → 페이지 테이블 교체
- 물리 메모리 복사 없음
COW의 다른 활용 사례
mmap에서의 COW
// MAP_PRIVATE: COW 매핑
void *addr = mmap(NULL, size, PROT_READ|PROT_WRITE,
MAP_PRIVATE, fd, 0);
// 파일 내용을 읽을 때는 원본 페이지 캐시 공유
// 수정하면 프로세스 전용 복사본 생성
프로그래밍 언어에서의 COW
- Swift의 String, Array: 값 타입이지만 내부적으로 COW — 복사해도 수정 전까지 같은 버퍼 공유
- C++의 std::string: 과거에는 COW를 사용했지만, C++11부터 멀티스레드 문제로 금지
COW의 한계와 주의점
Redis의 fork() 문제
Redis 서버 (메모리 10GB)
│
└── 백그라운드 저장 시 fork()
→ COW로 자식 프로세스 생성
→ 부모(Redis)가 쓰기 작업을 계속하면
→ 수정된 페이지마다 COW 복사 발생
→ 최악의 경우 메모리 사용량 2배 (20GB)
- Redis의 BGSAVE, BGREWRITEAOF는 fork()를 사용
- 쓰기 부하가 높으면 COW 복사가 대량 발생하여 메모리 급증
- 대응:
vm.overcommit_memory설정, 충분한 여유 메모리 확보
대량 쓰기 시 성능
COW는 읽기 위주 워크로드에서 효율적이지만, fork() 직후 대량 쓰기가 발생하면 오히려 오버헤드가 커질 수 있습니다 (페이지 폴트 + 복사 비용).
핵심 포인트
- fork()가 빠른 이유: COW로 페이지 테이블만 복사 — 물리 메모리 복사 없음
- COW 폴트 과정: 쓰기 시도 → Protection Fault → OS가 페이지 복사 → 쓰기 허용
- fork() + exec()에서 COW의 가치: 어차피 exec()으로 메모리를 교체할 것이므로 복사가 무의미
- Redis와 COW: 실제로 COW의 부작용을 경험하는 대표적인 사례
정리
| 항목 | 설명 |
|---|---|
| COW 핵심 | 쓰기 전까지 물리 메모리 공유, 수정 시에만 복사 |
| fork()에서의 역할 | 페이지 테이블만 복사하여 프로세스 생성 비용 최소화 |
| 트리거 | 읽기 전용 페이지에 쓰기 → Protection Fault |
| 장점 | 메모리 절약, fork() 속도 향상 |
| 주의점 | 쓰기 집중 시 오히려 오버헤드 (Redis BGSAVE 등) |