포스트

프로세스란 무엇인가 — 주소 공간·PCB·fork와 exec, 그리고 COW의 마법

프로세스란 무엇인가? 가상 주소 공간 구조, PCB(task_struct), fork와 exec, copy-on-write(COW)까지 프로세스의 실체를 /proc 실습과 함께 파헤칩니다.

프로세스란 무엇인가 — 주소 공간·PCB·fork와 exec, 그리고 COW의 마법

“프로그램과 프로세스는 같은 말이 아니다”

/usr/bin/python은 디스크에 가만히 누워 있는 바이트 덩어리입니다 — 죽어 있는 명령의 묶음이죠. 그런데 python을 두 번 실행하면, 같은 파일에서 태어난 두 개의 살아 있는 실행 인스턴스가 각자의 메모리·각자의 진행 상태를 갖고 따로 돕니다. 디스크의 바이트가 프로그램, 메모리에서 살아 도는 실행 인스턴스가 프로세스입니다.

앞 글에서 OS는 CPU를 시분할로 여러 프로세스에 나눠준다고 했습니다. 그렇다면 “프로세스 하나”란 정확히 무엇으로 이루어진 실체일까요? 이 글은 프로세스의 해부도 — 주소 공간 + 실행 문맥(PCB) — 를 펼쳐 보고, 유닉스가 새 프로세스를 만드는 기묘하고 우아한 방식인 fork/exec, 그리고 그걸 빠르게 만드는 copy-on-write까지 따라갑니다.

프로세스 = 주소 공간 + 실행 문맥

프로세스를 구성하는 건 크게 둘입니다.

  • 주소 공간(address space): 그 프로세스가 보는 메모리 전체. 코드·전역변수·힙·스택이 여기 들어갑니다. 핵심은 이게 가상이라는 것 — 각 프로세스는 “0번지부터 나 혼자 메모리를 다 쓴다”는 환상을 받습니다(상세는 10편 가상 메모리).
  • 실행 문맥(PCB): “지금 어디까지 실행했나”를 담는 커널 자료구조. 레지스터 값·프로그램 카운터·프로세스 상태·열린 파일 목록·메모리 맵 포인터 등. 리눅스에선 task_struct입니다.

먼저 주소 공간의 지도를 봅시다. 높은 주소에서 낮은 주소로, 스택은 아래로 자라고 힙은 위로 자라며 가운데 빈 공간을 향해 서로 다가갑니다.

0x7FFF… 높은 주소 0x0 낮은 주소 Stack ↓ (지역변수·함수 프레임) 함수 호출마다 ↕ 빈 공간 · mmap·공유 라이브러리 malloc마다 Heap ↑ (malloc/new) BSS (0으로 초기화된 전역) Data (초기화된 전역) Text (코드 · 읽기전용) 하나의 프로세스가 보는 메모리 전체 = 가상 주소 공간. 각 프로세스는 "혼자 다 쓴다"는 환상을 받는다(10편). 스택·힙이 만나면 → stack overflow

여기서 두 가지를 봐두세요. (1) 코드(Text)는 읽기 전용이라 같은 프로그램을 여러 번 띄워도 물리 메모리에서 공유됩니다 — 메모리를 아끼는 첫 번째 트릭. (2) 스택과 힙은 빈 공간을 사이에 두고 반대 방향으로 자랍니다. 재귀가 너무 깊으면 스택이 한계를 넘어 stack overflow, 힙이 끝없이 커지면 메모리 부족(OOM)이 됩니다.

PCB: 커널이 들고 있는 프로세스의 신분증

주소 공간이 “프로세스가 보는 메모리”라면, PCB(Process Control Block) 는 “커널이 그 프로세스에 대해 아는 모든 것”입니다. 리눅스의 task_struct에는 대략 이런 게 들어 있습니다.

항목내용왜 필요한가
PID / PPID프로세스 ID, 부모 ID식별·계층(프로세스 트리)
상태running/ready/waiting…스케줄러가 누굴 돌릴지 결정
레지스터·PC멈춘 순간의 CPU 문맥컨텍스트 스위치 시 저장/복원(6편)
메모리 맵페이지 테이블 포인터주소 공간 전환
파일 디스크립터 표열린 파일·소켓read()의 fd 0,1,2가 여기
우선순위·스케줄링 정보nice·vruntime 등CFS 스케줄링(5편)
부모/자식·시그널관계·대기 상태wait()·시그널 전달(7편)

