[Daily morning study] fork()와 exec() 시스템 콜 - 프로세스 생성 메커니즘

#daily morning study

Image


프로세스 생성의 두 축: fork()와 exec()

리눅스에서 새 프로세스를 만드는 방법은 크게 두 가지 시스템 콜이 담당한다.

  • fork() — 현재 프로세스를 복제해서 자식 프로세스를 만든다.
  • exec() — 현재 프로세스의 이미지를 새 프로그램으로 교체한다.

이 두 콜을 조합하면 “새 프로그램을 실행하는 새 프로세스”를 만들 수 있다. 쉘이 명령어를 실행할 때 바로 이 패턴을 쓴다.


fork() 동작 방식

#include <unistd.h>
pid_t pid = fork();

if (pid == 0) {
    // 자식 프로세스: fork() 반환값이 0
} else if (pid > 0) {
    // 부모 프로세스: fork() 반환값이 자식의 PID
} else {
    // 에러: -1 반환
}

fork()를 호출하면 커널은 현재 프로세스의 복사본을 만든다. 복사 범위는 다음과 같다.

복사되는 것복사되지 않는 것
가상 주소 공간 (코드/데이터/스택/힙)파일 락
열려 있는 파일 디스크립터자식의 pending 시그널
시그널 핸들러 설정비동기 I/O 작업
환경 변수, 프로세스 그룹스레드 (주 스레드만 복사)

fork() 이후 부모와 자식은 같은 코드를 공유하지만, 별도의 가상 주소 공간을 갖는다. 한쪽이 변수를 수정해도 다른 쪽에는 영향이 없다.


Copy-on-Write (COW)

단순 복사라면 수십~수백 MB의 메모리를 모두 복사해야 해서 매우 비싸다. 실제로는 Copy-on-Write 기법을 사용한다.

  • fork() 직후 부모와 자식은 같은 물리 페이지를 공유한다.
  • 페이지는 읽기 전용으로 표시된다.
  • 어느 한쪽이 해당 페이지에 쓰기를 시도하면 그때 복사가 일어난다.
fork() 직후:
  부모 VA → 물리 페이지 P (읽기 전용)
  자식 VA → 물리 페이지 P (읽기 전용)

자식이 쓰기 시도:
  → Page Fault 발생
  → 커널이 P를 복사해서 P' 생성
  자식 VA → 물리 페이지 P' (읽기/쓰기)
  부모 VA → 물리 페이지 P (읽기 전용 → 읽기/쓰기로 복원)

exec()를 바로 호출하면 어차피 메모리를 교체하므로 COW 덕분에 불필요한 복사가 전혀 일어나지 않는다.


exec() 동작 방식

exec() 계열 함수는 하나의 시스템 콜(execve)을 감싸는 라이브러리 함수들이다.

#include <unistd.h>

// 가장 기본적인 형태
execve("/bin/ls", argv, envp);

// 편의 래퍼들
execl("/bin/ls", "ls", "-l", NULL);
execvp("ls", argv);   // PATH에서 탐색

exec()가 호출되면:

  1. 커널이 실행 파일(ELF 등)을 읽는다.
  2. 현재 프로세스의 가상 주소 공간을 새 프로그램으로 완전히 교체한다.
  3. 코드, 데이터, 스택, 힙이 모두 새 프로그램의 것으로 대체된다.
  4. PID는 변하지 않는다.
  5. 성공하면 반환하지 않는다. 실패 시에만 -1을 반환한다.

exec() 이후에도 유지되는 것:

  • PID, PPID
  • 열려 있는 파일 디스크립터 (단, FD_CLOEXEC 플래그가 설정된 FD는 닫힘)
  • 환경 변수 (새로 지정하지 않으면)
  • 시그널 마스크

fork-exec 패턴: 쉘의 동작 원리

쉘이 ls -l을 실행할 때 내부적으로 이렇게 동작한다.

// 쉘 내부 의사 코드
pid_t pid = fork();

if (pid == 0) {
    // 자식 프로세스
    // 입출력 리다이렉션이 필요하면 여기서 FD를 조작
    execvp("ls", args);
    // exec 성공 시 여기까지 오지 않음
    exit(1); // exec 실패
} else {
    // 부모(쉘)는 자식을 기다림
    waitpid(pid, &status, 0);
}

fork와 exec 사이의 짧은 구간에서 자식 프로세스는 자신의 환경을 설정할 수 있다. 파일 디스크립터를 열거나 닫고, 시그널 핸들러를 초기화하고, 리다이렉션을 위해 dup2()를 호출하는 등의 작업이 여기서 일어난다.


vfork()

vfork()는 fork()의 최적화 버전이다.

  • 자식이 exec() 또는 exit()를 호출할 때까지 부모가 블록된다.
  • 자식은 부모의 주소 공간을 그대로 공유한다 (복사 없음).

주소 공간을 공유하므로 자식이 exec() 전에 부모의 변수를 건드리면 매우 위험하다. 현대 리눅스에서는 fork() + COW가 효율적이어서 vfork()의 실제 사용 사례는 드물다.


posix_spawn()

fork() + exec() 패턴의 POSIX 표준 대안이다.

#include <spawn.h>

posix_spawn(&pid, "/bin/ls", NULL, NULL, argv, envp);

임베디드 환경처럼 fork()가 비싸거나 불가능한 곳에서 사용한다. 내부적으로는 fork() + exec()를 쓰거나, 플랫폼이 제공하는 더 효율적인 메커니즘을 사용할 수 있다.


정리

항목fork()exec()
역할프로세스 복제프로세스 이미지 교체
PID새 PID 생성변경 없음
메모리COW로 복사 최적화완전히 교체
반환부모: 자식 PID, 자식: 0성공 시 반환 없음
주 용도자식 프로세스 생성새 프로그램 실행

프로세스 생성에서 fork()와 exec()를 분리한 이유는 그 사이에 자식 프로세스가 자신의 환경(FD, 시그널 등)을 자유롭게 설정할 수 있도록 하기 위해서다. 이 설계는 파이프, 리다이렉션, 데몬화 등을 매우 자연스럽게 구현할 수 있게 해준다.