본문으로 바로가기
반응형

프로세스의 이해와 활용

지금까지 우리가 알아본 내용만 가지고, 연결요청의 순서를 따져서 첫번째 클라이언트부터 백번째 클라이언트까지 순차적으로 연결을 허용해서 서비스를 제공하는 '일렬종대' 서비스 서버는 만들 수 있다.

물론 첫번째로 접속한 클라이언트는 이 서버에 불만이 없겠지만, 불과 0.5초 차이로 순서가 100번대로 밀린 클라이언트는 불만이 많을 것이다.

 

 

두가지 유형의 서버

진정으로 클라이언트(고객)을 생각한다면 모든 클라이언트의 만족도를 평균 이상으로 끌어올려야한다.

대학교 강의를 수강신청하는 날만봐도 어떤 기분인지 다들 알 것이다. 그래서 모든 클라이언트의 만족도를 평균 이상으로 끌어올리는 방법에 대해 논의해보고자 한다.

 

 

다중접속 서버의 구현 방법들

전체적인 서비스 제공시간이 조금 늦어지더라도 연결요청을 해오는 모든 클라이언트에게 동시에 서비스를 제공해서 평균적인 만족도를 높일 필요가 있다. 그리고 네트워크 프로그램은 CPU의 연산을 필요치 않는 데이터의 송수신 시간이 큰 비중을 차지하므로, 둘 이상의 클라이언트에게 동시에 서비스를 제공하는 것이 CPU를 보다 효율적으로 사용하는 방법이 된다. 때문에 우리는 둘 이상의 클라이언트에게 동시에 서비스를 제공하는 다중 접속 서버에 대해 논의하고자 한다.

다음은 다중접속 서버의 구현 모델 및 구현 방법이다.

 

- 멀티 프로세스 기반 서버 : 다수의 프로세스를 생성하는 방식으로 서비스 제공

- 멀티 플렉싱 기반 서버    : 입출력 대상을 묶어서 관리하는 방식으로 서비스 제공

- 멀티 쓰레딩 기반 서버    : 클라이언트의 수만큼 쓰레드를 생성하는 방식으로 서비스 제공

 

일단 이중에서 예제는 첫번째 방법인 멀티프로세스 기반의 서버 구현에 대해 알아볼 것이다.

그런데 이는 윈도우에서는 지원하지 않는 방식이기 때문에 리눅스를 통해(지금까지도 리눅스였지만) 알아볼 것이다.

 

 

프로세스 ID : ps au 명령문 

프로세스의 생성방법에 대해 살펴보기에 앞서 프로세스 ID에 대해 간단히 설명하겠다. 모든 프로세스는 생성되는 혀앹에 상관없이 운영체제로부터 ID를 부여 받는다. 그리고 이를 가리켜 '프로세스 ID'라 하는데, 이는 2 이상의 정수 형태를 띤다. 참고로 숫자 1은 운영체제가 시작되자마자 실행되는(운영체제의 실행을 돕는) 프로세스에게 할당되기 때문에 우리가 만들어내는 프로세스는 1이라는 값의 ID를 받을 수 없다.

 

일단 리눅스상에서 현재 실행중인 프로세스를 확인해보자.

위에서 보이는 바와 같이 ps 명령어를 통해 실행중인 프로세스를 간단히 확인할 수 있다. 특히 PID(process ID)도 함께 보이고 있음에 주목하자. 참고로 이미지에는 ps 명령어에 옵션 a와 u를 지정해서 모든 프로세스에 대한 다양한 정보를 확인할 수 있게 하였다.

 

 

fork 함수 호출을 통한 프로세스의 생성

fork 함수는 호출한 프로세스의 복사본을 생성한다. 즉 전혀 새로운 다른 프로그램을 바탕으로 프로세스를 생성하는 것이 아니라 이미 실행중인 fork 함수를 호출한 프로세스를 복사하는 것이다.

 

그리고는 두 프로세스 모두 fork 함수의 호출 이후 문장을 실행하게 된다. 그런데 완전히 동일한 프로세스로, 메모리 영역까지 동일하게 복사하기 때문에 이후의 프로그램 흐름은 fork 함수의 반환 값을 기준으로 나뉘도록 프로그래밍을 해야한다. 따라서 fork 함수의 다음 특징을 이용해서 프로그램의 흐름을 구분해야 한다.

 

- 부모 프로세스 : fork 함수의 반환 값은 자식 프로세스의 ID

- 자식 프로세스 : fork 함수의 반환 값은 0

 

여기서 '부모 프로세스(Parent Process)'란 원본 프로세스, fork 함수를 호출한 주체가 된다. 반면 '자식 프로세스(Child Process)'는 부모 프로세스의 fork 함수 호출을 통해 복사된 프로세스를 의미한다. 간단히 fork 함수 호출 이후 실행의 흐름을 정리하면 다음과 같다.

10-1 fork 함수 호출

 

위 그림에서 보이듯이 부모 프로세스가 fork 함수를 호출하는 순간 자식 프로세스가 복사되어 각각 fork 함수 호출의 반환 값을 받게 된다. 그런데 복사 이전 부모프로세스가 전역변수 gval의 값을 11로, 지역변수 lval의 값을 25로 증가시켰기 때문에 증가된 상태로 복사가 이뤄진다.

 

