시스템 프로그래밍

[System Programming] Basic Skill for Shell Lab

짱일모 2022. 11. 15. 21:54

Shell Lab을 하기 위한 기본기가 되는 UNIX의 C로 구현된 프로세스 제어 함수들에 대해 알아봅시다.

UNIX는 C프로그램을 이용해서 다음과 같은 프로세스 제어 기능을 제공합니다.
1. 프로세스 ID 가져오기
2. 프로세스 생성하기, 프로세스 종료하기
3. Reaping Child Process (자식 프로세스 정리하기)
4. 프로그램 로딩하기 및 실행하기

1. 프로세스 ID 가져오기
types.h 다음과 같은 함수가 정의되어 있습니다.
1. pid_t getpid(void) : 호출한 함수의 프로세스 id를 리턴.
2. pid_t getppid(void) : 호출한 함수의 부모의 프로세스 id를 리턴.

2-1. 프로세스 생성하기
int fork() : 호출하는 프로세스와 동일한 새 프로세스를 생성합니다. 자식 프로세스에서 fork()의 리턴 값은 0이 되고, 부모 프로세스에서 fork()의 리턴 값은 자식 프로세스의 프로세스 id가 됩니다.
fork() 함수는 호출이 1번되면 리턴은 2번된다는 점이 아주 특이합니다.

즉 위의 코드를 실행하면, 자식 프로세스에서는 hello from child가 출력되고, 부모 프로세스에서는 hello from parent가 출력됩니다.

다음 Key Point에 유의하며 코드를 살펴봅시다.

KeyPoint 1. 부모와 자식은 동일한 코드를 실행합니다. fork의 리턴 값으로 부모와 자식을 구분합니다.
KeyPoint 2. 부모와 자식은 동일한 상태로 시작하지만, 각각의 사본을 갖습니다.

위의 코드가 실행되면, fork 이후에 부모 프로세스와 같은 자식 프로세스가 생성되고, 자식 프로세스의 pid는 0, 부모 프로세스의 pid는 자식 프로세스의 프로세스 id로 초기화 됩니다.
그런 다음, 분기문에서 pid == 0, 즉 자식 프로세스인 경우 Child has x = 2 가 출력되고, 부모 프로세스에서는 Parent has x = 0 가 출력됩니다.

자식 프로세스도 fork해서 자식의 자식을 만들 수 있습니다!

아래 그림을 통해 살펴보도록 합시다.

2-2. 프로세스 종료하기
void exit(int status) : 종료 상태 status 값을 가지고 종료합니다. 정상 리턴 시 status 0을 반환합니다.
atexit() 함수는 exit할 때 실행할 함수를 등록하는 함수입니다.

추가로 프로세스를 종료할 때 발생할 수 있는 좀비의 개념에 대해 알아봅시다.

프로세스가 종료되어도 여전히 시스템의 자원을 소유하게 됩니다. 이런 종료되었지만 정리되지 않은 프로세스를 좀비라고 칭합니다.

프로세스가 어떻게 종료되는지 살펴봅시다. (Reaping Procedure)

먼저, 부모 프로세스가 자신의 종료한 프로세스에 대해 Reaping을 수행합니다.
부모는 종료 상태 정보를 넘겨받습니다.
커널은 종료된 프로세스를 시스템에서 제거한다.
부모가 종료하지 않은 경우 init "Process"가 종료합니다. (Init Process는 Shell 밖의 OS 쪽에서 동작하는 Process를 의미합니다.)

이렇게 말로 설명하면 감이 잘 안오니, 실제 예시를 보며 Zombie를 어떻게 처리하는지 봅시다.

위 함수를 분석해봅시다.
먼저 fork를 하면서 자식 프로세스를 생성합니다. 그리고 자식 프로세스에서는 "Terminating Child, PID = 자식 프로세스의 아이디"를 출력하고 exit(0)로 종료합니다. 그리고 부모 프로세스에서는 "Running Parent, PID = 부모 프로세스의 아이디"를 출력하고 부모 프로세스는 무한 루프를 돌며 계속 실행됩니다.

