> [!abstract] Introduction > 지금 이 순간에도 컴퓨터는 수십, 수백 개의 작업을 동시에 처리하고 있습니다. 웹 브라우저에서 영상을 재생하는 동시에 메신저로 대화하고, 백그라운드에서는 파일을 다운로드합니다. 이 모든 것을 가능하게 하는 핵심 추상화가 바로 프로세스*Process*입니다. 이 글에서는 프로세스가 무엇이고 왜 필요한지, 그리고 운영체제가 프로세스를 어떻게 다루는지를 살펴봅니다. ## 프로세스의 역사 프로세스가 무엇인지를 깊이 이해하기 위해서는, 이 개념이 컴퓨터와 함께 어떻게 발전해왔는지를 살펴보는 것이 도움이 된다. 컴퓨터가 막 탄생했던 시절, 프로그램은 주로 천공 카드*punched card*라는 물리적인 형태를 갖추고 있었고, 컴퓨터는 주변기기를 통해 프로그램에 기록된 명령을 순서대로 읽고 실행했다. 프로그램 하나를 시작하고 나면 그 프로그램이 끝나야 다른 프로그램을 투입할 수 있었기 때문에, 사용자는 자신의 프로그램과 데이터를 작업 대기열*job queue*에 걸어놓고 앞선 작업이 끝날 때까지 기다려야 했다. 이때 작업*job*은 컴퓨터를 차지한 바로 그 작업을 가리키는 말이었고, 컴퓨터가 이 작업을 실행하는 최소 단위를 태스크*task*라 불렀다.[^edsac-video][^job-task-difference][^job-kernel-terminology] ![[OldJobQueue.png|실제 작업 대기열에 추가되고 있는 프로그램(물리)]] 위 그림은 EDSAC 소개 영상 중 프로그램이 기록된 천공 카드를 작업 대기열에 추가하고 있는 모습이다. 운영자*operator*가 천공 카드를 테이프 리더기에 놓으면 컴퓨터가 프로그램을 읽고 실행했고, 결과값이 적힌 종이를 보관함에 꽂아 의뢰자가 찾아가도록 했다. 컴퓨터가 발전하면서 이제 프로그램은 천공 카드가 아닌 저장 장치에 실행 가능한 형태로 저장된 명령어의 집합이 되었다. 운영자 대신 운영체제*operating system*가 컴퓨터를 관리하고, 물리적인 작업 대기열은 큐*queue*라는 자료구조로 구현되는 추상적인 개념이 되었다.[^unix-shell-job-abstraction] 이러한 변화 속에서 오늘날의 프로그램과 프로세스 개념이 자리 잡게 된다. [^edsac-video]: 케임브리지 대학의 EDSAC 소개 영상으로, 1951년 당시 천공 카드와 운영자 기반의 수동 컴퓨터 운영 방식을 보여준다. 자세한 내용은 [이 링크](https://www.youtube.com/watch?v=6v4Juzn10gM)에서 확인할 수 있다. [^job-task-difference]: Job, Task, Process의 역사적 구분을 설명하는 StackOverflow 답변이다. 오늘날 이 용어들의 경계는 모호해졌으나, 초기 컴퓨터에서는 명확한 구분이 있었다. 자세한 내용은 [이 링크](https://stackoverflow.com/questions/3073948/job-task-and-process-whats-the-difference)에서 확인할 수 있다. [^job-kernel-terminology]: 커널 수준에서 프로세스나 스레드를 다룰 때 작업*job*이라는 용어를 프로세스나 스레드 대신 사용하기도 한다: "Tasks or jobs are simply the pieces of work that should be performed in separate flows of execution." (Amini, 2019, p. 395) [^unix-shell-job-abstraction]: 이제 작업은 한줄의 커맨드를 실행한 결과로 생성된 프로세스를 가리키는 개념이 되었다: "Unix shells use the abstraction of a job to represent the processes that are created as a result of evaluating a single command line." (Bryant and O'Hallaron, 2016, p. 796) ## 프로그램과 프로세스 프로세스를 이해하기 위해서는 먼저 프로그램*program*과 프로세스의 차이를 명확히 구분할 필요가 있다. 프로그램은 디스크에 저장된 실행 가능한 명령어의 집합이다. 아직 실행되지 않은 상태의 프로그램은 그 자체로는 아무것도 하지 않는 정적인*static* 존재다. 텍스트 에디터든 웹 브라우저든, 실행하기 전에는 그저 저장 장치 위의 파일에 불과하다. 프로세스는 이 프로그램이 메모리에 올라와 실행되고 있는 상태, 즉 **실행 중인 프로그램의 동적인 인스턴스**다(Silberschatz, Galvin and Gagne, 2018). 하나의 프로그램에서 여러 프로세스가 만들어질 수 있고, 각 프로세스는 서로 독립적인 실행 흐름을 갖는다. 같은 웹 브라우저 프로그램을 두 번 실행하면, 두 개의 독립적인 프로세스가 생성되는 것이 그 예시다. ![[Loading.png]] 프로그램이 프로세스로 전환되려면 운영체제가 해당 프로그램을 디스크에서 읽어 주 메모리로 적재*load*해야 한다. 이 과정에서 운영체제는 프로세스를 위한 메모리 공간을 할당하고, 프로세스를 관리하기 위한 자료구조를 생성하며, 고유한 식별 번호를 부여한다. 프로그램이 단순한 명령어의 나열이라면, 프로세스는 그 명령어를 실행하기 위한 모든 맥락*context*을 포함하는 살아 있는 개체인 셈이다. 이 적재 과정은 커널의 권한이 필요하므로, 사용자 모드에서 커널 모드로의 전환이 수반되며, 이러한 전환은 [[System Call|시스템 호출]]을 통해 일어난다. ## 프로세스의 구성 프로세스가 메모리에 올라오면 운영체제는 해당 프로세스만의 가상 주소 공간*Virtual Address Space*을 마련한다.[^virtual-address-space] 이 공간은 크게 네 개의 영역으로 나뉜다. ![[LinuxProcessVirtualMemory.png]] **코드(텍스트) 영역**은 프로그램의 실행 코드가 저장되는 곳이다. 프로그램을 메모리에 적재할 때 디스크에서 읽어온 기계어 명령이 이곳에 놓이며, 실행 도중에 크기가 변하지 않는다. **데이터 영역**에는 전역 변수*global variable*와 정적 변수*static variable*가 저장된다. 프로그램이 시작될 때 할당되고, 프로세스가 종료될 때까지 유지되므로 역시 크기가 고정되어 있다. **힙 영역**은 프로그램이 실행 중에 동적으로 할당하는 메모리가 위치하는 공간이다. C 언어의 `malloc()`이나 Java의 `new` 연산자로 할당한 메모리가 여기에 해당한다. 프로그램이 실행되면서 크기가 늘어나거나 줄어들 수 있으며, 낮은 주소에서 높은 주소 방향으로 성장한다. **스택 영역**은 함수 호출 시 생성되는 지역 변수, 매개변수, 반환 주소 등이 저장되는 공간이다. 함수가 호출될 때마다 스택 프레임*stack frame*이 쌓이고, 함수가 반환되면 해당 프레임이 제거된다. 높은 주소에서 낮은 주소 방향으로 성장하며, 힙과 반대 방향으로 확장되는 구조 덕분에 두 영역이 서로의 공간을 효율적으로 활용할 수 있다. 리눅스에서는 이 가상 주소 공간을 `mm_struct`와 `vm_area_struct` 같은 자료구조로 구현하며, 각 가상 메모리 영역*VMA*이 연결 리스트와 레드-블랙 트리로 관리된다. ![[LinuxVirtualMemory.png]] [^virtual-address-space]: 가상 주소 공간이란 각 프로세스가 마치 자신만의 연속적인 메모리 공간을 독점하고 있는 것처럼 보이게 하는 추상화 기법이다. 실제 물리 메모리와의 매핑은 운영체제와 하드웨어([[Translation Lookaside Buffer]] 등)가 담당한다. ## 프로세스의 상태 운영체제는 한정된 CPU 자원을 여러 프로세스에게 공정하게 배분해야 한다. 이를 위해 각 프로세스에 상태*state*를 부여하고, 상태에 따라 자원을 할당하거나 회수한다. 프로세스가 가질 수 있는 상태는 일반적으로 다섯 가지다(Silberschatz, Galvin and Gagne, 2018). ![[ProcessStateFlowChart.png]] **new**는 프로세스가 막 생성된 상태다. 운영체제가 해당 프로세스를 위한 자료구조를 만들고 초기화하는 중이다. **ready**는 프로세스가 실행될 준비를 마치고 CPU를 할당받기를 기다리는 상태다. 실행 대기열*Ready Queue*에 들어가 [[Scheduler#스케줄링 알고리즘|스케줄러]]가 자신을 선택해주기를 기다린다. **running**은 CPU를 할당받아 명령을 실행하고 있는 상태다. 단일 코어 환경에서는 한 시점에 하나의 프로세스만이 이 상태에 있을 수 있다. **waiting**은 입출력 완료나 시그널*signal* 수신 등 특정 이벤트가 발생하기를 기다리는 상태다. 해당 이벤트가 발생하면 ready 상태로 전이된다. **terminated**는 프로세스가 실행을 마치고 종료된 상태다. 운영체제는 이 프로세스에 할당했던 자원을 회수한다. 이 상태 전이는 운영체제가 프로세스를 효율적으로 관리하기 위한 핵심 메커니즘이다. running 상태의 프로세스가 입출력을 요청하면 waiting 상태로 전이되고, 그 사이에 ready 상태의 다른 프로세스가 CPU를 할당받아 running 상태가 된다. 이러한 전환을 [[Areas/Notes/Context Switch|컨텍스트 스위치]]라 하며, 이 과정 덕분에 사용자는 여러 프로그램이 동시에 실행되고 있다고 느낄 수 있다. ![[ReadyQueueAndWaitQueue.png]] 운영체제는 위와 같이 실행 대기열과 대기열을 통해 프로세스를 상태별로 분류하여 관리한다. ### 리눅스의 프로세스 상태 리눅스에서는 위의 이론적 상태를 바탕으로 다섯 가지 프로세스 상태를 지원한다. ![[LinuxProcessState.png]] **`TASK_RUNNING`**은 프로세스가 실행 가능한 상태를 의미한다. 현재 실행 중이거나 실행 대기열*run queue*에 들어가 있는 상태다. 사용자 공간에서 실행된 프로세스는 이 상태만 가질 수 있으며, 커널 공간에서 실행 중인 프로세스도 이 상태에 속한다. **`TASK_INTERRUPTIBLE`**은 프로세스가 특정 조건이 발생하기를 기다리며 쉬는 중인 상태다. 기다리는 조건이 발생하면 커널은 프로세스의 상태를 `TASK_RUNNING`으로 바꾼다. 프로세스가 시그널을 받은 경우에는 조건에 상관없이 실행 가능한 상태로 바뀐다. **`TASK_UNINTERRUPTIBLE`**은 시그널을 받아도 실행 가능한 상태로 바뀌지 않는다는 점만 제외하면 `TASK_INTERRUPTIBLE`과 같은 상태다. 프로세스가 방해받지 않고 특정 조건을 기다려야 하거나, 기다리는 조건이 금방 발생하는 경우에 사용된다. **`__TASK_TRACED`**는 디버거 등의 다른 프로세스가 `ptrace`를 통해 해당 프로세스를 추적하고 있는 상태다. **`__TASK_STOPPED`**는 프로세스의 실행이 정지된 상태로, 실행 중이지도 않고 실행 가능한 상태도 아니다. `SIGSTOP`, `SIGTSTP`, `SIGTTIN`, `SIGTTOU` 같은 시그널을 받거나 디버그 중에 시그널을 받으면 이 상태가 된다. ## 프로세스의 관리 운영체제가 수많은 프로세스를 추적하고 관리하려면, 각 프로세스에 대한 정보를 체계적으로 기록할 수단이 필요하다. 이 역할을 하는 것이 [[Process Control Block|PCB(Process Control Block)]]다. ![[PCBLayout.png|PCB의 대략적인 구조]] PCB는 프로세스마다 하나씩 존재하는 자료구조로, 프로세스의 상태, 프로그램 카운터*Program Counter*[^program-counter], CPU 레지스터 값, 메모리 관리 정보, 입출력 상태 등 프로세스를 통제하기 위한 모든 정보를 담고 있다. 운영체제는 이 PCB를 리스트 형태로 관리하며, [[Scheduler#스케줄링 알고리즘|스케줄러]]가 다음에 실행할 프로세스를 선택할 때도 이 리스트를 참조한다. ![[TaskList.png]] 리눅스에서는 PCB를 `task_struct`라는 구조체로 구현하고 있으며, 이 구조체들이 원형 이중 연결 리스트인 태스크 리스트*task list*를 이룬다. PCB의 세부 구조와 리눅스에서의 구현에 대해서는 [[Process Control Block]] 포스트에서 자세히 다룬다. [^program-counter]: 프로그램 카운터는 CPU가 다음에 실행할 명령어의 주소를 저장하는 레지스터다. 프로세스가 중단되었다가 다시 실행될 때, PCB에 저장된 프로그램 카운터 값을 복원함으로써 중단된 지점부터 실행을 재개할 수 있다. ## 프로세스의 생성과 종료 대부분의 운영체제에서 프로세스는 다른 프로세스에 의해 생성된다. 새로운 프로세스를 만든 프로세스를 부모 프로세스*parent process*, 만들어진 프로세스를 자식 프로세스*child process*라 부르며, 이 관계가 연쇄적으로 이어져 프로세스 트리*process tree*를 형성한다.[^process-tree-init] 리눅스에서 새로운 프로세스를 생성하는 [[System Call#왜 시스템 호출이 필요한가|시스템 호출]]은 [[fork()|`fork()`]]다. `fork()`를 호출하면 현재 프로세스의 복사본이 만들어지며, 부모와 자식 프로세스는 `fork()` 이후의 코드를 각각 독립적으로 실행한다.[^fork-called-once-returns-twice] 자식 프로세스가 완전히 다른 프로그램을 실행해야 할 때는 [[exec()|`exec()`]] 계열 함수를 사용하여 프로세스의 메모리 공간을 새로운 프로그램으로 교체한다. ```c #include <unistd.h> pid_t pid = fork(); if (pid == 0) { // 자식 프로세스: 새로운 프로그램 실행 execl("/bin/ls", "ls", "-l", NULL); } else if (pid > 0) { // 부모 프로세스: 자식 프로세스가 끝나기를 기다림 wait(NULL); } ``` 프로세스의 종료는 자발적 종료와 비자발적 종료로 나눌 수 있다. 프로세스가 모든 작업을 마치고 `exit()` 시스템 호출을 통해 스스로 종료하는 것이 자발적 종료이고, 처리할 수 없는 시그널이나 예외에 의해 강제로 종료되는 것이 비자발적 종료다. 어느 쪽이든 커널은 해당 프로세스에 할당된 자원을 회수하고 부모 프로세스에 종료 사실을 알린다. 각 시스템 호출의 커널 내부 구현에 대한 자세한 내용은 [[fork()]]와 [[exec()]] 포스트에서 다루며, 프로세스 종료의 전체 과정은 [[프로세스 종료]] 포스트에서 상세히 살펴본다. 프로세스 종료 과정에서 발생할 수 있는 특수한 상황으로는 좀비 프로세스*zombie process*와 고아 프로세스*orphan process*가 있다. 좀비 프로세스는 자식 프로세스가 종료되었지만 부모 프로세스가 아직 종료 상태를 수거하지 않아 PCB가 남아 있는 상태이며, 고아 프로세스는 부모 프로세스가 먼저 종료되어 부모를 잃은 프로세스를 말한다. 리눅스에서 고아 프로세스는 `init` 프로세스(PID 1)가 새로운 부모로 지정된다.[^orphan-process-subreaper] [^process-tree-init]: 리눅스에서 모든 프로세스의 조상은 부팅 시 커널이 생성하는 `init` 프로세스(PID 1)다. `pstree` 명령어로 프로세스 트리를 확인할 수 있다. [^fork-called-once-returns-twice]: `fork()`는 한번 호출하고 두번 반환되는*called once, returns twice* 특이한 함수다. 부모 프로세스에게는 자식 프로세스의 PID를, 자식 프로세스에게는 0을 반환한다(Bryant and O'Hallaron, 2016). [^orphan-process-subreaper]: 최근 리눅스 커널(v3.4 이후)에서는 `PR_SET_CHILD_SUBREAPER` 옵션을 사용하여 `init`이 아닌 다른 프로세스가 고아 프로세스의 부모 역할을 맡을 수도 있다. systemd가 대표적인 예시다. ## 프로세스와 스레드 프로세스는 강력한 격리를 제공하지만, 그만큼 비용이 크다. 새로운 프로세스를 생성할 때마다 독립적인 주소 공간을 만들어야 하고, 프로세스 간 데이터를 주고받으려면 [[Inter-Process Communication#왜 프로세스 간 통신이 필요한가|IPC(Inter-Process Communication)]]라는 별도의 통신 메커니즘을 거쳐야 한다. 이러한 비용 문제를 해결하기 위해 등장한 것이 [[Thread#스레드란 무엇인가?|스레드]]다. 스레드는 프로세스 내부에서 코드, 데이터, 힙 영역을 공유하면서 각자 독립적인 스택과 레지스터를 가지는 실행 단위다. 같은 프로세스에 속한 스레드끼리는 메모리를 공유하므로 별도의 IPC 없이도 빠르게 데이터를 주고받을 수 있고, 스레드 생성과 [[Areas/Notes/Context Switch|컨텍스트 스위치]] 비용도 프로세스에 비해 훨씬 적다. 프로세스와 스레드의 차이에 대한 자세한 내용은 [[Thread]] 포스트에서 다룬다. --- ## 출처 - Silberschatz, A., Galvin, P.B. and Gagne, G. (2018) *Operating System Concepts*. 10th edn. Hoboken: Wiley. - Bryant, R.E. and O'Hallaron, D.R. (2016) *Computer Systems: A Programmer's Perspective*. 3rd edn. Boston: Pearson. - Love, R. (2010) *Linux Kernel Development*. 3rd edn. Upper Saddle River: Addison-Wesley. - Tanenbaum, A.S. and Bos, H. (2023) *Modern Operating Systems*. 5th edn. Hoboken: Pearson. - Amini, H. (2019) *Hands-on System Programming with Linux*. Birmingham: Packt Publishing.