OS - 1.3 (PV) (2) Why fork( ) and exec( )?

hyeok's Log·2022년 9월 13일
1

OperatingSystems

목록 보기
3/14

  지난 포스팅 막바지에선 fork, wait과 같은 Process API POSIX의 System Call들에 대해 알아보았다.

  • fork를 하면 Parent Process의 모든 Image가 Copy된다.

    • 즉, Child Process는 Parent Process의 복사본인 것이다.
      • 단, fork의 Return Value는 달랐다. Child Process에겐 0을, Parent Process에겐 Child Process의 PID를 반환한다고 했다. (이는 곧 PID가 다름을 의미)
  • 그리고, Parent Process에선 wait System Call을 통해 fork 시 발생할 수 있는 무작위 순서의 수행을 막고, 수행 순서를 고정시킬 수 있다고 했다.

    • Parent Process가 wait( )을 통해 Child로부터의 종료 Signal을 기다림으로써 Child Process가 반드시 Parent Process보다 먼저 수행되도록 만들 수 있다. ★★★
      • 허나, 알다시피 wait System Call의 주목적은 이러한 '순서 조정'보다도, Child Process가 Zombie Process로 잔존하는 것을 막는데에 있다고 했다. ★
        • Child Process의 Resources를 OS에서 모두 회수하는 Reaping이 목적이다.

  금번 포스팅은 이에 이어서 계속해서 Process API를 설명하고, 관련된 Process 개념을 포괄적으로 다루겠다.


Zombie vs Orphaned

Zombie Process

Zombie(Defunct) Process : Execution은 마쳤지만, Process Table Entry가 아직 Process Table에 남아있는 Process

Process가 Terminated이지만, 해당 Process의 Parent Process가 wait과 같은 Call을 통해 아직 'Exit Status를 회수 (Reaping)'하지 않은 상황!

일반적으로 코딩 실수나 버그 등으로 일어난다.

"모든 프로세스"는 일시적으로 Zombie State에 있다가 누군가(대부분 자신의 Parent)에 의해 Reaping되어 정상적인 Terminated State가 된다. ★★★

즉, '시간을 기준'으로 보면, Zombie라 함은, Child Process가 Terminate한 직후부터 Reaping되기 이전까지라 할 수 있겠다. ★★★

  • Parent Process가 wait System Call을 호출하면 Child Process는 정상적으로 Terminate한다.

  • Zombie Process는 Process Table에서 Process Table Entry를 그대로 남겨놓고 있다. 즉, 공간을 차지하고 있는 것이다.

    • 하지만, Terminated이므로 Main Memory나 CPU를 더 이상 점유할 일은 없다. 즉, 쓸모없는 녀석들이다.
      • 만약, 이들을 그대로 냅두면, Process Table이란 것은 Finite Resource이기 때문에, 언제가는 과도하게 쌓여 '더 이상 Process 생성이 불가능한 상태'와 같이 시스템 성능에 현격한 저하가 발생할 수 있는 것이다.
  • 아무튼, 쉽게 말해, Zombie Process는 'Parent Process는 수행 중인데 Child Process가 죽었을 때, 그때의 Child Process'이다.


Orphaned Process

Orphan/Orphaned Process : Child Process는 수행 중인데 Parent Process가 wait을 Call하지 않고 먼저 Terminated가 되었을 때, 그때의 Child Process

쉽게 말해, Parent가 Reaping하지 못하고 Child보다 먼저 종료되버린 상황에 발생한다.

