SP - 1.4 Process Control - Advanced

hyeok's Log·2022년 3월 23일
1

SystemProgramming

목록 보기
4/29
post-thumbnail

  1.3 포스팅에서 이야기한 프로세스 컨트롤에 대해 더 자세히 다루어보자.

Process Graph Exercise

  지난 포스팅에서 공부한 프로세스 그래프는 다음과 같은 목적을 가진다.

Process Graph의 목적 : Concurrent Program의 Statements들의 Partial Ordering을 Capture한다!

  • 오직 left에서 right로 흐르는 Partial Ordering만 Valid, Feasible하다.

~> 이러한 그래프를 그려, 동시 프로그램에서 파악하기 어려운 명령 수행 흐름을 이해하려고 하는 것이다!

  • Concurrent Program에서 Parent와 Child 중 누가 먼저 실행될지는 사용자나 App이 결정하는 것이 아니다.
    • OS가 이를 결정한다.
      • 누가? OS의 Scheduler가!!! ★★★

Example 1


~> 지난 시간 다룬 코드에 대한 프로세스 그래프이다.

~> 이 프로세스 그래프에 대해 아래와 같이 'Relabled Graph'를 도출할 수 있다.
(Relabled Graph : Process Graph를 Vertex와 Edge만 두어 간단하게 표현한 그래프)
(말 그대로, 다시 Label화했다는 뜻임)

Process Graph를 Relabled Graph로 바꾼 다음, Vertex의 흐름을 따지는데, 하나의 연결 선을 파악하다가, leftToRight Rule에 어긋나는 흐름이 있는 Total Ordering은 Infeasible한 것이다!


Example 2


~> Process Graph를 보고 판단할 수 있어야하고, Relabled Graph를 보고 판단할수도 있어야한다.


Example 3


Example 4


Child Process Reaping

Zombie Process

  • 프로세스가 종료되어도, 사실 OS 커널 내의 자료구조에는 '종료된 프로세스'에 대한 정보가 남아있다.

    • 이 정보들을 free해주어야한다.
    • 이 정보들을 free하지 않으면,
      • 'Memory Leakage(메모리 누수)'가 발생할 수 있다.
        • 불필요한 메모리 점유가 발생하므로!
      • 'Security Hole(보안 취약점)'이 생긴다.
        • 누가 OS를 해킹하면 그 정보를 알아낼 수 있으므로!
  • 프로세스를 fork하면, 사용자(APP)가 fork System Call을 한 것이지만, 결국 프로세스 생성 자체는 OS 커널이 한다.

    • Child Process가 생성되고, 추후 시간이 지나서 Child Process가 종료되면, OS 커널에는 이와 관련된 자료구조, Metadata가 남아있다.
      • 이 정보들은 OS의 'Exit Status', 'OS Table' 등에 남아있다.
        ~> 이들을 지워야한다.
  • 만약, 종료된 프로세스에 대한 정보가 free되지 않으면, 그러한 프로세스를 'Zombie Process'라고 부른다.

Zombie Process : Terminated, but not freed process!

Zombie Process : Half Alive + Half Dead

So we call this as 'Zombie' !!!


Reaping

Reaping : Zombie Process를 제거해주는 작업

영단어 'reap'은 '거두다/수확하다'라는 뜻을 가진다.

  • Reaping은 Parent Process가 해야한다. (Foreground Task 기준)
    • 정확히 말하면, Parent Process가 OS에게 "저 Child Process 좀 Reaping해줘!!"라고 요청하는 것이다.
  • 만약, Parent가 Terminated Child를 Reaping하지 않으면?
    • 'Orphaned Child(고아가 된, 부모를 잃은 Child Process)'는 'Init Process(PID가 1인 최초의 조상 프로세스)'가 Reaping하게 된다.

따라서, Init Process가 결국엔 Orphaned Child를 지우게 된다.
하지만, 프로그래머가 명시적으로 Reaping을 수행해야 'Long-Running Process'를 유지할 수 있다.

중간 중간 필요 공간을 계속 마련할 수 있으므로! You know what i'm saying?

ex) Shell과 Server는 오랜 시간 수행된다.
~> 이들이 정상적으로 동작하려면, Init에게 Child Reaping을 맡기지 말고, 사용자가 Concurrent Program을 개발 시, 명시적으로 Reaping을 해주어 CPU와 메모리 부담을 덜어주어야한다.


Zombie Process Example (Bug 1)

