icon

안동민 개발노트

3장 : 프로세스

프로세스의 개념


디스크에 저장된 실행 파일은 프로그램입니다. 이 프로그램을 실행하면 메모리에 적재되어 CPU에 의해 실행되는데, 이 실행 중인 인스턴스가 프로세스(Process)입니다. 같은 프로그램을 두 번 실행하면 두 개의 독립적인 프로세스가 생성됩니다. 크롬 브라우저를 두 개 열면, 같은 프로그램이지만 서로 다른 두 개의 프로세스가 각각의 메모리 공간에서 독립적으로 동작합니다.

이 구분이 중요한 이유는, 프로그램은 그저 디스크에 저장된 정적인 바이트 시퀀스일 뿐이고, OS가 실제로 관리하는 단위는 프로세스이기 때문입니다. OS가 CPU를 할당하고, 메모리를 보호하고, I/O를 중재하는 모든 작업의 대상이 프로세스입니다. 운영체제를 이해한다는 것은 곧 프로세스를 이해한다는 것과 같습니다.


프로세스 메모리 구조

프로세스가 메모리에 적재되면, OS는 다음과 같은 구조로 메모리를 배치합니다. 이 구조를 이해하면 스택 오버플로우, 메모리 누수, 세그먼테이션 폴트 같은 오류의 원인이 명확해집니다.

텍스트(Text/Code) 영역: 실행할 기계어 코드가 저장됩니다. 읽기 전용이므로 프로세스가 자기 코드를 수정할 수 없습니다. 읽기 전용으로 보호하는 이유는 두 가지입니다. 첫째, 프로그램의 코드가 실행 중에 변경되면 예측 불가능한 동작이 발생합니다. 둘째, 같은 프로그램의 여러 프로세스가 텍스트 영역을 공유할 수 있어 메모리를 절약합니다. 크롬 탭 10개가 모두 같은 크롬 코드를 사용하는데, 각각 별도로 적재하면 낭비입니다.

데이터(Data) 영역: 전역 변수와 정적(static) 변수가 저장됩니다. 이 영역은 다시 두 개로 나뉩니다. .data 섹션은 초기값이 있는 전역 변수(int count = 10;)를 저장하며, 프로그램 바이너리에 초기값이 포함됩니다. .bss 섹션은 초기값이 없는 전역 변수(int total;)를 저장하며, 바이너리에는 크기 정보만 있고 실행 시 0으로 초기화됩니다. .bss를 별도로 관리하는 이유는 바이너리 파일 크기를 줄이기 위해서입니다. 0으로 초기화할 변수를 위해 수 MB의 0을 바이너리에 저장할 필요가 없습니다.

힙(Heap) 영역: 프로그래머가 런타임에 동적으로 할당하는 메모리입니다. C의 malloc(), C++의 new, Java의 new, Python의 모든 객체 생성이 이 영역을 사용합니다. 주소가 높은 방향으로 커집니다. 할당 후 해제하지 않으면 메모리 누수(Memory Leak)가 발생합니다. Java, Python, Go 등은 가비지 컬렉터가 자동으로 해제하지만, C/C++에서는 프로그래머가 직접 free()/delete로 해제해야 합니다.

스택(Stack) 영역: 함수 호출 시 생성되는 스택 프레임이 쌓입니다. 각 프레임에는 지역 변수, 함수 매개변수, 복귀 주소(함수가 끝나면 돌아갈 주소)가 저장됩니다. 주소가 낮은 방향으로 커집니다. 함수가 반환되면 프레임이 제거되므로, 지역 변수의 메모리는 자동으로 회수됩니다. 재귀 호출이 너무 깊으면 스택이 한계를 넘어 스택 오버플로우(Stack Overflow)가 발생합니다.

메모리 영역별 변수 위치
#include <stdio.h>
#include <stdlib.h>

int global_init = 42;       /* .data 영역 */
int global_uninit;           /* .bss 영역 */

