Unix/Linux fork 시스템 콜과 프로세스 생성 방식

Unix/Linux fork 시스템 콜의 프로세스 복제, Copy-on-Write, exec 조합, 자식 프로세스 관리와 대안을 정리한다.

2026-08-14 · 최초 발행 2026-01-12

현재 실행 상태에서 자식 프로세스를 만드는 fork

fork()는 현재 프로세스를 바탕으로 자식 프로세스를 생성하는 Unix/Linux 시스템 콜이다. 호출이 끝나면 부모와 자식이라는 두 프로세스가 존재하며, 둘은 fork() 다음 줄부터 각각 실행을 이어 간다.

프로세스 기반 병렬 처리와 명령 실행이 대표적인 사용처다. 부모 프로세스의 데이터 영역, 힙, 스택, 파일 디스크립터, 환경 변수는 자식 생성의 기반이 된다. 프로그램 코드인 텍스트 세그먼트와 열린 파일은 공유되며, 열린 파일의 오프셋도 공유한다. 반면 PID, PPID, 프로그램 카운터는 각 프로세스의 상태로 분리된다.

fork()부모 프로세스자식 프로세스생성부모 PID: 1000자식 PID: 1001PPID: 1000독립 실행

커널은 fork() 호출 시 새 프로세스를 만들고 PCB(Process Control Block)를 복사한다. 이후 메모리 공간은 Copy-on-Write 방식으로 다루며, 부모와 자식 모두 fork() 이후의 코드를 실행한다.

부모와 자식은 반환값으로 자신이 어느 쪽인지 판단한다. 부모에게는 자식 PID인 0보다 큰 값이, 자식에게는 0이 돌아온다. 실패한 경우에는 -1이 반환되고 errno가 설정된다.

#include <unistd.h>
#include <stdio.h>

int main() {
    pid_t pid = fork();

    if (pid < 0) {
        // fork 실패
        perror("fork failed");
        return 1;
    } else if (pid == 0) {
        // 자식 프로세스
        printf("Child: PID = %d, PPID = %d\n", getpid(), getppid());
    } else {
        // 부모 프로세스
        printf("Parent: PID = %d, Child PID = %d\n", getpid(), pid);
    }

    return 0;
}

Copy-on-Write가 메모리 복사를 미루는 방식

전통적인 방식이라면 fork() 시점에 전체 메모리를 즉시 복사해야 하므로 시간과 메모리가 낭비된다. Copy-on-Write(COW)는 처음에는 페이지 테이블만 복사하고 물리 메모리를 공유한다. 어느 한쪽이 쓰기를 시도한 페이지에 대해서만 별도 복사가 일어난다.

이 방식은 빠른 fork()와 메모리 절약에 유리하다. 자식이 곧바로 exec를 호출해 프로세스 이미지를 바꾸는 경우가 많다는 점에도 맞는다.

fork()페이지 테이블복사물리 메모리공유읽기공유 메모리사용쓰기페이지 복사(COW)

반환값으로 분기하는 기본 패턴

fork() 뒤의 출력문은 부모와 자식이 각각 실행하므로 두 번 출력된다.

#include <unistd.h>
#include <stdio.h>

int main() {
    printf("Before fork\n");

    fork();

    printf("After fork\n");  // 두 번 출력 (부모, 자식)

    return 0;
}

출력은 다음과 같다.

Before fork
After fork
After fork

부모가 자식 종료를 기다려야 할 때는 wait()를 사용한다.

#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        // 자식 프로세스
        printf("Child: My PID = %d, Parent PID = %d\n",
               getpid(), getppid());
        sleep(2);  // 2초 대기
        printf("Child exiting\n");
    } else {
        // 부모 프로세스
        printf("Parent: My PID = %d, Child PID = %d\n",
               getpid(), pid);
        wait(NULL);  // 자식 종료 대기
        printf("Parent: Child finished\n");
    }

    return 0;
}

여러 번의 fork()는 각 프로세스에서 다시 호출될 수 있다. n번 fork하면 2^n개 프로세스가 생성된다.

#include <unistd.h>
#include <stdio.h>

int main() {
    fork();   // 1번 fork: 2개 프로세스
    fork();   // 2번 fork: 4개 프로세스
    fork();   // 3번 fork: 8개 프로세스

    printf("PID = %d\n", getpid());

    return 0;
}
fork()fork()fork()초기1개2개4개8개

exec로 자식 프로세스의 프로그램을 교체할 때

exec 계열 시스템 콜은 현재 프로세스 이미지를 새 프로그램으로 교체한다. execl, execlp, execle, execv, execvp, execve가 이 계열에 속한다. PID는 유지되며, 성공하면 새 프로그램이 실행되므로 호출 지점으로 반환되지 않는다.

execl("/bin/ls", "ls", "-l", NULL);
// 실행되면 ls 프로그램으로 교체
// 이 줄은 실행되지 않음 (성공 시)

일반적인 패턴은 자식이 새 프로그램을 실행하고, 부모가 그 종료를 기다리는 구조다.

#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        // 자식: 새 프로그램 실행
        execl("/bin/ls", "ls", "-l", NULL);
        // execl 실패 시만 도달
        perror("execl failed");
        return 1;
    } else {
        // 부모: 자식 종료 대기
        wait(NULL);
        printf("Child finished\n");
    }

    return 0;
}

셸에서 ls -l 같은 명령을 입력하면 셸은 fork()로 자식을 만들고, 자식에서 execl("/bin/ls", "ls", "-l", NULL)을 실행한다. 부모 셸은 wait()로 대기한 뒤 명령이 끝나면 다음 프롬프트를 표시한다.

