icon

안동민 개발노트

3장 : 프로세스

프로세스 생성과 종료


운영체제에서 새로운 프로세스는 어떻게 만들어질까요? 대부분의 OS에서 프로세스는 기존의 다른 프로세스가 생성합니다. 부팅 시 커널이 만드는 첫 프로세스(PID 1)를 제외하면, 모든 프로세스는 부모-자식 관계를 가집니다. 이 관계가 트리 구조를 형성하여, 시스템의 모든 프로세스는 PID 1을 뿌리로 하는 프로세스 트리(Process Tree)를 구성합니다.

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

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

모든 프로세스가 systemd(PID 1)에서 파생된 것을 볼 수 있습니다. sshd → bash → vim으로 이어지는 체인은 SSH로 접속한 사용자가 셸에서 vim을 실행한 것을 보여줍니다.


fork()와 exec()

Unix/Linux에서 프로세스를 생성하는 핵심 시스템 콜은 fork()exec()입니다. 이 두 시스템 콜의 조합은 Unix의 가장 근본적인 디자인 패턴이며, 셸에서 명령어를 실행하는 것부터 서버가 클라이언트 요청을 처리하는 것까지 거의 모든 곳에서 사용됩니다.

fork() — 프로세스 복제

fork()는 현재 프로세스를 복제합니다. 호출한 순간, 부모 프로세스의 거의 모든 것 — 메모리 공간, 파일 디스크립터, 환경 변수, 시그널 핸들러 — 을 복사한 새로운 자식 프로세스가 만들어집니다. 부모와 자식은 독립적인 프로세스이므로, 한쪽의 변수를 수정해도 다른 쪽에 영향을 주지 않습니다.

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는 한 번만 실행됩니다. fork() 이후의 코드는 부모와 자식이 각각 실행하므로 두 번 출력됩니다. pid 값의 차이로 부모와 자식이 서로 다른 분기를 실행합니다.

메모리 전체를 복사한다고 하면 성능 우려가 커 보이지만, 실제로는 COW(Copy-On-Write) 기법 덕분에 효율적입니다. fork() 직후에는 부모와 자식이 같은 물리 메모리 페이지를 공유하며, 둘 중 하나가 페이지를 수정하려 할 때 그 페이지만 실제로 복사합니다. 자식이 exec()으로 다른 프로그램을 실행하면 부모의 메모리를 전혀 사용하지 않으므로, 대부분의 페이지가 복사 없이 폐기됩니다. COW가 없었다면 fork()는 매우 비용이 큰 연산이었을 것입니다.

exec() — 프로그램 교체

exec()는 현재 프로세스의 코드, 데이터, 스택 등 메모리 내용을 새 프로그램으로 완전히 교체합니다. PID는 유지되고, 열린 파일 디스크립터는 (기본적으로) 상속되지만, 프로그램 코드와 메모리 레이아웃은 완전히 새 것으로 바뀝니다.

exec() 호출이 성공하면 돌아오지 않습니다. 기존 프로그램의 코드 자체가 사라졌으므로, 돌아올 곳이 없습니다. exec() 다음 줄의 코드가 실행되었다면, 그것은 exec()가 실패했다는 뜻입니다.

fork()와 exec()의 조합
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.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(EXIT_FAILURE);
    } else {
        /* 부모 프로세스: 자식 종료 대기 */
        int status;
        waitpid(pid, &status, 0);

        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;
}

셸에서 명령어를 실행할 때마다 이 패턴이 정확히 반복됩니다. bash에서 ls -la를 입력하면:

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

fork()exec()를 분리한 것이 Unix의 핵심 설계 결정입니다. 이 분리 덕분에 fork()exec() 전에 자식 프로세스의 환경을 조작할 수 있습니다. 파일 디스크립터를 바꿔서 입출력을 리다이렉션하거나, 환경 변수를 변경하거나, 시그널 핸들러를 설정할 수 있습니다. 셸의 파이프(|), 리다이렉션(>, <), 백그라운드 실행(&)이 모두 이 메커니즘으로 구현됩니다.

Windows의 프로세스 생성

Windows는 fork()/exec() 대신 CreateProcess() 하나로 프로세스를 생성합니다. 새 프로세스를 만들면서 동시에 프로그램을 지정합니다. Unix 방식보다 단순하지만, 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 모듈은 Unix에서는 내부적으로 fork()를, Windows에서는 spawn()을 기본 방식으로 사용합니다. p.start()가 새 프로세스를 생성하고, p.join()이 종료를 기다립니다. join()을 호출하지 않으면 메인 프로세스가 먼저 종료되어 자식이 고아가 될 수 있습니다.

Python에서 fork()의 주의점이 있습니다. Python의 GIL(Global Interpreter Lock) 때문에 멀티스레딩이 CPU 바운드 작업에서 병렬성을 제공하지 못합니다. 이 때문에 CPU 집약적 작업에서는 multiprocessing(프로세스 기반)을 사용합니다. 각 프로세스가 독립적인 Python 인터프리터와 GIL을 가지므로 진정한 병렬 실행이 가능합니다.


wait()와 종료 상태

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

  • WIFEXITED(status): 정상 종료 여부. WEXITSTATUS(status)로 종료 코드를 얻습니다.
  • WIFSIGNALED(status): 시그널에 의한 종료 여부. WTERMSIG(status)로 시그널 번호를 얻습니다.
  • WIFSTOPPED(status): 정지(Stopped) 여부.

종료 코드 0은 성공, 0이 아닌 값은 오류를 의미합니다. 셸에서 echo $?로 직전 명령어의 종료 코드를 확인할 수 있습니다. CI/CD 스크립트에서 명령어의 성공/실패를 판단하는 기준이 바로 이 종료 코드입니다.


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

프로세스의 생명주기에서 두 가지 비정상적인 상태가 있습니다. 둘 다 부모-자식 관계에서 발생하는 문제입니다.

좀비 프로세스 (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>

void sigchld_handler(int sig) {
    /* WNOHANG: 종료된 자식이 없으면 즉시 반환 */
    while (waitpid(-1, NULL, WNOHANG) > 0);
}

int main() {
    signal(SIGCHLD, sigchld_handler);
    /* ... fork() 등 ... */
}

고아 프로세스 (Orphan Process)

부모 프로세스가 자식보다 먼저 종료된 경우입니다. 자식은 부모 없는 상태가 됩니다. 이때 커널이 자동으로 해당 자식의 부모를 PID 1(init/systemd)로 재설정합니다. PID 1은 주기적으로 wait()를 호출하여 고아가 된 자식의 종료 상태를 수거합니다. 따라서 고아 프로세스는 자동으로 정리되므로, 좀비처럼 문제를 일으키지는 않습니다.

하지만 의도치 않은 고아 프로세스가 백그라운드에서 계속 실행되면 리소스를 낭비할 수 있습니다. Docker 컨테이너에서 PID 1이 일반 애플리케이션인 경우, 이 애플리케이션이 고아 프로세스를 수거(reaping)하지 않으면 좀비가 누적될 수 있습니다. 이것이 Docker에서 tini 같은 경량 init을 사용하는 이유입니다.

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

# 좀비의 부모 프로세스 찾기
ps -eo pid,ppid,stat,cmd | grep Z
# 1234 5678 Z+ [defunct]
# → PID 5678이 wait()를 하지 않는 부모

# 시스템 전체 좀비 수
cat /proc/loadavg
# 0.15 0.10 0.08 2/312 45678
#                  ↑ running/total (zombie 포함)

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

목차