본문 바로가기

CS/운영 체제

3.3 프로세스의 생성과 복사

 

3.3 프로세스의 생성과 복사
3.3.1 프로세스 생성
3.3.2 복사, fork()
3.3.3 프로세스 오버레이, exec ()
3.3.4 자식 기다리기, wait()
3.3.5 프로세스 종료, exit ()
3.3.6 UNIX OS에서의 프로세스 생성 및 종료 과정

 

 단순히 메모리에 올리는 것만으로는 프로세스가 만들어지지 않는다. 프로세스를 생성하기 위해서는 최종적으로 PCB를 만들어줘야한다. 지금부터 프로세스의 생성 과정에 대해 알아보겠다.


3.3.1 프로세스 생성

 프로세스는 언제 생성될까? 크게 다섯 가지로 분류할 수 있다. 

  • 시스템 부팅 과정에서 필요한 프로세스 생성
  • 사용자의 로그인 후 사용자와 대화를 위한 프로세스 생성
  • 새로운 프로세스를 생성하도록 하는 사용자의 명령
  • 배치 작업 실행 시
  • 사용자 응용 프로그램이 시스템 호출로 새 프로세스 형성

중요한 것은 메모리에 올라갔다고 전부 프로세스가 아니라는 점이다. 그냥 메모리에 올라가만 있는 것은 아무런 의미가 없다. 운영체제가 제어할 수 없는 상태이기 때문이다. PCB가 존재하여 운영체제가 제어가능한 형태, 즉 CPU를 할당받아 실행이 가능한 형태가 되어야 비로소 프로세스라고 부른다. 

 

프로세스 생성 과정

1. 생성하려는 실행 파일의 경로를 OS에 전달

2. OS는 메모리에 프로그램을 적재.

  • 코드 영역에 프로그램의 코드를 적재시키고, 데이터 영역에 전역/정적 변수들을 할당.
  • 스택과 힙에는 아직 아무것도 없으므로 초기화만 시킨다.

3. PCB 공간을 할당받고 (malloc) 필요한 정보를 채움.

  • 프로세스 식별자를 결정 - 새로운 PID 번호 할당
  • 프로세스 정보 기록
  • 프로세스 테이블에서 새 항목 할당
  • 새로 할당된 프로세스 테이블에 PCB 연결

4. PCB에 프로세스 상태를 ready 상태로 표시하고, 준비 큐에 장착

 

위의 과정을 보면 알겠지만 프로세스 하나를 만드는데 할 일이 굉장히 많다. 우리가 프로그램을 실행시킬 때마다 이 과정을 수행하려고 한다면 매우 많은 시간이 필요할 것이다. 그래서 실제로 OS는 프로세스의 생성에 매번 이 과정을 반복하지 않고 복사하는 꼼수를 쓴다. 바로 fork()이다.

 

3.3.2 복사, fork()

 말 그대로 기존에 있는 프로세스가 다른 프로세스를 생성하는 것이다. 프로세스를 복사하는 시스템 콜을 통해서 프로세스를 생성한다. 리눅스에서는 fork() 시스템 콜이 이에 해당한다.

 

 UNIX 계열의 OS는 시스템이 부팅할 때, 0번 프로세스 init 만을 자체적으로 생성한 후 나머지 프로세스는 복제를 통해 생성한다. 자주 사용되는 프로세스에 대해 매번 반복할 필요가 없고, 관리상 편해지고 (프로세스 계층 구조), 프로세스 간 통신이 용이해진다.

 

 그렇다면 본격적으로 fork() 시스템 콜에 대해 알아보자.

 

fork() 시스템 콜

 실행 중인 프로세스로부터 새로운 프로세스를 복사하는 함수다. 정말 똑같이 복사하기 때문에 실행 중인 프로세스 그대로의 프로세스가 하나 더 만들어진다.

 

 fork를 호출한 프로세스를 부모 프로세스, fork된 프로세스는 자식 프로세스라고 부른다. 자식 프로세스는 부모 프로세스의 모든 환경, 메모리, PCB등을 복사한다. 부모와 동일한 모양이지만, 독립된 주소 공간에 위치한다는 점을 기억하자. 또한 당연히 다른 프로세스이므로 PID와 PPID (Parent PID), CPID(Child PID, 자식이 없다면 -1)는 달라진다. 그리고 독립된 주소 공간에 위치해 있으므로 (다른 집에 산다고 생각하자.) 메모리 관련 정보 또한 다르다.