위 코드를 실행한 후 linux의 ps명령어를 통해 프로세스를 확인해 봅시다.

./fork7 & (&는 프로그램을 백그라운드로 실행시키는 명령어입니다.) 명령을 Shell에 치고 Enter를 친 이후 결과를 보니 위 코드를 보며 예측한 결과가 나타는 것을 볼 수 있습니다. Parent의 Process ID 는 6639, Child의 Process ID는 6640임을 알 수 있습니다.

그리고 ps명령어를 통해 현재 실행 중인 프로세스를 확인해봅시다.
부모 프로세스(PID 6639)는 여전히 실행 중인 상태에 있고 자식 프로세스(PID 6640)는 실행이 종료된 후 <defunct> 상태로 정리되지 않고 여전히 자원을 점유하며 남아있는 것을 확인할 수 있습니다. 즉 현재 자식 프로세스는 프로세스가 종료된 뒤 정리되지 않은 "좀비" 상태입니다.

이를 kill 6639 명령어로 부모 프로세스를 강제 종료 시켜봅시다. 그리고 ps 명령어를 통해 다시 한번 현재 실행 중인 프로세스를 확인해보면 부모 프로세스(PID 6639)와 자식 프로세스(PID 6640)이 모두 정리된 것을 확인할 수 있습니다.

이런식으로, 자식 프로세스가 종료된 후 좀비로 남아 있을 때, 부모 프로세스가 종료되면 자동으로 좀비가 된 자식 프로세스도 같이 제거됩니다. 이것은 Init Process에 의해 제거된 것입니다. 다시 정리하면, Init Process가 부모 프로세스가 종료되면 부모 프로세스를 제거하면서 종료된 자식 프로세스도 제거해준 것입니다.

다음 예시를 통해 자식 프로세스가 자동으로 정리되지 않고 계속 동작하는 경우를 살펴봅시다.

fork8 함수는 자식 프로세스를 하나 생성하고, 자식 프로세스에서는 "Running Child, PID = 자식 프로세스 ID" 를 출력하고 무한 루프를 돌며 계속 동작합니다.
부모 프로세스에서는 "Terminating Parent, PID = 부모 프로세스의 ID"를 출력하고 exit(0)를 통해 부모 프로세스를 종료합니다.

이것을 Shell을 통해 실행시킨 결과를 봅시다.

./fork8 로 실행시킨 후 ps 명령어를 통해 현재 실행 중인 프로세스들을 살펴보면, Parent 프로세스의 PID 6675는 종료되어, 현재 실행 중인 프로세스에 등장하지 않습니다. 반면 자식 프로세스는 무한 루프를 돌며 동작 중이기 때문에 PID 6676으로서 계속 실행 중임을 알 수 있습니다. 이렇게 부모 프로세스가 종료됐음에도 불구하고 자식 프로세스가 종료되지 않으면 계속 자원을 차지하며 존재하므로 kill 명령을 통해 수동적으로 삭제해주어야 합니다. 그 명령이 kill 6676입니다.

이쯤에서 지금까지 알아본 내용들을 간략히 요약하고 넘어가겠습니다.

첫번째, 프로세스가 종료하는 경우 커널은 종료된 프로세스를 시스템에서 즉시 제거하지 않습니다.
두번째, 종료된 프로세스는 자식의 부모가 제거될 때까지 종료된 상태로 남아있게 됩니다. 즉, 부모가 죽지 않으면서 계속 동작하면 좀비가 된 자식이 제거되지 않습니다. 이것이 문제가 되는 경우입니다.
세번째, 부모가 정상적으로 자식을 정리하지 않고 종료되면, 커널은 Init Process(PID = 1)로 하여금 대신 정리하도록 합니다.

