프로세스
프로세스는 컴퓨터 메모리에 올라와있는, 실행중인 프로그램을 말한다.
(실행중이지 않은 프로그램은 하드디스크나 SSD에 파일로서 존재하는 프로그램을 말한다.)
만약 여러 개의 프로그램을 실행해서 여러개의 프로세스가 생성되면 과연 정말로 여러 프로세스가 동시에 실행되고 있는 것일까?
여러 개의 프로세스를 실행하는 것을 '멀티 프로그래밍' 이라고 하는데, 아래 이미지와 같이 구현된다.
그림에서 보는 것처럼 CPU가 프로세스를 하나씩 번갈아가면서 실행하는 것이 멀티 프로그래밍이다.
하드웨어적으로는 (a) 와 같이 하나의 프로그램 카운터가 각 프로세스를 넘나들면서 실행하며,
개념적으로는 4개의 독립된 프로그램 카운터가 존재하는 것처럼 동작한다.
그래서 시간에 따른 프로세스별 실행 흐름은 (c)와 같은 모습으로 나타난다.
프로세스는 크게 2가지 정보를 갖고 있다.
1. address space (core image)
메모리에 적재된 프로세스는 메모리 내에서 위 그림과 같이 다양한 영역으로 나누어진 공간을 갖는다.
메모리의 낮은 주소에는 고정된 크기의 Text 영역이 존재하고, 그 위에는 Data 영역이 존재한다.
Text 영역에는 프로그램의 소스코드와 같은 정보가 들어있다.
Data 영역에는 프로그램의 시작부터 끝까지 존재하는 static 변수들 (전역 변수들)이 사용하는 공간이 들어간다.
Text, Data 영역은 크기가 고정되어 있어 정적 할당 영역이라고도 한다.
Stack 영역에는 함수가 호출될 때 함수 안에서 사용된 변수 데이터가 들어가고,
malloc 과 같은 함수를 통해서 동적으로 메모리를 할당할 때는 Heap 영역에 공간에 할당된다.
Stack, Heap 영역은 크기가 변할 수 있어서 동적 할당 영역이라고도 한다.
2. process table entry (process control block = PCB)
CPU는 프로세스를 번갈아가면서 실행하므로, 다른 프로세스를 실행한 후 다시 프로세스를 이어서 실행할 때, 이전의 실행했던 정보를 어딘가에 저장해두어야 하는데, 이 정보를 저장하는 공간이 프로세스 테이블이다.
프로세스 테이블에는 프로세스마다 하나의 엔트리가 저장되는데, 이를 프로세스 테이블 엔트리 또는 PCB (프로세스 제어 블록)라고 한다.
이 데이터는 메모리의 커널 영역과 사용자 영역 중 커널 영역에 저장된다.
그리고 프로세스를 만들면 PCB가 생성되고, 프로세스가 종료되면 PCB가 폐기된다.
프로세스 테이블 엔트리에 저장되는 주요 정보들은 다음과 같다.
- 프로세스 ID (PID)
- 프로세스 상태 (running, blocked, ready)
- 프로그램 카운터를 포함한 프로세스의 레지스터 값들
- 스택 포인터
- 메모리 영역 별 시작 주소 (텍스트 영역, 데이터 영역, 스택 영역)
- 입출력을 하는 경우, 현재 프로세스가 오픈한 파일과 할당된 입출력 장치에 대한 정보
- 프로세스를 생성한 UID, GID
프로세스 상태
프로세스는 크게 3가지 상태를 가질 수 있다.
이 그림은 프로세스의 상태를 동그라미로, 다른 상태로의 전이를 화살표로 나타내었다.
프로세스의 상태 관리는 운영체제의 스케줄러가 담당한다.
- Running (실행 상태)
CPU가 직접 해당 프로세스를 실행시키고 있는 상태이다.
프로세스 입장에서는 CPU를 할당받은 상태라고도 볼 수 있다.
프로세스는 정해진 시간 동안 CPU를 이용할 수 있으며, 정해진 시간이 지나면 타이머 인터럽트가 발생하여 준비 상태(Ready)로 돌아간다.
만약 실행 중에 입출력 장치를 사용해야 하는 경우에는 대기 상태 (Blocked) 로 돌아간다.
- Ready (준비 상태)
실행이 가능한 (runnable) 상태이지만 아직 다른 프로세스가 CPU를 사용중이라서 기다리는 상태를 말한다.
- Blocked (대기 상태)
어떤 프로세스가 read() 시스템 콜을 해서 입출력장치를 사용하여 데이터를 읽고 있는 중이라고 해보자.
저장장치에서 데이터를 읽는 속도는 CPU의 실행속도에 비해 상대적으로 매우 느리기 때문에 잠시 기다려야 한다.
이 경우에 이 프로세스는 실행 가능한 상태가 아니므로 Blocked 상태로 잠시 내려오게 된다.
그리고 read() 가 완료되면 이 프로세스는 다시 Ready 상태로 넘어온다.
프로세스 계층 구조
프로세스는 실행 도중에 시스템 콜을 통해 다른 프로세스를 생성할 수 있다.
이때 새로운 프로세스를 생성하는 프로세스를 '부모 프로세스', 생성된 프로세스를 '자식 프로세스'라고 한다.
부모 프로세스와 자식 프로세스 사이의 관계는 이렇게 트리 모양으로 나타낼 수 있다.
A 프로세스가 B, C 프로세스를 생성하고
B 프로세스는 D, E, F 프로세스를 생성한 모습이다.
이런 트리를 '프로세스 트리' 라고 한다.
프로세스 트리의 루트에 존재하는 프로세스는 운영체제가 실행될 때 처음 생성되는 최초의 프로세스로서, init process 라고도 한다.
UNIX 에서는 프로세스와 그 자식 프로세스들이 모여 프로세스 그룹을 형성하기도 한다.
리눅스에서 쉘을 이용하는 것도 하나의 쉘 프로세스에 의해 이루어지는데, 쉘에 명령어를 입력해서 또 프로세스를 실행할 수 있다.
(프로세스 그룹이라는 건, 이렇게 쉘에서 실행한 프로세스들은 쉘을 닫으면 일반적으로 같이 종료되니까 하나의 그룹으로 묶여있다고 표현한 것이 아닐까 생각해본다.)
* 윈도우는 이런 프로세스 계층 구조의 개념이 없고, 모든 프로세스가 다 동등한 지위를 갖는다.
UNIX 시스템은 '유저' 의 개념이 있다.
각각의 유저는 UID를 가지며 여러 그룹에 속할 수 있고, 각 그룹은 GID를 갖는다.
이떄 특정 UID 를 가진 유저는 슈퍼 유저로서 특별한 권한을 갖는다.
유닉스의 프로세스가 시작되면 프로세스는 자신을 실행한 유저의 UID 정보를 함께 갖는다.
그리고 이 프로세스가 생성하는 자식프로세스는 같은 UID를 갖는다.
프로세스 생성
프로세스는 기존의 프로세스가 프로세스를 생성하는 시스템 콜(fork)을 호출해야만 생성될 수 있다.
프로세스가 생성되는 주요 상황은 다음과 같다.
1. 시스템 부팅 시
운영 체제가 부팅되면 프로세스가 생성되는데, 이때는 특히 '데몬 프로세스' 가 생성된다.
데몬 프로세스는 '백그라운드 프로세스' 라고도 하며, 우리 눈에 보이지 않는 뒤에서 돌아가는 프로세스를 말한다.
2. 기존 프로세스가 fork 시스템 콜을 호출한 경우
이때는 부모 프로세스와 완전히 동일한 프로세스를 복사해서 만들어낸다.
그리고 exec() 시스템 콜을 호출하여 Text 영역에 자식 프로세스가 실행할 프로그램을 가져오고, 나머지 영역을 초기화한다.
exec() 시스템 콜은 필수는 아니다. 이 시스템 콜을 호출하지 않으면 부모 프로세스와 자식 프로세스가 동일한 코드를 병행해서 실행하게 된다.
3. 사용자가 새로운 프로세스 생성을 요청한 경우
커맨드 쉘에서 명령어를 입력하는 경우에 해당한다.
4. 배치 작업을 시작하는 경우
배치 작업은 여러개의 작업을 모아놨다가 한번에 실행하는 것을 말한다.
운영체제가 시작될 때 만들어지는 최초의 프로세스, init process는 기존 프로세스가 없으니 fork() 를 통해서 만들어질 수 없다.
따라서 이런 프로세스는 운영체제가 직접 수제로 만들어준다.
프로세스 종료
프로세스의 종료는 크게 자발적 종료와 비자발적 종료로 구분할 수 있다.
자발적 종료에는 다음과 같이 2가지 종료 방법이 있다.
1. Normal Exit
2. Error Exit
1번째 경우는 return 0 를 통해 프로세스가 명시적으로 정상적으로 종료한 것을 말하고,
2번째 경우는 다른 리턴 값을 주면서 명시적으로 에러를 말하면서 종료하는 경우를 말한다.
비자발적 종료에도 다음과 같이 2가지 종료 방법이 있다.
1. Fatal Error
2. Killed by another process
1번째 경우는 프로래머가 의도하지 않은 에러가 발생했을 때 발생한다.
0으로 나누거나, 허용되지 않은 메모리 위치에 접근하는 등 프로그램 버그로 발생한다.
2번째 경우는 kill 시스템 콜을 통해서 직접 프로세스를 종료하는 방법이다.
그 밖에도 프로세스를 관리하는 시스템 콜은 다양하게 있다.
지금까지 본 프로세스 생성(fork, 정확히는 복사), 종료(kill) 시스템 콜 말고도,
프로세스를 다른 프로그램으로 덮어쓰는 exec()과 (core image를 덮어쓴다고도 한다.)
자식 프로세스가 종료되기를 기다리는 waitpid(), 메모리를 더 요청하거나 기존의 메모리를 반환하는 시스템 콜 등이 있다.
프로세스 관리
이번엔 프로세스를 관리하는 과정을 간단한 슈도 코드로 살펴보자.
while (true) {
type_prompt();
read_command(command, parameter);
if (fork() != 0) {
waitpid(-1, &status, 0);
} else {
execve(command, parameter, 0);
}
}
이 소스코드는 어떤 쉘 프로그램의 모습을 나타낸 것이다.
type_prompt() 를 통해서 프롬프트를 출력하고, read_command() 로 명령어와 파라미터를 받는다.
이 과정은 계속 반복되어야 한다.
명령어를 입력을 받은 뒤에는 fork() 시스템 콜을 호출하여 이 쉘 프로세스와 똑같은 프로세스를 복제한다.
따라서 자식 프로세스가 실행되는 시점에서 자식 프로세스도 if 문 부분의 코드를 똑같이 실행한다!
부모 프로세스 입장에서는 복제에 성공했다면 복제한 자식 프로세스의 PID (process ID) 를 반환한다.
이 ID 값은 일반적으로 0이 아니기 때문에 자식 프로세스가 실행을 다 할 때까지 대기한다.
자식 프로세스 입장에서는 fork() 당한 입장이기 때문에 fork() == 0 이 되어서 else 문으로 들어간다.
그리고 execve() 시스템 콜을 호출하여 현재 프로세스의 core image를 이 프로세스가 실행하려는 command 프로그램에 맞게 교체한다.
그러면 더이상 자식 프로세스는 이 무한 반복문을 도는 소스코드가 아니라 다른 소스코드를 돌릴 것이고, 정상적으로 종료가 되었다면 0을 반환할 것이다.
다시 부모 프로세스 입장으로 돌아오면 자식 프로세스가 정상적으로 종료가 되었다면 waitpid() 시스템 콜이 처리되고 if 문을 나와서 다시 처음부터 반복을 돌게 된다.
프로세스를 관리하는 시스템 콜에는 BRK 라는 시스템 콜이 있다.
영어로 break 의 줄임말을 뜻하는데, data segment (영역을 세그먼트라고 표현하기도 한다) 의 end address를 재조정하는 시스템 콜이다.
이 시스템 콜을 통해 data 영역의 크기를 조정할 수 있고, 이 과정에서 새로 생겨난 영역을 Heap 영역이라고 부른다.
(그러면 의문점이 여기서는 마치 Data 영역의 크기가 바뀔 수 있는 것처럼 묘사했는데 책에서는 Data 영역은 고정적이고 Heap 영역이 가변적이라고 했다. 무엇이 맞는 말인거지?? 어셈블리를 들을 때도 data 영역은 크기가 변하지 않는다고 하였다..)
https://velog.io/@stok97/SW%EC%A0%95%EA%B8%80Malloc-Lab-brk-sbrk
이 블로그 정리글에 따르면 brk 는 힙 영역의 크기를 변화시킨다.
이때 bss 가 uninitialized data 라고도 불리는 것을 보면, 운영체제에서 말하는 data 영역은 넓은 의미로서 (힙, bss, data) 를 통칭하는 것일 수도 있겠다는 생각이 들었다.
자세한 건 교수님께 질문을 해봐야 할 것 같기도..
인터럽트 처리
인터럽트는 컴퓨터구조에서 나오는 개념으로, CPU의 동작을 방해하는 하드웨어 신호이다.
(시스템 콜도 인터럽트의 일종이다. '소프트웨어 인터럽트' 라고 부른다.)
인터럽트가 발생하는 과정을 예로 들어보면, 프로세스의 상태 변화 상황을 생각해볼 수 있다.
프로세스가 실행 중에 I/O 를 요청하고 대기(blocked) 상태로 내려왔다고 해보자.
해당 프로세스는 CPU를 다른 프로세스에게 넘겨주고 (교수님 표현으로는) 어두컴컴 곳에서 울고 있다.
그 상황에서 만약 디스크에서 데이터를 읽어오는 작업이 모두 끝났다면 디스크로에 의해 CPU에 인터럽트가 걸린다.
(하드웨어적으로 인터럽트 신호를 보내는 것이다)
인터럽트가 걸리게 되면 '인터럽트 핸들링' 과정을 거쳐야 한다.
그러면 현재 실행중인 프로세스의 프로그램 카운터와 같은 정보들을 모두 스택에 임시 저장해두고, 인터럽트를 처리한다.
인터럽트에는 그 인터럽트가 발생했을 때 처리하는 '인터럽트 서비스 루틴' 쌍이 있다.
인터럽트 벡터 테이블이라는 자료구조에 특정 인터럽트가 발생했을 때 실행해야하는 인터럽트 서비스 루틴의 시작 주소를 저장해둔다.
인터럽트를 걸은 입출력 장치의 번호(입출력 장치는 여러개 있을 수 있고, 각각에 번호가 있다.)가 같이 CPU에 전달되는데, 인터럽트 벡터 테이블에서 해당 번호에 맞는 인터럽트 벡터를 찾아 인터럽트 서비스 루틴을 실행한다.
인터럽트 서비스 루틴이 실행되면 어셈블리 코드가 레지스터를 저장하고 새로운 스택을 준비한 뒤 C 코드로 작성된 인터럽트 서비스 루틴이 실행되어 인터럽트를 처리한다. 이 과정에서 읽어온 데이터를 버퍼에 저장한다.
버퍼에 저장하는 과정이 끝나면 이제 대기 상태에 있던 프로세스는 실행이 가능한 상태가 되므로 Ready 상태로 넘어가고, 스케줄러는 다음으로 실행할 프로세스를 결정한다.
그리고 인터럽트 서비스 루틴은 스택에 저장해둔 내용을 기반으로 기존에 실행하다가 중지된 프로세스의 내용이 들어있는 새로운 프로세스를 만들어서 마저 실행한다. (이 프로세스는 자신이 아무것도 한 게 없는데 갑자기 중지된 것이나 마찬가지라 보통은 중지된 프로세스를 다시 실행한다.)
마지막으로 ready 상태에 있던 프로세스가 다시 running 상태가 되면, 그때는 버퍼에 들어있는 데이터를 활용해서 추가적인 작업을 처리할 수 있게 된다.
정리하면 다음과 같은 순서라고 할 수 있다.
1. 디스크에서 데이터를 다 읽었다는 인터럽트가 발생한다.
2. 해당 입출력 장치의 번호를 보고 인터럽트 벡터 테이블에서 인터럽트 벡터를 찾는다.
3. 인터럽트 벡터가 가리키는 인터럽트 서비스 핸들러를 호출한다.
4. 인터럽트 서비스 핸들러는 기존에 실행중이던 프로세스 정보를 스택에 백업하고, 새로운 스택 공간을 준비한다.
5. 다음으로 디스크에서 읽어온 데이터를 버퍼에 담는다. 이 과정에서 준비된 스택 공간을 활용한다.
6. 데이터를 버퍼에 다 담았다면 스케줄러가 다음에 실행할 프로세스를 결정하며, 기존에 blocked 상태에 있던 프로세스를 ready 상태로 옮긴다.
7. 인터럽트 서비스 핸들러는 백업했던 프로세스 정보를 가지고 새로운 프로세스를 만들어 아까 실행하던 작업을 이어서 실행하도록 한다.
8. ready 상태로 옮겨진 프로세스가 running 상태로 옮겨져 실행되면 그때 버퍼에 들어있는 값을 활용한다.
'CS > 운영체제' 카테고리의 다른 글
[운영체제] 6. 프로세스 동기화 (4) - 식사하는 철학자 문제 (2) | 2024.10.19 |
---|---|
[운영체제] 5. 프로세스 동기화 (3) - Message Passing, Barrier (0) | 2024.10.19 |
[운영체제] 4. 프로세스 동기화 (2) - 세마포, 뮤텍스, 모니터 (0) | 2024.10.19 |
[운영체제] 3. 프로세스 동기화 (1) - 상호배제와 Busy Waiting (2) | 2024.10.17 |
[운영체제] 1. 시스템 콜 (0) | 2024.10.15 |