← System Programming DUJINLABS.COM

Process · Linux userspace / kernel ABI

fork와 copy-on-write

fork가 주소 공간을 즉시 복사하지 않고 page table과 객체 참조를 복제한 뒤 write fault에서 실제 page를 나누는 과정을 다룹니다.

Series
06 / 37
Build
cc -std=c17 -Wall -Wextra -O2 fork_cow.c -o fork_cow
Run
./fork_cow
Kernel
Linux 6.18.37 LTS

큰 process에서 fork 직후 메모리가 두 배가 되지 않는 이유는 무엇인가?

fork는 새 task와 mm 관련 구조를 만들지만 anonymous page 내용을 전부 복사하지 않는다. parent와 child PTE를 write-protect하고 같은 physical page를 참조하게 한 뒤 어느 쪽이 쓰면 page fault에서 private copy를 만든다.

copy-on-write는 공짜가 아니다. page table 복제, TLB shootdown, 이후 write fault와 page copy 비용이 있으며 multithread process에서는 fork 후 child가 호출할 수 있는 함수도 제한된다.

구조 그림

그림 1. fork 직후와 write fault 뒤의 page 참조

Parent page table

  • VA 0x4000
  • PTE: read-only + COW
  • mapcount reference

Before write

  • Physical page A
  • content=1
  • parent + child 공유

Child page table

  • VA 0x4000
  • PTE: read-only + COW
  • write fault 발생

After child write

  • Parent → page A
  • Child → new page B
  • content B=2

fork 직후 parent와 child PTE는 같은 physical page를 read-only/COW로 가리킨다. child의 첫 쓰기에서만 새 page가 생긴다.

호출 흐름

그림 2. 사용자 코드에서 관찰 가능한 결과까지
fork() 현재 thread가 호출
copy_process task와 resource 복제
dup_mm VMA/page table
write fault private page copy
wait child 회수

fork 시점의 복제와 child가 page를 쓸 때의 복제를 구분한다. RSS 합계만 보면 shared page를 중복 계산해 실제 물리 사용량을 오해할 수 있다.

그림 3. 커널 내부에서 지나가는 주요 지점
kernel_clone clone_args 해석
copy_process task_struct
copy_mm CLONE_VM 분기
copy_page_range COW PTE
handle_mm_fault wp fault

함수 이름을 외우기 위한 그림이 아니다. 반환값, 파일 디스크립터, 메모리 매핑, 대기 큐 가운데 무엇이 다음 단계로 전달되는지 확인한다.

Linux 6.18.37 LTS 소스 위치

glibc 함수에서 멈추지 않고 syscall 구현과 커널 객체가 만나는 파일까지 내려간다. 링크는 동일한 태그의 원본 파일을 가리킨다.

파일함수·구조체여기서 볼 것
kernel/fork.c kernel_clone(), copy_process() 새 task와 공유/복제 flag 결정
kernel/fork.c copy_mm(), dup_mm() CLONE_VM 여부와 mm_struct 수명
mm/memory.c copy_page_range(), do_wp_page() COW page table과 write-protect fault

실행 예제 원본

아래 코드는 설명을 위해 중간 줄을 생략한 의사 코드가 아니다. 파일로 빌드해 실행할 수 있는 최소 예제다.