다만 fork 함수의 반환 값의 차로인해 부모 프로세스는 lval의 값을 1증가시키지만, 이는 자식 프로세스의 lval에 영향을 미치지 않는다. 마찬가지로 자식 프로세스는 gval의 값을 1 증가시키지만, 이는 부모 프로세스의 gval의 영향을 미치지 않는다. fork 함수 호출 이후에는 두 프로세스가 동일한 코드를 실행하는 완전히 다른 프로세스가 되기 때문이다.

 

예제를 통해 지금 언급한 내용을 실제로 확인해보자.

// fork.c
#include <stdio.h>
#include <unistd.h>

int gval = 10;       // 전역 변수로 선언
int main(int argc, char *argv[])
{
   pid_t pid;
   int lval = 20;
   gval++;
   lval += 5;

   pid = fork();
   if(pid == 0) // if Child Process
      gval+=2, lval+=2;
   else         // if Parent Process
      gval-=2, lval-=2;

   if(pid == 0)
      printf("Child Proc : [%d, %d] \n", gval, lval);
   else
      printf("Parent Proc : [%d, %d] \n", gval, lval);
   return 0;
}

실행결과는 fork 함수 호출 이후에 부모 프로세스와 자식 프로세스가 서로 완전히 분리된 메모리 구조를 지니고 있음을 보이고 있다. fork 함수와 관련해서는 더 많은 예제가 필요하지는 않다.

이 예제하나로도 fork 함수의 프로세스 생성이 의미하는 바를 충분히 이해할 수 있을 것이다.

 

 

프로세스 & 좀비(Zombie) 프로세스

파일을 여는 것 못지않게 닫는 것이 중요하다. 마찬가지로 프로세스도 생성 못지않게 소멸이 중요하다.

만약에 프로세스를 대충 소멸시켜놓으면, 이들이 좀비가 되어서 우리를 괴롭힐지도 모른다.

 

 

좀비 프로세스?

이때 언급하는 좀비는 우리가 생각한 그 좀비가 맞다. 프로세스 세계에서의 좀비는 프로세스에서 생성되고나서 할 일을 다 하면(main 함수 실행이 완료되면) 사라져야 하는데 사라지지 않고 좀비가 되어 시스템의 중요한 리소스(용량)을 차지하기도한다. 이 상태에 있는 프로세스를 가리켜 '좀비 프로세스'라 하는데, 이는 시스템에 부담을 주는 원인이 되기도 한다. 때문에 좀비 프로세스를 소멸시켜야한다. 물론 이를 위해 좀비의 소멸방법을 정확히 알아야 할 것이다.

 

 

좀비 프로세스의 생성 이유

좀비 프로세스의 생성을 막기에 앞서 좀비 프로세스의 생성 이유를 먼저 살펴보자.

fork 함수의 호출로 생성된 자식 프로세스가 종료되는 상황 두가지를 예로 들면 다음과 같다.

 

- 인자를 전달하면서 exit를 호출하는 경우

- main 함수에서 return 문을 실행하면서 값을 반환하는 경우

 

exit 함수로 전달되는 인자 값과 main 함수의 return 문에 의해 반환되는 값 모두 운영체제로 전달된다.

그리고 운영체제는 이 값이 자식 프로세스를 생성한 부모 프로세스에게 전달될 때까지 자식 프로세스를 소멸시키지 않는데, 바로 이 상황에 놓여있는 프로세스를 가리켜 좀비 프로세스라 한다. 즉, 자식 프로세스를 좀비 프로세스로 만드는 주체는 운영체제라는게 된다. 그렇다면 이 좀비 프로세스를 언제 소멸될까?

 

이는 해당 자식 프로세스를 생성한 부모 프로세스에게 exit 함수의 인자 값이나 return 문의 반환 값이 전달될때이다.

 

그렇다면 어떻게 부모 프로세스에게 값을 전달해야 할까? 부모 프로세스가 가만히있는데 운영체제가 알아서 값을 전달해주지는 않는다. 부모 프로세스의 적극적인 요청이 있어야(함수 호출이 있어야) 운영체제는 값을 전달해준다.

 

반대로 말하면 부모 프로세스가 자식 프로세스의 전달값을 요청하지 않으면, 운영체제는 그 값을 계속해서 유지하게되고 결국 자식 프로세스는 좀비의 상태로 오랫동안 머물러 있어야 한다. 결국 부모가 책임지고 자신이 낳은 자식을 거둬들여야하는 상황이 된것이다. 그럼 이제 좀비 프로세스를 만들어보자.

// zombie.c
#include <stdio.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
   pid_t pid = fork();

   if(pid == 0) // 자식 프로세스
   {
      puts("Hi, I am a child Process");
   }
   else         // 부모 프로세스
   {
      printf("Child Process ID : %d \n", pid);
      sleep(30);
   }

   if(pid == 0)
      puts("End child process");
   else
      puts("End parent process");
   return 0;
}

좀비 프로세스가 남아있다!

프로그램을 실행하면 위의 상태에서 잠시 멈추게 된다. 이 상태를 벗어나기 전에(30초 내에) 자식 프로세스의 좀비 여부를 확인해야한다. 좀비의 확인은 ps au 명령어를 새로운 터미널창에 입력해줘서 확인할 수 있다.

 

참고로 30초 대기시간이 지난 후에 부모 프로세스가 종료되면 PID가 6459인 부모 프로세스가 좀비가 된 자식프로세스가 함께 소멸되는 것을 확인할 수 있다.

 

 

좀비 프로세스의 소멸1 : wait 함수의 사용

자식 프로세스의 소멸을 위해서는 부모 프로세스가 자식 프로세스의 전달 값을 요청해야한다.

