수업/Network Programming

[Network Programming] MultiProcess 기반 서버

hw-ani 2023. 5. 29. 01:32

앞서 배운 iterative server는 동시 접속을 처리하지 못한다.

동시 접속을 처리하기 위해선 아래 기법들을 사용할 수 있다.

    1. MultiProcess 기반 서버

    2. MultiPlexing 기반 서버

    3. MultiThreading 기반 서버

 

우린 우선 멀티 프로세스 기반 서버에 대해 배운다.

프로세스란 실행중인 프로그램으로, 그것과 관련된 메모리, 리소스 등을 총칭하는 의미이다.

각 프로세스는 고유의 ID를 가지고 있다. (process id == pid)

 

 

 

fork

#include <unistd.h>

pid_t fork(void);

fork() 함수를 호출하면 fork 함수의 "return값을 제외한" 해당 프로세스의 모~든 것이 복제되어 새로운 프로세스가 만들어진다. 즉, 변수의 값은 물론이고 source code나 PC resgister의 값도 복사되기 때문에 fork() 호출 이후로 같은 source code가 두개의 process에서 동시에 실행된다. 이때 보통 우리는 fork()의 return 값을 이용해 조건문으로 두 부모자식 process가 다른 일을 하도록 한다.

 

성공 시 부모 프로세스에선 자식 프로세스의 pid자식 프로세스에선 0을 반환한다.

실패 시 -1을 반환한다.

 

 

 

> 좀비 프로세스(Zombie process)란?

실행이 완료됐음에도 리소스가 여전히 메모리 공간에 남아있는 프로세스를 말한다. 부모 프로세스가 종료되면 좀비 상태로 있던 자식 프로세스도 함께 소멸되긴 하지만, 좀비 프로세스를 남겨 두는 것은 메모리 낭비이다.

좀비 프로세스가 생성되는 대표적인 이유는, 자식 프로세스가 부모 프로세스에게 반환값을 전달하지 않는 경우이다.

즉, child process의 return 문의 값이나 exit()의 인자가 parent process로 전달돼야한다.

child process의 반환값을 parent process가 받기 위해선 크게 두가지 방법이 있다.

 

 

 

 

좀비 프로세스 소멸 1 : wait

#include <sys/wait.h>

pid_t wait(int * status);

첫번째 인자로 자식 프로세스의 종료 상태를 받아올 변수의 주소를 전달한다. 종료 상태가 필요없다면 NULL을 준다.

성공 시 process id 를, 실패 시 -1 을 반환한다.

 

 

int status;
pid_t pid = fork();   //fork!!

if (pid == 0)         //자식 프로세스라면..
    return 3;
else {                //부모 프로세스라면..
    pid = fork();     //한번 더 fork!!
    if (pid == 0)     //자식 프로세스라면..
        exit(7);
    else {
        wait(&status); //wait 함수로 자식이 종료될때까지 block
        if (WIFEXITED(status))  //첫번째로 종료된 child 반환값 출력
            printf("Child send one: %d\n", WEXITSTATUS(status));
        
        wait(&status); //wait 함수로 자식이 종료될때까지 block, 자식이 두 놈있으니
        if (WIFEXITED(status))  //두번째로 종료된 child 반환값 출력
            printf("Child send two: %d\n", WEXITSTATUS(status));
    }
}

//출력 결과
//Child send one: 3
//Child send two: 7

wait() 함수를 호출하면, child process가 종료될 때까지 기다리다(blocking 상태!!). 특정 child를 기다리는게 아니라 임의의 child가 종료되길 기다린다.

status 변수에 담긴 내용은 다양한 macro 함수를 통해 해석할 수 있는데, 여기선 두가지 macro 함수를 사용했다.

 

WIFEXITED(status) : child process가 정상 종료한 경우 true를 반환한다.

WEXITSTATUS(status) : child process의 전달 값을 반환한다.

 

Q. 그냥 status 값을 바로 test하면 되지 않나? 이런 macro 함수를 쓰는 이유는?
A. 저 int 값 안엔 딱 return value만 드가는게 아니라 다양한 정보가 포함되기 때문이다. 링크

 

 

 

 

 

좀비 프로세스 소멸 2 : waitpid

#include <sys/wait.h>

pid_t waitpid(pid_t pid, int * status, int options);

wait() 함수의 단점은 무조건 블로킹 상태에 놓인다는 점이었다. child가 끝날 때까지 아무것도 못한다.

하지만 waitpid() 함수를 쓰면 블로킹 상태에 놓이지 않게 할 수 있다.

 

첫번째 인자로 종료를 확인하고자하는 process의 ID를 전달한다. -1을 전달하면 wait처럼 임의의 프로세스가 종료하길 기다린다.

두번째 인자는 wait() 함수의 인자와 같은 역할을 한다.