fork() 실행 과정

 자식은 부모의 Program Counter도 복제하기 때문에, fork()다음의 if(pid==0) 라인부터 시작된다. 즉, pid = fork(); 이전 라인을 실행하지 못하는 것이다.

 

 

 fork() 또한 함수이기 때문에 리턴값이 있다. 부모 프로세스에게는 자식 프로세스의 pid가 리턴되고, 자식 프로세스에게는 0이 리턴된다.

 

 1 #include <stdio.h>
 2 #include <sys/types.h>
 3 #include <sys/wait.h>
 4 #include <unistd.h>
 5
 6 int main() {
 7 pid_t pid;
 8 int i, sum=0;
 9
 10 pid = fork(); // fork! -> create a child process
 11 if(pid > 0) { // Run by the parent
 12 printf("Parent: fork()'s return == Child pid = %d\n", pid);
 13 printf("Parent: pid = %d\n", getpid());
 14
 15 wait(NULL); // Wait for the child
 16 printf("Parent has been finished\n");
 17 return 0;
 18 }
 19 else if(pid == 0) { // Run by the child
 20 printf("\t-Child: fork()'s return pid = %d\n", pid);
 21 printf("\t-Child: pid = %d, parent's pid = %d\n", getpid(), getppid());
 22
 23 for (i=1; i<=100; i++) sum += i;
 24
 25 printf("\t-Child: sum = %d\n", sum);
 26 return 0;
 27 }
 28 else { // Error
 29 printf("fork error");
 30 return 0;
 31 }
 32 }

 

fork()의 장점과 단점

먼저 fork() 시스템 호출의 장점이다.

 

 당연하게도 프로세스의 생성 속도가 빠르다. 추가 작업 없이 자원을 상속할 수 있으며, 자동적으로 생겨나는 프로세스 계층 구조로 시스템 관리를 효율적으로 할 수 있다.

 

그러나 단점 역시 존재한다.

 

 매번 모든 Context의 복사본은 만드는 것은 매우 비효율적이다. 특히 맨 처음 만든 프로그램 프로세스(0) 이외에는 다른 프로그램을 동작할 수 없다. 메모장 프로세스라고 가정하면 계속 메모장만 쓸 수 있는 것이다. 그래서 UNIX OS는 fork() 이후 다른 응용 프로그램의 프로세스를 실행할 수 있는 exec() 시스템 콜을 호출한다.

 

3.3.3 프로세스 오버레이, exec()

 exec() 시스템 콜은 기존의 프로세스를 새로운 프로세르로 전환하는 함수이다. 현재 실행중인 프로세스의 주소 공간에 새로운 응용프로그램을 적재하고 실행하는 것이다. fork()는 새로운 프로세스를 복사하는 시스템 호출이라면, exec()는 프로세스는 그대로 둔 채 내용만 바꾸는 시스템 호출이다. 옷을 입혀준다고 생각하면 편하겠다. 실행 파일을 로딩하여 현재 프로세스의 이미지 위에 단순히 덮어쓴다.

 

 프로세스를 새로 생성하는 것이 아니라는 점을 주의하자. PID의 변경이 없으며, 프로세스의 메모리 공간 (코드, 데이터, 힙, 스택)에 새로운 프로그램이 적재될 뿐이다. 보통 fork()를 통해 생성된 자식 프로세스가 exec()를 실행하고, loader가 exec를 통해 호출된다.

 

exec() 실행 과정

1. 메모리

  • 코드 영역에 있는 기존의 내용을 지우고 새로운 코드로 바꿔버린다.
  • 데이터 영역이 새로운 변수로 채워지고 힙/스택 영역이 리셋된다.

2. PCB

  • PID, PPID, CPID, 메모리 관련은 유지된다. 즉, 새로운 프로세스가 전환되더라도 종료 후 부모 프로세스로 돌아올 수 있다.
  • Program counter 및 기타 레지스터, 파일 정보들은 모두 리셋된다.

3.3.4 자식 기다리기, wait()

 지금까지 배운 것을 요약해보자. 프로세스를 처음부터 생성하는 과정은 복잡하다. 따라서 우리는 fork() 시스템 콜을 통해 부모 프로세스를 그대로 복사해 자식 프로세스를 만들었고, 자식 프로세스에서 exec() 시스템 콜을 호출하여 로더를 호출해 새로운 응용 프로그램을 자식 프로세스에게 적재시켰다. 이렇게 생성된 자식 프로세스는 새로운 코드를 통해 열심히 일을 할 것이다. 그렇다면 부모 프로세스는 뭘 해야할까?

 

 보통 자식 프로세스가 끝나기를 기다렸다가, 자식 프로세스가 종료되면 이어서 실행한다. wait() 시스템 콜이다.

 

