[열혈TCP/IP] 10 멀티프로세스 기반의 서버 구현

5 minute read

10 멀티프로세스 기반의 서버 구현

  • 프로세스 : 실행 중인 프로그램
  • 멀티프로세스 기반 서버 : 다수의 프로세스를 생성하는 방식으로 서비스 제공
    • 쿼드 코어 : 4개의 연산장치가 존재하는 CPU
    • 코어의 수만큼 프로세스를 동시에 실행시킬 수 있다.
    • 코어의 수를 넘어서는 프로세스가 생성되면, 프로세스 별로 코어에 할당되는 시간이 나뉜다.
  • 멀티플렉싱 기반 서버 : 입출력 대상을 묶어서 관리하는 방식으로 서비스 제공
  • 멀티쓰레딩 기반 서버 : 클라이언트의 수만큼 쓰레드를 생성하는 방식으로 서비스 세공

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

#include <unistd.h>

pid_t fork(void);

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

  • 부모 프로세스 : fork 함수의 반환값은 자식 프로세스의 PID
  • 자식 프로세스 : fork 함수의 반환값은 0
//부모 프로세스
int gval = 10;
int main (void){
    int lval = 20;
    lval += 5;
    gval++;
    pid_t pid=fork();
    if(pid == 0){
        gval++;
    }else{
        lval++; //실행됨
        ...
   }
}
//자식 프로세스
//gval은 11로 복사
int main (void){
    //lval은 25로 복사
    . . . .

    pid_t pid = fork();
    if(pid==0){
        gval++; //실행됨
    }else{
        lval++;
    }
}

좀비 프로세스의 생성이유

좀비 프로세스의 생성 이유를 먼저 살펴보자. fork() 함수의 호출로 생성된 자식 프로세스가 종료되는 상황 두 가지를 예로 들면 다음과 같다.

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

exit()과 main의 return에 의해서 반환되는 값 모두 운영체제로 전달된다. 그리고 운영체제는 이 값이 자식 프로세스를 생성한 부모 프로세스에게 전달될 때까지 자식 프로세스를 소멸시키지앟는다. 이 상황의 프로세스를 좀비 프로세스라고 한다. 바꿔말하면,

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

//좀비 프로세스 만들기 예제
#include <stdio.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
	pid_t pid=fork();
	
	if(pid==0)     // if Child Process
	{
		puts("Hi I'am a child process");
	}
	else
	{
		printf("Child Process ID: %d \n", pid);
		sleep(30);     // Sleep 30 sec.
	}

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

좀비 프로세스의 상태는 콘솔에서 ps au를 치고 STATUS가 z+인 것을 찾으면 된다.

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

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

  • WIFEXITED : 자식 프로세스가 정상 종료한 경우 true를 반환한다.
  • WEXITSTATUS : 자식 프로세스의 전달 값을 반환한다.
if (WIEXITED(status)){
    puts("Nornal itermination! ");
    printf("Child pass num : %d ", WEXITSTATUS(status));
}

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

waitpit() 함수는 종료를 확인하고자하는 pid를 전달하면 wait()함수와 마찬가지로 해당 프로세스가종료될 때까지 기다린다. -1을 전달하면 임의의 자식 프로세스가 종료되기를 기다린다. 3번째 param에 WHOHANG을 전달하면 종료된 자식 프로세스가 존재하지 않아도 블로킹 상태에 있지않고 0을 반환하면서 함수를 빠져나온다.

책 232페이지

10-3 시그널 핸들링

시그널 핸들링은 언제 자식 프로세스가 종료될지 몰라서 사용한다. 언제까지 계속 waitpid를 호출할 수 없기 때문이다. 그래서 시그널을 사용한다.

프로세스 : 운영체제야, 내가 생성한 자식 프로세스가 종료되면 zombie_handler라는 이름의 함수를 호출해줘
운영체제 : 알겠어, 그 상황에서 실행해야 할 문장들을 그 함수에 잘 묶어둬

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

