안동민 개발노트

본문 시작

프로세스 생성과 종료

fork·exec·wait의 역할과 부모-자식 프로세스의 생명주기를 코드로 확인하고 좀비와 고아 프로세스를 구분합니다.

운영체제에서 새로운 프로세스는 어떻게 만들어질까요?

대부분의 OS에서 프로세스는 기존의 다른 프로세스가 생성합니다.

부팅 시 커널이 만드는 첫 프로세스(PID 1)를 제외하면, 모든 프로세스는 부모-자식 관계를 가집니다.

Linux 사용자 공간에서는 PID namespace의 init(PID 1)을 기준으로 프로세스 트리를 살펴볼 수 있습니다. 커널 스레드 등까지 모두 같은 사용자 공간 계보라고 설명하지는 않습니다.

pstree 명령어로 이 트리를 직접 볼 수 있습니다.

프로세스 트리 확인
pstree -p
# systemd(1)─┬─sshd(1234)───sshd(5678)───bash(9012)───vim(3456)
#             ├─nginx(2345)─┬─nginx(2346)
#             │             └─nginx(2347)
#             └─cron(4567)

위 출력은 systemd를 사용하는 시스템의 사용자 공간 계보를 설명하는 예시입니다. 실제 PID와 init 프로그램은 환경에 따라 달라집니다.

sshd → bash → vim으로 이어지는 체인은 SSH로 접속한 사용자가 셸에서 vim을 실행한 것을 보여줍니다.


fork()와 exec()

프로세스 생성과 종료에서는 프로세스 상태, 커널 자료구조, 부모/자식 정리 지점을 확인합니다.

Unix/Linux의 대표적인 패턴은 fork()로 생성하고 exec 계열 함수로 프로그램을 교체하는 것입니다. exec()라는 단일 함수 대신 예제의 execlp()처럼 구체적인 API를 사용합니다.

이 두 동작의 조합은 Unix의 가장 근본적인 디자인 패턴이며, 셸에서 명령어를 실행하는 것부터 서버가 클라이언트 요청을 처리하는 것까지 거의 모든 곳에서 사용됩니다.

fork() — 프로세스 복제

fork()는 현재 프로세스를 복제합니다.

호출한 순간, 부모 프로세스의 거의 모든 것 — 메모리 공간, 파일 디스크립터, 환경 변수, 시그널 핸들러 — 을 복사한 새로운 자식 프로세스가 만들어집니다.

일반적인 private 메모리의 변수는 부모와 자식이 각각 수정합니다. MAP_SHARED 매핑은 예외이며, 복제된 파일 디스크립터도 같은 open file description을 참조하여 파일 오프셋 등을 공유할 수 있습니다.

fork()의 가장 독특한 점은 반환값입니다.

fork()는 한 번 호출되지만 두 번 반환됩니다.

부모 프로세스에게는 자식의 PID(양의 정수)를 반환하고, 자식 프로세스에게는 0을 반환합니다.

실패하면 -1을 반환하고 자식은 생성되지 않습니다.

fork()의 기본 동작
#include <stdio.h>
#include <unistd.h>

int main() {
    printf("Before fork: PID=%d\n", getpid());

    pid_t pid = fork();
    /* 이 시점부터 두 개의 프로세스가 각각 실행됨 */

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

    return 0;
}

fork() 이전의 printf 호출 자체는 한 번입니다. 다만 표준 출력을 파일로 리다이렉션하면 아직 flush되지 않은 버퍼가 복제되어 같은 문장이 두 번 나올 수 있습니다. 한 번만 출력해야 한다면 fork() 전에 fflush(stdout)의 성공을 확인합니다.

fork() 이후의 코드는 부모와 자식이 각각 실행하므로 두 번 출력됩니다.

pid 값의 차이로 서로 다른 분기를 실행하며 출력 순서는 보장되지 않습니다. 이 첫 예제는 부모가 자식을 기다리지 않으므로 자식이 getppid()를 읽기 전에 부모가 종료되어 재입양될 수도 있습니다.

메모리 전체를 복사한다고 하면 성능 우려가 커 보이지만, 실제로는 COW(Copy-On-Write) 기법 덕분에 효율적입니다.

