프로세스란 말을 많이 들어보셨죠? 컴퓨터에서 프로세스는 가장 간단히 말해서 실행 중인 프로그램을 의미합니다. 실행을 위해 커널에 등록된 개체로서, 커널은 이 프로세스들을 관리하여 전체적인 시스템 퍼포먼스를 위해 노력하죠. 전형적인 시스템은 마치 수십 개, 아니 수백 개의 프로세스들이 동시에 돌아가는 것처럼 보입니다. 위 그림과 같이 코드와 데이터로 이루어진 저장된 실행 파일들이 프로세스에 실려 OS(커널) 의 관리에 의해 CPU 위에서 실행됩니다. 이 때 또 중요한 개념 중 하나가 CPU Virtualization
입니다. 실제 컴퓨터에는 제한된 개수의 CPU 가 장착 되어 있습니다. 이는 부족하기에 OS 가 Time Sharing
기법을 통해서 마치 많은 가상의 CPU 들이 존재하는 것과 같은 환상을 만들어 냅니다. 간단하게 설명해서 하나의 CPU 로 하나의 프로세스를 돌리다가 다른 프로세스를 돌리고.. 하는 식으로 시간 단위를 쪼개어 마치 여러 CPU 위에 여러 프로세스가 동작하고 있는 것처럼 보이게 하는 것이죠.
다시 한 번 정리하자면 프로세스는 실행 중인 프로그램, 커널에 의해 등록되고 관리되는 개체를 의미합니다. 다만 특별한 점은 active 한 개체라는 것이지요. 실행 동안 시스템 리소스를 요청하고, 할당하고, 풀어줄 수도 있다는 겁니다. 위는 익숙한 메모리 영역을 나타냅니다. 프로세는 아래와 같은 메모리를 포함하죠.
또한, program counter
, stack pointer
, frame pointer
등을 지정하기 위한 레지스터 가져야 합니다. 또한, 프로세스가 연 파일들에 대한 리스트, 즉 I/O information
도 가지고 있어야 하죠.
커널이 프로세스를 통제하기 위해 각각의 프로세스에 대한 정보를 담고 있는 단위를 우리는 PCB(Process Control Block) 이라 부릅니다. PCB 의 정보들은 OS 마다 다릅니다. 리눅스에서는 Process descriptor 로서 tast_sturct
라는 구조체가 그 역할을 하죠. 물론 커널이 이 PCB 에 접근하는 속도는 전체 시스템 퍼포먼스에 큰 영향을 끼치게 됩니다. Task Control Block
이라고도 불리는 PCB 는 아래와 같은 정보들을 담고 있습니다.
우리가 직접 프로세스를 통재하기 위해서는 Process API
를 사용해야 합니다. 크게 아래와 같은 기능들이 있죠.
프로세스 상태에는 Running, Ready, Blocked 가 있습니다. Running
은 프로세스가 프로세서에 올라가 명령어들을 실행시키고 있는 상태를 의미합니다. 말그대로 프로그램이 실행되는 것이죠. Ready
는 프로세스가 다시 실행될 준비가 된 상태를 의미합니다. 준비는 되었는데 OS 가 어떤 이유로 인해 지금 당장 실행을 시키지는 않고 있는 겁니다. Blocked
는 다른 이벤트가 완료될 때까지 Ready 상태가 될 수 없는 상태를 의미합니다. 대표적인 예시로 I/O 요청이 있습니다. 프로세스가 요청한 디스크로부터의 I/O 가 완료될 때까지 프로세스는 멈춰있어야 하지요.
프로세스가 생성되는 과정은 어떻게 될까요? 순서대로 알아봅시다.
이러한 프로세스는 PCB 의 pid
를 통해 식별되고 관리 됩니다. 특별한 점은 프로세스는 트리 의 구조를 한다는 것입니다. 어떤 프로세스에서 새로운 프로세스를 생성하는 것은 자식 노드(프로세스)를 만드는 것과 같습니다.
이 때 부모와 자식이 리소스들을 모두 공유할 수도, 공유하지 않을 수도, 아니면 자식이 부모의 리소스 중 일부만을 공유하도록 지정할 수 있습니다. 뿐만 아니라 부모와 자식 프로세스가 concurrently 하게 동시에 실행될 수도, 자식이 종료될 때까지 부모 프로세스가 기다리도록 할 수도 있죠.
리눅스의 ps
명령어로 현재 실행중인 프로세스들을 확인할 수 있습니다. 위의 예시를 보면 컴퓨터를 키면 기본적으로 실행되는 systemd
, kthreadd
들을 부모로 수많은 자식 프로세스들을 확인할 수 있습니다. 이제 리눅스에서의 프로세스 API 들을 살펴보도록 하겠습니다.
가장 먼저 프로세스를 생성하는 fork() 입니다.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
printf("hello world (pid:%d)\n", (int)getpid());
int rc = fork();
if (rc < 0)
{ // fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
}
else if (rc == 0)
{ // child (new process)
printf("hello, I am child (pid:%d)\n", (int)getpid());
}
else
{ // parent goes down this path (main)
printf("hello, I am parent of %d (pid:%d)\n", rc, (int)getpid());
}
return 0;
}, I am parent of %d (pid:%d)\n", rc, (int) getpid()); }
return 0; }
fork()
실행 시 호출 프로세스, 즉 부모 프로세스의 모든 주소 공간이 복제가 됩니다. 쉽게 말해 프로세스의 복사본이라 생각하시면 됩니다. 자식 프로세스도 이 똑같은 코드를 실행하는 것이죠. 이 때 부모 프로세스에서는 fork() 의 리턴 값이 rc
는 자식 프로세스의 PID
가 되고 자식 프로세스에게는 0
이 되게 됩니다.
그렇기에 스케줄링 따라 자식과 프로세스 중 어떤 것이 prinf 가 먼저 실행되는지가 다르기 때문에 위와 같은 두 가지 상황이 발생하게 됩니다. 자식 프로세스에게 rc 는 0 이지만 getpid()
함수를 통해서 자신의 고유 PID 를 가져오면 됩니다.
다음으로 wait() 함수 입니다. 위와 똑같은 코드에서 else
구문을 다음과 같이 바꿔봅니다.
else
{ // parent goes down this path (main)
int wc = wait(NULL);
printf("hello, I am parent of %d (wc:%d) (pid:%d)\n", rc, wc, (int)getpid());
}
부모 프로세스에서 wait()
을 호출하게 되면 자식 프로세스에서 실행이 종료(exit()
)될 때까지 기다리게 됩니다. 이 때의 리턴값으로 기다렸던 자식 프로세스의 PID
가 됩니다.
그래서 결과는 아래와 같이 항상 자식 프로세스가 먼저 실행되게 되는 겁니다.
그 다음으로 exec() 은 새로운 프로세스를 생성하지만, 자식 프로세스가 되는 것이 아닌 새로운 프로세스로 대체됩니다. 프로세스가 바뀌게 되는 것이지요. 앞에서 자식 프로세스에 해당하는 if 문을 아래와 같이 바꿔봅시다.
else if (rc == 0)
{ // child (new process)
printf("hello, I am child (pid:%d)\n", (int)getpid());
char *myargs[3];
myargs[0] = strdup("wc"); // program: "wc" (word count)
myargs[1] = strdup("p3.c"); // argument: file to count
myargs[2] = NULL; // marks end of array
execvp(myargs[0], myargs); // runs word count
printf("this shouldn't print out");
}
exec() 에는 execl(), execlp(), execle(), execv(), execvp(), execvpe()
등 다양한 변형이 있고 위는 execvp 를 사용한 예시입니다. 자식 프로세스는 새로운 wc
라는 프로세스로 대체된 것을 확인할 수 있습니다. 따라서, 밑의 printf
문은 execvp() 가 성공적으로 호출됬다면 실행되지 않게 됩니다.
마지막으로 프로세스의 종료는 exit() 시스템 콜을 행하여 OS 에 요청할 수 있습니다. OS 에 의하여 리소스들은 모두 해제되며 직전에 배운 wait()
에서 아래와 같이 매개변수를 통하여 상태 데이터들이 반환되게 됩니다.
pid = wait(&status)
이 exit()
은 프로세스가 자체적으로 자신의 실행을 종료하지만, 부모 프로세스가 자식 프로세스를 강제로 종료하는 abort() 시스템 콜도 존재합니다. 자식 프로세스가 너무 많은 리소스를 잡아먹고 있거나 자식 프로세스가 잡고 있는 작업이 더 이상 필요 없어질 때 사용할 수 있겠지요?
지금까지 배웠듯, 당장 위 그림에서도 확인할 수 있다시피 부모 프로세스는 자식 프로세스를 만들어 종료될 때까지 기다린 후 상태 정보를 회수한 뒤 자신의 일을 재개하는 것이 일반적입니다. 그러나, 이런 경우도 있을 수 있지요.
부모가 자식을 기다리지 않는 경우입니다. 그래서 자식은 종료는 되었는데 부모 프로세스로부터 리소를 회수받지 못한 것입니다. 이런 자식 프로세스를 Zombie, 좀비 프로세스라고 부릅니다. 더 심하게는 부모 프로세스가 먼저 종료가 되어버릴 수 있습니다. 이런 경우 Orphan 이라고 하는데, 이럴 때는 reaper
가 모든 orphan 들의 부모가 되어 회수함으로써 해결하죠.
프로세스는 컴퓨터 내에서 Queue 의 형태로 관리됩니다.
대표적으로 ready
상태에 있는 프로세스들을 보관하는 Ready queue 가 있습니다. 프로세서를 요청 중이며, 모든 다른 리소스들은 할당이 되어있는 상태이지요. 실행되기 위해 프로세서로만 가면 되는 친구들입니다. 프로세서가 이용 가능해질 때 여기서 실행할 프로세서를 선택해야 하는 데 이를 Process scheduling 이라 합니다. 뒤에 공부하게 될 것입니다. 또한 blocked
상태에 있는 프로세스들은 I/O queue(device queue) 에 보관됩니다. 디바이스 응답이 끝나 interrupt
가 이루어지면 비로소 ready queue 로 이동하게 되는 것이지요. 이를 개괄적으로 나타낸 그림은 아래와 같습니다.