이제 다시 본론으로 돌아와 UNIX의 프로세스 제어 방법 중 세번째인 자식 프로세스 정리하기(Reaping Child Process)에 대해 살펴봅시다.

3. Reaping Child Process
1. int wait(int *child_status) : wait 함수는 explicit하게 프로세스를 제거하는 함수입니다. wait 함수의 특징은 다음과 같습니다.
one. 현재 프로세스를 자신의 자식 프로세스가 종료될 때까지 정지시킵니다.
two. 리턴 값은 자식 프로세스의 ID가 됩니다.
three. 만일 child_status != null 인 경우, 자식 프로세스의 종료 이유를 나타내는 상태 정보를 갖습니다. 그리고 나서, 커널은 자식 프로세스를 제거합니다. 이와 같이 부모 프로세스가 자식 프로세스를 명시적으로 삭제할 수 있습니다.

wait 함수의 사용 예제를 살펴보며 이해해봅시다.

fork9 함수는 자식 프로세스를 하나 생성하고, 자식 프로세스에서는 "HC : hello from child"를 출력하고 "Bye"를 출력하고 exit을 만나 종료합니다. 부모 프로세스에서는 "HP : hello from parent"를 출력하고 wait 함수에 의해 자식 프로세스가 종료될 때까지 정지 된 후 자식 프로세스가 종료된 이후에 자식 프로세스는 제거되고 "CT : child has terminated"를 출력하고 "Bye"를 출력한 뒤 exit을 만나 종료합니다.

조금 더 눈에 보이게 프로세스 분기를 그려보겠습니다.

프로세스 분기를 그리면 자식 프로세스와 부모 프로세스가 어떻게 동작하는지 파악하기 쉽습니다.

wait 함수의 사용에 대해 조금 더 복잡한 예시를 살펴보겠습니다.

fork10은 process id N개를 저장할 배열인 pid를 선언하고, fork를 통해 pid[i]에 자식 프로세스 id를 저장합니다. (부모 프로세스에서 fork 함수의 리턴 값은 자식 프로세스의 id 였음을 복기해봅시다.) 그리고 자식 프로세스는 exit(100+i)를 만나
종료됩니다. 부모 프로세스는 wait 함수가 실행되어 자식 프로세스가 종료될 때까지 부모 프로세스는 정지 상태가 된 다음 자식 프로세스가 종료되면 자식 프로세스가 정리되고 분기문을 만나 "Child '자식 프로세스의 id' terminated with exit status 'exit code'"를 출력하거나, "Child '자식 프로세스의 id' terminate abnormally"를 출력합니다.

WIFEXITED와, WEXITSTATUS에 대해 알아보겠습니다.

  1. WIFEXITED : Wait IF EXited의 의미의 매크로로, 정상적인 종료(exit코드를 만나 종료되거나 return에 의해 종료)라면 True를 반환합니다.
  2. WEXITSTATUS : 정상적인 종료(exit코드를 만나 종료되거나 return에 의해 종료)라면 exit의 상태정보 (ex . exit(1) 이라면 1) 을 반환합니다.

N의 크기가 5일 때, fork10의 실행결과를 살펴보겠습니다.

자식 프로세스가 exit code 100부터 104를 가지고 종료된 뒤, 부모 프로세스에 의해 "Child '자식 프로세스의 id' terminated with exit status 'exit code'"가 출력되고 있음을 알 수 있습니다. 이 때 출력되는 자식 프로세스의 아이디가 정렬되어있지 않은 것을 보니 wait 함수는 무순(순서가 없음)의 자식 프로세스가 종료될 때까지 정지된 다음 다시 동작함을 알 수 있습니다.

그렇다면 특정 자식 프로세스가 종료될 때까지 부모 프로세스를 정지시킨 다음, 자식 프로세스 종료 이후 자식 프로세스를 정리하고 부모 프로세스를 동작시킬 수 있는 방법은 없을까요?