빌드cc -std=c17 -Wall -Wextra -O2 fork_cow.c -o fork_cow
01#define _DEFAULT_SOURCE
02#include <stdio.h>
03#include <stdlib.h>
04#include <sys/mman.h>
05#include <sys/wait.h>
06#include <unistd.h>
07
08int main(void)
09{
10    size_t length = 16 * 1024 * 1024;
11    unsigned char *area = mmap(NULL, length, PROT_READ | PROT_WRITE,
12                               MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
13    if (area == MAP_FAILED)
14        return 1;
15    for (size_t i = 0; i < length; i += 4096)
16        area[i] = 1;
17
18    pid_t pid = fork();
19    if (pid < 0)
20        return 1;
21    if (pid == 0) {
22        for (size_t i = 0; i < length; i += 4096)
23            area[i]++;
24        _exit(area[0] == 2 ? 0 : 2);
25    }
26
27    int status;
28    if (waitpid(pid, &status, 0) < 0)
29        return 1;
30    printf("parent=%u child_status=%d\n", area[0], WEXITSTATUS(status));
31    return munmap(area, length) != 0;
32}

코드 조각별 설명

실제 코드 12행MAP_PRIVATE | MAP_ANONYMOUS

파일과 무관한 private mapping을 만든다. fork 뒤 처음에는 page를 공유하지만 한 process의 쓰기가 다른 process에 보이지 않는다.

실제 코드 15행i += 4096

예제는 일반적인 4 KiB page마다 한 byte를 써 physical page를 미리 fault-in한다. 실제 page size는 sysconf(_SC_PAGESIZE)로 구해야 한다.

실제 코드 18행pid_t pid = fork

parent에는 child PID, child에는 0이 돌아오며 두 실행 흐름 모두 같은 다음 instruction에서 시작한다.

실제 코드 23행area[i]++;

child의 첫 쓰기마다 write-protect fault가 발생해 private physical page가 만들어지는 관찰 지점이다.

실제 코드 24행_exit(area[0]

fork 후 child에서 stdio buffer를 다시 flush하지 않고 syscall 수준 종료를 사용한다.

세부 동작

01

복제되는 것은 page 내용보다 mapping 규칙이다

VMA는 address range, protection, file/anonymous backing을 설명한다. fork는 VMA tree와 page table을 복제하고 writable private mapping PTE를 양쪽에서 read-only처럼 다뤄 write fault를 유도한다.

MAP_SHARED mapping과 실제 shared memory는 COW 대상이 아니며 쓰기가 같은 backing page에 보인다.

02

multithread + fork에는 좁은 안전 구간이 있다

fork를 호출한 thread만 child에 남는다. 다른 thread가 잡고 있던 userspace mutex는 잠긴 상태로 복제될 수 있지만 풀어 줄 thread는 존재하지 않는다.

exec 전 child에서는 async-signal-safe 함수만 호출하고, 복잡한 준비는 parent에서 끝내거나 posix_spawn을 고려한다.

03

메모리 사용량은 PSS로 확인한다

parent와 child RSS를 단순 합산하면 공유 중인 COW page를 두 번 센다. /proc/PID/smaps_rollup의 Pss, Private_Dirty, Shared_Dirty를 fork 직후와 child write 뒤에 비교한다.

transparent huge page가 켜져 있으면 write fault 한 번의 분할·복사 단위가 관찰 결과에 영향을 준다.

객체와 수명

대상언제 생기고 없어지는가확인할 값
task_structcopy_process에서 생기고 release_task에서 최종 해제된다pid, state, files/mm pointer
mm_structfork에서 복제되고 마지막 mm user가 빠질 때 해제된다VMA, page table, mm_users
COW pagefork 전 page를 공유하다 write fault에서 private page로 나뉜다mapcount, PSS, dirty

실패 조건과 오해하기 쉬운 부분

겉으로 보이는 현상실제 원인 후보확인 방법
fork가 ENOMEM/EAGAINmemory commit, pid/cgroup/user process limitulimit -u, pids.current, overcommit 설정
child에서 deadlock사라진 thread가 잡은 userspace lockpthread_atfork와 child call list
fork 뒤 latency 급증page table 복제와 COW faultperf stat page-faults, smaps_rollup

직접 확인

  1. child write loop 앞에서 sleep을 넣고 fork 직후 parent/child smaps_rollup의 Pss를 비교한다.
  2. write loop를 제거한 실행과 유지한 실행의 minor-faults를 perf stat으로 비교한다.
  3. MAP_PRIVATE를 MAP_SHARED로 바꾸고 parent에서 area[0] 값이 어떻게 달라지는지 확인한다.
실행./fork_cow
추적strace -f -e trace=clone,wait4,mmap,munmap ./fork_cow

원문