3.3.5 프로세스 종료, exit()

 그렇다면 부모 프로세스는 자식 프로세스가 종료되었는지 어떻게 알 수 있을까? 바로 exit() 시스템 콜을 통해 알 수 있다. 종료를 명시적으로 알림으로써 부모는 자식이 사용하던 자원을 빠르게 회수할 수 있는 것이다.

 

 종료 코드

 부모 프로세스에게 상태나 종료의 이유를 전달하는 값이다. exit(1)의 형태이다. 보통 정상종료는 0, 나머지는 1-255 범위 내에서 임의로 사용한다. main() 함수의 리턴값 (return 0)이 바로 exit(0)과 동일하다. 내부적으로 exit() 시스템 콜이 호출되도록 컴파일 되어있으며, 따라서 OS가 자동적으로 exit()를 호출하도록 하여 프로그램을 종료 시키도록 하게한다.

 

 이 종료 코드를 부모가 확인해야지 최종적으로 자식 프로세스가 종료되는 것이다.

 

exit() 시스템 콜을 통한 프로세스 종료 과정

1. 프로세스의 모든 자원 반환

  • 코드, 데이터, 스택, 힙 등의 모든 메모리 자원을 반환
  • 열어놓은 소켓이나 파일 닫음

2. PCB에 프로세스 상태를 Terminated로 변경, PCB에 종료 코드 저장

  • 아직 PCB가 프로세스 테이블에서 제거된 것은 아니다. 부모가 종료를 확인해야 최종적으로 종료된다.

3. 자식 프로세스들을 init 프로세스에게 입양.

4. 부모 프로세스에게 SIGCHID 신호 전송. (일종의 종료 신호)

  • 부모 프로세스는 SIGCHID를 수신하고 wait() 시스템 호출로 자식의 종료 코드를 읽는다. 죽은 자식이 남긴 정보를 확인 후에야 자식의 PCB가 완전히 제거된다.

만약 부모가 자식의 종료 신호를 제 때 수신하지 못한다면 어떻게 될까? 자식은 좀비 프로세스가 된다. PCB가 남아있으므로 이 좀비 프로세스의 존재를 ps 명령어로 확인할 수 있다. 모든 자원은 반납한 상태이기에 메모리 SIZE는 0이고, 하는 일은 없지만 PCB만 남아있는 것이다. 메모리는 잡아먹지 않지만, PCB를 할당받은 상태이기에 좀비 프로세스가 너무 많다면 문제가 될 수 있다. 또한 사용자가 소소한 멘탈 대미지를 받는다. 내 컴퓨터 속 워킹데드.

 

3.3.6 UNIX OS에서의 프로세스 생성 및 종료 과정

 한마디로, fork() → exec()의 구조라고 할 수 있다. fort()를 통해 프로세스를 만들고, exec()를 통해 필요한 프로세스를 실행한다. 이 때, 생성한 프로세스는 부모 프로세스, 생성된 프로세스는 자식 프로세스이다. 부모는 wait()를 통해 자식을 기다리고, 자식은 exit()를 통해 자신의 종료를 알린다.

 

 모든 프로세스의 최초의 조상은 1번 init 프로세스라는 것 또한 추가로 기억하자.

 

장점

 이런 방식의 프로세스 생성 및 종료 과정을 채택하는 이유가 있다. 

 

1. 프로세스 생성 과정이 간소화 된다.

  • 매번 프로세스를 처음부터 생성할 필요 없이 복사 후 새 옷 입히기 하면 끝난다.

2. 프로세스 관리가 쉬워진다. 부모를 통해 자식을 관리할 수 있다. (계층 구조)

  • abort() 호출 시, 부모가 신호를 보내면 자식을 죽일 수 있다.

3. 프로세스 간 통신 용이

  • 프로세스는 독자적인 메모리 공간을 가진 것 처럼 운영되기 때문에 서로 간섭 및 통신이 불가하다. 이 때, 서로 통신이 필요할 경우 SIGCHLD 등을 이용한다.
  • 또한 파일을 통해 의사소통 할 수도 있는데 여기서 fork()가 사용된다.

'CS > 운영 체제' 카테고리의 다른 글

4.0 스레드  (1) 2024.04.20
3.4 프로세스 계층 구조  (2) 2024.04.20
3.2 프로세스 메모리 구조  (1) 2024.04.17
3.1 프로세스의 개념  (4) 2024.04.17
3.0 프로세스  (1) 2024.04.17