1.3 포스팅에서 이야기한 프로세스 컨트롤에 대해 더 자세히 다루어보자.
지난 포스팅에서 공부한 프로세스 그래프는 다음과 같은 목적을 가진다.
Process Graph의 목적 : Concurrent Program의 Statements들의 Partial Ordering을 Capture한다!
~> 이러한 그래프를 그려, 동시 프로그램에서 파악하기 어려운 명령 수행 흐름을 이해하려고 하는 것이다!
~> 지난 시간 다룬 코드에 대한 프로세스 그래프이다.
~> 이 프로세스 그래프에 대해 아래와 같이 'Relabled Graph'를 도출할 수 있다.
(Relabled Graph : Process Graph를 Vertex와 Edge만 두어 간단하게 표현한 그래프)
(말 그대로, 다시 Label화했다는 뜻임)
Process Graph를 Relabled Graph로 바꾼 다음, Vertex의 흐름을 따지는데, 하나의 연결 선을 파악하다가, leftToRight Rule에 어긋나는 흐름이 있는 Total Ordering은 Infeasible한 것이다!
~> Process Graph를 보고 판단할 수 있어야하고, Relabled Graph를 보고 판단할수도 있어야한다.
프로세스가 종료되어도, 사실 OS 커널 내의 자료구조에는 '종료된 프로세스'에 대한 정보가 남아있다.
프로세스를 fork하면, 사용자(APP)가 fork System Call을 한 것이지만, 결국 프로세스 생성 자체는 OS 커널이 한다.
만약, 종료된 프로세스에 대한 정보가 free되지 않으면, 그러한 프로세스를 'Zombie Process'라고 부른다.
Zombie Process : Terminated, but not freed process!
Zombie Process : Half Alive + Half Dead
So we call this as 'Zombie' !!!
Reaping : Zombie Process를 제거해주는 작업
영단어 'reap'은 '거두다/수확하다'라는 뜻을 가진다.
따라서, Init Process가 결국엔 Orphaned Child를 지우게 된다.
하지만, 프로그래머가 명시적으로 Reaping을 수행해야 'Long-Running Process'를 유지할 수 있다.중간 중간 필요 공간을 계속 마련할 수 있으므로! You know what i'm saying?
ex) Shell과 Server는 오랜 시간 수행된다.
~> 이들이 정상적으로 동작하려면, Init에게 Child Reaping을 맡기지 말고, 사용자가 Concurrent Program을 개발 시, 명시적으로 Reaping을 해주어 CPU와 메모리 부담을 덜어주어야한다.
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
}
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 // 사라졌음을 알 수 있다.
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> ./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 상황이다!
여기서 설명할 Flow는, 실제 UNIX 계열 시스템에서 지원하는 'Parent-Child 동기화 기능'이다. Child Process Reaping 과정이 정상적으로 수행될 경우, 이러한 흐름을 가지게 된다.
Pparent 프로세스가 있고, 여기서 fork를 요청해 Pchild가 생성되었다.
Pchild가 동작하다가 종료된다. 종료되면, Pparent에게 Signal을 보낸다.
Pparent가 그 Signal을 받기 위해선, wait이라는 함수를 호출해야한다.
Pparent가 wait을 통해 Signal을 받으면, OS에게 Pchild Reaping 요청을 보낸다.
~> 이 말은 즉슨, Pparent가 Pchild로부터 SIGCHILD라는 종료 시그널을 받아야 OS에게 Reaping 요청을 하게 되고, 요청을 받은 OS가 Pchild의 OS Table 내 정보를 지우는 것이다.
int wait(int *child_status) : 현재 (호출한) 프로세스의 Child Process들이 종료될때까지 현재 프로세스를 Suspend하는 함수이다.
wait함수의 반환값은 Signal을 보낸 Terminated Child의 PID이다.
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가 깨어난다.
(미지의 Context Switch 후) Parent가 다시 일을 시작하게 되는 순간, 일을 하기에 앞서, 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라고 문제인 것이 아니라!
이번엔 단일 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가 생성되는 것이다.
N번의 각각의 fork에서, fork의 반환값이 0이 아닌 상황에서는, parent로서 남아있는 for문을 더 돌고, for문의 마지막 순회에서는 fork 이후 for문 밖으로 나간다.
생성된 N개의 child가 순서대로 exit할지는 전혀 모르는 것이다. OS가 이를 스케쥴링하고, Context Switch가 있고 해서, 이는 알 수 없다.
이때, Parents는 waitpid를 통해 특정 PID에 해당하는 Child로부터의 종료 시그널을 받을 때까지 잠시 Suspended되고, 시그널을 받으면 Wake Up하고, 그 다음 PID에 해당하는 Child의 SIGCHLD를 기다리고,... 이러면서 반복하는 것이다.
"Child (Specific PID) terminated with exit status (Status)"의 형태로 쭉 N만큼 출력하게 되고, 출력되는 PID의 순서대로 Process가 Terminate되었다고 인식할 수 있다. 먼저 종료된 child가 먼저 reaping되므로.
우리는 fork로 새 프로세스를 생성한 다음, execve 함수로 해당 '새 프로세스'에 '원하는 프로세스의 코드'를 보낼 수 있다.
int execve(char *filename, char *argv[], char *envp[])
현재 프로세스(주로, fork로 생성된 새 프로세스)의 Code, Data, Stack 영역을 모두 새로운 프로그램에 대한 정보로 덮어씌운다.
filename에는 'Executable File의 이름'이 들어간다.
Argument list인 argv도 받아들인다. (이게 execve의 'v'의 의미임. vector)
envp에는 'Environment Variable List'가 온다. (이게 execve의 'e'의 의미임. environment)
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에 들어있다.
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 <┘
금일 포스팅은 여기까지이다.