본문 바로가기

[CSAPP] 8장 예외적인 제어 플로우 (3/4) Process Control

출처
Randal E. Bryant & David R. O'Hallaron. (2015). Computer Systems: A Programmer's Perspective (3rd ed.). Pearson.

4. 프로세스 제어 Process Control

Unix는 C 프로그램의 프로세스를 조작할 수 있는 많은 시스템 콜들을 제공한다. 

 

(1) 프로세스 ID 얻기

모든 프로세스는 고유한 양수 (비음수)의 프로세스 ID (process Id, PID)를 가진다. getpid 함수는 이 함수를 호출한 프로세스의 PID를 return한다. getppid 함수는 부모의 PID를 return한다.

#include <sys/types.h>
#include <unistd.h>

/* Returns: PID of either the caller or the parent */
pid_t getpid (void);
pid_t getppid (void);

 

getpid와 getppid 함수는 pid_t 타입의 정수 값을 return한다. Linux 시스템의 types.h에서 pid_t는 int로 정의되어 있다.

 

(2) 프로세스 생성과 종료

개발자의 관점에서 볼 때 프로세스에는 세 가지 상태가 있다.

 

Running

CPU에서 실행 중이거나 실행을 위해 대기하고 있으며, 커널에 의해 스케줄링될 프로세스

 

Stopped

프로세스의 실행이 중단되었으며, 스케줄링되지 않을 프로세스. 프로세스는 SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU 시그널을 받으면 SIGCONT 시그널을 받을 때까지 중단된다. SIGCONT 시그널을 받으면 다시 실행되기 시작한다. (시그널이란 소프트웨어 interrupt의 한 형태이다.)

 

Terminated

영구적으로 멈춘 프로세스. 프로세스는 다음 3가지 이유로 종료된다. (1) 기본 행동이 프로세스를 종료시키는 것인 시그널을 받았을 때, (2) main 함수에서 return할 때, (3) exit 함수를 호출했을 때

#include <stdlib.h>

/* This function does not return */
void exit (int status);

 

exit 함수는 exit status라는 상태로 프로세스를 종료시킨다. (exit status를 설정하는 다른 방법으로는 main 함수에서 정수 값을 return하는 것이 있다.)

부모 프로세스는 fork 함수를 호출하여 새롭게 실행될 자식 프로세스를 생성한다.

#include <sys/types.h>
#include <unistd.h>

/* Returns: 0 to child, PID of child to parent, -1 on error */
pid_t fork (void);

 

새롭게 생성된 자식 프로세스는 부모 프로세스와 거의 동일하다. 자식 프로세스는 부모 프로세스의 사용자-레벨 주소 공간의 복사본(내용은 동일하지만 별개의)을 가진다. 이 주소 공간에는 코드 세그먼트, 데이터 세그먼트, 힙, 공유 라이브러리, 사용자 스택이 있다. 자식 프로세스는 부모 프로세스의 open file descriptor들의 복사본도 가진다. 이는 부모 프로세스가 fork를 호출했을 때 자식 프로세스가 부모 프로세스에서 열린 모든 파일들을 읽고 쓸 수 있다는 의미이다. 부모 프로세스와 자식 프로세스 사이에 가장 큰 차이점은 그들이 다른 PID를 갖는다는 점이다.

fork 함수는 흥미롭기도 하고 헷갈리기도 한다. 왜냐하면 fork 함수가 한 번 호출될 때 return을 두 번 하기 때문이다. 한 번은 fork를 호출한 프로세스 (부모 프로세스)에 return되고 한 번은 새롭게 생성된 자식 프로세스에 return된다. 부모 프로세스에서 fork 함수는 자식 프로세스의 PID를 return한다. 자식 프로세스에서 fork는 0을 return한다. 자식 프로세스의 PID는 0이 될 수 없기 때문에 return 값이 0이라면 그것은 자식 프로세스에 return된 값이다. fork의 return 값은 프로그램이 부모 프로세스를 실행 중인지 자식 프로세스를 실행 중인지를 분명하게 알려준다.

