프로세스란 말을 많이 들어보셨죠? 컴퓨터에서 프로세스는 가장 간단히 말해서 실행 중인 프로그램을 의미합니다. 실행을 위해 커널에 등록된 개체로서, 커널은 이 프로세스들을 관리하여 전체적인 시스템 퍼포먼스를 위해 노력하죠. 전형적인 시스템은 마치 수십 개, 아니 수백 개의 프로세스들이 동시에 돌아가는 것처럼 보입니다. 위 그림과 같이 코드와 데이터로 이루어진 저장된 실행 파일들이 프로세스에 실려 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 로 이동하게 되는 것이지요. 이를 개괄적으로 나타낸 그림은 아래와 같습니다.