그 아이디어를 구현한 함수가 바로 waitpid 함수입니다.

2. waitpid(pid, &status, options) : pid 파라미터로 프로세스 id를 받아 특정 프로세스 종료될 때까지 기다립니다. pid 값이 -1이면 wait 함수와 동일하게 동작합니다. &status는 wait 함수의 파라미터와 동일합니다. options 파라미터가 0이면 종료된 자식 프로세스를 기다리고, 1(==WNOHANG)이면 자식 프로세스가 종료된 상태인지 단 한번만 확인하고 부모 프로세스를 동작시킵니다. 2(==WUNTRACED)이면 정지되거나 종료된 자식 프로세스를 기다립니다.

조금 전에 wait 함수에서 사용했던 fork10 함수와 거의 동일하게 작성된 fork11 함수를 통해 waitpid의 사용 예시를 살펴봅시다.

fork11의 실행 결과를 살펴봅시다.

wait 함수를 사용한 fork10 함수와는 다르게, 종료되는 자식들이 지정한 순서(waitpid의 pid 파라미터로 들어간 pid[i])대로 나타나는 것을 알 수 있습니다.

다음 이야기로 넘어가봅시다.

wait 함수는 자식 프로세스가 종료될 때까지 부모 프로세스를 정지시킨 다음 자식 프로세스가 종료되면 이를 정리하고 부모 프로세스를 재개시킨다고 했습니다.

그럼 이 "정지"를 개발자가 수동적으로 시킬 수 있는 방법은 무엇일까요? 바로, sleep과 pause 입니다. 이 두 함수에 대해 알아보도록 하겠습니다.

unsigned int sleep(unsigned int secs) : 자기 자신을 secs 초 동안 정지(Suspend)시킵니다. 정상적으로 깨어날 때에는 0을 반환하고 그 외의 경우, 잔여 시간을 초(secs)로 리턴합니다.

int pause(void) : pause는 pause를 호출하는 프로세스를 시그널(signal, 뒤에 배울 개념입니다.)을 받을 때까지 잠재웁니다.

즉 정지(Suspend)는 어떤 Event가 발생할 때까지 (세팅한 시간이 지나거나, 시그널을 받거나) 동작을 잠시 중지시키는 동작입니다.

이제 UNIX의 프로세스 제어의 마지막 방법인 프로그램 로딩하기와 실행하기에 대해 알아보겠습니다.

4. 프로그램 로딩하기 및 실행하기
int execve(char filename, char argv[], char *envp[])
실행파일 filename을 현재 프로세스의 환경변수를 이용하면서 argv로 현재의 code, data, stack(System Programming, Process 포스팅을 참조하세요!)을 덮어 씌웁니다. argv와 envp는 null문자로 끝나는 포인터 배열입니다. execve는 한번 호출되고, 리턴하지 않습니다. 에러가 발생하면 그 때 리턴합니다.

execve의 사용 예시를 살펴보겠습니다.

fork 를 통해 자식 프로세스를 생성한 후 자식 프로세스에서 execve을 통해 myargv[0]에 해당하는 파일을 실행시킵니다. 만약 실행이 되지 않고 에러가 발생했다면 execve이 음수 값을 리턴하며 "myargv[0] : Command not found"가 출력될 것임을 예상할 수 있습니다.

지금까지 UNIX에서 프로세스를 제어하는 4가지 방법에 대해 알아보았습니다. 마지막으로 리눅스 프로세스 체계에 대해 살펴보고 진짜 Shell Lab을 진행해봅시다.

리눅스 프로세스 체계는 [0]번 프로세스에서 앞서 얘기했던 init Process가 있고 그 자식 프로세스로 login shell 과 login shell 의 자식 프로세스들과 그 자식 프로세스들의 자식 프로세스들 ... 로 이루어져있습니다.