Computer Science 기본 지식/소켓 프로그래밍

[TCP/IP 소켓 프로그래밍] (7-1) 다중 접속 서버 - 프로세스

로파이 2021. 3. 29. 17:35

출처 : 열혈 TCP/IP 소켓 프로그래밍 윤성우 저

 

둘 이상의 클라이언트에게 서비스를 제공하기 위한 다중 접속 서버 구현이 필요하다.

 

다중 접속 서버 구현을 위한 방법

1. 멀티프로세스 기반 서버 : 다수의 프로세스를 생성하는 방식

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

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

 

먼저 멀티 프로세스 기반 서버를 알아본다.

본 예제는 리눅스 기반 코드이다.

 

프로세스란

(운영체제에 등록되어) 메모리 공간을 차지한 상태에서 실행중인 프로그램을 의미한다.

 

- fork 함수

pid fork(void);

성공 시 프로세스 ID, 실패 시 -1 반환

 

fork 함수는 호출한 프로세스의 복사본을 생성한다. 기존 프로세스에 할당한 메모리를 모두 복사하고 자식 프로세스로써 새로운 프로세스를 생성한다.

 

fork.cpp

#include <cstdio>
#include <unistd.h>

int g_val = 10;
int main(int argc, char**argv)
{
    pid_t pid;
    int l_val = 20;

    pid = fork();
    if(pid == 0) // if child process
    {
        ++g_val;
        ++l_val;
    }
    else{// if parent process
        --g_val;
        --l_val;
    }
    
    if(pid == 0)
    {
        printf("Child Proc: [%d, %d]\n", g_val, l_val);
    }
    else{
        printf("Parent Proc: [%d, %d]\n", g_val, l_val);
    }
    return 0;
}

fork()를 분기로 하여금 if문에서 pid == 0일 경우 자식 프로세스의 실행영역이고 그렇지 않다면 부모 프로세스의 실행영역이다.

위를 실행한다면 다음과 같이 출력되어 각 프로세스의 다른 실행 흐름을 보여준다.

 

- 좀비 프로세스

좀비 프로세스는 생성된 프로세스가 자원을 반납하고 사라지지 않아 시스템 상에 남아있는 프로세스를 가리킨다.

main()을 엔트리로 갖는 프로세스의 종료조건은 exit() 함수 혹은 return을 통해 프로세스가 종료되는데, 이 때 exit(int) 함수 인자 혹은 return 반환값이 운영체제로 전달되어 부모 프로세스에 전달된다. 만약 종료 함수를 호출했더하더라도 이 값이 부모 프로세스에게 전달되지 않으면 프로세스가 해제되지 않는다.

 

- wait 함수

pid_t wait(int* startloc);
  • 성공 시 종료된 자식 프로세스 ID, 실패 시 -1
  • wait 함수는 이미 종료된 자식 프로세스가 있다면, 자식 프로세스의 반환값이 startloc에 저장된다.
  • startloc에는 종료된 프로세스의 복합적인 정보가 담겨 있으니 다음 함수로 종료 반환 인자를 추출할 수 있다.

wait 함수는 블로킹 함수로 현재 종료된 자식 프로세스가 없다면, 블로킹 상태로 대기한다.

  • WIFEXITED: 자식 프로세스가 정상 종료한 경우 true를 반환
  • WEXITSTATUS: 자식 프로세스의 반환값
if(WIFEXITED(status))
{
	puts("Normal Termination");
	printf("Child pass num: %d", WEXITSTATUS(status));
}
  • 두 개의 자식 프로세스를 생성하고 두 프로세스의 반환값을 얻어 출력한다.

wait.cpp

#include <cstdio>
#include <cstdlib>
#include <unistd.h>
#include <sys/wait.h>

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

    // first child process
    if(pid == 0)
    {
        return 3;
    }
    else
    {
        printf("Child PID: %d \n", pid);
        pid = fork();

        // second child process
        if(pid == 0)
        {
            exit(7);
        }
        // parent process
        else
        {
            printf("Child PID: %d \n", pid);
            
            // catches first child process
            wait(&status);
            if(WIFEXITED(status))
            {
                printf("Child send one: %d \n", WEXITSTATUS(status));
            }

            // catches second child process
            wait(&status);
            if(WIFEXITED(status))
            {
                printf("Child send two: %d \n", WEXITSTATUS(status));
            }

            sleep(30);
        }
    }

    return 0;
}

 

- waitpid 함수

pid_t waitpid(pid_t pid, int *startloc, int options);
  • 성공 시 종료된 자식 프로세스의 pid 실패 시 -1
  • pid: 종료를 확인하고자하는 프로세스 ID, -1 전달시 임의의 프로세스를 관측.

