참고 자료
프로세스의 개념
가상화란?
한 개의 자원을 여러 개의 가상 자원으로 만들어 낸 것
프로그램은 디스크에 저장된 명령어와 데이터의 집합이다.
운영체제는 시분할 방식으로 cpu를 나눠쓴다. 따라서 일정 시간이 지나면 실행 중인 프로세스를 멈추고, 제어권을 실행해야 할 프로세스로 넘겨준다.
프로세스 전환 시에 실행하는 것이 문맥 교환이다. 문맥 교환을 하기 전에 운영체제는 현재 프로세스의 레지스터 값, 프로그램 카운터, 스택 포인터 등을 저장하게 된다. 그리고 새로운 프로세스의 실행 상태를 가져와 cpu 레지스터에 복원한다.
메커니즘 → 운영체제가 필요한 기능을 구현하는 방법
정책 → 운영체제가 어던 결정을 내리기 위한 규칙 및 알고리즘
프로세스 → 실행 중에 접근하거나 영향을 받은 자원의 목록
프로그램 카운터 → 실행 중인 명령어를 알려준다
스택 포인터 & 프레임 포인터 → 함수의 변수와 리턴 주소를 저장하는 스택을 관리한다.
프로세스를 실행하기 위해서는 하드웨어가 무조껀 필요하다. 메모리는 명령어를 읽거나 데이터를 저장하며 레지스터는 명령어가 데이터를 직접 읽거다 변경하기 때문이다
생성 → 간단한 코드를 작성해서 실행시키면 운영체제가 새로운 프로세스를 생성한다
제거 → 스스로 종료하지 않는 프로세스를 강제로 종료시킨다.
대기 → 특정 프로세스의 작업이 끝날 때까지 기다려야 함
각종 제어 → 프로세스 일시 정지 및 다시 시작 등의 제어 기능들을 제공.
상태 -> 프로세스의 현재 상태 정보를 제공
로딩 → 프로그램의 코드와 정적 데이터를 메모리, 즉 프로세스의 주소 공간으로 불러오는 행위.
그러니까 프로그램을 실행시키기 위해서는 우전 디스크에 있는 프로그램 파일을 읽어와 메모리에 올려놔야 한다는 말이다.
스택 메모리 할당 → C 프로그램은 지역 변수, 함수 인자, 리턴 주소 등을 저장하기 위해 스택을 사용한다.
힙 메모리 할당 → 동적으로 할당되는 메모리 공간. 가변 크기의 자료구조를 위해 쓰인다.
입출력 초기화 → 프로그램의 입출력을 위한 초기화 작업.
실행 → 말그대로 프로세스가 실행 중인 상태
준비 → 프로세스가 실행할 준비는 되어있지만 운영체제가 다른 프로세스를 실행시키고 있는 중이므로 대기 상태이다.
대기 → 프로세스의 수행을 중단시키는 연산.
‘실행’ 상태에서 ‘준비’ 상태로의 전이는 프로세스가 나중에 다시 스케줄 될 수 있는 상태가 되었다는 것을 의미한다. 프로세스가 입출력 요청 등의 이유로 대기 상태가 되면 요청 완료 등의 이벤트가 발생할 때까지 대기 상태로 유지된다. 이벤트가 발생하면 프로세스는 다시 준비 상태로 전이되고 운영체제의 결정에 따라 바로 다시 실행될 수 있다.
운영체제는 입출력 요청 등의 작업이 발생하면 해당 프로세스를 대기 상태로 보내버린다. 그리고 요청한 작업이 끝났을 경우 준비 상태로 전이시키고, 스케줄러에 따라 실행 상태로 전이시키는 것을 결정하게 된다.
2개의 프로세스가 있다고 가정을 한다.
1. Process0은 입출력을 요청하고 요청한 작업이 완료되기를 기다린다
2. 프로세스는 디스크를 읽거나 네트워크로부터 패킷을 기다릴 때 대기 상태로 전이한다.
3. 운영체제는 Process0이 CPU를 사용하지 않는다는 것을 감지하고, Process1을 실행시킨다.
4. Process1이 실행되는 동안 입출력이 완료되고 Process0은 준비 상태로 다시 전이된다.
5. Process1은 종료되고, Process0이 실행되어 종료된다.
위 내용을 보면 처음에는 프로세스0이 cpu를 점유했지만 입출력 작업을 요청하고 요청된 작업이 완료될 때까지 프로세스0이 cpu를 점유한다면 cpu는 놀게 될 수 밖에 없다. 이것은 자원 이용률을 낮추게 됨으로 운영체제는 프로세스0이 아무 작업을 하지 않고 놀고 있을 때 프로세스0을 대기 상태로 전이시키고, 프로세스1을 실행시킨다.
프로세스1은 요청된 작업이 완료가 되더라도 바로 실행되지 않고, 준비 상태에 있다가 프로세스1이 종료되면 그때서야 cpu를 사용할 수 있게 된다.
위 내용을 봤을 때 운영체제가 어떻게 프로세스0이 놀고 있는 지 아는거지? 라는 궁금증이 생길 수 있다. 프로세스는 입출력 작업이 필요할 때 시스템 콜을 통해서 운영체제에게 현재 입출력 작업이 필요하다는 것을 알려준다고 한다. 이를 통해 운영체제는 해당 프로세스가 cpu를 쓸 일이 없다는 것을 알게 된다. 그리고 입출력 작업이 끝나면 해당 장치에서는 인터럽트를 발생시키고, 운영체제에서 인터럽트를 처리하면서 프로세스를 다시 준비 상태로 옮겨둔다.
운영체제 역시 일종의 프로그램이기에, 다른 프로그램들처럼 여러 정보를 저장하고 관리하기 위한 자료구조를 갖고 있습니다.
운영체제는 cpu, 메모리, 디스크, 입출력 장치 등을 관리하기 위해 작성되었으며 커널 공간에서 돌아가는 특수한 프로그램이다.
운영체제도 프로그램이라면 실행되기 위해서는 메모리에 올라와야 한다.
아울러 운영체제는 입출력 작업 등으로 인해 대기(blocked) 상태에 있는 프로세스도 추적해야 합니다. 해당 입출력이 완료되면, 운영체제는 이 정보를 토대로 대기 중이던 프로세스를 깨워 실행 가능한 상태(ready)로 만들어 줄 수 있어야 하죠.
대기 상태에 있는 프로세스도 운영체제가 깨워줘야 하기 때문에 계속 추적하고 있다고 한다.
프로세스 API는 운영체제(OS)가 애플리케이션에 제공하는 인터페이스로, 사용자 프로그램이 운영체제의 다양한 기능을 사용할 수 있도록 해주는 시스템 호출이다. 이는 프로세스의 생성, 종료, 정지, 재개와 같은 기본적인 관리 작업뿐만 아니라, 프로세스 상태 정보 제공, 메모리 할당, 파일 접근 등 가상 머신 관련 기능을 요청하는데 필수적이다.
라고 하는데 지금까지 프로젝트 진행할 때 운영체제에 접근해서 프로세스의 생성, 종료를 직접 컨트롤 할 일이 없었던 것 같은데…
fork() 시스템 콜은 현재 실행 중인 프로세스(부모 프로세스)와 똑같은 복사본인 새로운 프로세스(자식 프로세스)를 생성하는 기능을 합니다.
한마디로 부모랑 똑같은 자식 프로세스를 만든다는 것. 이렇게 생성된 자식 프로세스는 부모 프로세스의 메모리 공간을 복사하여 가지게 된다.
자식 프로세스를 만드는 이유는 멀티 코어 환경에서 병렬 처리가 가능하기 때문이다. 이 덕분에 작업 처리 속도가 크게 향상된다. 또한 자신과 똑같은 자식을 생성하고 자신의 작업을 자식에게 위임함으로써 자식은 실행되다가 죽더라도 부모는 자식의 결과를 가지고 다음 작업을 이어나갈 수 있다.
자식 프로세스는 부모 프로세스의 메모리 공간을 복사
자식 프로세스와 부모 프로세스가 동일한 메모리 공간을 가진다고 하는데 이러면 자식과 부모가 동시에 똑같은 변수에 접근한다면 자칫 변수의 값이 잘못될 수도 있다. 따라서 운영체제는 독립된 가상 메모리 공간을 제공해 이러한 문제를 해결한다.
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main() {
pid_t pid;
pid = fork();
if (pid == 0) { // 자식 프로세스
printf("이것은 자식 프로세스입니다.\n");
} else if (pid > 0) { // 부모 프로세스
printf("이것은 부모 프로세스입니다.\n");
} else { // fork 실패
printf("fork() 실패\n");
}
return 0;
}
결과 :
이것은 부모 프로세스입니다.
이것은 자식 프로세스입니다.
이것은 자식 프로세스입니다.
이것은 부모 프로세스입니다.
결과를 보면 알 수 있듯이 부모 프로세스라고 해서 먼저 실행이 된다는 보장이 없다. 자식 프로세스가 먼저 실행될 수도 있다는 얘기이다. 왜 그런걸까? 운영체제는 fork()가 호출됨과 동시에 부모와 자식을 생성하고 준비 상태로 이전시킨다. 그리고 어떤 프로세스를 실행시킬지는 cpu 스케줄러의 결정에 달려있다.
-1을 감소시킨다는 개념이 아니였다… 부모 프로세스가 실행될 때 pid의 값은 자식 프로세스의 PID값을 리턴하기 때문에 양수가 된다. 그리고 자식 없는 프로세스는 0을 반환 받는다.
부모 프로세스가 자식 프로세스가 종료될 때까지 기다린다. 기다리면서 자식 프로세스의 종료 상태 값을 얻는다.
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main() {
pid_t pid;
int status;
pid = fork();
if (pid == 0) { // 자식 프로세스
printf("자식 프로세스 실행\n");
} else if (pid > 0) { // 부모 프로세스
wait(&status); // 자식 프로세스의 종료를 대기
printf("부모 프로세스 재개\n");
} else { // fork 실패
printf("fork() 실패\n");
}
return 0;
}
결과:
자식 프로세스 실행
부모 프로세스 재개
// 자식 종료 상태(raw status) = 0
wait()으로 인해 부모 프로세스는 자식 프로세스가 종료될 때까지 기다려야 한다.
프로세스가 새로운 프로그램을 실행하게 해준다.
exec() 호출은 현재 프로세스의 이미지를 새로운 프로그램의 이미지로 교체한다.
프로세스 이미지 → 프로세스 실행에 필요한 메모리 안의 전체 상태 즉, 프로세스를 구성하는 코드와 데이터가 메모리에 올라와 있는 모습을 말한다.
ex)
#include <stdio.h>
#include <unistd.h>
int main() {
char *args[] = {"echo", "Hello, exec()!", NULL};
execvp("echo", args);
// execvp 호출 후 이 코드는 실행되지 않습니다.
printf("이 문장은 실행되지 않습니다.\n");
return 0;
}
결과 :
Hello, exec()!
execvp()이 실행되면 현재 프로세스 이미지를 새로운 프로세스 이미지로 교체하기 때문이다. 따라서 교체된 이후의 코드는 존재하지 않는 코드가 되기 때문에 출력되지 않는다.
운영체제는 CPU 가상화를 위해 제한적 직접 실행이라는 기법을 사용합니다. 이 기법의 기본 아이디어는 프로그램을 CPU에서 직접 실행시키되, 운영체제가 CPU 제어권을 잃지 않도록 프로세스의 행동에 제한을 두는 것입니다.
왜 프로세스의 행동에 제한을 두는 것일까?
- 프로세스를 위한 메모리를 할당하고 프로그램을 메모리에 적재합니다.
- CPU를 사용자 모드로 전환하고 프로그램의 main() 함수로 이동합니다.
- 프로그램이 실행되면서 시스템 콜이 호출되면 커널 모드로 전환되고 운영체제가 해당 요청을 처리합니다.
- 요청 처리가 완료되면 다시 사용자 모드로 돌아가 프로그램 실행을 계속합니다.
사용자 모드에서 실행되는 코드의 특정 연산을 제한하는 이유가 뭘까?
프로세스가 모든 연산을 수행하도록 내버려둔다면 안정성과 보안에 문제가 생기기 때문이다.
그렇다면 제한된 연산을 실행하기 위해서는 어떻게 해야할까?
하드웨어는 두 가지 실행 모드를 제공하여 운영체제를 지원합니다 :
- 사용자 모드(user mode): 응용 프로그램이 실행되는 모드로, 하드웨어 자원에 대한 접근이 제한됩니다.
- 커널 모드(kernel mode): 운영체제가 실행되는 모드로, 모든 하드웨어 자원에 접근할 수 있는 권한을 가집니다.
사용자 모드에서 제한된 연산을 수행하려면 커널 모드로 전환해야 한다. 보안상 민감한 명령은 커널 모드에서만 수행할 수 있도록 제한함.
- trap: 사용자 모드에서 커널 모드로 전환하는 명령어
- return-from-trap: 커널 모드에서 사용자 모드로 돌아가는 명령어
프로세스는 제한된 연산이 필요할 때 시스템 콜을 호출하여 trap을 발생시키고, 운영체제는 요청받은 연산을 대신 수행한 뒤 return-from-trap을 통해 다시 프로세스에게 제어를 넘겨줍니다.