void make_zombie1() {
	/* Child */
	if (fork() == 0) {	
		printf("Child Termination, PID = %d\n", getpid());		// pid 1235
		exit(0);
	} 
    /* Parent */
	printf("Running Parent, PID = %d\n", getpid());				// pid 1234
	while (1)
		;													// Infinite Loop
}
  • Child가 생성되고 나서, Child는 자기 알아서 종료한다.
    • 그런데, Parent는 무한루프를 돈다. 즉, 종료되지 않는다.
      • Parent가 Child를 지워야하는데, 그러지 못하고 있는 상황이다.
  • 이를 Linux 상에서 확인해보면 아래와 같다.
linux> ./make_zombie1 &							// Background 실행!
[1] 1234
Running Parent, PID = 1234
Child Termination, PID = 1235

linux> ps
PID 	TTY 	TIME 		CMD
1111 	ttyp9 	00:00:00 	tcsh
1234 	ttyp9 	00:00:03 	forks				// Parent : 계속 도는중
1235 	ttyp9 	00:00:00 	forks <defunct>		// Child ~> defunct는 좀비를 의미
1333 	ttyp9 	00:00:00 	ps

linux> kill 1234								// 안끝나고 있으니, Parent Kill 명령!
[1] Terminated

linux> ps
PID 	TTY 	TIME 		CMD
1111 	ttyp9 	00:00:00 	tcsh				// Parent를 Kill하니 비로소 좀비가
1333 	ttyp9 	00:00:00 	ps					// 사라졌음을 알 수 있다.
  • Parent Process에서 프로세스 트리를 따라 쭉 올라가보면 맨 위에 루트노드로 Init 프로세스가 있다.
    • Parent Process를 kill 명령으로 죽였다.
    • Parent가 죽었으니, 그 아래의 Zombie상태인 Child 프로세스는 Orphaned Child가 된다.
    • Init가 Orphaned Child를 대신 Kill 해준다.

Endless Child Process Example (Bug 2)

void make_zombie2() {
	/* Child */
	if (fork() == 0) {	
		printf("Running Child, PID = %d\n",	getpid());			// 1235
		while (1)
			;												 // Endless
	} 
    else {  /* Parent */
		printf("Parent Termination, PID = %d\n", getpid());		// 1234
		exit(0);
	}
}
  • 이번엔, 자식 프로세스가 안끝나고 있는 상황이다. 역시나 Linux에서 이를 확인해보자.
linux> ./make_zombie2						// Foreground
Parent Termination, PID = 1234
Running Child, PID = 1235

linux> ps
PID 	TTY 	TIME 		CMD
1111 	ttyp9 	00:00:00 	tcsh
1235 	ttyp9 	00:00:06 	forks			// Child가 종료되지 않고 있음.
1333 	ttyp9 	00:00:00 	ps

linux> kill 1235

linux> ps
PID 	TTY 	TIME 		CMD
1111 	ttyp9 	00:00:00 	tcsh
1333 	ttyp9 	00:00:00 	ps

~> Child는 끝나지 않고 있는데 Parent가 먼저 종료해버렸다. 이 상황에선, defunct도 뜨지 않고 그냥 Child가 살아있다. 이 역시 버그 상황이고, 바람직하지 않은 상황이다.

~> 직접 죽이긴 했지만, 그렇지 않는다면, 어떻게 해야할까?
=> 여기선 Init가 Orphaned Child를 바로 제거해주지 않고 있다. Parent가 먼저 종료되어 버렸기 때문이다. Parent가 자신의 Orphaned Child 존재를 알리지 못한 것이다.

fork 후 자식만 종료되고 부모는 계속 돌거나, 또는 부모만 종료되고 자식은 계속 돌고 있으면 Child는 Zombie Process가 된다. 전자의 경우 다행히 Init이 알아서 제거해주고, 후자의 경우엔 그냥 계속 남아있는다.

이 두 상황 모두 Bug 상황, Zombie Process 상황이다!


Parent & Child Synchronization

  여기서 설명할 Flow는, 실제 UNIX 계열 시스템에서 지원하는 'Parent-Child 동기화 기능'이다. Child Process Reaping 과정이 정상적으로 수행될 경우, 이러한 흐름을 가지게 된다.

  • Pparent 프로세스가 있고, 여기서 fork를 요청해 Pchild가 생성되었다.

  • Pchild가 동작하다가 종료된다. 종료되면, Pparent에게 Signal을 보낸다.

    • "나(Child)는 이제 종료된다~"라고 말이다. (SIGCHLD)
  • Pparent가 그 Signal을 받기 위해선, wait이라는 함수를 호출해야한다.

    • wait함수는 대기하면서 Signal을 받는 함수이다.
  • Pparent가 wait을 통해 Signal을 받으면, OS에게 Pchild Reaping 요청을 보낸다.

    • "OS야~ 내 자식 좀 Reaping해주라~"

