운영체제 학습 노트 목차

프로세스 — 격리된 실행 단위

OS가 *CPU를 추상화한 것이 프로세스*라고 했다. 좀 더 정확히 말하면, 프로세스는 실행 중인 프로그램의 독립된 인스턴스다. 같은 메모장 프로그램을 두 번 열면 프로세스가 둘이고, 둘은 서로의 메모리를 전혀 못 본다. 이 격리가 프로세스의 핵심 가치다 — 한 프로그램이 폭주해도 남에게 번지지 않는다. 그 격리가 어떻게 이뤄지고, 프로세스를 어떻게 만들고 전환하는지를 본다.

프로세스는 무엇으로 이뤄지나 — 주소 공간과 PCB

프로세스 하나에는 *자기만의 가상 주소 공간*이 통째로 주어진다. 이 공간은 보통 네 칸으로 나뉜다.

다이어그램 로딩 중…

코드(실행할 명령)·데이터(전역 변수)·(동적 할당 객체가 사는 곳)·스택(함수 호출·지역 변수)이다. 그리고 OS는 각 프로세스를 **PCB(Process Control Block)**라는 자료구조로 관리한다 — PID(번호)·현재 상태·우선순위·레지스터 값·열린 파일·메모리 정보 등 그 프로세스의 신분증이자 저장된 상태다. (출처: Process vs Thread.)

격리의 비결은 가상 주소 공간이다. 프로세스 A의 0x1000번지와 B의 0x1000번지는 — 같은 번지처럼 보여도 물리 메모리의 전혀 다른 곳으로 번역된다(다음 가상 메모리 편). 그래서 A는 B의 메모리를 가리킬 방법조차 없다 — 하드웨어가 번역 단계에서 막는다. 이게 프로세스 격리의 실체다.

프로세스는 어떻게 태어나나 — fork와 exec

프로세스무에서 생기지 않고 — 기존 프로세스가 자신을 복제해 만든다. 유닉스 계열에서는 두 시스템 콜의 조합이다. **fork**는 부모를 거의 그대로 복제한 자식 프로세스를 만든다(주소 공간·열린 파일까지 복사 — 실제로는 *copy-on-write*로 바뀔 때만 복사해 효율적이다). **exec**는 그 프로세스의 내용을 다른 프로그램으로 덮어쓴다. 그래서 셸이 명령을 실행할 때 — fork로 자식을 만들고, 그 자식이 exec로 원하는 프로그램이 된다. 모든 프로세스는 이렇게 부모-자식 트리를 이룬다.

한 번에 하나뿐인 CPU를 나눠 쓰기 — 상태와 컨텍스트 스위치

CPU 코어는 *한 순간에 한 프로세스*만 실행한다. 그래서 프로세스는 여러 상태를 오간다 — 준비(ready, 실행 대기), 실행(running, CPU 점유 중), 대기(waiting, I/O 등을 기다림). OS는 이들을 빠르게 번갈아 실행해 동시에 도는 것처럼 보이게 한다. 한 프로세스에서 다른 프로세스로 CPU를 넘기는 작업이 **컨텍스트 스위치**인데 — 현재 프로세스의 레지스터·상태를 PCB에 저장하고, 다음 프로세스의 것을 복원한다.

문제는 — 프로세스 간 컨텍스트 스위치는 비싸다는 것이다. 주소 공간이 바뀌므로, CPU의 TLB(주소 변환 캐시)와 캐시를 비워야(flush) 한다 — 그러면 그다음 한동안은 캐시 미스로 느려진다. (출처: Context switch.) 바로 이 프로세스 전환의 비싼 값 때문에 — 더 가벼운 실행 단위가 필요해졌고, 그게 다음 편의 *스레드*다.