즉, 'Parent가 없는 Child Process', 그래서 '고아(Orphan)'인 것!

  • 예를 들어, 지난 포스팅의 wait System Call Example Code에서, Parent가 wait을 호출하기 전에 모종의 이유로 Segmentation Fault가 떠버려 Child를 회수하지 않고 종료되었다고 하자. 그렇다면 이때의 Child Process는 Parent Process보다 먼저 죽었든지 아니든지 여부와 상관없이 Orphaned Process가 된다. ★★

  • 일반적으로 UNIX 계열 OS에서는 이러한 Orphan Process들에 대해, 이들의 New Parent로 init Process를 할당해준다. ★

    • 이를 Reparenting 또는 Adoption이라고 부르기도 한다. (입양으로 비유하는 것)

      • 따라서, 엄밀히 보면, init가 입양하면 더 이상 Orphan은 아닌 것. 허나, 관습적으로 init이 Adopt한 Process들은 그대로 Orphan이라 칭한다.
    • 그리고 init Process는 주기적으로 wait을 호출해 이러한 Orphaned Process들의 Exit Status를 회수하고 Reaping한다. ★★★

      • 그래서 우리가 wait(Reaping)없이 Parent Process를 종료시켜도 왠만해선 Zombie가 남지 않는 것! Child가 종료되면 init이 알아서 제거해주니까.

Cautions

  • 허나, Long-Lived Zombie는 그럼에도 불구하고 발생한다. 아주 간단한 예시는, Child가 먼저 종료되고 나서, Parent가 이들을 wait으로 Reaping하지 않은채 계속해서 수행하고 있는 상황이다. Parent가 Child 종료 이후 Reaping 없이 계속 수행하고 있으므로, 이 Children은 Orphaned Process가 아니고, 따라서 init에 의해 회수되지 않고 그대로 좀비로 남아있는다. (SP에서 알아봤듯이, 이땐 ps 명령 시 <defunct>라고 표시된다.) ★★★

    • Parent가 종료되면 이들이 Zombie Process이면서 동시에 Orphaned Processes가 되어 비로소 init Process에게 Adopt되어 Reaping될 것이다. (아래서 다시 설명) ★★★
  • 또한, Child는 종료되지 않은채, Parent만 종료되는 상황도 존재한다. Orphaned지 않냐고? 맞다. 허나, 이때 Child가 종료되지 않고 무한루프를 돈다고 해보자. 이 친구는 시스템 자체가 종료되기 전까진 계속해서 Resource를 소모하는 골칫거리가 될 것이다. ★★★

    • 이땐 Orphaned이지만 Adopt된채 계속 수행되고 있어 Zombie(defunct 표시)라고 표시되지도 않는다. ★★
  • 한편, Orphaned Process가 init Process에게 입양되고 나서, 추후 Child가 종료되었을 때, init Process의 Orphaned Process Reaping이 완벽히 수행되지 않는 경우도 존재한다.

    • '모종의 이유(init 자체에 버그가 생겨 정상 동작을 하지 않는 경우)'로 인해 Orphaned Process에 대한 Reaping by init Process가 이뤄지지 않고 그냥 Zombie Process가 되는 것이다. ★
      • 이러한 케이스에선, Linux Shell에서 'ps -aux' 명령어를 쳤을 때, <defunct> 표시가 되어 있으면서 동시에 Parent의 PID가 1이라고 표시된다. ★★
        • 이들은 "kill -9 (PID)" 명령으로 강제 종료 Signal을 받아도 죽지 않는다. init의 Child Process는 User가 kill로 죽일 수 없다. (애초에 좀비 프로세스 Killing이란 불가능하다. 이미 죽은 것을 어떻게 또 죽이는가 ★★★)
          • 이때는, 위의 Orphaned 원리를 이용해, Parent Process를 Kill하면 Reaping할 수 있는데, 이 경우에는 Parent가 init이다. init을 Kill하면 System 자체가 비정상 종료되버린다. ★★★
            • 결국, 이 경우엔 Reboot만이 답이다.

~> 이처럼, Zombie/Orphaned Process가 생겨나면 시스템 관리가 Tricky해지기 때문에 애초에 Programmer가 잘 관리해주는 것이 중요하다.


