> [!abstract] Introduction
> 지금 이 순간에도 컴퓨터는 수십, 수백 개의 작업을 동시에 처리하고 있습니다. 웹 브라우저에서 영상을 재생하는 동시에 메신저로 대화하고, 백그라운드에서는 파일을 다운로드합니다. 이 모든 것을 가능하게 하는 핵심 추상화가 바로 프로세스*Process*입니다. 이 글에서는 프로세스가 무엇이고 왜 필요한지, 그리고 운영체제가 프로세스를 어떻게 다루는지를 살펴봅니다.
## 프로그램과 프로세스
프로세스를 이해하기 위해서는 먼저 프로그램*program*과 프로세스의 차이를 명확히 구분할 필요가 있다.
프로그램은 디스크에 저장된 실행 가능한 명령어의 집합이다. 아직 실행되지 않은 상태의 프로그램은 그 자체로는 아무것도 하지 않는 정적인*static* 존재다. 텍스트 에디터든 웹 브라우저든, 실행하기 전에는 그저 저장 장치 위의 파일에 불과하다.
프로세스는 이 프로그램이 메모리에 올라와 실행되고 있는 상태, 즉 **실행 중인 프로그램의 동적인 인스턴스**다(Silberschatz, Galvin and Gagne, 2018). 하나의 프로그램에서 여러 프로세스가 만들어질 수 있고, 각 프로세스는 서로 독립적인 실행 흐름을 갖는다. 같은 웹 브라우저 프로그램을 두 번 실행하면, 두 개의 독립적인 프로세스가 생성되는 것이 그 예시다.
![[Loading.png]]
프로그램이 프로세스로 전환되려면 운영체제가 해당 프로그램을 디스크에서 읽어 주 메모리로 적재*load*해야 한다. 이 과정에서 운영체제는 프로세스를 위한 메모리 공간을 할당하고, 프로세스를 관리하기 위한 자료구조를 생성하며, 고유한 식별 번호를 부여한다. 프로그램이 단순한 명령어의 나열이라면, 프로세스는 그 명령어를 실행하기 위한 모든 맥락*context*을 포함하는 살아 있는 개체인 셈이다.
## 프로세스의 구성
프로세스가 메모리에 올라오면 운영체제는 해당 프로세스만의 가상 주소 공간*Virtual Address Space*을 마련한다.[^virtual-address-space] 이 공간은 크게 네 개의 영역으로 나뉜다.
![[LinuxProcessVirtualMemory.png]]
**코드(텍스트) 영역**은 프로그램의 실행 코드가 저장되는 곳이다. 프로그램을 메모리에 적재할 때 디스크에서 읽어온 기계어 명령이 이곳에 놓이며, 실행 도중에 크기가 변하지 않는다.
**데이터 영역**에는 전역 변수*global variable*와 정적 변수*static variable*가 저장된다. 프로그램이 시작될 때 할당되고, 프로세스가 종료될 때까지 유지되므로 역시 크기가 고정되어 있다.
**힙 영역**은 프로그램이 실행 중에 동적으로 할당하는 메모리가 위치하는 공간이다. C 언어의 `malloc()`이나 Java의 `new` 연산자로 할당한 메모리가 여기에 해당한다. 프로그램이 실행되면서 크기가 늘어나거나 줄어들 수 있으며, 낮은 주소에서 높은 주소 방향으로 성장한다.
**스택 영역**은 함수 호출 시 생성되는 지역 변수, 매개변수, 반환 주소 등이 저장되는 공간이다. 함수가 호출될 때마다 스택 프레임*stack frame*이 쌓이고, 함수가 반환되면 해당 프레임이 제거된다. 높은 주소에서 낮은 주소 방향으로 성장하며, 힙과 반대 방향으로 확장되는 구조 덕분에 두 영역이 서로의 공간을 효율적으로 활용할 수 있다.
[^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 상태가 된다. 이러한 전환을 [[Context Switch|컨텍스트 스위치]]라 하며, 이 과정 덕분에 사용자는 여러 프로그램이 동시에 실행되고 있다고 느낄 수 있다.
![[ReadyQueueAndWaitQueue.png]]
운영체제는 위와 같이 실행 대기열과 대기열을 통해 프로세스를 상태별로 분류하여 관리한다.
## 프로세스의 관리
운영체제가 수많은 프로세스를 추적하고 관리하려면, 각 프로세스에 대한 정보를 체계적으로 기록할 수단이 필요하다. 이 역할을 하는 것이 [[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-called-once-returns-twice] 자식 프로세스가 완전히 다른 프로그램을 실행해야 할 때는 `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()` 시스템 호출을 통해 스스로 종료하는 것이 자발적 종료이고, 처리할 수 없는 시그널이나 예외에 의해 강제로 종료되는 것이 비자발적 종료다. 어느 쪽이든 커널은 해당 프로세스에 할당된 자원을 회수하고 부모 프로세스에 종료 사실을 알린다.
프로세스 종료 과정에서 발생할 수 있는 특수한 상황으로는 좀비 프로세스*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 없이도 빠르게 데이터를 주고받을 수 있고, 스레드 생성과 [[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.