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

10-1 : 프로세스의 이해와 활용

다중접속 서버의 구현 모델

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

Windows에서는 멀티프로세스 기반의 서버 구현을 지원하지 않음

프로세스(Process) : 메모리 공간을 차지한 상태에서 실행중인 프로그램
모든 프로세스는 운영체제로부터 프로세스 ID를 부여받음

  • 1번은 운영체제가 시작되자마자 실행되는 프로세스에 할당
  • 따라서 프로세스 ID는 2 이상의 정수 형태를 띔
  • ps 명령어로 PID를 확인할 수 있음

프로세스의 생성에는 fork() 함수를 사용

#include <unistd.h>

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

fork() 함수는 호출한 프로세스의 복사본을 생성
생성된 프로세스는 함수를 호출한 이후(fork() 함수의 반환 이후)의 문장을 실행하게됨

  • 부모 프로세스에서 fork() 함수 반환값은 자식 프로세스의 PID
  • 자식 프로세스의 fork() 함수 반환값은 0

fork() 함수를 사용한 예시

// fork.cpp

#include <iostream>
#include <unistd.h>

using namespace std;

int gval = 10;

int	main(int argc, char *argv[])
{
	pid_t	pid;
	int		lval = 20;
	gval++, lval += 5;

	pid = fork();
	if (pid == 0)	// child
		gval += 2, lval += 2;
	else 			// parent
		gval -= 2, lval -= 2;
	
	if (pid == 0)
		cout << "Child Proc : [" << gval << ", " << lval << "]\n";
	else
		cout << "Parent Proc : [" << gval << ", " << lval << "]\n";

	return 0;
}

10-2 : 프로세스 & 좀비(Zombie) 프로세스

좀비 프로세스 : main() 함수의 실행을 완료한 이후에도 사라지지 않고 시스템의 중요한 리소스를 차지하는 프로세스

프로세스가 종료되는 상황

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

부모 프로세스는 자식 프로세스로부터 exit() 함수의 인자값이나 return 문의 반환값이 전달되어야만 자식 프로세스를 소멸시킴
따라서 부모 프로세스가 자식 프로세스에게 해당 값을 함수 호출을 통해 요청하여야함

좀비 프로세스 예시

// zombie.cpp

#include <iostream>
#include <unistd.h>

using namespace std;

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

	if (pid == 0)	// child
		cout << "Hi, I'm a child process\n";
	else
	{
		cout << "Child Process ID : " << pid << endl;
		sleep(30);
	}

	if (pid == 0)
		cout << "End Child process\n";
	else
		cout << "End parent process\n";
	
	return 0;
}

후면처리(Background Processing)을 통해 프로그램을 후면에서 실행시킬 수 있음

  • &의 입력으로 후면처리 유도
  • 프로그램의 실행과는 별도로 다음 명령을 추가로 입력할 수 있음


자식 프로세스의 전달값은 함수를 통해 요청할 수 있음

#include <sys/wait.h>

pid_t wait(int *statloc);
// 성공시 종료된 자식 PID, 실패시 -1 반환)

wait() 함수 호출시 자식 프로세스가 종료되어있다면 해당 프로세스가 종료되면서 전달한 값이 매개변수로 전달된 주소의 변수에 저장됨
단, 해당 변수에는 자식 프로세스가 종료되면서 전달된 값 외에 존재하는 다른 정보들이 있기 때문에 WIFEXTEDWEXITSTATUS 매크로 함수를 통해 분리해주어야함

  • WIFEXITED : 자식 프로세스가 정상 종료된 경우 TRUE 반환
  • WEXITSTATUS : 자식 프로세스의 전달 값 반환

wait() 함수를 사용한 예시

// wait.cpp

#include <iostream>
#include <unistd.h>
#include <sys/wait.h>

using namespace std;

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

	if (pid == 0)
		return 3;
	else
	{
		cout << "Child PID : " << pid << endl;
		pid = fork();
		if (pid == 0)
			exit(7);
		else
		{
			cout << "Child PID : " << pid << endl;
			wait(&status);
			if (WIFEXITED(status))
				cout << "Child send one : " << WEXITSTATUS(status) << endl;

			wait(&status);
			if (WIFEXITED(status))
				cout << "Child send two : " << WEXITSTATUS(status) << endl;
			sleep(30);
		}
	}
	return 0;
}


