[Socket] 멀티프로세스 기반의 서버 구현

둘 이상의 클라이언트에게 동시에 서비스를 제공하는 다중접속 서버에 대해 알아보자.

대표적인 다중접속 서버의 구현 방법에는 다음과 같은 것들이 있다.

  • 멀티프로세스 기반 서버
    • 다수의 프로세스를 생성하는 방식으로 서비스 제공
  • 멀티플렉싱 기반 서버
    • 입출력 대상을 묶어서 관리하는 방식으로 서비스 제공
  • 멀티쓰레딩 기반 서버
    • 클라이언트의 수만큼 쓰레드를 생성하는 방식으로 서비스 제공

프로세스(Process)

멀티프로세스 기반 서버의 서비스 주체가 되는 프로세스를 먼저 이해해보자. 프로세스란, 메모리 공간을 차지한 상태에서 실행중인 프로그램을 의미한다. 멀티프로세스 운영체제는 둘 이상의 프로세스를 동시에 생성 가능하다. 여기서 프로세스는 생성되는 형태에 상관없이 OS로부터 ID를 부여 받는데, 이를 가리켜 “프로세스 ID”라 부르고 2 이상의 정수 형태를 띤다.

fork함수

프로세스의 생성에는 몇 가지 방법이 있는데, 먼저 fork함수에 대해 알아보자.

#include <unistd.h>

pid_t fork(void);
//성공 시 프로세스 ID, 실패 시 -1 return

fork함수는 호출한 프로세스의 복사본을 생성한다. 전혀 새로운 다른 프로그램을 바탕으로 프로세스를 생성하는 것이 아니라, 이미 실행중인(fork함수를 호출한) 프로세스를 복사한다. 그리고 두 프로세스 모두 fork함수 호출 이후 문장을 실행하게 된다. 그런데 완전히 동일한 프로세스로, 메모리 영역까지 동일하게 복사하기 때문에 이후의 프로그램 흐름은 fork함수의 반환 값을 기준으로 나뉘도록 프로그래밍 해야 한다.

프로세스 & 좀비 프로세스

좀비 프로세스

파일과 마찬가지로 프로세스도 생성 못지않게 소멸이 매우 중요하다.

프로세스가 생성되고 난 뒤 할 일을 다 하면(main함수의 실행을 완료하면) 사라져야 하는데 사라지지 않고 좀비가 되어 시스템의 중요한 리소스를 차지하기도 한다. 이 상태에 있는 프로세스를 가리켜 “좀비 프로세스”라 한다.

실행이 완료되었음에도 불구하고, 소멸되지 않은 프로세스

좀비 프로세스의 생성 원인은 자식 프로세스가 종료되면서 반환하는 상태 값이 부모 프로세스에게 전달되지 않을 경우 해당 프로세스는 소멸되지 않고 좀비가 된다.

자식 프로세스가 종료되는 상황은 다음과 같다.

  • 인자를 전달하면서 exit를 호출하는 경우
  • main함수에서 return문을 실행하면서 값을 반환하는 경우

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

자식 프로세스의 소멸을 위해서는 부모 프로세스가 자식 프로세스의 전달 값을 요청해야 한다. 이때 호출되는 함수가 wait함수이다.

#include <sys/wait.h>

pid_t wait(int* statloc);
//성공 시 종료된 자식 프로세스ID, 실패 시 -1 return

이 함수가 호출되었을 때, 이미 종료된 자식 프로세스가 있다면, 자식 프로세스가 종료되면서 전달한 값이 매개변수로 전달된 주소의 변수에 저장된다.

주의할 점은, wait함수는 호출된 시점에서 종료된 자식 프로세스가 없다면, 임의의 자식 프로세스가 종료될 때까지 블로킹(Blocking) 상태에 놓인다.

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

wait함수의 블로킹이 문제가 된다면 waitpid함수를 호출하면 된다.

#include <sys/wait.h>

pid_t waitpid(pid_t pid, int* statloc, int options);
//성공 시 종료된 자식 프로세스ID(or 0), 실패 시 -1 return
  • pid: 종료를 확인하고자 하는 자식 프로세스 ID
  • options: WNOHANG을 인자로 전달하면, 종료된 자식 프로세스가 존재하지 않아도 블로킹 상태에 있지 않고, 0을 반환하면서 함수를 빠져 나온다.

시그널 핸들링

자식 프로세스가 언제 종료될 줄 알고 waitpid함수를 계속 호출하고 있을까? 이 문제의 해결책을 알아보자.

자식 프로세스 종료의 인식주체는 OS이다. 따라서 OS가 부모 프로세스에게 생성한 자식이 종료되었음을 알려줄 수 있다면 효율적인 프로그램을 구현할 수 있지 않을까? 그러면 부모 프로세스도 하던 일을 잠시 멈추고, 종료와 관련된 일을 처리하면 된다.