void func() {
    int local = 10;          /* 스택 영역 */
    int *heap = malloc(100); /* 힙 영역 (포인터 자체는 스택) */

    printf("Code:   %p\n", (void *)func);
    printf("Data:   %p\n", (void *)&global_init);
    printf("BSS:    %p\n", (void *)&global_uninit);
    printf("Heap:   %p\n", (void *)heap);
    printf("Stack:  %p\n", (void *)&local);

    free(heap);
}

int main() {
    func();
    return 0;
}

이 프로그램을 실행하면 각 영역의 주소가 출력됩니다. 코드 영역은 낮은 주소에, 힙은 그 위에, 스택은 높은 주소에 위치하는 것을 확인할 수 있습니다. 힙과 스택 사이에는 빈 공간이 있어, 두 영역이 각각 반대 방향으로 확장할 수 있습니다.

Linux에서 실행 중인 프로세스의 메모리 맵을 확인하려면 /proc/<PID>/maps 파일을 읽으면 됩니다.

프로세스 메모리 맵 확인
cat /proc/self/maps
# 주소 범위          권한   오프셋    장치    inode  경로
# 5600a0000000-5600a0001000 r--p 00000000 08:01 1234 /usr/bin/cat
# 5600a0001000-5600a0005000 r-xp 00001000 08:01 1234 /usr/bin/cat  (텍스트)
# 7f1234000000-7f1234021000 rw-p 00000000 00:00 0    [heap]
# 7ffd12340000-7ffd12361000 rw-p 00000000 00:00 0    [stack]

r-xp는 읽기+실행 권한(코드 영역), rw-p는 읽기+쓰기 권한(힙, 스택)입니다. 이 맵을 보면 프로세스가 어떤 메모리 영역을 어떤 권한으로 사용하는지 한눈에 파악할 수 있습니다.


프로세스의 상태

프로세스는 생성부터 종료까지 여러 상태를 거칩니다. 이 상태 모델은 OS가 수많은 프로세스를 효율적으로 관리하는 기반입니다.

New(생성): 프로세스가 만들어지고 있는 상태입니다. OS가 PCB를 할당하고, 메모리 공간을 설정하는 중입니다.

Ready(준비): 실행될 준비가 완료되어 CPU 할당을 기다리는 상태입니다. Ready Queue에 들어갑니다. "나는 실행할 수 있다. CPU만 주면 바로 간다." 이 상태에서는 CPU만 부족한 것이고, 다른 자원은 모두 확보된 상태입니다.

Running(실행): CPU를 점유하여 명령어를 실행하고 있는 상태입니다. 단일 코어에서는 한 번에 정확히 하나의 프로세스만 Running 상태입니다. N개의 코어가 있으면 최대 N개의 프로세스가 동시에 Running 상태일 수 있습니다.

Waiting/Blocked(대기): I/O 완료나 이벤트 발생을 기다리는 상태입니다. CPU가 비어 있어도 이 프로세스는 실행될 수 없습니다. 요청한 I/O가 끝나야만 다시 Ready로 돌아갑니다. 예를 들어 디스크에서 파일을 읽고 있거나, 네트워크 응답을 기다리거나, 사용자의 키보드 입력을 기다리는 경우입니다.

Terminated(종료): 실행이 완료되었거나 강제 종료된 상태입니다. 사용하던 메모리, 파일 디스크립터 등의 자원이 OS에 반환됩니다. 단, 부모 프로세스가 종료 상태를 수거(wait())할 때까지 PCB는 남아 있습니다.

Linux에서는 추가로 두 가지 상태가 있습니다. Stopped는 SIGSTOP 시그널을 받아 일시 정지된 상태입니다. 터미널에서 Ctrl+Z를 누르면 이 상태가 됩니다. fg 명령으로 재개할 수 있습니다. Zombie는 프로세스가 종료되었지만 부모가 아직 wait()를 호출하지 않은 상태입니다.