~> 이 말은 즉슨, Pparent가 Pchild로부터 SIGCHILD라는 종료 시그널을 받아야 OS에게 Reaping 요청을 하게 되고, 요청을 받은 OS가 Pchild의 OS Table 내 정보를 지우는 것이다.


wait

int wait(int *child_status) : 현재 (호출한) 프로세스의 Child Process들이 종료될때까지 현재 프로세스를 Suspend하는 함수이다.

  • Child Process Termination 신호가 올때까지 계속 현재 프로세스를 Suspend시킨다.
    • Signal이 오면 현재 Process를 Wake Up시키고, 종료된 Pchild의 pid를 전달한다.

      wait함수의 반환값은 Signal을 보낸 Terminated Child의 PID이다.

  • child_status 값이 NULL이 아니라면, child_status의 값은, Child Process가 종료된 이유와 Exit Status에 대한 정보를 담게 된다.
    • 관련된 전역 변수들이 매크로로 정의되어있다.
      • WIFEXITED(정상종료), WEXITSTATUS, WIFSIGNALED(시그널받아 종료), WTERMSIG, WIFSTOPPED(중단된 프로세스), WSTOPSIG, WIFCONTINUED
        ~> 자세한 내용은 구글링해보자.

wait Example 1

void desirable_fork() {
	int child_status;

	/* Child */
	if (fork() == 0) {
		printf("Hello from Child\n");
		exit(0);
	} 
    else {	/* Parent */
		printf("Hello from Parent\n");
		wait(&child_status);		// parent는 wait을 호출하면, Signal 도달 전까지 blocked(suspended)
		printf("Child has terminated!\n");
	}
	
    printf("Good Bye!\n");
}

  이 예제 코드를 실행하면, 아래와 같은 Process Graph가 형성된다. wait함수가 존재하기 때문에 Child로 뻗었던 Path가 다시 Parent의 Path로 돌아오고 있음에 주목하자.

  • Child의 exit : OS에게, "나(Child) 종료된다는 것을 내 Parent에게 알려줘!"라고 요청한다.

  • 요청을 받은 OS는, 내장된 Signal(SIGCHLD)를 Parent Process에게 전달한다.

  • Signal이 도달하면, wait으로 인해 Suspend 되어 있던 Parent가 깨어난다.

    • OS가 Parent에게 "너(Parent) 이제 CPU 써야하니까 줄서서 기다리고 있어!"라고 한다.
  • (미지의 Context Switch 후) Parent가 다시 일을 시작하게 되는 순간, 일을 하기에 앞서, Signal 도착 여부를 확인한다. (항상)

    • Bit Vector로 이를 수행하는데, 이는 Signal에서 다시 설명할 것이다.
  • Signal 도착을 Parent가 감지하게 되면, OS에게 "OS야, 내 Child 좀 Reaping 해줘!"라고 요청하게 된다.


즉, 초반에 다룬 fork 예제 코드들은 모두 'Parent-Child 동기화'를 적용하지 않았기 때문에 바람직한 fork가 아닌 것이다.

fork를 호출하면, 항상 Parent에 wait을 두어 대기시키도록 하자!!
(Shell 기준 Foreground Job에 대해선 항상 적용)

※ 만약, 바로 위의 예제 코드에서 wait이 없으면, child와 parent는 둘 다 종료는 되지만, child가 Zombie Process가 되는 것이다.
~> 물론, 잠시동안이다. 왜냐하면, Parent가 죽고 나서, Orphaned Child를 Init이 알아서 죽여줄 것이기 때문이다.

※ 한편, wait이 있다고 해도, 사실, Child Process는 종료되면, 무조건 아주 잠깐이라도 Zombie가 된다. 왜냐? wait이 작동하기 전까지의 시간 퀀텀이 존재하므로!
~> reaping되지 않는 Zombie가 문제인 것이다. 그냥 Zombie라고 문제인 것이 아니라!


wait Example 2

  이번엔 단일 Child가 아니라 Children이 있는 상황을 보자.

void multiple_child_fork() {
	pid_t pid[N];
	int i, child_status;

	for (i = 0; i < N; i++)
		if ((pid[i] = fork()) == 0) 	/* Child */
			exit(100+i); 

	for (i = N-1; i >= 0; i--) {
		pid_t wpid = waitpid(pid[i], &child_status, 0);

		if (WIFEXITED(child_status))
			printf("Child %d terminated with exit status %d\n", wpid, WEXITSTATUS(child_status));
		else
			printf("Child %d terminate abnormally\n", wpid);
}
}