다음은 fork를 이용하여 자식 프로세스를 생성하는 부모 프로세스의 예시이다.

 

이 예시는 몇 가지 미묘한 지점들을 보여준다.

 

호출은 한 번, 리턴은 두 번

fork 함수는 부모에 의해 한 번 호출되지만 두 번 return된다. 자식 프로세스를 하나 생성하는 프로그램에게는 간단한 일이지만 fork의 여러 인스턴스를 생성하는 프로그램의 경우 혼란스러울 수 있으며 신중하게 사용되어야 한다.

 

동시 실행

부모 프로세스와 자식 프로세스는 동시에 실행되는 별개의 프로세스이다. 논리적 제어 흐름에 있는 부모 프로세스와 자식 프로세스의 명령은 커널에 의해 임의적인 방식으로 교차될 수 있다. 위의 예시 프로그램에서는 부모 프로세스가 자신의 printf 문 실행을 먼저 마친 다음에 자식 프로세스가 실행된다. 하지만 다른 시스템에서는 반대의 결과를 보여줄 수도 있다. 

 

내용은 같지만 별개의 주소 공간

fork 함수가 부모 프로세스와 자식 프로세스에 return되자마자 두 프로세스를 중단시키면 각 프로세스의 주소 공간이 동일하다는 것을 알 수 있다. 각 프로세스는 같은 사용자 스택, 같은 지역 변수 값, 같은 힙, 같은 전역 변수 값 그리고 같은 코드를 가진다. 그러나 부모 프로세스와 자식 프로세스는 별개의 프로세스이고 각각 자신만의 사적 주소 공간을 가지기 때문에 부모 프로세스와 자식 프로세스가 지역 변수에 변화를 주었을 때 다른 프로세스에는 영향을 주지 않으면서 각자 다른 값을 유지할 수 있다.

 

공유 파일

자식 프로세스는 부모 프로세스의 open file을 그대로 상속받는다.

 

fork를 처음 배운다면 프로세스 그래프를 그려보는 것이 도움이 될 수 있다. 프로세스 그래프는 프로그램 statement의 부분 순서 partial ordering를 보여주는 precedence graph 선행 그래프이다. 각각의 정점 a는 프로그램 statement의 실행을 의미한다. 간선 a->b는 statement a가 statement b 전에 일어나야 함을 의미한다. 간선은 변수의 현재 값 등의 정보로 라벨링될 수 있다. printf statement에 해당하는 정점들은 printf의 output으로 라벨링된다. 각각의 그래프는 main을 호출하는 부모 프로세스에 해당하는 정점에서부터 시작된다. 이 정점은 들어오는 간선은 없고 오직 하나의 나가는 간선만을 가진다. 각 프로세스에 대한 정점의 시퀀스는 exit 호출에 해당하는 정점과 함께 끝이난다. 이 정점은 오직 하나의 들어오는 간선을 가지며 나가는 간선은 없다.

 

싱글 프로세서에서 실행되는 프로그램의 경우 정점들의 위상 정렬 topological sort이 명령문의 실행 가능한 순서를 표현해준다. 왼쪽에서 오른쪽으로 가는 간선을 그리고 간선에 방향성을 부여해주면 그것이 위상 정렬이다.

process graph는 중첩된 fork 호출을 하는 프로그램을 이해할 때 특히 도움이 될 수 있다. 예를 들어 다음 그림은 소스 코드에 두 번의 fork 호출이 있는 프로그램을 보여준다. 이 프로그램은 4개의 프로세스를 실행하는데 프로세스들은 아무 순서로나 실행될 수 있다.

 

getpid()로 확인해보면 실행할 때마다 프로세스의 실행 순서가 달라진다.

 