위 신호들을 signal(SIGCHLD, mychild)와 같이 실행하면 된다. 책 236페이지

sigaction() 함수는 signal 함수의 문제점(유닉스 계열의 운영체제에서는 차이점이 있을 수 있음)을 보완하고 대체할 수 있는 함수이다.

책 239페이지

10-4 멀티테스킹 기반의 다중접속 서버

  • 서버의 부모 프로세스는 클라가 연결요청을 하면 자식 프로세스를 만들고, 자식 프로세스와 클라를 연결한다.
  • 에코 클라이언트는 서버의 부모 프로세스에게 연결 요청을 하고, 서버의 자식 프로세스랑 연결이 된다.

위 기능을 구현하는 과정은 아래와 같다.

  1. 1단계 : 에코 서버(부모 프로세스)는 accept 함수호출을 통해서 연결요청을 수락한다.
  2. 2단계 : 이때 얻게 되는 소켓의 fd를 자식 프로세스를 생성해서 넘겨준다.
  3. 3단계 : 자식 프로세스는 전달받은 fd를 바탕으로 서비스를 제공한다.
//다중접속 에코 프로그램의 클라이언트
#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);
	else 
		read_routine(sock, buf);

	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);
			return;
		}
		write(sock, buf, strlen(buf));
	}
}
void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

//멀티 태스킹 에코 프로그램의 서버
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.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);
		if(clnt_sock==-1)
			continue;
		else
			puts("new client connected...");
		pid=fork();
		if(pid==-1)
		{
			close(clnt_sock);
			continue;
		}
		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);
	}
	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);
}

서버 코드에서 accept() 이후 fork를 하기 때문에 부모와 자식 프로세스 모두 accept를 통해서 만들어진 소켓의 fd를 갖는다. fork 함수가 호출되면서 부모 프로세스의 모든 것이 복사되니 소켓도 함꼐 복사될 것 같지만, 소켓은 프로세스의 소유가 아니다. 엄밀히 말해서 운영체제의 소유이기 때문에 프로세스의 소유인 소켓의 fd만 복사된다. 소켓의 소유 주체가 운영체제인 점을 생각하지 않아도 소켓이 복사도니다면 동일한 port에 할당된 소켓이 둘 이상된다는 문제점이 있기 때문에 소켓 자체가 복사되는 것이 아닌 것을 생각할 수 있다.

하나의 소켓에 두 개의 파일 디스크립터가 존재하는 경우, 두 개의 파일디스크립터가 모두 종료(소멸)되어야 소켓이 소멸된다. 다라서 자식 프로세스가 클라와 연결되엉 있는 소켓을 소멸하려고 해도 소멸되지 않고 계속 남아있게 된다. (이는 서버 소켓도 마찬가지이다.) 그래서 fork 함수호출 후에는 서로 상관이 없는 소켓의 파일 디스크립터를 닫아줘야 한다.

위 코드에서 serv_sock은 클라의 연결요청을 받아들이는소켓이고, clnt_sock은 accept이후 반환된 클라와 연결될 때 사용되는 소켓의 fd이다. 그래서 자식 프로세스는 서버 소켓의 fd가 필요없어서 close(serv_sock)을 실행하고 다음 본인의 로직을 실행하고, else 부분에서는 close(clnt_sock)으로 부모 프로세스에서 자식 프로세스에서만 쓰이는 클라이언트 소켓 fd를 close 한다.

10-5 TCP의 입출력 투린(Rountine) 분할

클라이언트에서 read()와 write()를 동기방식으로 진행하는데 이를 클라이언트 쪽에서 fork를 통해서 클라이언트의 부모 프로세스는 read()를 하고, 클라이언트의 자식 프로세스는 write()를 하는 방식으로 코드를 짤 수 있다. 가능한 것이지 이 기능을 에코 프로그램에서 유의미한 기능을 하는 것은 아니다.

책 250 페이지