프로세스 생성과 종료
fork·exec·wait의 역할과 부모-자식 프로세스의 생명주기를 코드로 확인하고 좀비와 고아 프로세스를 구분합니다.
운영체제에서 새로운 프로세스는 어떻게 만들어질까요?
대부분의 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(4567)모든 프로세스가 systemd(PID 1)에서 파생된 것을 볼 수 있습니다.
sshd → bash → vim으로 이어지는 체인은 SSH로 접속한 사용자가 셸에서 vim을 실행한 것을 보여줍니다.
각 프로세스는 자신을 만든 부모의 PID를 기억하므로, 트리를 거슬러 올라가면 사용자 프로그램이 어떤 세션과 셸에서 시작됐는지 알 수 있다.
- systemd
PID 1 systemd 사용자 공간 프로세스 트리의 뿌리가 된다.
- sshd
PPID 1 · PID 742 sshd 원격 접속을 받아 사용자 세션을 만든다.
- bash
PPID 742 · PID 816 bash 입력한 명령을 실행할 자식 프로세스를 만든다.
- vim
PPID 816 · PID 903 vim 셸에서 선택한 실제 사용자 프로그램이 실행된다.
fork()와 exec()
프로세스 생성과 종료에서는 프로세스 상태, 커널 자료구조, 부모/자식 정리 지점을 확인합니다.
셸이 명령을 실행할 때는 부모를 남기기 위해 fork로 자식을 만들고, 자식 안에서 exec로 새 프로그램을 덮어쓴다.
- fork
shell fork 부모와 자식 분리
- exec
child exec 코드와 데이터 교체
- wait
parent wait 종료 상태 회수
- COW
kernel COW 페이지 복사는 지연
Unix/Linux에서 프로세스를 생성하는 핵심 시스템 콜은 fork()와 exec()입니다.
이 두 시스템 콜의 조합은 Unix의 가장 근본적인 디자인 패턴이며, 셸에서 명령어를 실행하는 것부터 서버가 클라이언트 요청을 처리하는 것까지 거의 모든 곳에서 사용됩니다.
fork() — 프로세스 복제
fork()는 현재 프로세스를 복제합니다.
호출한 순간, 부모 프로세스의 거의 모든 것 — 메모리 공간, 파일 디스크립터, 환경 변수, 시그널 핸들러 — 을 복사한 새로운 자식 프로세스가 만들어집니다.
부모와 자식은 독립적인 프로세스이므로, 한쪽의 변수를 수정해도 다른 쪽에 영향을 주지 않습니다.
fork()의 가장 독특한 점은 반환값입니다.
fork()는 한 번 호출되지만 두 번 반환됩니다.
부모 프로세스에게는 자식의 PID(양의 정수)를 반환하고, 자식 프로세스에게는 0을 반환합니다.
실패하면 -1을 반환하고 자식은 생성되지 않습니다.
#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()가 실패했다는 뜻입니다.
#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(127);
} 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;
}셸에서 외부 foreground 명령을 실행할 때 이 패턴이 대표적으로 나타납니다.
cd, export 같은 shell builtin은 보통 같은 셸 프로세스 안에서 처리되고, 파이프라인이나 백그라운드 작업은 분기가 더 복잡해집니다.
bash에서 ls -la를 입력하면:
- bash가
fork()로 자식을 만듭니다. - 자식이
exec("ls", ...)로ls프로그램을 실행합니다. ls가 출력을 마치고exit(0)으로 종료합니다.- bash(부모)가
wait()로 자식의 종료 상태를 수거합니다. - bash가 다음 프롬프트를 표시합니다.
fork()와 exec()를 분리한 것이 Unix의 핵심 설계 결정입니다.
이 분리 덕분에 fork() 후 exec() 전에 자식 프로세스의 환경을 조작할 수 있습니다.
파일 디스크립터를 바꿔서 입출력을 리다이렉션하거나, 환경 변수를 변경하거나, 시그널 핸들러를 설정할 수 있습니다.
셸의 파이프(|), 리다이렉션(>, <), 백그라운드 실행(&)이 모두 이 메커니즘으로 구현됩니다.
Windows의 프로세스 생성
Windows는 fork()/exec() 대신 CreateProcess() 하나로 프로세스를 생성합니다.
새 프로세스를 만들면서 동시에 프로그램을 지정합니다.
Unix 방식보다 단순하지만, fork()와 exec() 사이에 환경을 조작하는 유연성은 부족합니다.
Python에서의 프로세스 생성
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이 기본이며, POSIX 계열도 Python 3.14부터 기본 방식이 forkserver로 바뀌었습니다.
특정 방식이 필요하면 multiprocessing.get_context() 또는 set_start_method()로 명시합니다.
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) 여부. 보통WUNTRACED옵션을 주었거나 추적 중인 자식에서 의미가 있습니다.
종료 코드 0은 성공, 0이 아닌 값은 오류를 의미합니다.
셸에서 echo $?로 직전 명령어의 종료 코드를 확인할 수 있습니다.
CI/CD 스크립트에서 명령어의 성공/실패를 판단하는 기준이 바로 이 종료 코드입니다.
좀비 프로세스와 고아 프로세스
아래 다이어그램은 fork/exec/wait의 정상 생명주기와 좀비·고아 방지 흐름을 함께 보여줍니다.
bash에서 ls 를 실행하면 부모는 자식을 만든 뒤 기다리고, 자식은 프로그램 이미지를 바꿔 실행한 다음 종료 상태를 남긴다.
- 11. fork · 흐름 분기
부모 자식 PID를 받고 셸 상태를 유지한다. 자식 반환값 0을 받고 새 실행 경로로 갈라진다.
- 22. exec · 이미지 교체
부모 waitpid 에서 자식 종료를 기다린다. 자식 PID는 유지한 채 코드·데이터·스택을 ls 로 바꾼다.
- 33. exit · 결과 보관
부모 커널의 종료 알림으로 대기에서 깨어난다. 자식 실행 자원을 반납하고 종료 상태만 커널에 남긴다.
- 44. waitpid · 회수
부모 상태 코드를 읽고 다음 프롬프트를 연다. 자식 PCB의 마지막 기록까지 제거되어 완전히 사라진다.
프로세스 생성 흐름은 셸 명령 실행, 서버 워커 생성, 컨테이너 프로세스 관리까지 반복해서 등장합니다.
핵심은 fork()가 복제하고, exec()가 교체하며, wait()가 종료 상태를 수거한다는 점입니다.
셸이 명령어 하나를 실행할 때도 부모와 자식의 역할 분리가 정확히 지켜져야 좀비와 리소스 누수를 막을 수 있습니다.
- 부모를 복제한다
fork() 주소 공간과 파일 디스크립터를 공유 출발점으로 삼고 반환값으로 부모와 자식을 구분합니다.
- 필요할 때만 복사한다
COW 페이지를 바로 복사하지 않고, 쓰기 시점에 변경된 페이지만 분리합니다.
- 자식의 프로그램을 바꾼다
exec() PID는 유지하고 코드와 데이터는 새 실행 파일의 이미지로 교체합니다.
- 종료 상태를 수거한다
wait() 부모가 exit status를 읽어야 PCB 잔여 정보가 사라지고 좀비가 남지 않습니다.
부모-자식 관계에서 자주 헷갈리는 상태가 좀비와 고아입니다.
좀비는 이미 종료된 자식을 부모가 아직 회수하지 않은 상태이고, 고아는 살아 있는 자식보다 부모가 먼저 사라진 경우입니다.
좀비 프로세스 (Zombie Process)
자식 프로세스가 종료되었지만, 부모가 아직 wait()를 호출하지 않은 상태입니다.
자식의 프로세스 실체(코드, 데이터, 스택)는 이미 해제되었지만, 프로세스 테이블의 항목(PCB의 일부)이 남아 있습니다.
부모가 종료 상태를 읽어갈 수 있도록 보존되는 것입니다.
ps 명령어에서 상태가 Z로, COMMAND가 [defunct]로 표시됩니다.
소수의 좀비 프로세스는 메모리를 거의 차지하지 않으므로 문제가 되지 않습니다.
하지만 장기 운영 서버에서 부모가 wait()를 제대로 하지 않으면 좀비가 누적되어 사용 가능한 PID가 고갈될 수 있습니다.
PID가 고갈되면 새 프로세스를 생성할 수 없으므로 사실상 시스템이 마비됩니다.
좀비를 예방하는 방법은 세 가지입니다.
- 부모가
wait()/waitpid()를 적절히 호출합니다. - SIGCHLD 시그널 핸들러에서
waitpid(-1, &status, WNOHANG)을 호출합니다. signal(SIGCHLD, SIG_IGN)으로 커널에게 자동 수거를 맡깁니다.
#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 namespace의 init 또는 가장 가까운 subreaper에 입양시킵니다.
입양한 프로세스가 나중에 wait()로 종료 상태를 회수해야 하므로, 컨테이너나 서비스 런처에서는 reaping 책임을 분명히 해야 합니다.
하지만 의도치 않은 고아 프로세스가 백그라운드에서 계속 실행되면 리소스를 낭비할 수 있습니다.
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()를 하지 않는 부모
# 프로세스 상태별 개수 확인
ps -eo stat= | awk '{count[substr($1,1,1)]++} END {for (s in count) print s, count[s]}'
# Z 3
# S 128
# R 2프로세스 생성과 종료 흐름은 fork(), exec(), exit(), wait()가 어느 쪽에서 실행되는지와 부모가 종료 상태를 수거하는지까지 함께 봐야 완성됩니다.
부모가 자식을 만들고, 자식은 새 프로그램으로 바뀌며, 종료 상태는 부모가 수거해야 프로세스 테이블 잔여 정보가 사라집니다.
- 부모 복제
fork 부모에는 자식 PID, 자식에는 0이 반환되어 두 실행 흐름이 갈라집니다.
- 페이지 공유
cow 메모리는 바로 복사하지 않고 쓰기 발생 시 변경된 페이지만 분리합니다.
- 이미지 교체
exec PID와 일부 파일 디스크립터는 유지하고 코드와 데이터는 새 프로그램이 됩니다.
- 자식 종료
exit 메모리와 열린 자원은 정리되지만 종료 코드는 부모가 읽을 때까지 남습니다.
- 상태 수거
wait 부모가 exit status를 읽으면 남은 PCB 항목까지 해제됩니다.
- 부모가 wait 호출
정상 종료 코드가 회수되고 자식의 프로세스 테이블 항목이 사라집니다.
- 자식만 먼저 종료
Zombie 부모가 wait를 미루면 defunct 항목이 남아 PID 공간을 차지합니다.
- 부모가 먼저 종료
Orphan 커널이 부모를 PID 1로 바꾸고 init 또는 systemd가 나중에 수거합니다.
다음 절에서는 프로세스 간에 데이터를 주고받는 IPC(프로세스 간 통신)를 다루겠습니다.
프로세스 문제는 어느 호출이 부모에서 실행되고, 어느 시점에 커널 상태가 남으며, 누가 종료 코드를 회수하는지까지 연결해야 설명됩니다.
- 1fork
부모 문맥을 복제하고 두 실행 흐름이 서로 다른 반환값을 받습니다. 부모: 자식 PID 확인 자식: 0을 받고 분기
- 2exec
PID는 유지한 채 코드, 데이터, 스택을 새 프로그램 이미지로 교체합니다. 열린 파일 일부 유지 주소 공간은 새로 적재
- 3exit
실행 자원은 정리하지만 종료 상태는 부모가 읽을 때까지 남깁니다. 파일과 메모리 반환 상태 코드는 커널에 보존
- 4wait
부모가 종료 상태를 읽고 프로세스 테이블의 남은 항목을 회수합니다. 수거 성공: 항목 제거 수거 지연: 좀비 위험
- 5진단 기준
좀비 자식은 끝났지만 부모가 아직 wait를 호출하지 않은 상태 고아 부모가 먼저 사라져 init 또는 subreaper가 새 부모가 된 상태 컨테이너 PID 1이 자식 수거 책임을 지지 않으면 좀비가 누적될 수 있음