단, wait() 함수 호출 시점에서 종료된 자식 프로세스가 없을 경우 임의의 자식 프로세스가 종료될 때까지 블로킹 상태가 됨에 유의해야함
이러한 블로킹 문제를 해결하기 위해 대신 waitpid() 함수를 사용할 수 있음

#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *statloc, int options);
// 성공시 종료된 자식 PID 또는 0, 실패시 -1 반환

waitpid() 함수를 사용한 예시

// waitpid.cpp

#include <iostream>
#include <unistd.h>
#include <sys/wait.h>

using namespace std;

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

	if (pid == 0)
	{
		sleep(15);
		return 24;
	}
	else
	{
		while (!waitpid(-1, &status, WNOHANG))
		{
			sleep(3);
			cout << "sleep 3sec.\n";
		}

		if (WIFEXITED(status))
			cout << "Child send " << WEXITSTATUS(status) << endl;
	}
	return 0;
}

10-3 : 시그널 핸들링

부모 프로세스가 항상 waitpid() 함수를 호출하고있을 수는 없으므로 운영체제가 자식 프로세스의 종료를 인식하여 부모 프로세스에게 알려줌
이를 시그널 핸들링(Signal Handling)이라고 함

  • 시그널 : 특정 상황이 발생했음을 알리기 위해 운영체제가 프로세스에게 전달하는 메시지
  • 핸들링 : 메시지에 반응해서 미리 정의된 작업을 진행

시그널 발생시 특정 함수를 호출하는데는 signal() 함수를 사용

#incldue <signal.h>

void (*signal(int signo, void (*func)(int)))(int);
// 시그널 발생시 호출되도록 이전에 등록된 함수의 포인터 반환
  • 첫 번째 인자 signo : 특정 상황에 대한 정보, SIGALRM / SIGINT / SIGCHLD 등이 존재
  • 두 번째 인자 함수포인터 : 호출될 함수의 주소값

alarm() 함수로 SIGALRM 시그널을 발생시킬 수 있음

#include <unistd.h>

unsigned int alarm(unsigned int seconds);
// 0 또는 SIGALRM 시그널이 발생하기까지 남아있는 시간을 초 단위로 반환
  • 인자로 양의 정수를 전달시 : 전달된 수에 해당하는 시간이 지나면 SIGALRM 시그널 발생
  • 인자로 0을 전달시 : 이전에 설정된 SIGALRM 시그널 발생 예약을 취소

시그널 핸들링 예시

// signal.cpp

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

using namespace std;

void	timeout(int sig)
{
	if (sig == SIGALRM)
		cout << "Time out!" << endl;
	alarm(2);
}

void	keycontrol(int sig)
{
	if (sig == SIGINT)
		cout << "CTRL+C pressed" << endl;
}

int		main(int argc, char *argv[])
{
	signal(SIGALRM, timeout);
	signal(SIGINT, keycontrol);
	alarm(2);

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

시그널 발생시 sleep() 함수로 인해 블로킹 상태에 있던 프로세스가 깨어남에 유의


signal() 함수는 유닉스 계열 운영체제별로 동작방식에 차이를 보일 수 있기 때문에 안정적으로 동작하는 sigaction() 함수의 사용이 권장됨

#include <signal.h>

int sigaction(int signo, const struct sigaction *act, struct stigaction *oldact);
// 성공시 0, 실패시 -1 반환

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

sigaction() 함수 사용 예제

// sigaction.cpp

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

using namespace std;

void	timeout(int sig)
{
	if (sig == SIGALRM)
		cout << "Time out!" << endl;
	alarm(2);
}

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

	alarm(2);

	for (int i = 0; i < 3; i++)
	{
		cout << "Wait..." << endl;
		sleep(100);
	}
	return 0;
}


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

// remove_zombie.cpp

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>

using namespace std;

