[OS] 프로세스의 생성과 복사

2022. 10. 11. 23:26TIL💡/OS

프로세스는 프로그램을 실행할 때 새로 생성된다.

사용자가 프로그램을 실행하면 운영체제는 프로그램을 메모리로 가져와 코드 영역에 넣고 프로세스 제어 블록을 생성한다.

그리고 메모리에 데이터 영역과 스택 영역을 확보한 후 프로세스를 실행한다.

 

프로세스를 새로 생성하는 방법뿐만 아니라 실행 중인 프로세스로부터 새로운 프로세스를 복사하는 방법도 있다.

 

fork() 시스템 호출의 개념

fork() 시스템 호출은 실행 중인 프로세스로부터 새로운 프로세스를 복사하는 함수이다.

커널에서 제공하는 이 함수는 프로세스를 복사하는 일종의 시스템 호출이다.

 

컴퓨터를 사용하다보면 fork() 시스템 호출을 자주 접하게 된다. 예를 들어 워드프로세서 프로그램으로 문서 작업을 하다가 새로운 워드프로세서 프로그램을 하나 더 실행하면 운영체제는 새로운 워드프로세서를 복사한다. 이렇게 복사하면 처음 워드프로세서 프로그램을 실행하는 속도보다 훨씬 빠르다.

 

프로세스를 복사할 때 기존의 프로세스는 부모 프로세스가 되고 새로 생긴 프로세스는 자식 프로세스가 되며, 두 프로세스는 부모-자식 관계로 연결된다.

 

fork() 시스템 호출의 동작 과정

부모 프로세스에는 자식 프로세스 구분자(CPID)에 자식 프로세스의 PID를 작성한다.

그리고 자식 프로세스에는 부모 프로세스 구분자(PPID)에 부모 프로세스의 PID를 작성한다.

 

자식 프로세스가 없으므로 자식 프로세스 구분자의 값이 -1이다.

 

fork() 시스템 호출의 장점

프로세스를 새로 만들지 않고 fork() 시스템 호출로 프로세스를 복사하면 다음과 같은 장점이 있다.

 

💡 프로세스의 생성 속도가 빠르다.

하드 디스크로부터 프로그램을 새로 가져오지 않고 기존 메모리에서 복사하기 때문에 자식 프로세스의 생성 속도가 빠르다.

워드프로세서 프로그램을 2개 실행했을 때 첫 번째 것보다 두 번째 것의 실행 속도가 더 빠른 것을 경험해보았을 것이다.

 

💡 추가 작업 없이 자원을 상속할 수 있다.

부모 프로세서가 사용하던 모든 자원을 추가 작업 없이 자식 프로세서에 상속할 수 있다.

예를 들어 부모 프로세스가 파일 A를 사용하기 위해 초기화했다면 자식 프로세스는 파일 A를 바로 사용할 수 있다.

 

💡 시스템 관리를 효율적으로 할 수 있다.

부모 프로세스와 자식 프로세스가 자식 프로세스 구분자와 부모 프로세스 구분자로 연결되어 있기 때문에, 자식 프로세스를 종료하면 자식이 사용하던 자원을 부모 프로세스가 정리할 수 있다.

프로세스를 종료하면 프로세스가 사용하던 메모리 영역, 파일, 하드웨어를 잘 정리해야 하는데, 이러한 정리를 부모 프로세스에 맡김으로써 시스템이 효율적으로 관리되는 것이다.

 

프로세스의 전환

같은 요리의 주문이 밀려들 경우 fork() 시스템 호출을 사용하여 복사하면 된다고 설명했는데, 운영체제는 주문하는 요리가 달라도 간단하게 처리하는 기능을 제공한다. fork() 시스템 호출로 요리를 복사한 후, 복사된 요리를 새로운 요리로 바꾸는 함수exec() 시스템 호출을 사용하면 된다.

 

exec() 시스템 호출의 개념

exec() 시스템 호출은 기존의 프로세스를 새로운 프로세스로 전환하는 함수이다.

  • fork(): 새로운 프로세스를 복사하는 시스템 호출이다.
  • exec(): 프로세스는 그대로 둔 채 내용만 바꾸는 시스템 호출이다. exec() 시스템 호출을 하면 현재의 프로세스가 완전히 다른 프로세스로 전환된다.

exec() 시스템 호출을 사용하는 목적은 프로세스의 구조체를 재활용하기 위함이다.

새로운 프로세스를 만들려면 프로세스 제어 블록(Process Control Block, PCB)을 만들고 메모리의 자리를 확보하는 과정이 필요하다.

또한 프로세스를 종료한 후 사용한 메모리를 청소(Garbage Collection)하기 위해 상위 프로세스와 부모-자식 관계를 만들어야 한다.

이 때 exec() 시스템 호출을 사용하면 이미 만들어진 프로세스 제어 블록, 메모리 영역, 부모-자식 관계를 그대로 사용할 수 있어 편리하다.

새로운 코드 영역만 가져오면 되기 때문에 운영체제의 작업이 수월하다.

 

exec() 시스템 호출의 동작 과정

exec() 시스템 호출의 동작 과정은 간단하다.

exec() 시스템 호출을 하면 코드 영역에 있는 기존의 내용을 지우고 새로운 코드로 바꿔버린다.

 