Q) Child가 먼저 죽고, Parent가 Reaping하지 않고 죽으면, 그 Zombie Process들은 어떻게 처리되나요?
A) 매우 좋은 질문이다. 위에서 언급하긴 했지만, 충분히 헷갈릴만하다. 특히, Orphaned Process에 대한 Formal Definition만 놓고 보면 더더욱 이 상황이 이해되지 않을 수 있다. 왜냐? Orphan Process는 Running Child Process라고 명시되어 있기 때문이다.
  허나, 걱정말라. Zombie Process도 마찬가지로 Orphaned가 될 수 있다. 즉, Zombie Process이면서 동시에 Orphaned Process일 수 있는 것이다. 이러한 Process는 어떻게 처리될까? 쉽다. 그냥 여느 Orphaned처럼 init이 이를 Adopt하고, 곧바로 Reaping하는 것이다. ★★★★★


exec( ) System Call

  exec( ) Function은 Caller가 Caller 자신과는 다른 Program을 실행하고자 할 때 사용하는 System Call이다.

  알다시피, Program 생성은 fork를 이용한다고 했다. 이때, fork는 단순히 'Parent Process(Caller)'의 Copy를 생성한다. 허나, 우리가 fork를 이용하는 목적은 Program의 생성이지, Caller의 복제가 아니다. (물론, 복제를 이유로 사용할 수 있다. Process-based Concurrent Server가 대표적 예시)

우리가 fork를 하는 이유는 단순히 Caller를 Copy하기 위함이 아니라, 새로운 Program을 실행시키기 위함이다.

이때, 우리는 fork 이후 exec System Call을 이용해, 새롭게 fork로 생성한 Process에 '목적 Program'를 입힌다.

  • exec 시, OS는 인자로 '목적 프로그램의 Binary Image'를 요구한다.

    • 또한, Stack과 Heap의 초기화 과정도 필요하다. for Program Running!

    따라서 exec은 'New Binary Image 덮어씌우기' + 'Stack & Heap 초기화' 작업을 수행한다.

    fork로 생성된 메모리 공간에 exec을 이용해 새로운 Program의 Binary Image를 덮어씌우고, 스택과 힙을 초기화하는 것이다. 목적 프로그램을 수행할 준비를 하는 것! ★★★

  • exec은 Paramter로 다음의 두 요소를 필수로 요구한다.

    • Binary File의 이름 (목적 Program의 이름)
    • Array of arguments (목적 Program에 넘길 인자들)
char *argv[3];
argv[0] = “echo”;
argv[1] = “Hello World!;
argv[2] = NULL;
exec(/bin/echo”, argv);
printf(“exec error occurs!\n”);		// exec이 정상 수행되면 여기서부턴 수행되지 않음.
									// 왜냐? 덮어씌워지니까! ★★★

~> 위의 예시 코드는 아주 간단한 exec 사용 예제이다. exec은 fork로 생성된 New Child Process에 목적 프로그램 코드를 덮어씌우므로, exec이 실패하지 않는다면, exec 이후의 기존 명령들은 모두 사라진다. 즉, 수행되어선 안된다.
=> 만약, 위 예시에서 printf문이 수행된다면, 그것은 exec 과정에서 문제가 발생했음을 의미한다.


exec System Call : Replace the existing contents of the memory with the new memory contents from the new binary file, which is the program that we want to execute!

  • 이러한 exec( )은 Return이 없다. 그저 새 Program을 실행할 뿐이다.
    • 반면, fork는 두 번의 Return이 있다고 했다. (기억)

  아래 역시 마찬가지로 간단한 exec 예시 코드이다.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>