* 표준 출력(stdout)은 기본적으로 줄 단위 버퍼링(line-buffered)이 되며, 특정 조건(예: \n 문자 출력, fflush(), 프로그램 종료)에서만 출력 버퍼가 플러시된다.

fflush를 썼을 때 출력 순서 (printf 사용 시 터미널에 바로바로 출력됨) / fflush를 쓰지 않았을 때 출력 순서 (자식 프로세스가 종료됐을 때와 부모 프로세스가 종료됐을 때 printf의 내용이 터미널에 출력됨)

 

(3) 자식 프로세스 거두기

프로세스가 어떤 이유에서든지 종료될 때 커널은 시스템에서 프로세스를 바로 삭제하지 않는다. 대신, 프로세스는 부모 프로세스에 의해 거두어질 때까지 terminated state에서 머문다. 부모 프로세스가 종료된 자식 프로세스를 거둘 때, 커널은 자식 프로세스의 exit status를 부모에게 넘기고 종료된 프로세스를 삭제한다. 이 지점에서 프로세스가 사라진다. 종료되었지만 아직 거두어지지 않은 프로세스를 좀비 프로세스라고 말한다.

부모 프로세스가 종료될 때 커널은 init 프로세스가 모든 고아 자식 프로세스의 입양 부모가 되도록 지정한다. init 프로세스는 PID 1을 가지고 있는데 시스템이 시작할 때 커널에 의해 생성되어서 절대로 종료되지 않으며 모든 프로세스의 조상이다. 만약 부모 프로세스가 자신의 좀비 자식 프로세스를 거두지 않고 종료하면 커널은 init 프로세스가 이 프로세스들을 거두도록 만든다. 하지만 shell이나 서버처럼 오래 실행되는 프로그램은 항상 자신의 좀비 자식 프로세스를 거두어야 한다. 좀비 프로세스는 실제로 실행되지는 않아도 여전히 시스템의 메모리 자원을 소비한다.

프로세스는 waitpid 함수를 호출하여 자신의 자식 프로세스가 종료되거나 멈추기를 기다린다.

#include <sys/types.h>
#include <sys/wait.h>

/* Returns: PID of child if OK, 0 (if WNOHANG), or -1 on error */
pid_t waitpid (pid_t pid, int *statusp, int options);

 

waitpid 함수는 복잡하다. 기본적으로 option이 0일 때 waitpid는 자신의 wait set에 있는 자식 프로세스가 종료될 때까지 waitpid를 호출한 프로세스 (부모 프로세스)의 실행을 중단한다. 만약 waitpid의 호출 시점에 wait set에 있는 프로세스가 이미 종료되었다면 waitpid는 즉시 return한다. 어떤 경우이든지 wiatpid는 waitpid 함수를 유발한, 종료된 자식 프로세스의 PID를 return한다. 이때 종료된 자식 프로세스는 부모 프로세스에 의해 거두어지고 커널은 자식 프로세스의 모든 흔적을 시스템에서 삭제한다.

 

Wait Set의 member 결정하기

wait set의 멤버는 pid 인자에 의해 결정된다. 

  • 만약 pid > 0 이면, wait set은 pid와 같은 프로세스 아이디를 가진 단독 자식 프로세스이다.
  • 만약 pid = -1 이면, wait set은 부모 프로세스의 모든 자식 프로세스로 이루어진다.

waitpid 함수는 Unix 프로세스 그룹과 같은 다른 유형의 wait set도 지원한다.

 

기본 행동 변경하기

option 인자를 설정하면 기본 행동을 변경할 수 있다. option은 WNOHANG, WUNTRACED, WCONTINUED 상수의 다양한 조합이다.

 

WNOHANG

wait set에 있는 자식 프로세스 중 아무것도 아직 종료되지 않았다면 즉시 return한다. (이때 return 값은 0이다.) 기본 행동의 경우 자식 프로세스가 종료될 때까지 waitpid를 호출한 프로세스를 중지시킨다. 이 옵션은 자식 프로세스가 종료되기를 기다리는 중에 다른 유용한 일을 지속하고 싶을 때 유용하다.

 