이제 요청을 위한 구체적인 방법을 알아보자. 요청하는 두 가지 방법이 있는데, 그 중 하나는 다음 함수를 호출하는 것이다.

#include <sys/wait.h>
pid_t wait(int * statloc);    // 성공 시 종료된 자식 프로세스의 ID, 실패 시 -1 반환

 

위 함수가 호출됬을때, 이미 종료된 자식 프로세스가 있다면, 자식 프로세스가 종료되면서 전달한 값(exit 함수의 인자 값, main 함수의 return 에 의한 반환 값)이 매개변수로 전달된 주소의 변수에 저장된다.

 

그런데 이 변수에 저장되는 값에는 자식 프로세스가 종료되면서 전달한 값 이외에도 다른 정보가 함께 포함되어 있으니, 다음 매크로 함수를 통해 값의 분리 과정을 거쳐야 한다.

 

- WIFEXITED : 자식 프로세스가 정상 종료한 경우 '참(true)'을 반환

- WEXITSTATUS : 자식 프로세스의 전달 값을 반환한다.

 

즉, wait 함수의 인자로 변수 status의 주소 값이 전달되었다면, wait 함수의 호출 이후에는 다음과 같은 유형의 코드를 구성해야 한다.

 

if(WIFEXITED(status))  // 정상 종료 하였는가?
{
    puts("Normal termination!");
    printf("Child pass num: %d", WEXITSTATUS(status)); // 반환 값은?
}

 

지금까지 설명한 내용을 바탕으로 예제를 작성해보자. 이 예제는 자식 프로세스가 좀비가 되도록 내버려두지 않는다.

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

int main(int argc, char *argv[])
{
   int status;
   pid_t pid = fork();

   if(pid == 0) return 3;
   else
   {
      printf("Child PID : %d \n", pid);
      pid = fork();
      if(pid == 0) exit(7);
      else
      {
         printf("Child PID : %d \n", pid);
         wait(&status);
         if(WIFEXITED(status));
            printf("Child send one : %d \n", WEXITSTATUS(status));

         wait(&status);
            printf("Child send two : %d \n", WEXITSTATUS(status));
         sleep(30);  // sleep 30 sec.
      }
   }
   return 0;
}

좀비가 존재하지 않는다!!!

 

위 실행결과에서 보이는 PID에 해당하는 프로세스가 존재하지 않음을 확인할 수 있다.

wait함수의 인해서 완전히 사라졌음을 확인해야한다. 그리고 두 자식 프로세스가 종료되면서 전달한 값 3과 7이 부모 프로세스에게 전달되었음도 확인할 수 있다.

 

이로써 wait 함수호출을 통한 좀비 프로세스의 소멸방법을 보였는데, 이 wait 함수는 호출된 시점에서 종료된 자식 프로세스가 없다면, 임의의 자식 프로세스가 종료될 때까지 블로킹상태에 놓인다는 특징이 있다. 때문에 함수 호출에 주의해야 한다.

 

 

좀비 프로세스의 소멸2 : waitpid 함수의 사용

wait 함수의 블로킹이 문제가 된다면 waitpid 함수의 호출을 고려하면 된다. 이는 좀비 프로세스의 생성을 막는 두번째 방법이자 블로킹 문제의 해결책이기도 하다.

#include <sys/wait.h>
pid_t waitpid(pid_t pid, int * statloc, int options); // 성공 시 종료된 자식 프로세스의 ID(또는 0), 실패 시 -1 반환

-pid : 종료를 확인하고자하는 자식 프로세스의 ID 전달, 이를 대신해서 -1을 전달하면 wait 함수롸 마찬가지로 임의의 자식 프로세스가 종료되기를 기다린다.
-statloc : wait 함수의 매개변수 statloc과 동일한 의미로 사용된다.
- options : 헤더파일 sys/wait.h에 선언된 상수 WNOHANG을 인자로 전달하면 종료된 자식 프로세스가 존재하지 않아도블로킹 상태에 있지 않고, 0을 반환하면서 함수를 빠져나온다.

 

위 함수를 사용하면서 예제를 작성해보자.

초점은 waitpid 함수 호출 시 블로킹이 되지 않음을 보이는 것에 맞춰져 있다.

// waitpid.c
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main(int argc, char *argv[])
{
   int status;
   pid_t pid = fork();

   if(pid == 0)
   {
      sleep(15);
      return 24;
   }
   else
   {
      while(!waitpid(-1, &status, WNOHANG))
      {
         sleep(3);
         puts("sleep 3sec.");
      }

      if(WIFEXITED(status))
         printf("Chlid send %d \n", WEXITSTATUS(status));
   }
   return 0;
}
 

실행 결과로는 while문의 puts가 5회 실행되었음을 확인할 수 있다.

그리고 이는 waitpid 함수가 블로킹 되지 않았음을 증명하는 결과도 된다.

 

 

시그널 핸들링

지금까지 프로세스의 생성 및 소멸방법에 대해서 살펴봤는데, 아직 해결하지 못한 문제가 하나 남아있다.

대부분의 상황에서 부모 프로세스도 자식 프로세스 못지 않게 바쁘다.

따라서 자식 프로세스의 종료를 기다리면서 waitpid 함수만 호출할 수는 없을 것이다.

그래서 이 문제의 해결책을 살펴보고자 한다.

 

 

운영체제

