#os #computerarchitecture
> [!introduction]
> 오늘날 우리는 컴퓨터로 여러가지 일을 동시에 합니다. 저도 지금 웹브라우저로 필요한 정보를 찾는 동시에 화면을 반으로 나누어 한쪽에 초고를 띄워놓고 나머지 반쪽 화면을 통해 이 글을 적고 있습니다. 어떻게 이런 일이 가능한 것일까요? 그 비밀은 프로세스*Process*라는 개념에 있습니다.
# 프로세스란 무엇인가?
컴퓨터라는 물건이 탄생한지 얼마 되지 않았던 시절, 아직 운영체제라는 개념이 존재하지 않았던 컴퓨터는 연구실에서나 볼 수 있는 귀한 물건이었고 한 대를 여러 사람이 같이 써야 할 만큼 비쌌다. 프로그램은 주로 천공 카드*punched card*라는 물리적인 형태를 갖추고 있었고, 컴퓨터는 주변기기를 통해 프로그램에 기록된 명령을 순서대로 읽고 그에 맞는 명령을 실행했다.

프로그램 하나를 시작하고 나면 그 프로그램이 끝나야 다른 프로그램을 투입할 수 있었기 떄문에, 컴퓨터를 사용하고자 하는 사람은 자신의 프로그램과 데이터를 대기열에 걸어놓고 먼저 신청한 사람의 작업이 끝날 때까지 기다려야 했는데, 이때 작업*job*은 컴퓨터를 차지한 바로 그 작업을 가리키는 말이었고, 컴퓨터가 이 작업을 실행하는 최소 단위를 태스크*task*라 불렀다.[^1][^2][^3]
![[Loading.png]]
오늘날도 컴퓨터는 프로그램을 실행한다. 하지만 컴퓨터가 발전하면서 이제 프로그램은 먼 옛날의 그 천공 카드가 아닌, 하드디스크와 같은 저장 장치에 실행할 수 있는 형태로 저장되어 있는 명령어의 집합이다. 컴퓨터가 프로그램을 실행하려면 저장 장치에서 프로그램을 읽어 RAM으로 불러오는 방식으로 프로그램을 실행하는데, 이 과정에서 앞서 소개한 개념이 오늘날 사용되는 프로그램과 프로세스의 개념으로 변화하게 된다. 프로그램은 이제 구멍이 뚫린 종이 카드가 아니라 특정 매체에 저장된 목적 코드*Object Code*이며, **프로세스는 실행 중인 프로그램의 상태, 컴퓨터에서 연속적으로 실행되고 있는 프로그램의 '동적인 상태'다.**[^4]
[^1]: [케임브리지 대학 수학 연구소의 컴퓨터 EDSAC에 대한 1951년 영상](https://sms.cam.ac.uk/media/739532)을 통해 초창기 컴퓨터가 어떻게 사용되었는지 엿볼 수 있다.
[^2]: 자세한 내용은 [이 링크](https://stackoverflow.com/questions/3073948/job-task-and-process-whats-the-difference)를 통해 확인할 수 있다. 오늘날 Job과 Task는 구분없이 사용되기도 하며 그 정의가 모호하다.
[^3]: 커널 수준에서 프로세스나 스레드를 다룰 때 작업*job*이라는 용어를 프로세스나 스레드 대신 사용하기도 한다: “Tasks or jobs are simply the pieces of work that should be performed in separate flows of execution.” (Amini, 2019, p. 395)
[^4]: 이제 작업은 한줄의 커맨드를 실행한 결과로 생성된 프로세스를 가리키는 개념이 되었다: “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)
# 프로그램에서 프로세스로
요약하자면 프로세스는 작동 중인 프로그램 및 그와 관련된 자원을 뜻한다. 그렇다면 프로그램은 어떻게 프로세스가 되는 것일까? 어떤 프로그램의 프로세스가 만들어지는 과정을 자세히 들여다보자.
## User Mode & Kernel Mode
오늘날 컴퓨터가 하는 일은 한두개가 아니다. 운영체제의 관점에서 보지 않아도 그렇고, 운영체제의 관점에서 보면 생각보다 컴퓨터가 하는 일이 엄청 많다. 하지만 그렇다고 해서 이 모든 일을 한번에 해낼 수는 없는 노릇이다. 컴퓨터가 사용 가능한 자원은 한정되어 있고 컴퓨터의 자원이 필요한 프로그램도 한두개가 아니다.
프로그램을 디스크에서 불러와 RAM에 프로세스를 만들기 위해서는 작업이 이루어지는 시간 동안 하드웨어를 제어할 권한이 필요하다. 그런데 CPU, 메모리, 입출력 장치 등의 하드웨어는 여러가지 작업에서 공통적으로 사용하는 핵심 자원이기 때문에, 프로그램을 디스크에서 읽어오는 와중에 다른 프로세스가 디스크나 메모리에 멋대로 개입해 작업을 망치게 둘 수는 없다. 그래서 일반적으로 운영체제에서는 임의의 프로세스가 하드웨어를 제멋대로 다룰 수 없도록 설계되어 있으며, 대신 커널*Kernel*[^5]을 통해서만 하드웨어를 다룰 수 있다.
[^5]: 운영체제의 핵심이 되는 프로그램으로, 시스템의 모든 것을 완전히 제어하고 시스템을 관리한다.
당연히 커널과 응용프로그램(사용자)의 활동 영역은 철저하게 분리되어 있으며, 프로세스에서 코드를 실행하는 상황은 커널의 권한이 필요없는 사용자 모드*User Mode*와 커널의 권한이 있어야 실행할 수 있는 커널 모드*Kernel Mode*로 구분할 수 있다.
## Exception & Trap
그렇다면 사용자 모드에서 커널 모드로 넘어가는 그 과정은 어떻게 이루어지는 것일까? 바로 여기서 예외*exception*와 트랩*trap*이라는 개념이 등장한다.
![[AnatomyOfAnException.png|Bryant and O'Hallaron, 2016, p. 760]]
예외란, 프로세스가 자신의 코드를 실행하다가 중단해야 하는 상황을 의미한다. 프로세스가 실행을 중단해야 하는 이유는 다양하다. 디스크에서 어떤 파일을 읽어오기 위해 사용자 모드에서 커널 모드로 넘어가야 하는 상황일 수도 있고, 프로세스가 강제 종료되는 상황일 수도 있다. 이렇듯 일단 프로세스를 중단해야 하는 일이 발생했을 때, 운영체제에서는 예외 처리를 위해 핸들러*Handler*에게 프로그램의 제어 권한을 잠시 넘기고 핸들러가 성공적으로 예외를 처리하면 다시 핸들러로부터 제어 권한을 넘겨받아 프로그램을 계속 실행한다. 예외 덕분에 응용프로그램과 운영체제는 평소에 분리되어 있다가 중요한 일이 생기면 서로 협동할 수 있다.
![[TrapHandling.png|Bryant and O'Hallaron, 2016, p. 763]]
트랩은 이 예외의 하위 개념 중 하나로, 응용프로그램이 고급 권한을 요구하는 명령을 수행하기 위해 **의도적으로** 발생시킨 예외다. 트랩을 통해 응용프로그램은 커널의 권한이 필요한 명령을 수행할 수 있으며, 이러한 명령을 시스템 호출*system call*이라 부른다. 그렇다면 핸들러는 어떻게 각 시스템 호출에 맞는 명령을 수행할 수 있는 걸까? 그 비밀은 예외 테이블*Exception Table*이라는 자료구조에서 엿볼 수 있다.
## Exception Table
프로세스를 생성시키기 위한 시스템 호출은 핸들러에게 제어 권한을 넘기는데, 이때 핸들러는 모든 시스템 호출을 처리하는 하나의 함수가 아니며 오직 하나의 예외만을 처리한다. 그래서 운영체제에는 보통 예외 처리를 담당하는 핸들러들을 가리키는 주소가 담긴 테이블이 구현되어 있다.
![[ExceptionTable.png|Bryant and O'Hallaron, 2016, p. 760]]
예외 테이블이라 불리는 이 테이블에는 여러가지 예외 상황을 규정하고 여기에 0부터 $n-1$까지의 번호를 겹치지 않게 하나씩 연결한다. 예를 들어, x86-64 시스템에서 실행되고 있는 리눅스 환경에서는 0으로 나누려 할 때 생기는 오류가 0번에 대응한다. 이 번호가 예외 테이블의 인덱스이며, 해당 인덱스가 가리키는 곳에는 예외 상황을 처리하기 위한 코드로 향하는 주소가 담겨 있어 예외 상황이 발생하면 커널은 상황에 맞는 코드를 실행할 수 있다. 여기에는 당연히 프로세스를 생성하기 위한 시스템 호출도 포함되어 있다.
운영체제의 양대산맥이라 할 수 있는 리눅스와 윈도우 모두 프로세스를 생성하기 위한 시스템 콜이 존재하는데, 리눅스의 `fork`, 윈도우의 `NtCreateProcess`가 바로 새로운 프로세스를 생성하는 시스템 호출이다.
현재 실행 중인 프로세스가 새 프로세스를 생성하는 시스템 콜을 실행하면 트랩이 발생하고, 이에 따라 프로그램의 제어 권한을 넘겨받은 커널에서는 트랩 테이블에서 프로세스를 생성하는 핸들러를 호출한다.
## 공간 마련하기
프로세스를 생성하는 핸들러에서는 프로세스가 사용할 주소 공간을 만들어주는데, 이 공간을 가상 메모리*Virtual Memory* 혹은 가상 주소 공간*Virtual Address Space*라 부른다. 커널은 새로운 프로세스를 위한 여러 자료구조를 생성하고 고유한 번호를 부여한다.

프로세스가 사용하는 가상 메모리에는 크게 네 개의 구역이 존재하는데, 각각 다음과 같다:
- Code(Text) Section
- 프로세스의 코드가 실행 가능한 상태로 저장되어 있다.
- 크기가 고정되어 있으며, 실행 도중에 그 크기가 변하지 않는다.
- Data Section
- 전역 변수들이 저장되어 있다.
- 크기가 고정되어 있으며, 실행 도중에 그 크기가 변하지 않는다.
- Stack Section
- 지역 변수들, 반환 주소, 함수 인자 등 일시적인 수명을 가진 변수들이 저장되어 있다.
- 실행 중에 그 크기가 변한다.
- Heap Section
- 실행 중에 동적으로 할당된 메모리가 저장되어 있다.
- 실행 중에 그 크기가 변한다.
## 로딩
이제 새로운 프로세스가 생겼으니 여기에 프로그램을 불러올 차례다. 커널에서 어떤 프로그램을 불러올 때, 커널에서는 다른 시스템 콜처럼 핸들러를 호출하고, 디스크에 저장되어 있는 프로그램을 주 기억 장치(보통 RAM)로 불러오기 위해 로더 프로그램*Loader Program*을 사용한다.
![[LoaderMap.png]]
로더 프로그램은 다음과 같은 작업을 수행한다.
+ 실행 맥락과 실행을 요청한 사용자의 권한을 확인한다.
+ 메인 메모리로부터 새 프로세스에 대한 메모리를 할당한다.
+ 실행 파일의 이진 파일 내용을 할당된 메모리로 복사한다.
+ 이는 대부분 데이터 및 텍스트 세그먼트를 포함한다.
+ 스택 세그먼트에 메모리 영역을 할당하고 초기 메모리 매핑을 준비한다.
+ 메인 스레드[^6] 및 스택 메모리 영역이 생성된다.
+ 커맨드 라인 인수를 메인 스레드의 스택 영역 최상단에 스택 프레임으로 복제한다.
+ 실행에 필요한 필수 레지스터를 초기화한다.
+ 프로그램의 진입점에 대한 첫번째 명령어를 실행한다.
이제 프로세스를 실행할 준비를 모두 마쳤다.
[^6]: 멀티스레드 프로그램에서 가장 중요한 스레드다. 실행 흐름의 측면에서 가장 처음 실행되고 가장 마지막으로 종료되는 스레드이며, 프로세스의 다른 모든 스레드가 시작되는 기원이다.
# 프로세스 관리
이렇게 만들어진 프로세스는 운영체제가 관리하며, 이 과정에서 일반적으로 프로세스 ID, 프로세스 상태, 프로세스 상태 블록, 프로세스 스케줄러 등 여러 요소가 추가된다.
## 프로세스 ID & 프로세스 그룹 ID
운영체제는 생성된 프로세스에 고유의 번호*Process Identification Number(PID)*를 붙여 관리한다. 프로세스를 만들 때는 현재 실행 중인 프로세스에서 만들게 되는데, 이때 현재 실행 중인 프로세스를 부모 프로세스*Parent Process*, 새로 생성되는 프로세스를 자식 프로세스*Child Process*라고 부른다. 부모 프로세스로부터 자식 프로세스가 생겨나기 때문에 프로세스의 의존을 나타내는 그래프는 트리의 형태를 띤다.
![[ProcessGroups.png|Bryant and O'Hallaron, 2016, p. 796]]
또한 프로세스의 수가 많아지면 이들을 묶어서 분류하기도 하는데, 이럴 때 사용하는 것이 프로세스 그룹 ID*Process Group ID*, 일명 PGID다. 그룹 ID가 같은 프로세스들은 한 그룹으로 묶이며, 이들은 PID와 PGID가 같은 프로세스를 뿌리 노드로 삼는 서브트리를 구성한다. 새로운 프로세스를 만들고 나면 부모 프로세스와 자식 프로세스 간의 관계를 다음과 같은 관점에서 규정할 수 있게 된다.
- Resource Sharing Option: 프로세스 간의 자원 공유*resource sharing*에 대한 옵션이다.
- 부모와 자식 프로세스가 모든 자원을 공유.
- 자식 프로세스가 부모 프로세스의 자원 중 일부만 공유.
- 부모 와 자식 프로세스가 서로 자원을 공유하지 않는다.
- Execution Option: 프로세스의 동시 실행*execution*에 대한 옵션이다.
- 부모와 자식 프로세스가 번갈아 가며 동시에 실행될 수 있다.
- 자식 프로세스가 종료되어야 부모 프로세스가 실행된다.
- Address Space Option: 프로세스의 주소 공간*Address Space* 에 대한 옵션이다.
- 자식이 부모와 동일한 데이터를 가진다.
- 자식 프로세스가 새로운 데이터를 가진다.
## 프로세스 상태
프로세스 ID와 프로세스 그룹 ID 외에도 운영체제는 합리적인 자원 분배를 위해 프로세스의 상태*state*를 확인한다.
![[ProcessState.png]]
운영체제에 존재하는 프로세스의 상태는 크게 다섯가지로 분류할 수 있다.
- new : 새로운 프로세서가 생성된 상태.
- ready : 프로세스가 실행 가능한 상태. 대기 상태에 있는 프로세스는 실행되기 위해 대기열에 있는 상태다.
- waiting : 프로세스가 특정 상황이 일어나기를 기다리고 있는 상태다. 프로세스가 기다리는 상황이 발생하면 커널은 프로세스의 상태를 '실행 중'으로 바꾼다.
- running : 프로세스가 현재 실행 중인 상태다.
- terminated : 프로세스가 종료된 상태다. 프로세스가 종료 상태가 되면 운영체제는 프로세스에 할당된 자원을 회수한다.
## Process Control Block
운영체제는 프로세스를 주기적으로 바꿔주면서 CPU가 최대한 쉬지 않고 일하게 만들어야 하기 때문에, 프로세스의 내부 구조와는 별개로 프로세스를 하나의 물체로 다룰 필요가 있다. 그래서 대부분의 운영체제에는 프로세스에 대한 정보를 담은 자료구조가 존재하는데, 이를 **Process Control Block(이하 PCB)**라 부른다.
![[PCBLayout.png|PCB의 대략적인 구조]]
PCB는 프로세스마다 하나씩 있는, 프로세스를 통제하기 위한 모든 정보들의 집합으로, Process Descriptor 혹은 Task Control Block라 부르기도 한다. 여기에는 다음의 정보가 포함되어 있다.
- 프로세스의 상태
- 프로세스의 번호
- 프로세스의 PC 레지스터에 저장된 주소
- CPU 레지스터에 저장된 값들
- CPU 스케줄링에 대한 정보
- 메모리 관리에 대한 정보
- I/O 상태에 대한 정보
## 프로세스 목록
운영체제는 자원을 효과적으로 사용하고 사용자가 여러 프로그램을 동시에 사용할 수 있도록 실행 가능한 프로세스를 한곳에 모아 관리하는데, 이때 사용하는 대표적인 자료구조가 큐*Queue*다. 새로 만들어진 프로세스는 우선 당장 실행할 수 있는 프로세스는 레디 큐*Ready Queue*에 투입되며, 이곳에 있는 프로세스는 스케줄러에 의해 선택되기만 하면 바로 실행될 수 있도록 만반의 준비가 되어 있는 상태다. 그 외에도 만약 프로세스가 어떤 작업을 오랫동안 기다려야 하는 경우(ex. 입출력) 그 시간에 다른 프로세스를 돌리는 것이 합리적인데, 이를 위해 어떤 작업을 오랫동안 기다려야 하는 프로세스는 모두 대기 큐*Wait Queue*애 투입된다.
![[ReadyQueueAndWaitQueue.png]]
프로세스를 저장하는 큐는 보통 연결 리스트의 형태로 구현되고, 스케줄러는 일정한 주기로 큐 내부의 프로세스 중 실행할 프로세스를 선택*Dispatch*한다.
## Context Switch
현재 실행 중인 프로세스가 없다면 스케줄러에 의해 선택된 프로세스는 다음 주기에 바로 실행할 수 있다. 그러나 만약 실행 중인 프로세스가 있다면 어떻게 기존 프로세스를 해치는 일 없이 기존의 프로세스를 새로운 프로세스로 교체할 수 있을까? 이때 사용하는 것이 바로 컨텍스트 전환*Context Switch*이다.
![[ContextSwitch.png]]
컨텍스트 전환은 실행 중인 한 작업에서 다른 작업으로 전환하는 것을 의미하는데, 구체적인 설명을 위해 프로세스 $P_0$에서 $P_1$로 컨텍스트 전환이 일어나는 상황을 가정해보자. 그러면 $P_0$의 상태를 $P_0$의 PCB에 저장한 다음 $P_1$의 PCB에서 $P_1$의 상태를 복원하게 되는데, PCB를 다룰 때는 인터럽트나 트랩을 통해 커널 모드에 돌입했다가 작업이 끝나면 다시 사용자 모드로 돌아온다.
# 프로세스 종료하기
프로세스가 모든 일을 마치고 종료될 때, 부모 프로세스는 상태 값을 받을 수 있고 자식 프로세스에게 할당됐던 자원들이 운영체제에 의해 해제된다. 시스템 호출*system call* 이나 코드에 의해 자식 프로세스가 강제로 종료될 수 있는데, 이때 운영체제에 따라 어떤 프로세스가 종료되면 해당 프로세스의 자식 프로세스가 모두 멈추거나*cascading termination*, 모든 프로세스의 부모인 최초의 프로세스를 부모로 다시 정하기도 한다.
프로세스가 종료되면, 커널은 프로세스가 가지고 있던 자원을 반납하고 부모 프로세스에 자식 프로세스가 종료된다는 소식을 알려준다. 일반적으로 이 과정은 자발적으로 일어나지만, 처리할 수도 없고 무시할 수도 없는 시그널이나 예외를 만나는 상황에서는 프로세스가 비자발적으로 종료되기도 한다.
## Zombie Process
이 과정에서 실제 프로세스는 종료했지만 부모 프로세스가 그 상태를 확인하지 못했을 때(ex. POSIX API를 사용하는 환경에서 `wait()` 함수를 호출하기 전에 자식 프로세스가 먼저 종료됨) 이 프로세스를 좀비 프로세스*zombie process*라고 부른다. 실제로는 종료된 프로세스지만, 이들을 관찰하는 부모 프로세스의 입장에서는 아직 종료된 프로세스가 아니다.
이러한 좀비 프로세스는 뭔가 조치를 취하지 않으면 영원히 시스템 자원을 잡아먹으며 성능을 저하시킨다. 그래서 좀비 프로세스가 속한 그룹의 다른 프로세스를 부모 프로세스로 지정하거나 그것이 불가능할 때는 `init` 프로세스를 이들의 새로운 부모 프로세스로 지정하는 것이 일반적인 해결책이다.
## Orphan Process
반대로 부모 프로세스가 종료를 위해 시스템 호출을 사용하지 못하고 먼저 종료된 경우, 자식 프로세스는 고아 프로세스*orphan process*가 되어 홀로 남겨진다. 종료 후 상태에 대한 정보를 넘길 부모 프로세스가 없는 상황을 해결하기 위해 [[POSIX]] 환경에서는 `init` 프로세스를 이들의 새로운 부모 프로세스로 지정한다.
---
## 참고 자료 & 더보기
### 참고 자료
+ Amini, Kamran. 2019. _Extreme C: Taking You to the Limit in Concurrency, OOP, and the Most Advanced Capabilities of C_. 1st ed. Birmingham: Packt Publishing Limited. (한국어판 : Amini, K. (2022). _전문가를 위한 C_ (박지윤, Trans.; 1st ed.). 한빛미디어.)
+ Silberschatz, A., Galvin, P. B., & Gagne, G. (2018). _Operating system concepts_ (10th ed.). Wiley.
+ Arpaci-Dusseau, R. H., & Arpaci-Dusseau, A. C. (2014). _Operating Systems: Three Easy Pieces_ (Version 0.8). Arpaci-Dusseau Books, Inc., http://www.ostep.org
+ R. E. Bryant and D. R. O’Hallaron, _Computer systems: a programmer’s perspective_, Third edition. Boston Amsterdam London: Pearson, 2016.