컨텍스트 스위치란 결국 “실행 중인 프로세스의 레지스터를 PCB에 저장하고, 다음 프로세스의 PCB에서 레지스터를 복원하는 것” 입니다. PCB가 곧 프로세스의 “세이브 파일”인 셈입니다.

프로세스의 일생: 상태 전이

프로세스는 태어나서 죽을 때까지 정해진 상태들을 오갑니다. 핵심은 Running ↔ Ready ↔ Waiting 삼각형입니다.

stateDiagram-v2
  [*] --> New: 생성(fork)
  New --> Ready: 주소공간·PCB 준비 완료
  Ready --> Running: 스케줄러 선택(dispatch)
  Running --> Ready: 타임슬라이스 만료 / 선점
  Running --> Waiting: I/O·이벤트 대기(blocking)
  Waiting --> Ready: I/O 완료(인터럽트)
  Running --> Terminated: exit()
  Terminated --> [*]: 부모가 wait()로 수거
  • Ready → Running: 스케줄러가 골라 CPU를 줌(dispatch). CPU가 1개면 한 번에 하나만 Running.
  • Running → Ready: 타임슬라이스가 끝나거나 더 급한 프로세스에 선점당함. 일을 못 끝낸 게 아니라 차례를 넘긴 것.
  • Running → Waiting: read()로 디스크를 기다리는 등 블로킹. CPU를 자발적으로 내려놓음 → 그동안 다른 프로세스가 CPU를 씀(이게 시분할이 효율적인 이유).
  • Terminated → 수거: 죽은 뒤에도 종료 코드를 부모가 wait()로 거둘 때까지 PCB가 남습니다. 이 짧은 유령 상태가 좀비입니다.

현실 체크 — 좀비와 고아. 좀비(zombie): 자식이 죽었는데 부모가 wait()를 안 해 종료 정보가 PCB에 남은 상태. 좀비는 메모리는 거의 안 쓰지만 PID를 점유해, 쌓이면 PID 고갈로 새 프로세스를 못 만듭니다. 고아(orphan): 부모가 먼저 죽은 자식. 고아는 init(PID 1)이 입양해 대신 wait() 해주므로 좀비가 안 됩니다. 즉 좀비를 만드는 건 죽은 자식이 아니라 게으른 부모입니다.

fork/exec: 새 프로세스를 만드는 유닉스의 방식

다른 OS는 “프로그램을 지정해 새 프로세스 생성”을 한 번에 합니다. 유닉스는 기묘하게도 이를 둘로 쪼갭니다.

  • fork(): 호출한 프로세스(부모)를 통째로 복제해 거의 똑같은 자식을 만듭니다. 자식은 부모의 주소 공간·열린 파일·레지스터를 그대로 물려받습니다. fork()는 두 번 리턴합니다 — 부모에겐 자식의 PID, 자식에겐 0.
  • exec(): 현재 프로세스의 주소 공간을 새 프로그램 이미지로 통째 교체합니다. PID는 그대로지만 코드·데이터가 싹 갈립니다. 성공하면 리턴하지 않습니다(돌아올 코드가 사라졌으니).

이 둘을 붙인 fork → (자식에서) exec 가 셸이 명령을 실행하는 바로 그 패턴입니다.

1
2
3
4
5
6
7
8
9
10
11
12
#include <unistd.h>
#include <sys/wait.h>

pid_t pid = fork();              /* 여기서 프로세스가 둘로 갈라진다 */
if (pid == 0) {                  /* ── 자식: 반환값 0 ── */
    char *argv[] = {"ls", "-l", NULL};
    execvp("ls", argv);          /* 주소공간을 ls로 교체 → 성공 시 리턴 안 함 */
    _exit(127);                  /* execvp 실패했을 때만 도달 */
} else if (pid > 0) {            /* ── 부모: 반환값 = 자식 PID ── */
    int status;
    waitpid(pid, &status, 0);    /* 자식 종료를 거둔다 → 좀비 방지 */
}