fork() 직후에는 부모와 자식이 같은 물리 메모리 페이지를 공유하며, 둘 중 하나가 페이지를 수정하려 할 때 그 페이지만 실제로 복사합니다.

자식이 곧 exec로 교체하면 기존 private 매핑 대부분을 복사하지 않고 해제할 수 있습니다. 부모의 매핑은 유지되며, 교체 전에 자식이 수행한 작업에서는 COW 복사가 생길 수 있습니다.

COW가 없었다면 fork()는 매우 비용이 큰 연산이었을 것입니다.

exec() — 프로그램 교체

exec()는 현재 프로세스의 코드, 데이터, 스택 등 메모리 내용을 새 프로그램으로 완전히 교체합니다.

PID는 유지되고, 열린 파일 디스크립터는 (기본적으로) 상속되지만, 프로그램 코드와 메모리 레이아웃은 완전히 새 것으로 바뀝니다.

exec() 호출이 성공하면 돌아오지 않습니다.

기존 프로그램의 코드 자체가 사라졌으므로, 돌아올 곳이 없습니다.

exec() 다음 줄의 코드가 실행되었다면, 그것은 exec()가 실패했다는 뜻입니다.

fork()와 exec()의 조합
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <errno.h>

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

    if (pid < 0) {
        perror("fork failed");
        exit(EXIT_FAILURE);
    }

    if (pid == 0) {
        /* 자식 프로세스: ls -la 실행 */
        printf("Child (PID=%d): executing ls...\n", getpid());
        execlp("ls", "ls", "-la", NULL);
        /* exec 성공 시 여기까지 오지 않음 */
        perror("exec failed");
        _exit(127);
    } else {
        /* 부모 프로세스: 자식 종료 대기 */
        int status;
        pid_t waited;
        do {
            waited = waitpid(pid, &status, 0);
        } while (waited == -1 && errno == EINTR);
        if (waited == -1) {
            perror("waitpid failed");
            return EXIT_FAILURE;
        }

        if (WIFEXITED(status)) {
            printf("Child exited normally, status=%d\n", WEXITSTATUS(status));
        } else if (WIFSIGNALED(status)) {
            printf("Child killed by signal %d\n", WTERMSIG(status));
        }
    }
    return 0;
}

waitpid()의 성공을 확인한 뒤에만 종료 상태 매크로를 사용합니다. 자식의 printf는 출력 버퍼에 남은 채 exec가 성공하면 표시되지 않을 수 있습니다. 이 코드에서 ls 출력 이전의 안내 문장까지 항상 나타난다고 보장하지 않습니다.

셸에서 외부 foreground 명령을 실행할 때 이 패턴이 대표적으로 나타납니다.

cd, export 같은 shell builtin은 보통 같은 셸 프로세스 안에서 처리되고, 파이프라인이나 백그라운드 작업은 분기가 더 복잡해집니다.

bash에서 ls -la를 입력하면:

  1. bash가 fork()로 자식을 만듭니다.
  2. 자식이 exec("ls", ...)로 ls 프로그램을 실행합니다.
  3. ls가 출력을 마치고 exit(0)으로 종료합니다.
  4. bash(부모)가 wait()로 자식의 종료 상태를 수거합니다.
  5. bash가 다음 프롬프트를 표시합니다.

fork()와 exec()를 분리한 것이 Unix의 핵심 설계 결정입니다.

이 분리 덕분에 fork() 후 exec() 전에 자식 프로세스의 환경을 조작할 수 있습니다.

파일 디스크립터를 바꿔서 입출력을 리다이렉션하거나, 환경 변수를 변경하거나, 시그널 마스크 등을 조정할 수 있습니다. 직접 등록한 시그널 핸들러는 성공한 exec 뒤 기본 처리로 재설정되므로 그대로 유지되지 않습니다.

셸의 파이프(|), 리다이렉션(>, <), 백그라운드 실행(&)이 모두 이 메커니즘으로 구현됩니다.

Windows의 프로세스 생성

Windows는 fork()/exec() 대신 CreateProcess() 하나로 프로세스를 생성합니다.

새 프로세스를 만들면서 동시에 프로그램을 지정합니다.

CreateProcess는 환경 블록·작업 디렉터리·시작 속성·핸들 상속 등을 인자로 지정합니다. 자식에서 fork와 exec 사이 코드를 실행하는 방식과는 인터페이스가 다릅니다.


