CS 프로그램/개발

TCP/IP 소켓프로그래밍 : 멀티 프로세스 기반 서버

parktest0325 2020. 9. 20. 00:12

프로세스

하드디스크에 있는 실행파일이 메모리에 로드되며 실행중인 프로그램. 

OS가 메모리에 코드, 힙, 스택 등의 영역을 할당하고 코드를 올려둔뒤 CPU에게 명령한다. 

프로세스마다 PID가 할당된다.

 

멀티프로세스

여러 프로그램을 메모리에 올린 뒤 순간마다 다른 프로세스를 처리하도록 운영체제가 CPU에게 지시하여 동시에 실행되는 것처럼 보이게 한다.

CPU에 있는 레지스터 값들도 프로세스별로 다른 값을 사용해야되기 때문에 프로세스가 스위칭될때 레지스터 값도 스위칭(컨텍스트 스위칭) 해줘야한다. 하드디스크의 SWAP 파일(임시파일)로 저장되며 프로세스가 변경될 때마다 값을 가져와서 레지스터에 담았다가 다시 뺐다가 해야된다. 

 

다중접속 서버의 구현방법 

멀티프로세스 서버 : 클라이언트가 요청할때마다 다수의 프로세스를 생성해서 서비스 제공

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

멀티쓰레딩 서버 : 클라이언트 수만큼 쓰레드를 생성

 

프로세스 생성 (fork)

pid_t fork(); 함수를 호출하면 호출한 프로세스를 그대로 복사하여 자식프로세스를 생성한다. 함수가 반환되면 부모프로세스는 자식프로세스의 pid 값을 가지고, 자식프로세스는 0이 반환된다. 둘다 fork함수 호출 이후부터 코드가 실행된다. 

int gval=10;
int main(void){
    int lval=20;
    pid_t pid=fork(); // 부모프로세스는 pid, 자식프로세스는 0 반환됨
    if(pid == 0)
        gval++; // 자식프로세스인 경우 실행
    else
        lval++; // 부모프로세스인 경우 실행
}

 

윈도우 시스템의 탐색기에서 프로세스를 실행한 경우 탐색기에서 fork 함수가 호출되며 탐색기는 부모프로세스가 되고 실행된 프로세스는 자식프로세스가 된다. 

리눅스 시스템에서도 마찬가지로 쉘에서 프로세스를 실행시키면 fork가 실행되며 자식프로세스를 만들게된다. 

 

자식프로세스 종료될때 운영체제는 자식프로세스의 종료 상태값을 가져간 뒤 자식프로세스를 그대로 둔다. 부모프로세스가 자식프로세스의 종료 사유에 해당하는 값을 획득 했을때 자식프로세스가 소멸되는데, 부모프로세스가 자식프로세스의 종료사유 값을 가져가지 않는다면 자식프로세스는 메모리에 그대로 남아 좀비프로세스가 된다.

 

만약 부모프로세스가 자식프로세스의 종료상태 값을 가져가지 않았더라도 부모프로세스가 종료되면 자식프로세스도 메모리에서 소멸된다. 

 

wait(int* statloc); 함수를 이용해서 자식프로세스의 반환값을 전달받을 수 있는데 statloc에 반환값이 세팅된다. 자식프로세스가 종료되지 않은 상태라면 종료될 때 까지 블로킹 상태에 빠진다. 

pid_t waitpid(pid_t pid, int * statloc, int options) : 성공시 종료된 자식프로세스의 PID 또는 0, 실패시 -1

 - pid : 종료를 확인하고자 하는 프로세스의 pid. -1을 전달하면 wait 처럼 자식프로세스가 종료될때까지 블록상태가됨

 - statloc : wait와 동일하다

 - options : WNOHANG 을 인자로 전달하면 종료된 자식프로세스가 없는경우 0을 반환하면서 함수를 빠져나온다.

 

WIFEXITED(status) : 자식프로세스가 정상 종료한 경우 true를 반환

WEXITSTATUS(status) : 자식프로세스의 전달값을 반환

 

 

시그널 핸들링

특정 상황이 발생했을 때 운영체제가 해당 프로세스에게 상황이 발생했음을 알리는 메세지를 시그널이라 부르며 시그널이 발생되었을 때 실행할 함수를 OS에 등록하여 OS가 해당 상황이 발생했을때 함수를 실행시키도록 한다.

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

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

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

 

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

시그널과 실행시킬 함수가 인자로 전달되며, 반환형은 int를 파라미터로 받는 void형 함수 포인터이다. 

-> 등록했던 함수의 포인터가 반환된다.

void sigproc(int sig){
    if(sig==SIGALRM)  // 동일한 함수를 시그널핸들러로 등록했기 때문에 시그널로 분기처리한다.
        puts("Time out!");
    else if(sig==SIGINT)
        puts("CTRL+C pressed");
    alarm(2);
}

int main(int argc, char *argv[]){
    int i;
    signal(SIGALRM, sigproc);
    signal(SIGINT, sigproc);
    alarm(2); // 2초마다 SIGALRM 발생
    
    for(i=0; i<3; i++){
        puts("wait...");
        sleep(100); // 100초 슬립이라 되어있지만 시그널이 발생하면 프로세스가 깨어난다.
    }
    return 0;
}

 

사실 signal함수는 표준화된 함수가 아니기 때문에 운영체제마다 다른 동작을 수행할 수 있다. 표준화 함수는 sigaction()

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

 - signo : 시그널 넘버

 - act : signo에 해당하는 시그널 발생 시 호출될 함수의 정보를 구조체로 전달

 - oldact : 이전에 등록되어있던 시그널핸들러의 함수포인터를 얻는데 사용되는 인자. 필요없다면 0을 전달