실무에서는 — 프로세스가 격리됐다는 건 서로 통신하기도 까다롭다는 뜻이다. 그래서 *프로세스 간 통신(IPC)*에는 파이프·소켓·공유 메모리·시그널을 따로 쓴다. 서비스를 별도 프로세스로 나누면 — 격리·독립 배포를 얻지만 통신 비용이 따른다는 트레이드오프가 있다. 거꾸로, 전역으로 하나만 공유되는 자원이라 해도 그건 한 프로세스 안의 이야기다 — 프로세스가 다르면 메모리도 다르니, 공유하려면 IPC가 필요하다.

실제로 눈으로 보기

fork가 두 번 반환한다

#include <stdio.h>
#include <unistd.h>

int main(void) {
    printf("시작 pid=%d\n", getpid());
    pid_t pid = fork();                 // 여기서 프로세스가 둘로 갈라진다

    if (pid == 0)      printf("자식 pid=%d ppid=%d\n", getpid(), getppid());
    else if (pid > 0)  printf("부모 pid=%d 자식=%d\n", getpid(), pid);
    else               perror("fork");  // 실패

    return 0;                            // 이 줄이 두 번 실행된다
}

fork 한 번에 반환이 두 번 일어난다 — 부모에게는 자식 PID를, 자식에게는 0을 준다. 이 반환값이 유일한 구분 수단이다. 코드는 완전히 같은데 반환값 하나로 갈라진다.

새 프로그램으로 갈아입기 — exec

if (fork() == 0) {
    execl("/bin/ls", "ls", "-l", NULL);   // 성공하면 이 아래는 실행되지 않는다
    perror("execl");                       // 여기에 왔다면 실패한 것이다
    _exit(1);
}
wait(NULL);                                // 부모는 자식을 거둔다

exec새 프로세스를 만들지 않는다. 지금 프로세스의 주소 공간을 통째로 새 프로그램으로 갈아치운다. 그래서 성공하면 돌아올 자리가 없다. 셸이 명령을 실행하는 방식이 정확히 이 fork + exec 조합이다.

좀비와 고아

ps -eo pid,ppid,stat,comm | grep -E 'Z|defunct'
#  8123  8100 Z    myapp <defunct>
#                  └ Z = 좀비

좀비이미 끝났는데 부모가 종료 상태를 안 거둬 간 프로세스다. 메모리는 반납했지만 종료 코드를 담은 항목이 프로세스 표에 남아 있다. 부모가 wait을 안 부르면 계속 쌓이고, 결국 PID가 고갈된다.

고아는 반대다 — 부모가 먼저 죽은 자식인데, 이 경우 init(PID 1)이 입양해 대신 거둬 준다. 그래서 고아는 문제가 안 되고 좀비만 문제다.

프로세스 상태를 어디서 보나

cat /proc/1234/stat | awk '{print $3}'    # R S D Z T 중 하나
ps -eo pid,stat,wchan:20,comm | head
#  PID STAT WCHAN                COMMAND
# 1234 D    io_schedule          myapp     ← D = 인터럽트 불가 대기(주로 디스크)

STATD프로세스kill -9으로도 안 죽는다. 커널에서 I/O 완료를 기다리는 중이라 시그널을 받을 수 없기 때문이다. "프로세스가 안 죽는다"는 상황의 대부분이 이것이고, 원인은 대개 느리거나 멈춘 저장 장치다.

정리하면, 프로세스는 실행 중인 프로그램의 독립 인스턴스로 — 자기만의 주소 공간(코드·데이터·힙·스택)과 PCB를 갖고, 가상 주소 공간으로 서로 완전히 격리된다. *fork+exec*로 부모에서 복제돼 태어나고, 준비·실행·대기를 오가며 *컨텍스트 스위치*로 CPU를 나눠 쓴다 — 단 프로세스 전환은 주소 공간·TLB를 갈아 비싸다. 격리는 안전을 주지만 통신(IPC)과 전환의 비용을 부른다. 이 비용을 줄이려는 더 가벼운 실행 단위, 스레드가 다음이다.

OS의 추상화 — 커널/유저 모드·시스템 콜스레드 — 공유·전환 비용·유저/커널·가상 스레드