세번째 인자로 sys/wait.h에 정의된 상수 WNOHANG을 전달하면 종료된 child process가 없어도 블로킹 상태에 있지 않고 0을 반환하며 함수를 빠져나온다.

이 옵션을 줘야 블로킹이 안되는 듯.. 기본적으론 wait()처럼 블로킹 되는 것 같고...

 

즉, 당장 종료된 child process가 없다면 일단 넘어가기 때문에 종료된 child process를 찾을 때까지 주기적으로 waitpid() 함수를 호출해주긴 해야한다. wait()랑 비교했을 때 장점은 blocking이 안되기 때문에 다른 일도 조금씩 할 수 있는거지 않을까...? 그래도 좀비 프로세스를 막으려면 반복해서 waitpid(~,~,WNOHANG) 을 호출해줘야 한다는 번거로움(?)이 있긴 하다.

 

 

int main(int argc, char *argv[]) {
    int status;
    
    pid_t pid = fork();  //fork!!
    if (pid == 0) {  //child process라면..
        sleep(15);
        return 24;
    }
    else {           //parent process라면..
        //주기적인 waitpid()로 child process 종료 확인
        while (!waitpid(-1, &status, WNOHANG) {
            sleep(3);
            puts("sleep 3sec.");
        }
        
        if (WIFEXITED(status))
            printf("Child send %d\n", WEXITSTATUS(status));
    }
    return 0;
}

//출력 결과
//sleep 3sec.
//sleep 3sec.
//sleep 3sec.
//sleep 3sec.
//sleep 3sec.
//Child send 24

 

 

 

 

 


Signal Handling

 

> 시그널이란?

특정 상황에 OS가 process에게 해당 상황이 발생했음을 알리는 일종의 메시지이다.

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

    SIGINT     : CTRL+C가 입력된 상황

    SIGCHLD : child process가 종료된 상황

 

특정 Signal이 발생했을 때, process별로 대응 방식을 지정할 수 있다.

 

 

signal

#include <signal.h>

void (*signal(int signo, void (*func)(int)))(int);
//return type void이고 parameter int 하나인 함수 포인터를 반환하네

signal 함수를 통해 특정 signal이 발생했을 때 우리가 정의한 함수를 호출하도록 할 수 있다.

첫번째 인자로 처리할 signal의 번호를 전달한다. 보통 SIGCHLD나 SIGINT 같은 macro 상수로 전달한다.

두번째 인자로 해당 signal이 발생했을 때 호출할 함수의 포인터를 전달한다. 참고로 이때 전달하는 함수는 void를 반환하고 int 하나를 인자로 가져야 한다. signal이 발생하면 handler 함수의 첫번째 argument로 해당 signal 번호가 들어간다.

 

성공 시 이전에 등록됐던 함수의 포인터를 반환한다.

실패 시 SIG_ERR을 반환한다.

 

signal(SIGINT, keycontrol); 로 signal() 함수를 호출하면, SIGINT signal이 발생했을 때 자동으로 keycontrol 함수가 호출된다.

 

void timeout(int sig) {
    if (sig == SIGALRM)
        puts("Time out!");
    alarm(2);
}
. . .
int main(int argc, char *argv[]) {
    signal(SIGALRM, timeout);
    alram(2);
    . . .

 

그런데 signal 함수는 OS마다 동작의 차이가 있을 수 있어서 아래에 설명하는 sigaction을 주로 쓴다고 한다.
signal은 과거 프로그램과 호환성을 유지하기 위해 제공된다.

 

 

 

 

sigaction

#include <signal.h>

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

signal 함수와 크게 다를건 없다. 두번째 인자에 구조체가 들어가는 것과 기존 반환 값을 아니라 arguemnt로 가져오는 것 정도...

첫번째 인자로 처리할 signal의 번호를 전달한다.

두번째 인자로 해당 signal 발생시 호출될 함수(signal handler)의 정보를 전달한다.

세번째 인자는 이전에 등록된 signal handler의 함수 포인터를 얻는데 사용하는 인자이다. 필요없다면 0을 전달한다.

 

성공 시 0, 실패 시 -1 을 반환한다.

 

두번째/세번째 인자에 사용된 구조체 선언

struct sigaction {
    void (*sa_handler)(int); //handler 함수
    sigset_t sa_mask;        //그냥 0으로 초기화
    int sa_flags;            //그냥 0으로 초기화
}

sa_mask와 sa_flags는 먼가 추가적인 목적에서 사용되는데 우리는 일단 0으로 초기화하고 쓴다.

따라서 사실상 signal 함수와 비교했을 때 handler를 구조체 안에 한번 더 넣어야하는게 번거로운거지 말곤 다를게 없다. 마지막 인자도 보통 0으로 줄거니까...

 

 

 

> sigaction()을 이용한 좀비 프로세스 소멸 예제 코드

void sig_handler(int sig) {
    int status;
    pid_t id = waitpid(-1, &status, WNOHANG);  //waitpid()!!
    if (WIFEXITED(status)) {
        printf("Removed process id: %d\n", id);
        printf("Child send: %d\n", WEXITSTATUS(status));
    }
}

int main(int argc, char *argv[]) {
    pid_t pid;
    struct sigaction act;       //sigaction을 위한 구조체!!
    act.sa_handler = sig_handler;
    sigemptyset(&act.sa_mask);  //sa_mask는 sigemptyset으로!!
    act.sa_flags = 0;
    sigaction(SIGCHILD, &act, 0);  //signal handler 등록!!
    . . .

마지막 line에서 sigaction으로 SIGCHILD의 handler를 등록했으므로, 이제 자식 프로세스가 종료되면 sig_handler 함수가 호출되고, waitpid()를 만나서 좀비 프로세스가 안되고 잘 종료될 것이다.(왜 wait() 말고 waitpid()를 쓴건진 모르겠네)

또 하나 더 짚을 점은 struct sigaction 구조체의 sa_mask 멤버를 0으로 초기화할 땐 sigemptyset 함수를 써야 한다는 것이다. 내부 구조가 어떤진 몰라도 일단 그냥 외우고 알고 있자.

 

 

 

 

 

 


MultiProcess 기반 다중 접속 서버

이제 multiProcess 기반 서버를 만드는데 배경 지식은 다 배웠다.

 

개념적인 부분만 먼저 말로 해보자면,

1. socket() 생성 후, bind()를 해두고 listen() 상태로 돌린다.

2. client가 연결 요청을 해서 accept()가 발생하면! fork()를 통해 두 프로세스로 나눈다.

3. process가 그대로 복사되므로, 초기엔 두 프로세스에 listening socket과 client socket 모두 존재한다.

4. parent는 계속해서 연결 요청을 처리하도록 하기 위해 client socket은 닫고, 다시  연결 요청을 처리하러 accept() 로 간다. (!구현! accept() 부터 포함되게 큰 반복문을 하나 만들면 된다.)

5. child는 client와의 통신을 처리하기 위해 listening socket은 닫고 데이터를 주고 받는다.

+ waitpid()가 있는 handler를 SIGCHILD signal을 처리할 수 있도록 sigaction으로 등록해둔다.(그래야 블로킹 없이 좀비프로세스를 방지할 수 있다.)

 

 

Q. parent에서 client socket을 닫으면 FIN이 가는 것 아닌가요?
A. 

 

 

//multiProcess 기반 다중 접속 echo Server
//without signal handling ver.
//위에서 본 자식 프로세스 종료에 따른 처리 과정도 위에 추가해야함.

. . .
while (1) {
    adr_size = sizeof(clnt_adr);
    clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &adr_size);
    if (clnt_sock == -1)  continue;  //accpet 오류 처리!!
    else                  puts("new client connected...");
    
    pid = fork();     //fork!!
    if (pid == -1) {  //error
        close(clnt_sock);
        continue;
    }
    if (pid == 0) {   //child process -> communication 담당
        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) 하러 간다.
}
. . .

이 코드는 좀비 프로세스를 막기 위해 wiatpid()와 sigaction() 함수를 호출하는건 보여주지 않고, 일부분만 보여준단 것에 주의하자. ※

 

 

 

 

 


입출력 루틴 분할

입력과 출력을 각각 다른 프로세스에서 하도록 하면, 보내고 받는 구조가 아니라 이를 동시에 진행하는 것이 가능하다. 그렇게 함으로써 구현도 비교적 편하게 할 수 있고, 입출력을 동시에 진행하므로 속도 향상도 기대할 수 있다.

물론 interactive(상호작용) 방식의 데이터 송수신이 진행된다면 이런 구조가 의미 없을 수도 있다.

 

구현 방식은 그냥 fork()를 해서 같은 socket에 대해 한 프로세스는 read()만 진행하고, 한 프로세스는 write()만 진행하는 것이다. 그게 가능한가 본인이 썼다가 본인이 다시 읽는 경우가 생길 수도 있지 않나 싶은데, 한 소켓에 대해 입출력 버퍼는 분리돼있으므로 한 소켓에 대해 입출력을 동시에 진행해도 전혀 문제없다. (file로 취급되는거지 진짜 file이랑 같진 않음. 파일처럼 한 소켓에 대해 write()로 출력한 내용을 그 소켓에 대해 read()해서 다시 읽어올 순 없다. write하면 목적지 socket으로 날아가버리지...)

자세한 코드는 강의자료 nsp-10 25페이지 혹은 책 참고.