자식 프로세스 종료의 주체는 운영체제이다. 따라서 운영체제가 열심히 일하고 있는 부모 프로세스에게 본인이 생성한 자식 프로세스가 종료되었다는 사실을 이야기해줄 수 있으면 효율적인 프로그램의 구현이 가능할 것이다.

 

만약 이야기를 해줄 수 있다면 부모 프로세스는 하던일을 잠시 멈추고 자식 프로세스의 종료와 관련된 일을 처리하면 될것이다. 이러한 시나리오의 구현을 위해 '시그널 핸들링(Signal handling)'이라는 것이 존재한다.

여기서 '시그널'은 특정상황이 발생했음을 알리기 위해 운영체제가 프로세스에게 전달하는 메세지를 의미한다.

그리고 그 메시지에 반응해서 메시지와 연관된, 미리 정의된 작업이 진행되는 것을 가리켜 '핸들링' 또는 '시그널 핸들링' 이라 한다. 이러한 원리는 인터럽트의 원리와 비슷하다고 볼 수 있다.

(인터럽트 : 기존에 하던 일을 멈추고 어떤 시그널이 발생하면 그 시그널의 처리를 하고난 다음에 다시 기존에 하던일을 처리하는 것)

 

 

시그널과 signal 함수

다음은 시그널 핸들링의 이해를 돕기 위한 프로세스와 운영체제의 대화 내용이다.

 

- 프로세스 : 운영체제야, 내가 생성한 자식 프로세스가 종료되면 zombie_handler라는 이름의 함수좀 호출해주라.

- 운영체제 : 그럼 니가 생성한 자식 프로세스가 종료되면, 니가 말한 zombie_handler라는 이름의 함수를 내가 대신

              호출해줄 테니, 그 상황에서 실행해야 할 문장들을 그 함수에 묶어두렴.

 

이 대화안에 시그널 핸들링과 관련된 내용 전부가 들어있다.

프로세스가 하는 이야기가 '시그널 등록'에 해당한다. 즉, 프로세스는 자식 프로세스의 종료라는 상황 발생시에 특정 함수의 호출을 운영체제에게 요구하는 것이다. 이 요구는 다음 함수의 호출을 통해 이뤄진다.

(이 함수를 시그널 등록 함수라 표현한다.)

#include <signal.h>
void (*signal(int signo, void (*func)(int)))(int); // 시그널 발생시 호출되도록 이전에 등록된 함수의 포인터 반환

- 함수이름 : signal

- 매개변수 선언 : int signo, void(*func)(int)

- 반환형 : 매개변수형이 int이고 반환형이 void인 함수 포인터

 

위 함수를 호출하면서 첫번째 인자로 특정 상황에 대한 정보를, 두번째 인자로 특정 상황에서 호출될 함수의 주소 값(포인터)를 전달한다. 그러면 첫번째 인자를 통해 명시된 상황 발생시, 두번째 인자로 전달된 주소 값의 함수가 호출된다.

참고로 signal 함수를 통해서 등록 가능한 특정 상황과 그 상황에 할당된 상수 몇몇을 정리하면 다음과 같다.

 

- SIGALRM  : alarm 함수 호출을 통해서 등록된 시간이 된 상황

- SIGINT     : CTRL + C 가 입력된 상황

- SIGCHLD  : 자식 프로세스가 종료된 상황

 

이제 시그널 등록방법에 대해 알아보았는데, 이렇게 시그널이 등록되면(등록된 상황 발생시), 운영체제는 해당 시그널에 등록된 함수를 호출해준다. 그럼 실제 예제를 통해 이러한 사실을 확인해볼텐데, 그에 앞서 alarm 함수를 알아보자.

#include <unistd.h>
unsigned int alarm(unsigned int seconds); // 0 또는 SIGALRM 시그널이 발생하기까지 남아있는 시간을 초 단위로 반환

 

위 함수를 호출하면서 양의 정수를 인자로 전달하면 전달된 수에 해당하는 시간(초 단위)이 지나서 SIGALRM 시그널이 발생한다. 그리고 0을 인자로 전달하면 이전에 설정된 SIGALRM 시그널 발생의 예약이 취소된다.

그런데 위의 함수호출을 통해서 시그널의 발생을 예약만 해놓고, 이 시그널이 발생했을때 호출되어야 할 함수를 지정하지 않으면(signal 함수 호출을 통해서) 프로세스가 그냥 종료되어 버리니 이를 주의해야 한다.

 

그럼 시그널 핸들링과 관련된 예제를 작성해보자.

// signal.c
#include <stdio.h>
#include <unistd.h>
#include <signal.h>

void timeout(int sig) // 시그널이 발생했을때 호출되어야할 함수(시그널 핸들러라고 부름)
{
   if(sig == SIGALRM)
      puts("Time out!");  // 2초 간격으로 SIGALRM 시그널을 반복 발생시키기 위해 alarm 함수 호출
   alarm(2);
}
void keycontrol(int sig)
{
   if(sig == SIGINT)
      puts("CTRL+C pressed");
}

int main(int argc, char *argv[])
{
   int i;
   signal(SIGALRM, timeout);    // 시그널 핸들러 등록
   signal(SIGINT, keycontrol);
   alarm(2);       // 시그널 SIGALRM의 발생을 2초 뒤로 예약

   for(i = 0; i < 3; i++)
   {
      puts("wait...");
      sleep(100);  // 시그널 발생과 시그널핸들러의 실행을 확인하기 위해 100초간 총 3회의 대기시간을 갖는다.
                   // 총 300초, 대략 5분이 지나야 프로그램이 종료되는데
                   // 실제 실행시간은 10초가 걸리지 않는다.
   }
   return 0;
}