void	read_childproc(int sig)
{
	int		status;
	pid_t	id = waitpid(-1, &status, WNOHANG);

	if (WIFEXITED(status))
	{
		cout << "Removed proc id : " << id << endl;
		cout << "Child send : " << WEXITSTATUS(status) << endl;
	}
}

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)
	{
		cout << "Hi! I'm child1 process" << endl;
		sleep(10);
		return 12;
	}
	else
	{
		cout << "Child proc id : " << pid << endl;
		pid = fork();
		if (pid == 0)
		{
			cout << "Hi! I'm child2 process" << endl;
			sleep(10);
			exit(24);
		}
		else
		{
			cout << "Child proc id : " << pid << endl;
			for (int i = 0; i < 5; i++)
			{
				cout << "Wait..." << endl;
				sleep(5);
			}
		}
	}
	return 0;
}

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

클라이언트의 연결요청이 있을 때마다 서버에서는 자식 프로세스를 생성해서 서비스를 제공하도록 에코 서버를 재구성

  1. 에코 서버(부모 프로세스)는 accept() 함수호출을 통해 연결요청을 수락
  2. 자식 프로세스를 생성해 1에서 얻은 소켓의 파일 디스크립터를 넘겨줌
  3. 자식 프로세스는 전달받은 파일 디스크립터를 바탕으로 서비스 제공

다중접속 에코 서버 예시

// echo_mpserver.c
// 클라이언트는 이전의 echo_client.cpp 사용

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>

using namespace std;

#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)
	{
		cout << "Usage : " << argv[0] << " <port>\n";
		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, (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
			cout << "new client connected..." << endl;
		
		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);
			cout << "client disconnected..." << endl;
			return 0;
		}
		else
			close(clnt_sock);
	}
	close(serv_sock);
	return 0;
}

void	read_childproc(int sig)
{
	int		status;
	pid_t	pid = waitpid(-1, &status, WNOHANG);

	if (WIFEXITED(status))
		cout << "Removed proc id : " << pid << endl;
}

void	error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

fork() 함수 호출을 통해 파일 디스크립터 복사시 소켓 자체가 복사되는 것이 아님에 유의
하나의 소켓에 둘 이상의 파일 디스크립터가 존재하는 경우, 모든 파일 디스크립터가 종료되어야 소켓이 소멸될 수 있음
따라서 fork() 함수의 호출 후에 서로 상관없는 소켓의 파일 디스크립터를 닫아주는 작업이 필요


10-5 : TCP의 입출력 루틴(Routine) 분할

입출력 프로세스가 다른 경우 서버로부터의 데이터 수신여부에 상관없이 데이터를 전송할 수 있음
따라서 클라이언트의 부모 프로세스는 데이터의 수신을, 자식 프로세스는 데이터의 송신을 담당하도록 구현

  • 이 경우 각각의 영역에서 수신 또는 송신에 관한 코드만 작성하면 되기 때문에 코드의 구현이 수월해짐
  • 또한 클라이언트가 데이터 수신여부에 관계없이 데이터를 전송하기 때문에 동일한 시간 내에서 데이터 송수신 분량이 상대적으로 많음

에코 클라이언트의 루틴 분할 예시

// echo_mpclient.cpp

#include <iostream>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

using namespace std;

#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)
	{
		cout << "Usage : " << argv[0] << " <IP> <port>\n";
		exit(1);
	}

	sock = socket(PF_INET, SOCK_STREAM, 0);
	if (sock == -1)
		error_handling("socket() error");

	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, (sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
		error_handling("connect() error");
	else
		cout << "Connected.........\n";

	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;
		cout << "Message from server : " << buf << endl;
	}
}

void	write_routine(int sock, char *buf)
{
	while (1)
	{
		cin.getline(buf, BUF_SIZE);

		if (!strcmp(buf, "q") || !strcmp(buf, "Q"))
		{
			shutdown(sock, SHUT_WR);
			return;
		}
		write(sock, buf, strlen(buf));
	}
}

void	error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