왜 굳이 둘로 쪼갰을까요? fork와 exec 사이의 빈틈이 강력하기 때문입니다. 자식이 exec 하기 직전에 파일 디스크립터를 조작할 수 있어, 셸의 리다이렉션·파이프(ls > out.txt, a | b)가 바로 이 틈에서 구현됩니다 — fork 후 자식의 stdout을 파일/파이프로 바꿔치기한 뒤 exec. 한 번에 합쳐진 API였다면 불가능했을 유연함입니다.

COW: fork가 통째 복제인데 왜 빠른가

여기서 의문이 생깁니다. fork()가 부모의 주소 공간을 통째로 복사한다면, 수 GB짜리 프로세스를 fork할 때마다 수 GB를 복사해야 할 텐데? 게다가 대부분은 fork 직후 곧장 exec로 다 갈아엎는데 — 그 복사는 순전히 낭비입니다.

해법이 copy-on-write(COW) 입니다. fork 시점엔 아무것도 복사하지 않고, 부모·자식이 같은 물리 페이지를 공유하되 전부 읽기 전용으로 표시합니다. 둘 중 하나가 어떤 페이지에 쓰려는 순간에만, 그 페이지에서 page fault가 나고 커널이 그 페이지 하나만 복제해 갈라줍니다.

부모 프로세스 자식 (fork) 물리페이지 P1 물리페이지 P2 물리페이지 P3 P2' (복제) fork 직후: 모든 페이지 공유 · 전부 읽기전용 자식이 P2에 쓰기 → 그 페이지만 복제(P2')되어 분리

COW 덕분에 fork()는 GB짜리 프로세스든 거의 즉시 끝납니다(페이지 테이블만 복제). 그리고 fork 직후 곧장 exec 하면 공유 페이지를 단 한 장도 복사하지 않고 그대로 버리니, 낭비가 0에 수렴합니다. “필요할 때까지 미룬다(lazy)” — OS 전반을 관통하는 핵심 철학이고, 디맨드 페이징(12편)에서 다시 만납니다.

현실 체크 — Redis의 fork와 COW. Redis는 디스크 스냅샷(RDB)을 만들 때 fork() 한 자식이 메모리를 그대로 보며 저장합니다. 평소엔 COW로 공짜지만, 저장 중 부모가 많은 키를 수정하면 그만큼 페이지가 복제돼 메모리가 순간 최대 2배까지 튈 수 있습니다. “fork는 공짜”가 아니라 “쓰기 전까지 공짜”임을 잊으면 프로덕션에서 OOM을 맞습니다.

직접 들여다보기: /proc로 프로세스 해부하기

리눅스는 모든 프로세스의 내부를 /proc/<pid>/ 가상 파일로 노출합니다 — 이 글의 모든 개념을 눈으로 확인할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
# 주소 공간 레이아웃을 그대로 본다 (text/heap/stack/공유라이브러리 매핑)
cat /proc/self/maps
#   ...-... r-xp  /usr/bin/cat        ← Text (읽기+실행)
#   ...-... rw-p  [heap]              ← Heap
#   ...-... rw-p  [stack]             ← Stack

# PCB의 사람이 읽을 수 있는 요약 (상태·메모리·스레드 수·fd 수)
grep -E 'State|VmRSS|Threads' /proc/self/status

# 프로세스 계층(트리)과 부모-자식 관계
pstree -p
ps -ef                     # PID·PPID·실행 명령
ps aux | awk '$8 ~ /Z/'    # 좀비(STAT에 Z) 찾기

cat /proc/self/maps 한 줄로, 위에서 그림으로 본 주소 공간이 실제 프로세스에 그대로 있다는 걸 확인할 수 있습니다.