WUNTRACED

wait set에 있는 프로세스가 종료되거나 멈출 때까지 waitpid를 호출한 프로세스의 실행을 중단시킨다. return을 유발한 종료되거나 멈춘 자식 프로세스의 PID를 return한다. 기본 행동은 종료된 자식만 return한다. 이 옵션은 종료된 자식 프로세스와 멈춘 자식 프로세스 모두를 확인하고 싶을 때 유용하다.

 

WCONTINUED

wait set에 있는 실행 중인 프로세스가 종료되거나 wait set에 있는 멈춘 프로세스가 SIGCONT 신호를 받고 재개될 때까지 waitpid를 호출한 프로세스의 실행을 중단시킨다. 

 

옵션들을 ORing을 하여 조합할 수 있다. 다음은 그 예이다.

 

WNOHANG | WUNTRACED

wait set에 있는 자식 프로세스 중 아무것도 종료되거나 멈추지 않았다면  return 값 0과 함께 즉시 return한다. 또는 멈추거나 종료된 자식 프로세스의 PID를 return한다.

 

거둔 자식 프로세스의 exit status 확인하기

statusp 인자가 NULL이 아니라면, waitpid는 return을 유발한 자식 프로세스에 대한 상태 정보를 status에 인코딩한다. status는 statusp가 가리키는 값이다. wait.h 파일에 status 인자를 해석할 수 있는 여러 매크로들이 정의되어 있다.

 

WIFEXITED(status)

자식 프로세스가 정상적으로 종료되었다면 exit 호출이나 return을 통해 true를 return한다.

 

WEXITSTATUS(status)

정상적으로 종료된 자식 프로세스의 exit status를 return한다. 이 상태는 WIFEXITED()가 true를 return했을 때만 정의된다.

 

WIFSIGNALED(status)

catch되지 않은 시그널로 인해 프로세스가 종료됐을 때 true를 return한다.

 

WTERMSIG(status)

자식 프로세스를 종료시킨 시그널의 숫자를 return한다. 이 상태는 WIFSIGNALED()가 true를 return했을 때만 정의된다.

 

WIFSTOPPED(status)

return을 유발시킨 자식 프로세스가 현재 멈춰있으면 true를 return한다.

 

WSTOPSIG(status)

자식 프로세스를 멈추게 만든 시그널의 숫자를 return한다. 이 상태는 WIFSTOPPED()가 true를 return했을 때만 정의된다.

 

WIFCONTINUED(status)

SIGCONT 시그널을 받아 자식 프로세스가 재시작했다면 true를 return한다.

 

에러 상태 Error Conditions

waitpid를 호출하는 프로세스에게 자식 프로세스가 없다면 waitpid는 -1을 반환하고 errno를 ECHILD로 설정한다. 만약 waitpid 함수가 시그널에 의해 interrupt되었다면 -1을 반환하고 errono를 EINTR로 설정한다.

 

wait 함수

wait 함수는 waitpid의 간단한 버전이다. wait(&status)를 호출하는 것은 waitpid(-1, &status, 0)을 호출하는 것과 같다.

#include <sys/types.h>
#include <sys/wait.h>

/* Returns: PID of child if OK or -1 on error */
pid_t wait (int *statusp);

 

waitpid 사용 예시

waitpid 함수가 다소 복잡하기 때문에 몇 가지 예시를 살펴보면 도움이 될 것이다. 다음 프로그램은 waitpid를 사용하여 N개의 자식 프로세스가 모두 종료되기를 (순서는 특정하지 않는다.) 기다린다. 

 

다음은 프로그램 실행 결과이다.

 