system() 함수도 내부적으로 fork + exec + wait를 수행한다.

system("ls -l");
// 내부적으로 fork + exec + wait 수행
(1) fork()(2) exec()(3) exit()(4) wait()Shell(부모)자식프로세스ls 프로그램실행종료Shell다음 프롬프트

상속되는 상태와 분리되는 상태

자식의 PPID는 부모의 PID가 되고, 이 관계는 프로세스 트리를 만든다. 관련 프로세스는 프로세스 그룹으로 묶일 수 있으며, 그룹은 신호 전달 단위가 된다.

열린 파일 디스크립터는 fork() 시점에 복사된다. 부모와 자식은 같은 파일 테이블 엔트리를 가리키므로 파일 오프셋을 공유한다. 한쪽이 읽으면 오프셋도 이동한다.

int fd = open("file.txt", O_RDONLY);
fork();
// 부모와 자식 모두 fd 사용 가능
// 한쪽에서 읽으면 파일 오프셋 이동

부모의 신호 핸들러는 자식에게 복사되어 같은 신호 처리가 적용된다. 다만 자식은 pending 신호를 상속하지 않는다.

종료 상태를 회수하지 못했을 때

Zombie 프로세스는 자식이 종료됐지만 부모가 wait()하지 않아 PCB가 남아 있는 상태다. 자원은 회수됐지만 PID는 점유한다. ps 결과의 STAT 열에서 Z로 확인할 수 있다.

ps aux | grep Z
# STAT 열에 Z 표시

부모가 wait()를 호출하거나 SIGCHLD 핸들러를 설정해 정리할 수 있다.

// 부모에서 wait() 호출
wait(NULL);

// 또는 SIGCHLD 핸들러
signal(SIGCHLD, SIG_IGN);  // 자동 정리

Orphan 프로세스는 부모가 먼저 종료해 자식이 고아가 된 경우다. init(PID 1) 프로세스가 이를 입양하고 주기적으로 wait()를 호출해 Zombie 상태를 방지한다.

Fork Bomb은 무한 fork로 시스템 자원을 고갈시키는 DoS 공격이다. 아래 명령은 절대 실행하면 안 된다.

:(){ :|:& };:
# Bash fork bomb

프로세스 수 제한은 다음처럼 설정할 수 있다.

ulimit -u 100  # 프로세스 수 제한

프로세스 생성 방식의 선택지

vfork는 부모와 자식이 메모리를 공유하며 COW를 사용하지 않는다. 자식이 먼저 실행되고 부모는 블록되며, 자식은 exec 또는 _exit 호출 전까지 이 상태에 있다. 잘못 사용하면 부모 메모리를 손상할 수 있어 현재는 fork + COW보다 거의 사용되지 않는다.

Linux의 clone은 공유할 자원을 선택할 수 있는 더 세밀한 인터페이스이며 쓰레드 구현에도 사용된다.

clone(fn, stack, CLONE_VM | CLONE_FS, arg);
// CLONE_VM: 메모리 공유
// CLONE_FS: 파일 시스템 정보 공유

posix_spawn은 POSIX 표준으로, fork + exec를 합친 기능을 제공한다. 특히 큰 프로세스에서 효율적이다.

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

pthread는 프로세스가 아니라 쓰레드를 만든다. fork는 메모리를 독립적으로 다뤄 격리 측면에서 안정성이 높지만 IPC가 필요하다. pthread는 메모리를 공유해 생성 비용과 Context Switch 비용이 낮고 직접 메모리에 접근할 수 있지만, 한 쓰레드의 오류가 다른 쓰레드에 영향을 줄 수 있다.

구분 fork (프로세스) pthread (쓰레드)
메모리 독립 (복사) 공유
생성 비용 높음 낮음
Context Switch 느림 빠름
통신 IPC 필요 직접 메모리 접근
안정성 높음 (격리) 낮음 (한 쓰레드 오류)

서버와 작업 처리에서의 fork

프로세스 기반 서버는 클라이언트 연결을 받은 뒤 fork()로 자식을 만들고, 자식이 해당 클라이언트를 처리하게 할 수 있다. 클라이언트별 처리가 분리되므로 한 클라이언트의 오류가 서버 전체에 영향을 주지 않는다는 장점이 있다. 반면 프로세스 생성 오버헤드가 있어 멀티쓰레드나 비동기 I/O가 더 효율적일 수 있다.

while (1) {
    client_sock = accept(server_sock, ...);

    pid_t pid = fork();
    if (pid == 0) {
        // 자식: 클라이언트 처리
        close(server_sock);
        handle_client(client_sock);
        exit(0);
    } else {
        // 부모: 다음 요청 대기
        close(client_sock);
    }
}

병렬 작업은 작업을 자식 프로세스에 나누고 부모가 모든 자식의 종료를 기다리는 방식으로 구성할 수 있다.

int num_workers = 4;
for (int i = 0; i < num_workers; i++) {
    if (fork() == 0) {
        // 자식: 작업 일부 처리
        process_chunk(i);
        exit(0);
    }
}

// 부모: 모든 자식 대기
for (int i = 0; i < num_workers; i++) {
    wait(NULL);
}

백그라운드 서비스인 데몬을 만들 때도 부모를 종료하고 자식이 새 세션을 만드는 흐름을 사용할 수 있다.

pid_t pid = fork();
if (pid > 0) {
    exit(0);  // 부모 종료
}
// 자식이 데몬으로 동작
setsid();  // 새 세션 생성
// ... 데몬 로직
fork프로세스UnixLinux시스템 콜