위 실행결과에서 빨간색 부분은 아무런 입력이 없을때의 실행결과이다.

노란색 부분은 실행 중간에 컨트롤 + C 를 입력해서 문자열이 출력된 것을 확인할 수 있다.

 

위 예제의 분석에 있어서 시그널이 발생하면 sleep 함수의 호출로 블로킹 상태에 있는 프로세그가 깨어난다는 사실을 주목해야 한다. 함수의 호출을 유도하는 것은 운영체제이지만, 그래도 프로세그가 잠들어 있는 상태에서 함수가 호출될 수는 없다.

 

따라서 시그널이 발생하면, 시그널에 해당하는 시그널 핸들러의 호출을 위해서 sleep 함수의 호출로 블로킹 상태에 있던 프로세스는 깨어나게 된다. 그리고 한번 깨어나면 다시 잠들지 않는다. 비록 sleep 함수 호출문에서 요구하는 시간이 채 지나지 않아도 말이다. 그래서 위 예제의 실행에 걸리는 시간은 채 10초가 되지 않는 것이다.

 

만약 컨트롤 + C를 연속해서 입력한다면, 1초도 채 걸리지 않을 수 있다.

 

 

sigaction 함수를 이용한 시그널 핸들링

지금까지 설명한 내용만 가지고도 좀비 프로세스의 생성을 막는 코드를 충분히 만들어 낼 수 있다.

그러나 함수를 하나 더 소개하고자 한다. sigaction 함수는 signal 함수와 유사하다. 이는 signal 함수를 대체할 수 있고, signal 함수보다 훨씬 안정적으로 동작한다. 안정적으로 동작하는 이유는 다음과 같다.

 

"signal 함수는 유닉스 계열의 운영체제 별로 동작방식에 있어서 약간의 차이를 보일 수 있지만,

 sigaction 함수는 차이를 보이지 않는다."

 

실제로 signal 함수는 과거 프로그램과의 호환성을 위해서 유지하기위해 사용하지, 최근에는 잘 사용하지 않는다.

그래서 sigaction 함수에 대해 소개하고자하는데, signal 함수의 기능을 대신할 수 있는 수준으로만 설명하고자 한다.

#include <signal.h>
int sigaction(int signo, const struct sigaction * act, struct sigaction * oldact);
// 성공 시 0, 실패 시 -1 반환

- signo : signal 함수와 마찬가지로 시그널의 정보를 인자로 전달
- act : 첫번째 인자로 전달된 상수에 해당하는 시그널 발생시 호출될 함수(시그널 핸들러)의 정보 전달
- oldact : 이전에 등록되었던 시그널 핸들러의 함수 포인터를 얻는데 사용되는 인자, 필요없다면 0 전달

 

위 함수 호출을 위해서는 sigaction이라는 이름의 구조체 변수를 선언 및 초기화해야 하는데, 이 구조체는 다음과 같이 정의되어있다.

struct sigaction
{
    void(*sa_handler)(int);
    sigset_t sa_mask;
    int sa_flags;
}

위의 구조체 멤버 중에서 sa_handler에 시그널 핸들러의 함수 포인터 값(주소 값)을 저장하면 된다.

그리고 sa_mask는 모든 비트를 0으로, sa_flags는 0으로 초기화한다. 이 두 멤버는 시그널 관련 옵션 및 특성의 지정에 사용되는데, 우리의 목적은 좀비 프로세스의 생성을 막는데 있으므로 이 두 멤버에 관한 설명은 생략하겠다.

 

이제 예제를 작성해보자. 아직 sigaction 함수의 사용에 필요한 모든 것을 언급하지 않았는데, 예제를 통해 보충하자.

// sigaction.c
#include <stdio.h>
#include <unistd.h>
#include <signal.h>

void timeout(int sig)
{
   if(sig == SIGALRM)
      puts("Time out!");
   alarm(2);
}

int main(int argc, char *argv[])
{
   int i;
   struct sigaction act;
   act.sa_handler = timeout;
   sigemptyset(&act.sa_mask);
   act.sa_flags = 0;
   sigaction(SIGALRM, &act, 0);

   alarm(2);

   for(i = 0; i < 3; i++)
   {
      puts("wait ... ");
      sleep(100);
   }
   return 0;
}

 

이로써 시그널 핸들링에 대해 알아봤는데, 이 내용을 바탕으로 좀비 프로세스의 소멸에 대해 알아보도록 하자.

 

 

시그널 핸들링을 통한 좀비 프로세스의 소멸

자식 프로세스가 종료된 상황에 대한 시그널 이름이 SIGCHLD라는 사실만 알면 쉽게 좀비 프로세스 생성을 막는 예제를 작성할 수 있다. 그럼 sigaction 함수를 기반으로 예제를 작성해보자.

// remove_zombie.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>

void read_childproc(int sig)
{
   int status;
   pid_t id = waitpid(-1, &status, WNOHANG);
   if(WIFEXITED(status))
   {
      printf("Remove proc id : %d \n", id);
      printf("Child send : %d \n", WEXITSTATUS(status));
   }
}