내용 확인문제

  1. 다음 중 프로세스에 대한 설명으로 옳지 않은 것을 모두 고르면?
    • 프로세스는 운영체제의 관점에서 프로그램의 실행 단위가 된다. (O)
    • 프로세스도 생성방식에 따라서 부모와 자식의 관계를 갖는다. (O)
    • 프로세스는 다른 프로세스를 포함할 수 있다. 즉, 하나의 프로세스는 자신의 메모리 영역에 또 다른 프로세스를 포함할 수 있다. (X)
    • 자식 프로세스는 또 다른 자식 프로세스를 생성할 수 있고, 이렇게 생성된 자식 프로세스 역시 또 다른 자식 프로세스를 생성할 수 있지만, 이들은 모두 하나의 프로세스와만 부모자식의 관계를 형성한다. (X)
  2. fork 함수가 호출되면 자식 프로세스가 생성되는데, 이 자식 프로세스의 특징으로 옳지 않은 것을 모두 고르면?
    • 부모 프로세스가 소멸되면 자식 프로세스도 소멸된다. (X)
    • 자식 프로세스는 부모 프로세스의 모든 것을 복사해서 생성되는 프로세스이다. (O)
    • 부모 프로세스와 자식 프로세스는 전역으로 선언되는 변수를 공유한다. (X)
    • fork 함수 호출로 생성된 자식 프로세스는 부모 프로세스가 실행한 코드를 처음부터 fork 함수가 호출된 위치까지 실행해온다. (X)
  3. 자식 프로세스가 생성되면 부모 프로세스의 모든 것을 복사하는데, 이 때 복사의 대상으로는 소켓의 파일 디스크립터도 포함이 된다. 그렇다면 복사된 파일 디스크립터의 정수 값은 원본 파일 디스크립터의 정수 값과 동일한지 확인하기 위한 프로그램을 작성해보자.

     // fork_checkfd.cpp
    
     #include <iostream>
     #include <unistd.h>
     #include <arpa/inet.h>
     #include <sys/socket.h>
    
     using namespace std;
    
     int gval = 10;
    
     int	main(int argc, char *argv[])
     {
         pid_t	pid;
         int		sock;
         sock = socket(PF_INET, SOCK_STREAM, 0);
    
         pid = fork();
         if (pid == 0)	// child
             cout << "in child socket fd : " << sock << endl;
         else 			// parent
             cout << "in parent socket fd : " << sock << endl;
    
         return 0;
     }
    

    복사된 파일 디스크립터의 값은 원본과 동일함

  4. 프로세스가 좀비가 되는 경우에 대해서 설명하고, 이를 막기 위해서 어떠한 방법을 취해야 하는지 설명해보자.

    자식 프로세스가 종료되는 상황이 부모 프로세스에 전달되지 않은 경우
    부모 프로세스에서 자식 프로세스에 대한 종료 값의 전달을 wait() 또는 waitpid() 함수를 통해 명시적으로 요청해야함

  5. SIGINT에 대한 핸들러를 등록하지 않은 상태에서 Ctrl+C 키가 입력되면, 운영체제가 지정해놓은 디폴트 이벤트 핸들러에 의해서 프로그램이 종료되어버린다. 그러나 Ctrl+C 키에 대한 핸들러를 직접 등록하면 프로그램은 종료되지 않고 프로그래머가 지정한 이벤트 핸들러가 대신 호출된다. 그렇다면 일반적인 프로그램에서 다음과 같이 동작하도록 이벤트 핸들러 등록을 위한 코드를 구성해보자. 그리고 간단히 문자열을 1초당 한번정도 반복 출력하는 프로그램을 작성해서 위의 이벤트 핸들러 등록 코드를 적용시켜보자.
    • Ctrl+C 키가 눌리면 정말로 프로그램을 종료할 것인지 묻고, 이에 대한 대답으로 ‘Y’가 입력되면 프로그램을 종료한다.
     // sigint.cpp
    
     #include <iostream>
     #include <unistd.h>
     #include <signal.h>
    
     using namespace std;
    
     #define BUF_SIZE 30
    
     void	timeout(int sig)
     {
         if (sig == SIGALRM)
             cout << "Time out!" << endl;
         alarm(2);
     }
    
     void	keycontrol(int sig)
     {
         char	buf[BUF_SIZE];
    
         if (sig == SIGINT)
         {
             cout << "Quit Program?" << endl;
             cin.getline(buf, BUF_SIZE);
    
             if (!strcmp(buf, "y") || !strcmp(buf, "Y"))
             {
                 exit(1);
             }
         }
     }
    
     int		main(int argc, char *argv[])
     {
         struct sigaction	act;
    		
         act.sa_handler = keycontrol;
         sigemptyset(&act.sa_mask);
         act.sa_flags = 0;
         sigaction(SIGINT, &act, 0);
    
         while (1)
         {
             cout << "Wait..." << endl;
             sleep(1);
         }
         return 0;
     }