개발 · 컴퓨터공학 / / 2021. 10. 17. 22:46

네트워크 프로그래밍 - 멀티프로세스 기반의 서버 구현

728x90
반응형

프로세스의 이해와 활용

다중 접속 서버란 둘 이상의 클라이언트에게 동시에 접속을 허용하여, 동시에 둘 이상의 클라이언트에게 서비스를 제공하는 서버를 의미한다.

다중 접속 서버를 구현하는 방법은 다음과 같은 방법들이 있다.

프로세스

간단하게는 실행 중인 프로그램을 뜻한다.

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

멀티프로세스 운영체제는 둘 이상의 프로세스를 동시에 생성 가능하다.

프로세스 ID

운영제제는 생성되는 모든 프로세스에 ID를 할당한다.

fork 함수를 통한 프로세스 생성

#include <unistd.h>

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

fork 함수가 호출되면, 호출한 프로세스가 복사되어 fork 함수 호출 이후를 각각의 프로세스가 독립적으로 실행하게 된다.

fork 함수 호출 이후의 반환 값은 다음과 같다. 

  • 부모 프로세스 : fork 함수의 반환 값이 자식 프로세스의 ID
  • 자식 프로세스 : fork 함수의 반환 값이 0

fork 실행을 분기점으로 parent와 child process가 나뉘게 된다. parent도, child도 fork return부터 그 이후 코드들을 실행한다. 

child proces는 fork 시점을 기준으로 변수들의 값이 복사되어서 실행되며, child process에서의 변수값 변동은 parent에 영향을 미치지 않는다. 

 위 코드에서 pid가 0인 경우 child 프로세스이므로 gval++이 실행되고, 0이 아니면 parent 프로세스이므로 lval++이 실행된다. 

프로세스 & 좀비 프로세스

좀비 프로세스

실행이 완료되었음에도 불구하고, 소멸되지 않은 프로세스 

프로세스도 main 함수가 반환되면 소멸되어야 한다. 

소멸되지 않았다는 것은 프로세스가 사용한 리소스가 메모리 공간에 여전히 존재한다는 의미이므로 시스템 성능에 영향을 미치게 된다. 

 

일반적으로 parent가 특정 역할을 child에게 맡기고, child process가 작업이 완료되면 parent에게 값을 넘기는 식으로 구조가 이루어진다. child 프로세스는 종료하면서 parent 프로세스에게 exit status 값을 전달한다.

 

자식 프로세스가 종료되면서 반환하는 상태 값이 부모 프로세스에게 전달되지 않으면 해당 프로세스는 소멸되지 않고 좀비 프로세스가 된다.

 

child 프로세스의 exit status값이 운영체제에 전달되는 경로는 다음과 같다.

 

// zombie.c

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;
root@my_linux:/tcpip# gcc zombie.c -o zombie
root@my_linux:/tcpip# ./zombie
Hi, I am a child process
End child process
Child Process ID: 16977

프로그램을 실행하면, fork의 반환값이 0인 자식 프로세스가 실행되고, 부모 프로세스에서는 자식 프로세스 ID를 출력하고 30초간 sleep한다. 

자식 프로세스가 먼저 종료하게 되므로, 부모 프로세스가 종료할 때 까지 자식 프로세스는 좀비 프로세스로 존재하게 된다. 

 

자식 프로세스의 종료 값을 반환 받을 부모 프로 세스가 소멸되면, 좀비의 상태로 있던 자식 프로 세스도 함께 소멸되기 때문에 부모 프로세스가 소멸되기 존에 좀비의 생성을 확인해야 한다.

 

wait 

// zombie.c

int status;
pid_t pid=fork();

if (pid==0)
{
	return 3;
}
else
{
	printf("Child PID: %d \n", pid);
	pid=fork();
	if(pid==0)
	{
		exit(7);
	}
	else
	{
		printf("Child PID: %d \n", pid);
		wait (&status);
		if (WIFEXITED(status))
			printf("Child send one: %d \n", WEXITSTATUS(status));
            
		wait(&status);
		if (WIFEXITED(status))
			printf("Child send two: %d \n", WEXITSTATUS(status));
		sleep(30); // Sleep 30 sec.
	}
}
root@my_linux:/tcpip# gcc wait.c -o wait
root@my_linux:/tcpip# ./wait
Child PID: 12337
Child PID: 12338
Child send one: 3
Child send two: 7