※ waitpid : wait함수이긴 한데, 특정한 PID를 가진 Process를 Target으로 삼아 wait하는 함수이다.
~> pid_t waitpid(pid_t pid, int &status, int options)

  • child를 N번 fork한다. 즉, N개의 Child가 생성되는 것이다.

    • 각 child process는 생성되면, 곧 이어 exit으로 종료한다.
  • N번의 각각의 fork에서, fork의 반환값이 0이 아닌 상황에서는, parent로서 남아있는 for문을 더 돌고, for문의 마지막 순회에서는 fork 이후 for문 밖으로 나간다.

  • 생성된 N개의 child가 순서대로 exit할지는 전혀 모르는 것이다. OS가 이를 스케쥴링하고, Context Switch가 있고 해서, 이는 알 수 없다.

    • 따라서, 이 예제에서는, Reaping 시 출력을 하여, Termination 순서를 알려고 하는 것이다.
  • 이때, Parents는 waitpid를 통해 특정 PID에 해당하는 Child로부터의 종료 시그널을 받을 때까지 잠시 Suspended되고, 시그널을 받으면 Wake Up하고, 그 다음 PID에 해당하는 Child의 SIGCHLD를 기다리고,... 이러면서 반복하는 것이다.

    • 이때, 각 child들의 종료 시그널이 Queue의 형태로 Parents 앞에서 기다리고 있을 것이다. ★★★
  • "Child (Specific PID) terminated with exit status (Status)"의 형태로 쭉 N만큼 출력하게 되고, 출력되는 PID의 순서대로 Process가 Terminate되었다고 인식할 수 있다. 먼저 종료된 child가 먼저 reaping되므로.


Execve

  우리는 fork로 새 프로세스를 생성한 다음, execve 함수로 해당 '새 프로세스'에 '원하는 프로세스의 코드'를 보낼 수 있다.

  • int execve(char *filename, char *argv[], char *envp[])

  • 현재 프로세스(주로, fork로 생성된 새 프로세스)의 Code, Data, Stack 영역을 모두 새로운 프로그램에 대한 정보로 덮어씌운다.

    • 이때, pid, openfiles 정보, signal context 등은 그대로 유지한다.
  • filename에는 'Executable File의 이름'이 들어간다.

    • #!interpreter로 시작하는 스크립트 파일이나, 아니면 오브젝트 파일이 이 filename이 될 수 있다.
    • 이때, ls와 같은 filename들은 /bin 안에 파일 형태로 코드가 저장되어 있는 것이다. ★
      • execve는 위치를 지정해주어야함. 따라서 filename인자로 "/bin/ls" 문자열을 넘겨야함.
      • execvp라는 유사 함수의 경우, 위치 지정 없이 단순한 파일명 'ls'로 코드복사를 할 수 있음.
  • Argument list인 argv도 받아들인다. (이게 execve의 'v'의 의미임. vector)

    • 관습적으로, argv[0]은 앞선 filename과 동일하다.
  • envp에는 'Environment Variable List'가 온다. (이게 execve의 'e'의 의미임. environment)

    • "name=value" 문자열들이 온다. ex) USER=droh
    • getenv, putenv, printenv
  • execve 함수는 한번만 호출되고, 반환되지 않는다.

    • 에러가 난 상황에만 반환값을 가진다.

ex) ">ls -al"이라는 쉘 명령
~> argv[0]==filename=="/bin/ls", argv[1]=="-al"
~> Shell Process(Parent)가 fork로 자신과 똑같은 Child Process를 만들고, execve를 이용해 ls라는 프로그램으로 해당 프로세스를 덮어씌우는 것이다. ★★



~> Shell이 fork로 Shell을 생성한 다음, execve로 인해 ls의 코드로 변하게 되어 일을 수행하게 되는 것이다. argv[1]=="-al"과 같은 옵션을 참조하여!


~> execve로 '새 프로그램'을 덮어씌우면, Stack은 위와 같은 형태로 구성된다. 환경 변수들과 argv 문자열들에 대한 정보가 담겨있는 채로 스택이 시작하는 것이다.

여담) 우리가 Shell에서 사용하는 대부분의 명령어는 /bin과 /usr/bin에 들어있다.


execve Example

if ((pid = Fork()) == 0) { 			/* Child */
	if (execve(argv[0], argv, environ) < 0) { 
		printf("%s: Command not found!\n", argv[0]); 
		exit(1); 
	} 
} 

  간단한 Shell 프로그램에 위와 같은 코드 블록을 넣을 수 있다. 이러한 Shell에 아래와 같이 명령을 입력할 경우, 스택은 아래와 같은 형태로 구성된다.

> /bin/ls -lt   /usr/include   <┘

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

0개의 댓글