Python에서의 프로세스 생성

process_example.py
import os
import multiprocessing

def worker(name):
    print(f"Worker {name}, PID: {os.getpid()}, Parent: {os.getppid()}")
    # 각 워커는 독립적인 프로세스 → 별도의 메모리 공간
    result = sum(range(1000000))
    print(f"Worker {name} done, result: {result}")

if __name__ == "__main__":
    print(f"Main process PID: {os.getpid()}")

    processes = []
    for i in range(4):
        p = multiprocessing.Process(target=worker, args=(i,))
        processes.append(p)
        p.start()

    for p in processes:
        p.join()  # 자식 프로세스 종료 대기 (= C의 waitpid)

    print("All workers completed")

Python의 multiprocessing 시작 방식은 플랫폼과 Python 버전에 따라 다릅니다.

Windows는 spawn을 사용하고, macOS는 Python 3.8부터 spawn이 기본이며, Linux 등 이를 지원하는 POSIX 플랫폼은 Python 3.14부터 forkserver가 기본입니다. macOS의 기본은 계속 spawn입니다.

특정 방식이 필요하면 multiprocessing.get_context() 또는 set_start_method()로 명시합니다.

p.start()가 새 프로세스를 생성하고, p.join()이 종료를 기다립니다.

이 예제처럼 non-daemon 자식을 사용하면 부모의 정상 종료 과정에서도 자동으로 join을 시도합니다. 명시적 join()은 “All workers completed”를 자식 종료 뒤에 출력하도록 완료 시점을 정합니다. 자세한 종료 규칙은 multiprocessing 문서를 참고합니다.

다중 프로세스를 선택하는 이유와 fork 자체의 주의점은 구분해야 합니다. 다중 스레드 프로세스를 fork하면 자식에 호출 스레드만 남아 잠금 상태를 다루기 까다롭습니다.

GIL이 활성화된 일반적인 CPython에서는 같은 인터프리터의 순수 Python 코드를 여러 스레드가 동시에 실행하지 못합니다. GIL을 해제하는 네이티브 확장과 선택적인 free-threaded 빌드는 별도로 판단합니다.

이 때문에 CPU 집약적 작업에서는 multiprocessing(프로세스 기반)을 사용합니다.

각 프로세스가 독립적인 Python 인터프리터와 GIL을 가지므로 진정한 병렬 실행이 가능합니다.


wait()와 종료 상태

부모 프로세스는 wait() 또는 waitpid() 시스템 콜로 자식의 종료를 기다리고, 종료 상태(Exit Status)를 수거합니다.

종료 상태에서 다음 정보를 알 수 있습니다.

  • WIFEXITED(status): 정상 종료 여부. WEXITSTATUS(status)로 종료 코드를 얻습니다.
  • WIFSIGNALED(status): 시그널에 의한 종료 여부. WTERMSIG(status)로 시그널 번호를 얻습니다.
  • WIFSTOPPED(status): 정지(Stopped) 여부. 보통 WUNTRACED 옵션을 주었거나 추적 중인 자식에서 의미가 있습니다.

종료 코드 0은 성공, 0이 아닌 값은 오류를 의미합니다.

셸에서 echo $?로 직전 명령어의 종료 코드를 확인할 수 있습니다.

CI/CD 스크립트에서 명령어의 성공/실패를 판단하는 기준이 바로 이 종료 코드입니다.


좀비 프로세스와 고아 프로세스

fork 이후 부모와 자식의 실행 순서
생성·교체·종료·수거fork가 두 실행 흐름을 만들고 자식의 exec는 PID를 유지한 채 프로그램을 교체합니다. 부모가 waitpid로 종료 상태를 수거합니다.부모기존 PID자식새 PIDfork(): 자식 생성자식 PID 반환0 반환exec 성공PID 유지waitpid로종료 대기exit종료 기록 남음종료 상태 수거이후 작업 재개

성공한 fork·exec와 종료한 자식의 waitpid 수거를 단순화했습니다. 부모가 waitpid를 먼저 호출할 수도, 자식이 먼저 종료할 수도 있습니다. exit와 수거 사이에는 종료 기록이 남습니다.