자식 프로세스가 거두어지는 순서는 시스템마다 다르다. 이러한 행동을 nondeterministic 행동이라고 하는데 동시성 처리를 힘들게 만드는 요인이다. 다음 프로그램은 이러한 nondeterminism을 제거한 것이다. 부모 프로세스에 의해 생성된 순서로 자식 프로세스를 거둔다.

 

(4) 프로세스를 sleep 상태로 만들기

sleep 함수는 특정 기간 동안 프로세스를 중단시킨다.

#include <unistd.h>

/* Returns: seconds left to sleep */
unsigned int sleep (unsigned int secs);

 

sleep 함수는 요청된 시간이 이미 지났으면 0을 return하고, 아직 시간이 안 지났으면 아직 더 sleep해야 하는 시간을 return한다. 후자의 경우 시그널에 의해 sleep 함수가 일찍 return되는 경우 발생할 수 있다.

 

puase 함수는 프로세스에 의해 시그널이 수신될 때까지 호출 함수를 sleep 상태로 만든다.

#include <unistd.h>

/* Always returns -1 */
int puase (void);

 

(5) 프로그램을 로드하고 실행하기

execve 함수는 현재 프로세스의 컨텍스트에 새로운 프로그램을 로드하고 실행시킨다.

#include <unistd.h>

/* Does not return if OK; returns -1 on error */
int exeve (const char *filename, const char *argv[],
           const char *envp[]);

 

execve 함수는 argv 인자 목록과 envp 환경 변수 목록을 가지고 filename이라는 실행 가능한 object 파일을 로드하고 실행시킨다. execve 함수는 filename을 찾을 수 없는 등의 에러가 있을 때만 호출 프로세스로 return한다. 그래서 fork 함수와 다르게 execve는 한 번 호출되고 절대 return되지 않는다.

인자 목록은 다음과 같은 자료 구조로 표현된다. argv 변수는 인자 문자열을 가리키는 포인터로 이루어진,  null로 끝나는 배열을 가리킨다. 관습적으로 argv[0]은 실행 가능한 object 파일의 이름이다. 

환경 변수 목록도 비슷한 자료 구조로 표현된다. envp 변수는 name=value 형태의 환경변수 문자열을 가리키는 포인터로 이루어진, null로 끝나는 배열을 가리킨다.

 

execve 함수는 filename 로드하고 나면 start-up 코드를 호출한다. start-up 코드는 스택을 셋업하고 새로운 프로그램의 main 함수에 제어를 넘긴다. main 함수의 형태는 다음과 같다.

int main (int argc, char **argv, char **envp);
/* Or */
int main (int argc, char *argv[], char *envp[]);

 

main 함수가 실행을 시작하면 사용자 스택은 다음과 같은 구조를 가진다. 스택의 아랫부분 (가장 높은 주소)부터 스택의 윗부분 (가장 낮은 주소) 순서로 따라가보자.

 

스택의 가장 아랫부분에는 인자와 환경변수 문자열이 있다. 그 위에는 스택에 있는 이 환경 변수 문자열을 가리키는 환경 변수 포인터의 배열이 온다. 전역 변수 environ은 환경 변수 포인터 중 가장 앞에 있는 envp[0]을 가리킨다. 그 위에는 argv[] 배열이 있다. argv 배열의 각 요소는 스택에 있는 인자 문자열을 가리킨다. 스택의 최상단에는 시스템 start-up 함수인 libc_start_main을 위한 스택 프레임이 있다.

main 함수를 위한 세 가지 함수는 다음과 같다. 각각의 인자는 x86-64 스택 규칙에 따라 레지스터에 저장된다. (1) argc. argv[] 배열에서 null이 아닌 포인터의 개수를 알려준다. (2) argv. argv[] 배열의 첫 번째 항목을 가리킨다. (3) envp. envp[] 배열의 첫 번째 항목을 가리킨다.

 

Linux는 환경 변수 배열을 조작할 수 있는 몇 가지 함수를 제공한다.

#include <stdlib.h>

/* Returns: pointer to name if it exists, NULL if no match */
char *getenv (const char *name);

 