면접/리뷰 단골 질문

  • Q. 프로그램과 프로세스의 차이는? → 프로그램은 디스크의 정적 바이트, 프로세스는 메모리에서 실행 중인 동적 인스턴스(주소 공간 + PCB). 한 프로그램에서 여러 프로세스가 나올 수 있다.
  • Q. fork()는 무엇을 반환하나? → 두 번 반환한다. 부모에겐 자식 PID(>0), 자식에겐 0, 실패 시 -1. 이 반환값으로 부모/자식 코드 경로를 가른다.
  • Q. fork가 전체 복사인데 왜 안 느린가? → copy-on-write. 처음엔 페이지를 공유(읽기전용)하고, 쓰기가 일어난 페이지만 그때 복제한다. fork 직후 exec 하면 복사가 거의 0.
  • Q. 좀비 프로세스란? 어떻게 막나? → 자식이 죽었으나 부모가 wait()로 종료 코드를 안 거둔 상태. 부모가 wait()/waitpid() 하거나 SIGCHLD 처리로 막는다. 부모가 먼저 죽은 고아는 init이 입양해 좀비가 안 된다.
  • Q. 스택과 힙은 어느 방향으로 자라나? → 스택은 높은 주소에서 낮은 주소로(아래로), 힙은 낮은 주소에서 높은 주소로(위로). 가운데 빈 공간에서 만나면 overflow.

정리

  • 프로세스 = 가상 주소 공간(Text·Data·BSS·Heap↑·Stack↓) + PCB(레지스터·상태·fd표·메모리맵 = task_struct).
  • PCB는 프로세스의 “세이브 파일” — 컨텍스트 스위치는 곧 PCB에 레지스터를 저장/복원하는 일.
  • 상태는 Running↔Ready↔Waiting 삼각형 + New/Terminated. 죽고도 안 거둬지면 좀비, 부모 잃으면 고아(init이 입양).
  • 유닉스는 생성을 fork(복제) + exec(교체) 로 쪼개, 그 틈에서 리다이렉션·파이프를 구현한다.
  • fork()가 빠른 이유는 COW — 쓰기 전까지 공유. “필요할 때까지 미룬다”는 OS의 핵심 철학.

다음 글: 한 프로세스 안에서 여러 실행 흐름을 돌리는 스레드와 동시성 — 주소 공간은 공유하되 스택·레지스터는 따로 갖는 가벼운 실행 단위, 그리고 그게 부르는 race condition의 세계로 이어집니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

시리즈 · 운영체제 A-Z (20편)

  1. 운영체제란 무엇인가 — 커널·유저모드·시스템콜, 왜 프로그램은 하드웨어를 직접 못 만지나
  2. 부팅 — 전원 버튼에서 커널까지, 그 1초 사이에 일어나는 모든 일
  3. 프로세스란 무엇인가 — 주소 공간·PCB·fork와 exec, 그리고 COW의 마법 지금 보는 글
  4. 스레드와 동시성 — 한 주소 공간 속 여러 실행 흐름, 그리고 비결정성의 시작
  5. CPU 스케줄링 — 누구에게 CPU를 줄까, FCFS부터 리눅스 CFS까지
  6. 컨텍스트 스위치 — 전환은 공짜가 아니다, 직접비용과 캐시·TLB 오염
  7. 프로세스 간 통신(IPC) — 격리된 프로세스가 협력하는 법, 파이프부터 공유 메모리까지
  8. 동기화 (1) — 경쟁 상태와 임계 구역, 뮤텍스 vs 스핀락
  9. 동기화 (2) — 세마포어·조건 변수, 그리고 데드락의 네 가지 조건
  10. 가상 메모리 — 모든 프로세스가 같은 주소에서 시작하는데 왜 안 겹치나
  11. 페이징·페이지 테이블·TLB — 4단계 주소 변환과 그 캐시
  12. 페이지 폴트·디맨드 페이징·스왑 — malloc 1GB가 즉시 성공하는 이유
  13. 페이지 교체 알고리즘 — 메모리가 꽉 찼을 때 누구를 쫓아낼까
  14. 메모리 할당자 — malloc은 어떻게 힙을 쪼개나, buddy와 slab까지
  15. 파일 시스템 (1) — 파일명은 파일이 아니다, inode와 VFS의 세계
  16. 파일 시스템 (2) — 쓰는 중 전원이 꺼지면? 저널링과 크래시 일관성
  17. I/O·인터럽트·DMA — CPU보다 백만 배 느린 장치와 효율적으로 대화하기
  18. 블록 I/O와 페이지 캐시 — 같은 파일을 두 번째 읽으면 왜 빠른가
  19. 가상화와 컨테이너 — VM과 컨테이너, 격리 스펙트럼의 양 끝
  20. 성능 관찰과 병목 분석 — "느려요"를 추측이 아니라 측정으로 (시리즈 마무리)