int main(int argc, char *argv[])
{
   pid_t pid;
   struct sigaction act;             // 시그널 SIGCHLD에 대한 시그널 핸들러 등록
   act.sa_handler = read_childproc;  // 이로써 자식 프로세스가 종료되면 위에서 정의한 read_read_childproc() 호출
   sigemptyset(&act.sa_mask);        // 이 함수 내에서의 waitpid 함수호출로 인해 자식 프로세스는 좀비가 되지않고 소멸된다.
   act.sa_flags = 0;
   sigaction(SIGCHLD, &act, 0);

   pid = fork();                     // 부모 프로세스를 통해 자식 프로세스 생성
   if(pid == 0)  // 자식 프로세스 실행영역
   {
      puts("Hi! I'm child process");
      sleep(10);
      return 12;
   }
   else    // 부모 프로세스 실행영역
   {
      printf("Child proc id : %d \n", pid);
      pid = fork();                  // 부모 프로세스를 통해 자식 프로세스 생성
      if(pid == 0)  // 또 다른 자식 프로세스 실행영역
      {
         puts("Hi! I'm other child process");
         sleep(10);
         exit(24);  // 정상종료 : 0, 0말고 다른 상수가 들어가면 상수에 해당되는 에러가 발생됬다는 뜻임
      }
      else
      {
         int i;
         printf("Child proc id : %d \n", pid);
         for(i = 0; i < 5; i++)  // 시그널 SIGCHLD의 발생을 대기하기위해 부모 프로세스를 5초간 5회 멈춰놓음.
         {
            puts("wait...");
            sleep(5);
         }
      }
   }
   return 0;
}

실행결과를 통해 종료된 자식 프로세스가 좀비가 되지않고 소멸되었음을 확인할 수 있다.

그럼 이제까지했던 프로세스 생성에 대한 지식을 서버 프로그램에 반영해보자.

 

 

멀티태스킹 기반의 다중접속 서버

우리는 fork 함수 호출을 통한 다중 접속 서버의 구현준비를 끝냈다. 이제 서버같은 서버를 구현할 차례이다.

 

 

프로세스 기반의 다중 접속 서버의 구현 모델

이전에 구현했던 에코 서버는 한번에 하나의 클라이언트에게만 서비스를 제공할 수 있었다. 즉, 동시에 둘 이상의 클라이언트에게 서비스를 제공하지 못하는 구조였다. 따라서 이번에는 동시에 둘 이상의 클라이언트에게 서비스를 제공하는 형태로 에코서버를 확장해 보자.

다음 그림은 이어서 구현할 멀티프로세스 기반의 다중접속 에코 서버의 구현모델을 보이고 있다.

 

위 그림에서 보이듯이 클라이언트의 서비스 요청(연결요청)이 있을때마다 에코 서버는 자식 프로세스를 생성해서 서비스를 제공한다. 즉, 서비스를 요청하는 클라이언트의 수가 다섯이라면 에코 서버는 추가로 다섯 개의 자식 프로세스를 생성해서 서비스를 제공한다. 이를 위해서 에코 서버는 다음의 과정을 거쳐야한다. 이것이 기존 에코 서버와의 차이점이다.

 

1단계 : 에코서버(부모 프로세스)는 accept 함수 호출을 통해 연결요청을 수락한다.

2단계 : 이때 얻게되는 소켓의 파일 디스크립터를 자식 프로세스를 생성해서 넘겨준다.

3단계 : 자식 프로세스는 전달받은 파일 디스크립터를 바탕으로 서비스를 제공한다.

 

여기서 혼란스러운 부분은 자식 프로세스에게 소켓의 파일 디스크립터를 넘기는 방법이다. 그러나 실제 코드상에서 이를 확인하면 이는 아무것도 아니다. 왜냐하면 자식 프로세스는 부모 프로세스가 소유하고 있는 것을 전부 복사하기 때문이다. 즉, 사실상 파일 디스크립터를 넘기는 과정은 별도로 거칠 필요가 없다.

 

 

다중 접속 에코 서버의 구현

다음 예제는 다중접속 에코 서버 구현코드이다. 멀티프로세스 기반이며, 앞서 했던 에코 클라이언트와 함께 실행해보자.

// echo.mpserv.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <signal.h>
#include <sys/wait.h>

#define BUF_SIZE 30
void error_handling(char* message);
void read_childproc(int sig);