int main(int argc, char *argv[]) {
	int ret, wc;
	printf("Hello World! (PID : %d)\n", (int)getpid());

	ret = fork();    
	if (ret < 0) { 						// fork 실패 시 음수 반환
		fprintf(stderr, "fork failed\n");
		exit(1);
	} 
    else if (ret == 0) { 				// Child Process Routine
		printf("This is Child (PID : %d)\n", (int)getpid());
        
		char *args[3];
		args[0] = strdup("echo"); 		// 목적 Program: echo  (strdup: 문자열 복사)
		args[1] = strdup("Goodbye World!");	// Arg: echo할 문자열
		args[2] = NULL; 				// Argument Array의 끝을 표시
        
		execvp(args[0], args); 			// exec계열 함수인 execvp를 이용해 wc 실행
		printf("exec Error occurs");	// exec Error가 아닌 이상 출력될 일 x 
	} 
    else { 								// Parent Routine
		wc = wait(NULL);				// 수행 순서 조정! 반드시 Child가 먼저 종료!
		printf("This is Parent (PID : %d)\n", (int)getpid());
	}
	return 0;
}

(출력)
> ./example
Hello World! (PID : 12300)
This is Child (PID : 12301)
Goodbye World!
This is Parent (PID : 12300)
>

~> New Process에 목적 Program이 잘 덮어씌워지고 있음을 주목하라. 이때, exec 계열 함수에는 execvp, execve 등등이 존재한다. 전자는 경로 지정 없이 단순한 프로그램 이름으로 exec을 수행하고, 후자는 경로 지정이 필요한 방식이다. 이외에도 다양한 exec 함수들이 있으며, 이들에 대한 자세한 사항은 ManPage에서 확인할 수 있다.


Why fork( ) and exec( )?

  이렇게 fork와 exec으로 이어지는 프로세스 생성 흐름을 이해했다면 다음과 같은 의문이 들 것이다.

"아니 왜 굳이 fork와 exec 과정이 분리되어 있는 것이죠? 그냥 프로세스 생성과 덮어씌우기를 한 번에 진행하면 되잖아요."

  매우 좋은 질문이다. 굳이 두 절차를 분리해놓은 것은 Weird하다. 허나, 그러한 데에는 다음과 같은 분명한 이유가 존재한다.

fork( )와 exec( )을 분리해놓음으로써 우리는 새 Program이 실행되기 전에 다양한 Setting과 I/O Redirection, PIPE 등을 할 수 있다.

  • I/O Redirection : cat my_code.c > new_code.txt

  • PIPE : ls | grep 'my' | tail -2


I/O Redirection

  I/O Redirection에 대해 잠시 알아보자. I/O Redirection은 지난 SP에서 Shell Project 수행 시 많이들 접해봤을 개념으로, 다음과 같이 정의할 수 있다.

I/O Redirection : File/Command/Program/Script의 Output을 Capturing해 그것을 또 다른 File/Command/Program/Script의 Input으로 전송하는 것

  • I/O Redirection 흐름 이해 ex) "cat my_code.c > new_code.txt"
    • Shell에선 Commands와 Arguments를 토대로 fork( )와 exec( )을 이용해 Program을 생성하고 수행할 것이다.

      • Shell에서 fork( )를 호출하고, 이어서 exec("cat", "cat my_code.c")를 호출한다.

      • 이때, exec 호출 이전에 Shell은 STDOUT을 닫고 new_code.txt File을 Open한다.

        • This is about 'File Descriptor' concepts!
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <sys/wait.h>

int main(int argc, char *argv[]) {
	int wc, ret = fork();
    
	if (ret < 0) { 					// fork 실패 시 음수 반환
		fprintf(stderr, "fork failed\n");
		exit(1);
	} 
    else if (ret == 0) { 			// Child Process Routine
		close(STDOUT_FILENO);		// Standard Output을 닫고
		open("./new_code.txt", O_CREAT|O_WRONLY|O_TRUNC, S_IRWXU);
		// new_code.txt 파일을 연다. (생성) ~> I/O Redirection ★★
        // The reason will be introduced in minutes!
        
		char *args[3];
		args[0] = strdup("cat");
		args[1] = strdup("cat my_code.txt");
		args[2] = NULL;
        
		execvp(args[0], args); 		// Program Execution
	} 
    else wc = wait(NULL); 			// Parent Process Routine
    
	return 0;
}