ps 명령어에서 볼 수 있는 프로세스 상태 코드를 정리하면 다음과 같습니다.

코드의미설명
RRunning/Runnable실행 중이거나 Ready Queue에 있음
SSleeping (Interruptible)I/O 대기 (시그널로 깨울 수 있음)
DSleeping (Uninterruptible)I/O 대기 (시그널로 깨울 수 없음, 주로 디스크 I/O)
TStoppedSIGSTOP으로 정지됨
ZZombie종료되었으나 wait()되지 않음

D 상태는 특별한 경우입니다. NFS 마운트가 응답하지 않거나, 디스크가 불량 섹터를 읽고 있을 때 프로세스가 D 상태에 빠질 수 있습니다. kill -9로도 종료할 수 없는데, 커널이 I/O 완료를 보장해야 데이터가 손상되지 않기 때문입니다. D 상태의 프로세스가 많다면 디스크나 네트워크 파일 시스템에 문제가 있다는 신호입니다.


상태 전이

상태 간의 전환은 다음과 같이 발생합니다. 각 전이의 원인을 이해하면, OS의 동작 흐름이 자연스럽게 따라옵니다.

  • New → Ready: 프로세스 생성이 완료되어 Ready Queue에 편입됩니다. fork() 시스템 콜이 성공적으로 완료된 시점입니다.

  • Ready → Running: 스케줄러가 이 프로세스를 선택하여 CPU를 할당합니다. 이 행위를 디스패치(Dispatch)라 합니다. 5장에서 스케줄러가 어떤 기준으로 선택하는지 다룹니다.

  • Running → Waiting: 프로세스가 I/O를 요청하거나 이벤트를 기다려야 할 때 발생합니다. read() 시스템 콜로 디스크 데이터를 요청하면, 데이터가 준비될 때까지 Waiting 상태로 전환됩니다. 이때 CPU가 놀지 않도록 스케줄러가 Ready Queue에서 다른 프로세스를 꺼내 실행합니다.

  • Running → Ready: 선점(Preemption)이 발생합니다. 타이머 인터럽트가 발생하여 현재 프로세스의 타임 슬라이스가 만료되었거나, 더 높은 우선순위의 프로세스가 Ready 상태가 되어 현재 프로세스를 밀어내는 경우입니다. 2장에서 다룬 타이머 인터럽트가 바로 이 전이를 일으킵니다.

  • Waiting → Ready: 기다리던 I/O가 완료되었습니다. 디스크 컨트롤러가 인터럽트를 보내면, 커널이 해당 프로세스를 Waiting Queue에서 Ready Queue로 옮깁니다. 곧바로 Running이 되는 것이 아니라, Ready Queue에서 스케줄러의 선택을 기다려야 합니다.

  • Running → Terminated: 프로세스가 exit() 시스템 콜을 호출하거나, main() 함수가 반환하거나, 처리되지 않은 시그널(SIGSEGV 등)로 강제 종료됩니다.

실무에서 이 상태 전이를 관찰하는 가장 간단한 방법은 top 또는 htop 명령어입니다.

프로세스 상태 실시간 관찰
top
# Tasks: 312 total,   1 running, 295 sleeping,   0 stopped,  16 zombie
#         ↑           ↑ Running    ↑ Waiting               ↑ 문제!

htop  # 더 직관적인 인터페이스, 트리 뷰 지원

top 출력의 첫 줄에서 Running, Sleeping(Waiting), Stopped, Zombie 프로세스의 수를 한눈에 볼 수 있습니다. Zombie가 0이 아니면 부모 프로세스가 wait()를 제대로 하지 않고 있다는 뜻이므로, 해당 부모를 찾아 수정해야 합니다.

다음 절에서는 OS가 프로세스의 모든 정보를 관리하는 PCB(Process Control Block)와 프로세스 전환의 핵심인 컨텍스트 스위칭을 다루겠습니다.

목차