fork를 통해 자식 프로세스를 생성하고, 생성된 자식 프로세스는 return 3으로 종료된다.

부모 프로세스에서는 fork를 또 진행하여 자식 프로세스를 생성하고, 자식 프로세스는 exit(7)로 status 7를 부모에게 전달한다.

부모 프로세스는 wait를 통해 자식 프로세스로부터 전달받은 값을 &status에 넣는다. 

 

WIFEXITED를 통해 자식 프로세스가 정상 종료한 경우 true를 반환하고,

WEXITSTATUS를 통해 자식 프로세스의 전달 값을 반환한다.

 

wait 함수의 경우 자식 프로세스가 종료되지 않은 상황에서는 반환하지 않고 blocking 상태에 놓인다는 특징이 있다.

 

waitpid

#include <sys/wait.h>

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

// pid : 종료를 확인하고자 하는 자식 프로세스의 Id 전달, 이를 대신해서 -1을 전달하면
// 	wait 함수와 마찬가지로 임의의 자식 프로세스가 종료되기를 기다린다.
// statloc : wait 함수의 매개변수 statloc과 동일한 의미로 사용된다.
// options : 헤더파일 sys/wait.h에 선언된 상수 WNOHANG을 인자로 전달하면,
// 	종료된 자식 프로세스가 존재하지 않아도 블로킹 상태에 있지 않고, 0을 반환하면서
//	함수를 빠져 나온다.

wait 함수는 블로킹 상태에 빠질 수 있는 반면, waitpid 함수는 blocking 상태에 놓이지 않게끔 할 수 있다는 장점이 있다.

// waitpid.c

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);
			puts("sleep 3sec.");
		}
		
        if (WIFEXITED(status))
			printf("Child send %d \n", WEXITSTATUS(status));
	}
	return 0;
}
root@my_linux:/tcpip# gee waitpid.c -o waitpid
root@my_linux:/tcpip# ./waitpid
sleep 3sec.
sleep 3sec.
sleep 3sec.
sleep 3sec.
sleep 3sec.
Child send 24

waitpid 함수 호출 시 첫 번째 인자로 -1, 세 번째 인자로 WNOHANG가 전달되면, 임의의 프로세스가 소멸되기를 기다리 되, 종료된 자식 프로세스가 없으면 0을 반환하면서 함수를 빠져나온다.

 

시그널 핸들링

특정 상황이 되었을 때 운영체제가 프로세스에게 해당 상황이 발생했음을 알리는 일종의 메시지를 가리켜 시그널이라 한다.

 

등록 가능한 시그널의 종류는 다음과 같다.

 

특정 상황에서 운영체제로부터 프로세스가 시그널을 받기 위해서는 해당 상황에 대해서 등록의 과정을 거쳐야 한다.

 

#include <signal.h>

void (*signal(int signo, void (*func)(int)))(int);
// 시그널 발생시 호출되도록 이전에 등록된 함수의 포인터 반환

위 세 함수중 주로 첫번째 signal 함수를 통해 자식프로세스가 종료될 경우 부모 프로세스에게 알려주고, 부모 프로세스는 mychild 를 호출한다. 

 

다음 예제를 보자

// signal.c