(출력)
> ./my_code
> cat new_code.txt
(위의 코드를 그대로 출력)
>

~> 이처럼 I/O Redirection을 수행할 수 있다. 위의 코드를 토대로 Shell Program에 I/O Redirection을 적용하는 것은 식은 죽 먹기일 것이다.
=> 여기서 중요한 것은, 단순한 I/O Redirection이 아니라, fork와 exec 과정이 분리되어 있기 때문에 위와 같은 출력 파일 변경이 가능하다는 것이다.
----> 그리고 알다시피, 이러한 '출력 파일 변경'은 File Descriptor 개념을 통해 좀 더 자세히 이해할 수 있다.


File Descriptor

  지난 SP에서 이미 File Descriptor에 대해 자세히 설명한 바 있다. 다음의 포스팅들(1편, 2편)에서 확인할 수 있다. 따라서, 본 포스팅에선 이 개념을 간단히 소개한다.

  • File Descriptor : File, Directory, Device 등을 나타내는(가리키는) Integer이다.

    • Process는 File Descriptor를 통해 File/Directory/Device 등을 Open(Access)한다.

      • 모든 Process는 각자 자신의 File Descriptor Table을 지닌다. (Only One)
        • 이 File Descriptor Table에는 최초 Process 생성 시점 기준 Default Setting으로 stdin(0번), stdout(1번), stderr(2번) File Descriptor들이 Open되어 있다. ★
          • 따라서 알다시피, 별다른 초기 설정 없이 바로 임의 파일을 Open하면 3번 Descriptor가 반환된다.
    • File Descriptor는 마치 Index처럼 0번부터 존재하고, 'fd'라고 줄여서 부른다.

  • File Descriptor Table의 각 File Descriptor Slot에는 FILE Pointer가 존재하고, 이들은 System의 특정 File을 가리킨다(Everything is a file).

    • 정확히는, 해당 File에 대한 Structure인 Open File Table을 가리킨다.
      • 이러한 Open File Table은 File, v/i-node Structure와 상관 없이 매 Open 시에 해당 File에 대해 하나씩 생성된다.
        • 'System-Wide Open File Table'
          • 다른 Process여도 이 OFT를 볼 수 있기 때문!

(현 시점에서 v-Node 등의 개념은 다루지 않을 것이다. 추후 다룰 것!)


  • File Descriptor와 각 System Call의 관계성

    • open( ) : 새로운 File Object와 새로운 File Descriptor를 할당하고, 해당 fd가 해당 File Object를 포인팅하게 설정한다.

      • fd를 할당할 때, 일반적으로 File Descriptor Table의 'Free File Descriptor (Empty Slot)' 중 가장 정수가 낮은 fd를 반환한다. ★★★
        • 그래서 초기 open 시 3번 fd인 것이고, 예를 들어 1번 fd를 close하고 연이어 새 파일을 open하면 1번이 할당되는, 그러한 원리이다. (바로 위 I/O Redirection 예제가 바로 여기에 해당한다!!!) ★★★
    • close( ) : fd를 해제한다.

      • fd 해제 후, 만약 해당 File Object와 연결되어 있는 File Descriptor가 더 이상 없다면, 해당 File Object도 해제(Deallocate)한다.
    • fork( ) : fork로 Child Process 생성 시, Parent Process의 File Descriptor Table은 Child Process에게 그대로 상속(복사)된다. ★★★★★

    • exec( ) : exec 시 Binary Image, Program Code, Stack, Heap 등은 모두 초기화되지만, File Descriptor Table은 그대로 유지된다. ★★★★★

    드디어 fork와 exec 과정이 분리된 이유가 보이는가?

    만약, 새롭게 생성되는 Program에 대해, 특정 File Descriptor 관계성을 설정하고자 한다면, exec 이전에 이를 조정하면 된다는 것이다. 그래서 두 과정이 분리되어 있는 것이다. 이러한 설정을 하고자!!! ★★★