부모-자식 관계에서 자주 헷갈리는 상태가 좀비와 고아입니다.

좀비는 이미 종료된 자식을 부모가 아직 회수하지 않은 상태이고, 고아는 살아 있는 자식보다 부모가 먼저 사라진 경우입니다.

좀비 프로세스 (Zombie Process)

자식 프로세스가 종료되었지만, 부모가 아직 wait()를 호출하지 않은 상태입니다.

자식의 프로세스 실체(코드, 데이터, 스택)는 이미 해제되었지만, 프로세스 테이블의 항목(PCB의 일부)이 남아 있습니다.

부모가 종료 상태를 읽어갈 수 있도록 보존되는 것입니다.

ps 명령어에서 상태가 Z로, COMMAND가 [defunct]로 표시됩니다.

소수의 좀비 프로세스는 메모리를 거의 차지하지 않으므로 문제가 되지 않습니다.

하지만 장기 운영 서버에서 부모가 wait()를 제대로 하지 않으면 좀비가 누적되어 사용 가능한 PID가 고갈될 수 있습니다.

PID가 고갈되면 새 프로세스를 생성할 수 없으므로 사실상 시스템이 마비됩니다.

좀비를 예방하는 방법은 세 가지입니다.

  • 부모가 wait()/waitpid()를 적절히 호출합니다.
  • SIGCHLD 시그널 핸들러에서 waitpid(-1, &status, WNOHANG)을 호출합니다.
  • signal(SIGCHLD, SIG_IGN)으로 커널에게 자동 수거를 맡깁니다.
좀비 방지: SIGCHLD 핸들러
#include <signal.h>
#include <sys/wait.h>
#include <errno.h>

void sigchld_handler(int sig) {
    int saved_errno = errno;
    (void)sig;
    /* WNOHANG: 종료된 자식이 없으면 즉시 반환 */
    while (1) {
        pid_t pid = waitpid(-1, NULL, WNOHANG);
        if (pid > 0 || (pid == -1 && errno == EINTR)) continue;
        break;
    }
    errno = saved_errno;
}

int main() {
    if (signal(SIGCHLD, sigchld_handler) == SIG_ERR) return 1;
    /* ... fork() 등 ... */
}

고아 프로세스 (Orphan Process)

부모 프로세스가 자식보다 먼저 종료된 경우입니다.

원래 부모를 잃었다는 의미이며 커널의 부모 관계가 영구히 비어 있는 상태는 아닙니다.

이때 커널은 해당 자식을 현재 PID namespace의 init 또는 가장 가까운 subreaper에 입양시킵니다.

입양한 프로세스가 나중에 wait()로 종료 상태를 회수해야 하므로, 컨테이너나 서비스 런처에서는 reaping 책임을 분명히 해야 합니다.

하지만 의도치 않은 고아 프로세스가 백그라운드에서 계속 실행되면 리소스를 낭비할 수 있습니다.

Docker 컨테이너에서 PID 1이 일반 애플리케이션인 경우, 이 애플리케이션이 고아 프로세스를 수거(reaping)하지 않으면 좀비가 누적될 수 있습니다.

이것이 Docker에서 tini 같은 경량 init을 사용하는 이유입니다.

좀비 및 고아 프로세스 확인
# 좀비 프로세스 수 확인
ps aux | awk 'substr($8,1,1)=="Z" {count++} END {print "Zombies:", count+0}'

# 좀비의 부모 프로세스 찾기
ps -eo pid,ppid,stat,cmd | awk 'NR==1 || $3 ~ /^Z/'
# 1234 5678 Z+ [defunct]
# → PID 5678이 wait()를 하지 않는 부모

# 프로세스 상태별 개수 확인
ps -eo stat= | awk '{count[substr($1,1,1)]++} END {for (s in count) print s, count[s]}'
# Z 3
# S 128
# R 2

진단 명령의 주석은 예시이며 PPID가 1이라는 사실만으로 원래 고아였다고 확정할 수 없습니다. SIGCHLD 예제는 핸들러 설치와 수거만 보여 주는 조각이며 실제 자식 생성·실행 대기는 생략했습니다.

다음 절에서는 프로세스 간에 데이터를 주고받는 IPC(프로세스 간 통신)를 다루겠습니다.