int main(int argc, char* argv[])
{
   int serv_sock, clnt_sock;
   struct sockaddr_in serv_adr, clnt_adr;

   pid_t pid;
   struct sigaction act;
   socklen_t adr_sz;
   int str_len, state;
   char buf[BUF_SIZE];
   if(argc != 2)
   {
      printf("Usage : %s <port> \n", argv[0]);
      exit(1);
   }

   act.sa_handler = read_childproc; // 좀비 프로세스 생성을 막기위한 코드 
   sigemptyset(&act.sa_mask);
   act.sa_flags = 0;
   state = sigaction(SIGCHLD, &act, 0);
   serv_sock = socket(PF_INET, SOCK_STREAM, 0);
   memset(&serv_adr, 0, sizeof(serv_adr));
   serv_adr.sin_family = AF_INET;
   serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
   serv_adr.sin_port = htons(atoi(argv[1]));

   if(bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr)) == -1)
      error_handling("bind() error");

   if(listen(serv_sock, 5) == -1)
      error_handling("listen() error");
   while(1)
   {
      adr_sz = sizeof(clnt_adr);
      clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz); // accept 함수 호출한 후에 밑의 fork 함수 호출
                                 // 때문에 이 구문에서 만들어진 소켓(클라이언트의 연결요청 수락과정에서 만들어진) 파일 디스크립터를
                                 // 부모 프로세스와 자식 프로세스가 동시에 하나씩 갖게 됨
      if(clnt_sock == -1)
         continue;
      else
         puts("new client connected....");
      pid = fork();
      if(pid == -1)
      {
         close(clnt_sock);
         continue;
      }
      if(pid == 0)  // 자식 프로세스 실행 영역
                    // 자식 프로세스가 실행되면서 클라이언트에게 에코 서비스가 제공됨.
                    // 그런데 close로 위의 socket을 닫고있는데 이는 자식 프로세스로 서버 소켓의 파일 디스크립터까지 복사되기 때문이다.
      {
         close(serv_sock);  // 자식 영역에서는 서버 소켓을 닫음(필요없는 소켓을 열어둘필요가 없기때문)
         while((str_len = read(clnt_sock, buf, BUF_SIZE)) != 0)
            write(clnt_sock, buf, str_len);  // 클라이언트 소켓을통해 데이터 주고받음

         close(clnt_sock);                   // 클라이언트 소켓 닫음
         puts("client disconnected...");
         return 0;
      }
      else
         close(clnt_sock);  // accept 함수 호출을 통해 만들어진 소켓의 파일 디스크립터가 자식 프로세스에게 복사되었으니
                            // 서버는 자신이 소유하고있는 파일 디스크립터를 소멸시켜야함.
   }
   close(serv_sock);        // 부모 프로세스에서 모든 클라이언트와의 연결이 끝났으니 서버소켓을 닫아줌
   return 0;
}

void read_childproc(int sig)
{
   pid_t pid;
   int status;
   pid = waitpid(-1, &status, WNOHANG);
   printf("removed proc id : %d \n", pid);
}