또한 데이터 영역이 새로운 변수로 채워지고 스택 영역이 리셋된다.

프로세스 제어 블록의 내용 중 프로세스 구분자, 부모 프로세스 구분자, 자식 프로세스 구분자, 메모리 관련 사항 등은 변하지 않지만 프로그램 카운터 레지스터 값을 비롯한 각종 레지스터와 사용한 파일 정보가 모두 리셋된다.

마치 프로세스를 처음 시작하는 것처럼 내용이 정리되는 것이다.

 

#include <stdio.h>
#include <unistd.h>

void main() {
	int pid;

	pid = fork();

	if(pid < 0) {
		printf("Error");
		exit(-1);
	}

	else if(pid == 0) {
		// child process
		execlp("mplayer", "mplayer", NULL);
		exit(0);
	}

	else {
		// 부모 프로세스의 대기 상태
		wait(NULL);
		printf("mplayer Terminated");
		exit(0);
	}
}

 

유닉스의 프로세스 계층 구조

유닉스에서 커널이 처음 메모리에 올라와 부팅이 되면 커널 관련 프로세스를 여러 개 만드는데, 그중 init 프로세스는 전체 프로세스의 출발점이 된다. init 프로세스는 일반 사용자 프로세스의 맨 위에 위치하며 fork()와 exec() 시스템 호출을 이용하여 자식 프로세스를 만든다.

 

고아 프로세스와 좀비 프로세스

부모 프로세스는 자원 회수를 위해 자식 프로세스가 끝날 때까지 기다려야 한다.

그런데 부모 프로세스가 먼저 종료되거나 자식 프로세스가 비정상적으로 종료되어 부모 프로세스에 연락이 안되는 경우가 있다.

이런 문제가 발생하면 자식 프로세스가 종료되지 않거나, 종료되었는데도 사용하던 자원이 그대로 남게 된다.

 

컴퓨터에 고아나 좀비 프로세스가 많아지면 자원이 낭비됨으로써 효율적인 운영에 방해가 된다.

따라서 운영체제는 반환되지 못한 자원을 회수하는 자원 회수를 주기적으로 해야한다.

C언어에서의 함수 맨 마지막의 exit() 또는 return()문은 자식 프로세스가 작업을 끝났음을 부모 프로세스에 알리는 것이다.

exit() 또는 return()문을 안 썼다고 해서 프로그램에 문제가 생기거나 좀비 프로세스가 되는 것은 아니다.

다만 자식 프로세스가 끝났음을 알려줌으로써 부모 프로세스는 미루어왔던 자원 정리나 자식 프로세스와의 동기화를 할 수 있다.

고아 프로세스 

부모 프로세스가 자식보다 먼저 죽는 경우에 발생한다.

이런 경우 커널은 이 프로세스가 누구의 부모 프로세스인지 확인한 후, 커널이 자식 프로세스의 부모 프로세스 ID를 1로(init 프로세스)로 바꿔준다.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
 
int main() {
     
    pid_t childPid;
    int i;
     
    childPid = fork();
     
    if(childPid > 0) {  // 부모 프로세스
        printf("부모 PID : %ld, pid : %d\n",(long)getpid(), childPid);
        sleep(2);
        printf("부모 종료\n");
        exit(0);
    }
    else if(childPid == 0){  // 자식 코드
        printf("자식 시작\n");
         
        for(i=0;i<10;i++) {
            printf("자식 PID : %ld 부모 PID : %ld\n",(long)getpid(), (long)getppid());
            sleep(1);
        }
         
        printf("자식 종료\n");
        exit(0);
    }
    else {  // fork 실패
        perror("fork Fail! \n");
        return -1;
    }
     
    return 0;
}

해당 코드를 수행해보면 아래와 같은 결과가 뜬다.

부모 프로세스가 죽은 후에는 Parent PID가 1로 나온다.

이를 통해 고아 프로세스의 자원을 init 프로세스가 회수한다.

좀비 프로세스

자식 프로세스가 종료했음에도 부모가 뒤처리를 하지 않을 때 발생한다.

자식 프로세스가 exit 시스템 콜을 호출하면서 종료되면 이 프로세스에 관련된 모든 메모리와 리소스가 해제되어 다른 프로세스에서 사용할 수 있게 된다.

자식 프로세스가 종료된 이후에 부모 프로세스가 자식 프로세스의 상태를 알고 싶을 수 있기 때문에 커널은 자식 프로세스가 종료되더라도 최소한의 정보(PID, 프로세스 종료 상태 등)를 가지고 있게 된다.

 

부모 프로세스가 좀비 프로세스의 종료 상태를 회수하게 되면(wait() 시스템콜 호출을 통해서) 좀비 프로세스는 제거된다.

 

터미널 창에 ps aux | grep 'Z' 명령어를 사용하여 좀비 프로세스를 출력한다.

'TIL💡 > OS' 카테고리의 다른 글

[OS] 프로세스의 구조  (0) 2022.10.11
[OS] DMA(Direct Memory Access)이란?  (0) 2022.10.10
[OS] Interrupt(인터럽트)  (0) 2022.09.30
Page Table의 작동 순서  (0) 2022.09.30
API와 SDK  (0) 2022.05.31