waitpid 함수는 wait 함수의 블로킹 상태를 없앨 수 있다. options 인자에 WNOHANG을 전달하면, 종료된 자식 프로세스가 존재하지 않아도 블로킹 상태에 있지 않고, 0을 반환하면서 함수를 빠져나온다.

 

waitpid.cpp

#include <cstdio>
#include <cstdlib>
#include <unistd.h>
#include <sys/wait.h>

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

    // first child process
    if(pid == 0)
    {
        sleep(5);
        return 24;
    }
    else
    {
        // 자식 프로세스 종료시 까지 대기
        while(!waitpid(-1, &status, WNOHANG))
        {
        }

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

    return 0;
}

시그널 핸들링

waitpid의 예시 코드에서 블로킹 상태에 있지 않음에도 결국 자식 프로세스가 종료될 때까지 기다려야 한다. 이렇게 현재 자식 프로세스가 종료되었는 지 계속 확인하는 busy-waiting 문제로 부모 프로세스는 CPU 자원을 사용하여 결과를 확인해야한다.

 

부모 프로세스를 잠시 쉬게했다가 자식 프로세스가 종료되었을 때 신호를 주어 자식 프로세스가 종료되었음을 알려주고 그 때 깨어나도록 하는 것이 시그널 핸들링이라고 한다.

 

프로세스는 운영체제에게 시그널 핸들링을 유발(Trigger)하는 신호 정보와 콜백 함수을 설정하여 시그널을 등록한다.

 

- signal 함수

#include <signal.h>

void (*signal(int signo, void (*func)(int))) (int);

시그널 등록 함수 signal은 함수의 반환 값을 void (int) 형의 함수 포인터로 지정하고 있다.

  • signo: 시그널 발동 조건
  • func: void (int) 형 함수 포인터로 시그널 핸들링으로 호출할 콜백 함수e

대표적이 시그널 발동 조건(상황)으로 전달할 수 있는 값이다.

  • SIGALRM: alaram 호출을 통해 등록된 시간이 만료된 상황
  • SIGINT: CTRL+C가 입력된 상황
  • SIGCHILD: 자식 프로세스가 종료된 상황

자식 프로세스가 종료할 때, void (int)형 함수 myfunc을 호출하고 싶다면 다음과 같이 시그널을 등록한다.

signal(SIGCHLD, myfunc);

 

- alarm 함수

unsigned int alarm(unsigned int seconds);

등록한 seconds 시간이 지나면 SIGALARM을 발생시키는 함수이다.

 

signal.cpp

#include <cstdio>
#include <unistd.h>
#include <signal.h>

void timeout(int sig)
{
    if(sig == SIGALRM)
        puts("Time out!");
    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);

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

실행 결과

메인 함수에서 wait... 출력 후 100초 동안 sleep 상태에 있을 거라 예상되지만 실제로는 alarm(2)의 3번의 호출로 block 상태에 있던 프로세스가 wakeup 하게 된다. timeout 함수를 보면 내부에서 alarm(2)를 호출하기 때문에 한 번 시그널 핸들링으로 timeout이 호출되면 alaram 설정이 되기 때문에 연쇄적으로 2초마다 프로세스를 깨운다.

 

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

sigaction 함수를 이용하여 시그널을 등록할 수 있다.

int sigaction(int signo, const struct sigaction* act, struct sigaction* oldact);
  • 성공 시 0, 실패 시 -1 
  • signo: 등록할 신호 정보
  • act: 시그널 발생시 호출할 함수
  • oldact: 이전에 등록되었던 함수, 없다면 0
struct sigaction
{
	void (*sa_handler)(int);
	sigset_t sa_mask;
	int sa_flags;
}

sigaction 구조체에 sa_handler가 siganl 함수에서 등록했던 콜백 함수를 대체하고 있다.

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;
}

 

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

remove_zombie.cpp

더보기
#include <cstdio>
#include <cstdlib>
#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("Removed proc id : %d\n", id);
        printf("Child send: %d \n", WEXITSTATUS(status));
    }
}

int main(int argc, char *argv[])
{
    pid_t pid;
    struct sigaction act;
    act.sa_handler = read_childproc;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;
    sigaction(SIGCHLD, & act, 0);

    pid = fork();
    if(pid == 0) // the first child process
    {
        puts("Hi! I am the first child proces");
        sleep(10);
        return 12;
    }
    else // parent process
    {
        printf("Child proc id: %d \n", pid);
        pid = fork();
        if(pid == 0) // the second child process
        {
            puts("Hi! I am the second child process");
            sleep(10);
            exit(24);
        }
        else
        {
            int i;
            printf("Child proc id: %d \n", pid);
            for(i=0;i<5;++i)
            {
                puts("wait...");
                sleep(5);
            }
        }
    }
    return 0;
}

자식 프로세스가 소멸할 때마다 read_childproc()를 호출하고 waitpid를 통해 함수 반환 인자를 알 수 있게 된다.