이것을 위해 ‘시그널 핸들링'이 존재한다.

시그널이란, 특정 상황이 되었을 때 OS가 프로세스에게 해당 상황이 발생했음을 알리는 메시지이다.

특정 상황에 대해 시그널을 받기 위해서는 해당 상황에 대해서 등록의 과정을 거쳐야 한다. 이를 시그널 등록이라 한다. 이때 사용되는 함수는 아래와 같다.

#include <signal.h>

void(*signal(int signo, void(*func)(int)))(int);
//시그널 발생 시 호출되도록 이전에 등록한 함수의 포인터 return

몇 가지 상황을 살펴보면,

  • SIGALRM : alarm함수호출을 통해서 등록된 시간이 된 상황
  • SIGINT : ctrl+C가 입력된 상황
  • **SIGCHLD** : 자식 프로세스가 종료된 상황

예를 들어, signal(SIGCHLD, mychild); 의 경우는 자식 프로세스가 종료되면 mychild 함수를 호출해 달라는 코드이다.

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

signal함수는 OS 별로 동작 방식에 있어 약간의 차이를 보일 수 있지만, sigaction 함수는 차이를 보이지 않기 때문에, signal 함수를 대체할 수 있고, 보다 안정적으로 동작한다.

#include <signal.h>

int sigaction(int signo, const struct sigaction* act, struct sigaction* oldact);

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

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

이를 이용해 좀비 프로세스의 생성을 막는 코드를 작성하면 아래와 같다.

...
struct sigaction act;
act.sa_handler = read_childproc;  //호출할 함수
sigemptyset(&act.sa_mask); //sa_mask의 모든 비트 0
act.sa_flags = 0;           //sa_flags 0으로 초기화
sigaction(SIGCHLD, &act, 0);
...

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

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

이전에 구현한 에코 서버는 한번에 하나의 클라이언트에게만 서비스를 제공할 수 있었다(동시에 둘 이상의 클라이언트에게 서비스 제공 불가). 이번에는 동시에 둘 이상의 클라이언트에게 서비스를 제공하는 형태로 에코 서버를 확장시켜 보자.

Untitled

클라이언트의 서비스 요청(연결요청)이 있을 때마다 에코 서버는 자식 프로세스를 생성해서 서비스를 제공한다.

연결이 하나 생성될 때마다 프로세스를 생성해서 해당 클라이언트에 대해 서비스를 제공

즉, 요청하는 클라이언트 수에 맞춰 자식 프로세스를 생성하는 것인데, 이를 위해 에코 서버는 다음과 같은 과정을 거쳐야 한다.

  1. 에코 서버(부모 프로세스)는 accept 함수 호출을 통해 연결요청을 수락한다.
  2. 이때 얻게 되는 소켓의 fd를 자식 프로세스를 생성해 넘겨준다.
  3. 자식 프로세스는 전달받은 fd를 바탕으로 서비스를 제공한다.

자식 프로세스에게 소켓의 fd를 넘기는 과정은 별도로 거칠 필요가 없는데, 자식 프로세스는 부모 프로세스가 소유하고 있는 것을 전부 복사하기 때문이다.

프로세스가 복사되는 경우 해당 프로세스에 의해 만들어진 소켓이 복사되는 것이 아니라, fd가 복사되는 것이다. 따라서, fork함수호출 후에는 서로에게 상관 없는 fd를 종료해야 한다.

...
accept(...);

pid = fork();
if(pid==0)
{
    close(serv_sock); // 상관 없는 fd닫기
    ...
}
else
    close(clnt_sock); // 상관 없는 fd닫기
...

TCP의 입출력 루틴 분할

지금까지 구현한 에코 클라이언트는 한번 데이터를 전송하면 에코 되어 돌아오는 데이터를 수신할 때까지 마냥 기다려야 한다. read와 write를 반복하는 구조이기 때문이다. 하지만, 프로세스를 생성해 데이터의 송신과 수신을 분리해보자.

Untitled

소켓은 양방향 통신이 가능하다. 따라서 위 그림과 같이 데이터 수신을 담당하는 부모 프로세스와 송신을 담담하는 자식 프로세스를 생성한다면 각각 별도로 진행시킬 수 있다.

Untitled

왼쪽은 이전 에코 클라이언트의 데이터 송수신 방식, 오른쪽은 입출력을 분리시킨 에코 클라이언트의 데이터 송수신 방식이다. 서버에서의 차이는 없다. 오른쪽의 경우, 데이터의 수신여부에 상관없이 데이터 전송이 가능하기 때문에 연속해서 데이터의 전송이 가능하다.