void error_handling(char* message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

실행 결과를 살펴보면 밑의 터미널 창에서 서버를 실행시킨 다음에 위의 새 터미널창 2개에서 각각 클라이언트를 접속하였다. 다수의 클라이언트에게 에코 서비스가 동시에 제공되있는 것을 확인할 수 있다.

 

 

fork 함수호출을 통한 파일 디스크립터의 복사

echo_mpserv.c 예제에서 fork 함수호출을 통한 파일 디스크립터의 복사를 보여줬다. 부모 프로세스가 지니고 있던 두 소켓(하나는 서버 소켓, 또 하나는 클라이언트와 연결된 소켓)의 파일 디스크립터가 자식 프로세스에게 복사되었다.

 

사실 파일 디스크립터만 복사된건지, 소켓도 복사가 되는 것인지 다소 이해하기 힘든 부분이 있다.

fork 함수가 호출되면 부모 프로세스의 모든 것이 복사되니 소켓도 함께 복사되었을 거라고 생각할 수 있다.

그러나 소켓은 프로세스의 소유가 아니다. 소켓은 운영체제의 소유이다. 다만 해당 소켓을 의미하는 파일 디스크립터만이 프로세스의 소유인 것이다. 그런데 굳이 소켓이 복사된다는 것은 다음의 이유로 이치에 맞지 않는다.

 

"소켓이 복사되면 동일한 PORT에 할당된 소켓이 둘 이상이 된다"

 

즉, echo_mpserv.c에서 fork 함수 호출결과는 다음과 같다.

fork 함수호출 이후에 하나의 소켓에 두 개의 파일 디스크립터가 할당된 모습을 보인다.

10-3 fork 함수의 호출과 파일 디스크립터의 복사

 

위 그림과 같이 하나의 소켓에 두 개의 파일 디스크립터가 존재하는 경우, 두 개의 파일 디스크립터가 모두 종료(소멸)되어야 소켓은 소멸된다. 때문에 위의 그림과 같은 형태를 유지하면 이후에 자식 프로세스가 클라이언트와 연결되어있는 소켓을 소멸하려해도 소멸되지않고 계속 남아있게 된다.(이는 서버 소켓도 마찬가지이다.)

 

그래서 fork 함수 호출 후에는 다음 그림에서 보이듯이 서로에게 상관이 없는 소켓의 파일 디스크립터를 닫아줘야 한다.

10-4 복사된 파일 디스크립터의 정리

위 그림의 형태로 파일 디스크립터를 정리하기위해 echo_mpserv.c의 close 함수를 호출한 것이다.

 

 

TCP의 입출력 루틴(Routine) 분할

우리는 fork와 관련된 매우 의미있는 내용을 살펴보았다. 이를 바탕으로 '입출력 루틴의 분할' 이라는 것을 클라이언트 영역에서 시도해보고자 한다.

 

 

입출력 루틴 분할의 의미와 이점

지금까지 구현한 에코 클라이언트의 데이터 에코방식은 다음과 같다.

 

서버로 데이터를 전송 -> 데이터가 에코되어 돌아올때까지 기다림 -> 에코되어 돌아온 데이터를 수신 -> 데이터 추가 전송

 

즉, 한번 데이터를 전송하면 에코되어 돌아오는 데이터를 수신할때까지 마냥 기다려야한다. 

이는 프로그램 코드의 흐름이 read와 write를 반복하는 구조였기 때문이다.

 

그런데 이렇게 해야했던 이유는 하나의 프로세스를 기반으로 프로그램이 동작했기 때문이다. 그러나 이제는 둘 이상의 프로세스를 생성할 수 있으니, 이를 바탕으로 데이터의 송신과 수신을 분리해보자. 분리를 위한 기본 모델은 다음과 같다.

10-5 에코 클라이언트의 입출력 루틴 분리 모델

 

위 그림에서 보이듯 클라이언트의 부모 프로세스는 데이터의 수신을 담당하고, 별도로 생성된 자식 프로세스는 데이터의 송신을 담당한다. 이렇게 구현하면 입력과 출력을 담당하는 프로세스가 각각 다르기때문에 서버로부터 데이터 수신여부에 상관없이 데이터를 전송할 수 있다.

 

이러한 구현방식을 택하는 이유에는 여러 가지가 있지만 가장 중요한 이유는 프로그램의 구현이 한결 수월해지는 것에 있다. 우리 입장에서는 프로세스를 하나 더 생성하는 방식인데 이러한 구현 방식을 따르면 프로세스 생성 이후에 부모 프로세스가 실행하는 영역은 데이터의 수신과 관련된 코드만 작성하면 되고, 자식 프로세스가 실행하는 영역은 데이터의 송신과 관련된 코드만 작성하면 되기 때문에 코드의 구현이 보다 수월하다.

사실 하나의 프로세스 내에서 데이터의 송수신을 모두 진행하게끔 구현하려면 그만큼 신경쓸 부분이 많아질 것이다.

 

입출력 루틴 분할의 또다른 장점을 들라고 한다면, 데이터 송신이 잦은 프로그램의 성능향상을 들 수 있다. 이에 대한 이해를 위해 다음 그림을 살펴보자.

10-6 데이터 송수신 방법의 비교

 

위 그림의 왼쪽은 이전 에코 클라이언트의 데이터 송수신 방식을, 그리고 오른쪽은 입출력 루틴을 분리시킨 에코 클라이언트의 데이터 송수신 방식을 보여준다. 일단 서버에서의 차이는 없다. 차이가 나는 부분은 클라이언트 영역이다. 입출력 루틴이 분리된 클라이언트는 데이터의 수신 여부에 상관없이 데이터 전송이 가능하기 때문에 연속해서 데이터의 전송이 가능하다. 

 

따라서 동일한 시간 내에서의 데이터 송수신 분량이 송수신 분량이 상대적으로 많을 수 밖에 없다. 그리고 이러한 성능적 차이는 데이터의 전송속도가 느린 환경에서 더 확실히 드러난다.

 

 

에코 클라이언트의 입출력 루틴 분할

입출력 루틴의 분할이 의미하는 바를 알아봤으니 이제 코드상에서 입출력 루틴을 분할해보자.

분할의 대상은 에코 클라이언트이다. 이 예제는 에코서버인 echo.mpserv.c와 함께 동작시키면 된다.

// echo_mpclient.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30
void error_handling(char* message);
void read_routine(int sock, char *buf);
void write_routine(int sock, char *buf);

int main(int argc, char* argv[])
{
   int sock;
   pid_t pid;
   char buf[BUF_SIZE];
   struct sockaddr_in serv_adr;
   if(argc != 3)
   {
      printf("Usage : %s <IP> <port> \n", argv[0]);
      exit(1);
   }

   sock = socket(PF_INET, SOCK_STREAM, 0);
   memset(&serv_adr, 0, sizeof(serv_adr));
   serv_adr.sin_family = AF_INET;
   serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
   serv_adr.sin_port = htons(atoi(argv[2]));

   if(connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
      error_handling("connect() error!");

   pid = fork();
   if(pid == 0)                 
      write_routine(sock, buf); // write_routine 함수에는 데이터 출력에 관련된 코드만 존재함
   else
      read_routine(sock, buf);  // read_routine 함수에는 데이터 입력에 관련된 코드만 존재
// 이렇게 입출력 루틴을 구분해서 각각의 함수로 정의하면 구현의 편의를 가져다줌

   close(sock);
   return 0;
}

void read_routine(int sock, char *buf)
{
   while(1)
   {
      int str_len = read(sock, buf, BUF_SIZE);
      if(str_len == 0)
         return;

      buf[str_len] = 0;
      printf("Message from server : %s", buf);
   }
}

void write_routine(int sock, char *buf)
{
   while(1)
   {
      fgets(buf, BUF_SIZE, stdin);
      if(!strcmp(buf, "q\n") || !strcmp(buf, "Q\n"))
      {
         shutdown(sock, SHUT_WR);  // 서버에게 EOF 전달을위해 셧다운 함수 호출
         return;      // return 실행 후에 main 함수 끝에 close 함수 호출로 EOF 전달을 기대할 수 있지만,
                      // 현재 커넥스 밑의 fork 함수 호출을 통해 파일 디스크립터가 복사된 상황이다.
                      // 그리고 이런 상황에서는 한번의 close 함수호출로 EOF 전달을 기대할 수 없다.
                      // 따라서 반드시 shutdown 함수호출을 통해 EOF 전달을 별도로 명시해야함.
      }
      write(sock, buf, strlen(buf));
   }
}

void error_handling(char* message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

실행결과는 우리가 잘 아는 에코 서버, 에코 클라이언트의 실행결과와 동일하다. 위 예제의 경우 입출력 루틴이 분리되었기 때문에, 출력의 간결함을 위해 이전 예제와 달리 "Input Message(Q to quit): "를 출력하지 않았다.

 

위의 문자열은 메세지의 수신여부에 상관없이 키보드로 문자열을 입력할 때마다 출력되기 때문에 출력의 형태가 이상해 보일 수 있다. 그럼 이로써 멀티태스킹 기반의 서버 구현에 대한 설명을 매듭짓도록 하겠다.

반응형