Examples

  • fork 시 File Descriptor 관점에서 어떤 일이 일어나는지 아래 그림을 통해 확인해보자.


  만약, 위와 같은 상황에서, Process P1이 다음과 같은 Code를 지녔다고 해보자. fork 이후 Process P2를 실행할 경우, 출력은 어떻게 이루어질까?

if(fork() == 0) { 		// Child Routine
	write(1, “Hello “, 6);
	exit();
} 
else { 				// Parent Routine
	wait();
	write(1, “World!\n”, 7);
}

~> 그렇다. Standard Output에 "Hello World!"라고 출력될 것이다. (왜냐, 수행 순서가 고정될 것이고, Parent에서 File Descriptor Table에 대한 별다른 조정이 없었으므로 1번 fd는 STDOUT을 가리키기 때문)


  • 그렇다면, 위의 코드에서 Child Process에 Output Redirection을 적용하면 어떻게 될까? 아래와 같이 코드를 수정하는 것이다.
if(fork() == 0) { 		// Child Routine
	close(1);			// 기존의 stdout을 닫음
    open("output.txt");	// 1번에 output.txt를!
    
	write(1, "Hello ", 6);
	exit();
} 
else { 					// Parent Routine
	wait();
    write(1, "World!\n", 7);
}

~> ProcessP1이 fork를 통해 Child Process인 ProcessP2를 생성한다. 이때, ProcessP2는 그대로 ProcessP1을 복사한 것에 불과하기 때문에 File Descriptor Table도 마찬가지로 동일하다.
~> ProcessP2는 이제 ProcessP1과는 개별적인 메모리 공간에 존재하는 개별적인 Process이다. 여기서, Child인 ProcessP2에서만 I/O Redirection을 수행한다.
~> "close(1);"로 Standard Output을 닫는다. 그렇다면, ProcessP2에서는 1번 fd가 비어있게 되고, 연이어 open을 수행하니, output.txt에 대한 File Descriptor는 1번이 되는 것이다.
~> Child Process에서 write를 수행하면, 해당 출력물은 output.txt 파일로 가게 된다. 따라서, ProcessP1을 Shell 상에서 실행시키면, output.txt에 ProcessP2가 "Hello "를, Shell Prompt에 ProcessP1이 "World!\n"를 출력하게 된다.


  아래의 예시 코드는 Shell 상에서 "cat input.txt"라는 Command를 입력했을 때와 동일한 기능을 수행하는 Program Code의 일부이다.

char *argv[2];
argv[0] = “cat”;
argv[1] = NULL;

if(fork() == 0) {					// Input Redirection
	close(0);						// Standard Input을 Close!
	open(“input.txt”, O_RDONLY);	// 0번 fd가 input.txt의 OFT을 가리킨다!!
	exec(“cat”, argv);
}

~> fork와 exec 사이에서 File Descriptor를 조정함으로써 I/O Redirection을 구현하고 있음에 주목하자.


dup( ) System Call

  dup(n) System Call은 Duplicate의 약자로, 인자로 입력받은 Integer n을 File Descriptor로 인식 후, 해당 fd에 대해, File Descriptor Table의 시작점에서부터 가장 첫 번째 Empty Slot을 찾아, 해당 Slot이 fd가 가리키는 File을 똑같이 가리키게 하는 시스템 콜이다.

  따라서, 만약, 아래와 같은 코드를 가진 Program이 있다면,

fd = dup(1);
write(1, "Hello ", 6);
write(fd, "World!\n", 7);

  아래 그림과 같은 처리가 Process의 File Descriptor Table에서 일어나고,

  결과적으로 Standard Output에 "Hello World!\n"가 출력된다.


이처럼, exec( )은 File Descriptor Table을 보존한다는 속성이 있어, fork와 exec을 구분해 프로세스 생성 시 원하는 I/O 설정 등의 Setting을 할 수 있는 것이다. 이 이유를 기억하라! ★



  금일 포스팅은 여기까지이다.

0개의 댓글