struct sigaction{
    void (*sa_handler)(int);	// 호출될 함수
    sigset_t sa_mask;		// 0으로 초기화 sigemptyset(&act.sa_mask); 를 사용함.
    int sa_flags;		// 0으로 초기화
}

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

void read_childproc(int sig){ // SIGCHLD 시그널이 발생한 경우 실행
    int status;
    pid_t id=waitpid(-1, &status, WNOHANG); // 자식프로세스의 상태 가져오기 (좀비상태 해제)
    if(WIFEXITED(status)){ // 종료된 경우 
        printf("Removed proc id: %d\n", id); // pid 출력
        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_flag=0;
    sigaction(SIGCHLD, &act, 0);
    pid=fork() ...
}

 

멀티프로세스 서버

1. 서버는 accept 함수 호출을 통해 클라이언트의 요청을 받고 연결된 소켓을 반환한다.

2. 자식 프로세스를 생성하고 소켓을 넘겨준다. (전달을 따로 해주지않아도 accept의 반환값은 동일하게 가지고있음)

3. 자식 프로세스는 전달받은 소켓을 통해 서비스를 제공한다.

 

fork는 프로세스 자체를 복사해서 자식프로세스로 만드는 것이기 때문에 부모와 동일한 코드가 실행되어왔고 환경도 동일하다. 부모프로세스는 서비스 소켓이 남아있고, 자식프로세스는 리스닝 소켓이 필요없기 때문에 각각 닫아줘야한다.

닫아주지 않으면 클라이언트에서 소켓을 닫아도 부모프로세스가 디스크립터를 가지고있기 때문에 스트림이 종료되지 않는다.

while(1){
    adr_sz=sizeof(clnt_adr);
    clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
    if(clnt_sock==-1)
        continue;
    else
        puts("new client connect...");
    pid=fork();
    if(pid == -1){ // 연결 에러
        close(clnt_sock);
        continue;
    }else if(pid == 0){ // 자식프로세스인 경우
        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); 
}

좀비프로세스 종료 로직은 윗단에 있음

 

입출력 루틴분할

기존 에코서버는 한번 입력 후 출력이 돌아올 때 까지 보낼 수 없다. 입력과 출력 로직을 분할해서 출력이 끝나지 않아도 입력을 계속할 수 있도록 할 수 있다.

소켓은 입력버퍼와 출력버퍼가 따로있어 양방향 통신이 가능하기 때문에 소켓을 나눌필요는 없다. 

채팅서버처럼 입출력 루틴을 분할하는 경우가 의미 있을 수 있고, 보낸 데이터에 대한 응답을 받고 처리하는 로직이라면 굳이 루틴을 분할하지 않아도 된다.

connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr))

pid=fork();
if(pid==0)
    write_routine(sock, buf);
else
    read_routine(sock,buf);
...

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);
            return;
        }
        write(sock, buf, strlen(buf));
    }
}

 

프로세스 통신

fork()를 사용하여 프로세스를 생성하면 각 프로세스가 할당한 메모리공간이 있는데, 서로의 메모리공간은 독립적이라 다른 프로세스의 메모리공간에 접근할 수 없다. 

그렇기 때문에 운영체제가 두프로세스 모두 접근 가능한 별도의 메모리 공간(pipe)을 할당하여 공유할 수 있게 한다.

 

fork로 자식프로세스가 만들어지기 전에 운영체제에게 요청하면 pipe라는 메모리 공간을 생성하게 되고, 이 메모리공간에 쓰기권한을 가진 파일디스크립터 1개, 읽기권한을 가진 파일디스크립터 1개도 리턴해준다. 이후 fork()를 실행하여 파일 자식 프로세스가 생성되면 파일디스크립터 2개도 복사될 것이고 pipe를 통해 통신을 수행할 수 있게 한다. 

 

int pipe(int filedes[2]) : filedes[0]은 데이터 읽기용 디스크립터, filedes[1]은 데이터 쓰기용 디스크립터 

int main(int argc, char* argv[]){
    int fds[2];
    char str[] = "who are you?";
    char buf[BUF_SIZE];
    pid_t pid;
    
    pipe(fds);
    pid=fork();
    if(pid==0){
        write(fds[1], str sizeof(str));
    }else {
        read(fds[0], buf, BUF_SIZE);
        puts(buf);
    }
    return 0;
}

자식프로세스에서 문자열을 write하고 부모프로세스에서 read 한것이다. read 함수는 블록함수이며 데이터를 읽게되면 리턴되는 함수이다. 그렇기때문에 write하고 기다려주지 않아도 부모프로세스가 읽을 수 있게 된다.

자식프로세스에서 write하고 부모프로세스에서 read 할 수 있지만, 자식프로세스에서도 read가 가능하다. 파이프는 read한 데이터는 사라지고 부모프로세스에서 자식프로세스로 데이터를 넘겨주기위해 write 후 다시 read 한다고 해도 두 프로세스의 시간적 차이 때문에 어떤 데이터가 read될지 알 수 없다. 

파이프를 통해 양방향 통신을 수행하려면, 파이프를 두개 만들어야된다. 

 

물론 부모자식간이 아니더라도 자식간 프로세스 통신도 가능하며, 운영체제 상의 모든 프로그램은 부모 프로세스가 존재한다.