void timeout(int sig)
{
	if(sigs=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;
}
root@my_linux:/tcpip# gcc signal.c -o signal
root@my_linux:/tcpip# ./signal
wait...
Time out!
wait...
Time out!
wait...
Time out!

시그널이 발생하면, sleep 함수의 호출을 통해서 블로킹 상태에 있던 프로세스가 깨어난다. 그래서 이 예제의 경우 코드의 내용대로 300초의 sleep 시간을 갖지 않는다.

 

sigaction

#include <signal.h>

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

// signo : signal 함수와 마찬가지로 시그널의 정보를 인자로 전달
// act : 첫 번째 인자로 전달된 상수에 해당하는 시그널 발생시 호출될 함수(시그널 핸들러)의
//	정보 전달.
// oldact : 이전에 등록되었던 시그널 핸들러의 함수 포인터를 얻는데 사용되는 인자, 필요없다면 
//	0 전달
struct sigaction
{
	void (*sa_handler)(int);
	signset_t sa_mask;
	int sa_flags;
}

sigaction 구조체 변수를 선언해서, 시그널 등록 시 호출될 함수의 정보를 채워서 위의 함수 호출 시 인자로 전달한다. sa_mask의 모든 비트는 0, sa_flags는 0으로 초기화! 이 둘은 시그널관련 정보의 추가 전달에 사용되는데, 좀비의 소멸을 목적으로는 사용되지 않는다.

 

다음 예제 코드를 보자

// sigaction.c

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

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); // timeout함수 실행
    
	alarm(2);
    
	for(i=0; i<3; i++)
	{
		puts("wait...");
		sleep(100);
	}
	return 0;
}
root@my linux: /tcpip# gee sigaction.c -o sigaction
root@my_linux:/tcpip# ./sigaction
wait...
Time out!
wait...
Time out!
wait...
Time out!

sigaction 구조체의 sa_handler안에 timeout이라는 함수를 대입한다.

sigaction을 실행하였을 때, SIGALRM 발생하는 경우, sa_handler 즉 timeout함수를 실행한다. 

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

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

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

 

sigaction 구조체 변수에 signal handler로 read_childproc함수를 등록하였다. 

SIGCHILD가 발생하였을 때, read_childproc함수가 실행되어 자식 프로세스가 zombie process가 되었을 때 소멸시킨다.

멀티태스킹 기반의 다중접속 서버

에코 클라이언트가 부모 프로세스에게 연결 요청을 하면, 자식 프로세스를 할당하여 에코 클라이언트를 담당하게 하여 데이터를 주고 받는다. 

 

에코 서버(부모 프로세스)는 accept 함수호출을 통해서 연결요청을 수락한다.

이때 얻게 되는 소켓의 파일 디스크립터를 자식 프로세스를 생성해서 넘겨준다.

자식 프로세스는 전달받은 파일 디스크립터를 바탕으로 서비스를 제공한다.

 

부모 프로세스는 연결요청을 받는 역할만 하고, 연결이 되면 자식에게 모든 메모리 영역이 copy되므로 자식 프로세스는 데이터를 주고 받는 역할만을 한다. 

 

다음 코드를 보자

// echo_mpserv.c

while(1)
{
	adr_sz=sizeof(clnt_adr);
	clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &ade_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(clat_sock, buf, str_len);

		close(clnt_sock);
		puts("client disconnected...");
		return 0;
	}
	else
		close(clnt_sock);
}

부모 프로세스는 while문을 통해 계속 실행한다. 실행 중에 accept을 통해 요청을 받아 소켓을 생성하여 클라이언트와 연결한다.

연결 후 자식 프로세스를 생성한다. 자식 프로세스는 상대방으로부터 read하고 write하는 echo 서비스를 한다. 상대방으로부터 FIN이 오면 read가 0을 반환하므로, while문을 벗어나 close하게 된다. 

부모 프로세스가 실행하는 코드인 close는 나중에 보자.

 

부모 프로세스는 연결요청을 확인하고, 상대방이 연결요청을 했다면 연결요청 대기 큐에 담는다.

accept은 SIN+ACK이 왔을 때 return되고 연결요청 대기 큐 안의 클라이언트로 새로운 소켓을 생성하고, 자식 프로세스를 생성하여 자식 프로세스가 해당 소켓에 read write 동작을 실행한다. 

부모 프로세스는 연결요청을 받고 소켓과 자식 프로세스를 연결시켜주는 역할만을 반복한다.

 

fork 함수의 descriptor 복사