getenv 함수는 환경 변수 배열에서 문자열 name=value를 검색한다. name=value를 찾으면 value에 대한 포인터를 return한다. 찾지 못하면 NULL을 return한다.

#include <stdlib.h>

/* Returns: 0 on success, -1 on error */
int setenv (const char *name, const char *newvalue, int overwrite);

/* Returns: nothing */
void unsetenv (const char *name);

 

만약 환경 변수 배열이 name=oldvalue 형태의 문자열을 가지고 있다면 unsetenv는 이를 삭제한다. setenv는 overwrite가 0이 아닐 경우에 oldvalue를 newvalue로 대체한다. 만약 name이 존재하지 않는다면 setenv는 name=newvalue를 배열에 추가한다.

 

(6) 프로그램을 실행시키기 위해 fork와 execve 사용하기

Unix shell이나 웹 서버와 같은 프로그램은 fork와 execve 함수를 굉장히 많이 사용한다. shell은 사용자를 위해 다른 프로그램을 실행시켜주는 애플리케이션 레벨의 상호작용적인 프로그램이다. 가장 원조 shell은 sh 프로그램이며 그 이후에 csh, tcsh, ksh 그리고 bash 등이 등장했다. shell은 read/evaluate 단계의 시퀀스를 수행한 후 종료된다. read 단계는 사용자로부터 커맨드 라인을 읽는 단계이다. evaluate 단계는 커맨드 라인을 파싱하고 사용자를 위해 프로그램을 실행시키는 단계이다.

 

다음은 간단한 shell의 main 함수를 보여준다. shell은 커맨드 라인 프롬프트를 출력하고, 사용자가 stdin에 커맨드 라인을 타이핑하기를 기다리고, 커맨드 라인을 evaluate한다.

 

다음은 커맨드 라인을 evaluate하는 코드를 보여준다. 첫 번째로 하는 일은 공백 기준으로 커맨드 라인을 파싱해주는 parseline 함수를 호출하고 execve에게 전달될 argv 벡터를 만드는 것이다. 첫 번째 인자는 내장된 shell 커맨드의 이름이거나 새로운 자식 프로세스의 컨텍스트에 로드되고 그 컨텍스트에서 실행될 실행 가능한 object 파일의 이름이다.

 

만약 마지막 인자가 '&'이라면 parseline은 1을 return한다. 이는 프로그램이 background에서 실행되어야 함 (shell이 프로그램이 종료되기를 기다리지 않음)을 의미한다. 마지막 인자가 '&'이 아니라면 parseline은 0을 return한다. 이는 프로그램이 foreground에서 실행되어야 함 (shell이 프로그램이 종료되기를 기다림)을 의미한다.

커맨드 라인을 파싱하고 나서 eval 함수는 builtin_command 함수를 호출한다. builtin_command 함수는 첫 번재 커맨드 라인 인자가 내장 shell 커맨드인지 확인한다. 만약 그렇다면 builtin_command는 즉시 커맨드를 해석하고 1을 return한다. 그렇지 않다면 0을 return 한다. 실제 shell은 다양한 커맨드를 가진다. 예를 들면 pwd, jobs, fg 등이 있다.

builtin_command가 0을 return하면 shell은 자식 프로세스를 생성하고 자식 프로세스에서 요청받은 프로그램을 실행시킨다. 만약 사용자가 프로그램을 백그라운드에서 실행하도록 요청했다면 shell은 루프의 시작점으로 return하여 다음 커맨드 라인을 기다린다. 그렇지 않다면 shell은 waitpid 함수를 이용하여 job이 끝나기를 기다린다. job이 종료되면 shell은 다음 반복으로 넘어간다.

이 간단한 shell은 문제가 있다. 왜냐하면 백그라운드에 있는 자식 프로세스를 거두지 않기 때문이다. 이 문제를 해결하려면 시그널을 이용해야 한다.