fork함수를 실행하면 부모 프로세스의 있는 모든 것들이 자식으로 복사된다. 하지만 소켓은 프로세스안에 있는 것이 아니라 OS가 가지고 있다.

프로세스는 소켓을 file descriptor를 통해 접근하므로 fork를 하면 file descriptor값이 copy되고 소켓 자체가 copy되는 것은 아니다. 

 

fork로 인하여 자식 프로세스를 생성하면, descriptor값이 복사되므로, 부모 프로세스와 자식 프로세스가 둘 다 client socket을 가리킨다. 하지만 client socket은 자식 프로세스가 담당해야한다. 해당 소켓이 필요없는 프로세스에서는 close해야 원하는 결과를 얻을 수 있다. 

 

close를 실행하면 file descriptor(table)의 link counter 값을 1감소시키고, link counter값이 0이되면 FIN을 보내는데, 부모 프로세스에서 client socket연결이 close되어 있어야 자식 프로세스에서 close를 호출할 때 link counter가 0이 되어 FIN을 socket에 전달할 수 있다. 

 

마찬가지로 server socker을 child process에서 담당하지 않으므로, child에서 close 해주어야 한다. 

 

즉 하나의 소켓에 두 개의 파일 디스크립터가 존재하는 경우, 두 파일 디스크립터 모두 종료되어야 해당 소켓이 소멸되기 때문에 fork 함수호출 후에는 서로에게 상관 없는 파일 디스크립터를 종료한다.

 

위의 코드를 다시 보자.

// echo_mpserv.c

while(1)
{
	adr_sz=sizeof(clnt_adr);
	clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &ade_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(clat_sock, buf, str_len);

		close(clnt_sock);
		puts("client disconnected...");
		return 0;
	}
	else
		close(clnt_sock);
}

socker을 담당하지 않는 프로세스에서는 close해야하므로 자식 프로세스가 실행하는 부분에서 close함수를 통해 server socker을 close하고, 부모 프로세스의 경우 client socker을 close한다. 

 

TCP의 입출력 루틴 분할

에코 클라이언트는 키보드로부터 입력 받아서 상대방에게 전송하고, 상대방으로부터 온 것을 화면에 뿌려주는 역할을 한다. 이 두 역할을 분리해서 child process에서 키보드 입력 write 작업을 담당하고, parent process가 상대방으로부터 데이터를 read하는 역할을 담당하도록 할 수 있다. 

입출력 루틴을 분할하면, 보내고 받는 구조가 아니라, 이 둘이 동시에 진행 가능하다.

 

다음 예시 코드를 보자.

// echo_mpclient.c

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

connect함수를 통해 SYN을 보내고, connect함수는 SYN + ACK이 오면 return한다. fork를 통해 자식 프로세스를 생성한다. 자식 프로세스는 sock에 write를 담당하고, 부모 프로세스는 read를 담당한다. 

// echo_mpclient.c

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") || !stecmp(buf, "Q\n"))
		{
			shutdown(sock, SHUT_WR);
			return;
		}
		write(sock, buf, strlen(buf));
	}
}

read routine을 보자.

while문을 통해 데이터를 계속 받는다. read를 통해 sock 소켓에서 들어오는 데이터를 buf로 담고, buf를 출력한다. 즉 수신한 정보를 화면에 뿌려주는 역할만을 한다. 

 

write routine을 보자.

write routine은 키보드에서 입력을 받아서 q나 Q가 아닐 경우에는 계속해서 상대방에게 write를 통해 데이터를 보낸다. 

shutdown은 데이터를 전송하는 것만을 close하는 half close인데, 당장은 자세히 알아보지 않고, close를 사용해도 된다.

q나 Q를 입력하면 상대방에게 FIN을 보내고, shutdown을 통해 데이터를 보내는 것만을 중지한다. 받을 수는 있다.

 

자식 프로세스와 부모 프로세스의 각각의 routine 실행 이후 자식 프로세스는 server socket을 close, 부모 프로세스는 client socket을 close해주는 작업이 필요하다.

728x90
반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유