나의 브을로오그으

#10. 멀티프로세스 기반의 서버구현 본문

네트워크/열혈 TCP_IP 소켓프로그래밍

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

__jhp_+ 2022. 8. 17. 18:50

#1. 프로세스의 이해와 활용

9단원까지 공부한 내용을 가지고 '일렬종대'의 서비스 서버를 구현하여 만들 수 있다. 단, 연결에 걸리는 시간이 클라이언트 당 1초라면, 100번째 클라이언트는 100초가 걸린다는 함정이 있다.

 

두 가지 유형의 서버

"첫 번째 연결요청자의 접속대기시간은 0초, 50번째 연결요청자의 접속대기시간은 50초 그리고 100번째 연결요청자의 접속대기시간은 100초! 그러나 일단 연결만 되면 1초 안에 서비스를 완료해 드립니다."

 

-> 물론 요청 순서가 5순위 안에 든다면 서비스 만족도는 높을 것이나... 이를 넘어선다면 클라이언트는 불만이 많을 것이다. 이럴바에 밑의 내용처럼 서비스 하는것이 낫다.

 

"모든 연결요청자의 접속대기시간은 1초를 넘기지 않습니다. 그러나 서비스를 제공받는데 걸리는 시간은 평균적으로 2~3초 정도 걸립니다."

 

 

다중접속 서버의 구현방법들

전체적인 서비스 제공시간이 조금 늦어지더라도 연결요청을 해오는 모든 클라이언트에게 동시에 서비스를 제공해서 평균적인 만족도를 높일 필요가 있다. 그리고 네트워크 프로그램은 CPU의 연산을 필요치 않는 데이터의 송수신 시간이 큰 비중을 차지하므로, 둘 이상의 클라이언트에게 동시에 서비스를 제공하는 것이 CPU를 보다 효율적으로 사용하는 방법이 된다. 

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

(※ 참고로 멀티프로세스 방식은 Windows에는 적절치 않는 방법이므로 Linux에 초점을 맞춘다.)

 

 

프로세스(Process)의 이해

프로세스란? 메모리 공간을 차지한 상태(메모리에 올라온, 메모리 할당한)에서 실행중인 프로그램

 

 

[※참고 CPU의 코어 수와 프로세스 수]

두 개의 연산장치가 존재하는 CPU를 가리켜 듀얼(Daul) 코어 CPU라 하고, 네 개의 연산장치가 존재하는 CPU를 가리켜 쿼드(Quad) 코어 CPU라 한다. 이렇듯 CPU에는 실제 연산장치에 해당하는 코어가 둘 이상 존재할 수 있으며, 코어의 수만큼 동시 실행이 가능하다. 반면 코어의 수를 넘어서는 개수의 프로세스가 생성되면, 프로세스 별로 코어에 할당되는 시간이 나뉘게 된다. 그러나 CPU가 고속으로 프로세스를 실행하기 때문에 우리는 모든 프로세스가 동시에 실행되는 것처럼 느끼게 된다. 물론 코어의 수가 많을수록 그 느낌은 더할 것이다.

 

 

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

#include <unistd.h>

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

fork 함수는 호출한 프로세스의 복사본을 생성한다. 즉, 전혀 새로운 다른 프로그램을 바탕으로 프로세스를 생성하는 것이 아니라 이미 실행중인, for 함수를 호출한 프로세스를 복사하는 것이다. 그리고 두 프로세스는 모두 fork 함수의 호출 이후 문장을 실행하게 된다.(정확하게는 fork함수의 반환 이후), 그런데 이렇게 복사된 프로세스는 완전히 동일한 프로세스로, 메모리 영역까지 동일하게 복사하기 떄문에 이후의 프로그램 흐름은 fork 함수의 반환 값을 기준으로 나뉘도록 프로그래밍을 해야 한다.

- 부모 프로세스      fork 함수의 반환 값은 자식 프로세스의 ID

- 자식 프로세스      fork 함수의 반환 값은 0

 

여기서 '부모 프로세스(Parent Process)란' 원본 프로세스, 즉, fork 함수를 호출한 주체가 된다. 반면 '자식 프로세스(Child Process)'는 부모 프로세스의 fork 함수 호출을 통해서 복사된 프로세스를 의미한다.

 

[부모 프로세스]

int gval = 10;
int main(void)
{
  int lval = 20;
  lval += 5;
  gval++;
  pid_t pid = fork(); 		// pid는 자식 프로세스 ID
  if (pid == 0)
    gval++;
  else
    lval++;			// 실행
}

 

[자식 프로세스]

int gval = 10;
int main(void)
{
  int lval = 20;
  lval += 5;
  gval++;
  pid_t pid = fork(); 		// pid는 0 여기부터 실행
  if (pid == 0)
    gval++;			// 실행
  else						
    lval++;					
}

1. 위 코드처럼 부모 프로세스에서 fork 함수 호출 순간 자식 프로세스가 복사 됨.

2. 반환 값으로 자식 프로세서의 ID를 받고, 복사 이전에 부모 프로세스가 전역변수 gval의 값을 11, 지역변수 lval의 값을 25로 증가된 상태로 복사된다.

3. 복사 이후 부모, 자식의 gval와 lval 모두 서로 독립적이다.(서로 영향을 주지 않음) 이유는 두 프로세스가 동일한 코드를 실행하는 완전히 다른 프로세스가 되기 떄문이다.

/* linux */
#include <stdio.h>
#include <unistd.h>

int gval = 10;
int main(int argc, char* argv[])
{
  pid_t pid;
  int lval = 20;
  gval++, lval += 5;
  
  pid = fork();
  if (pid == 0) // if Child Process
    gval += 2, lval += 2;
  else          // if Parent Process
    gval -= 2, lval -= 2;
    
  if (pid == 0)
    printf("Child Proc: [%d, %d] \n", gval, lval);
  else
    printf("Parent Proc: [%d, %d] \n", gval, lval);
  return 0;
}

1. 부모 프로세스 pid = 자식 프로세스, 자식 프로세스 pid = 0

2. pid에 따라 분기

 

 

[실행결과]

root@my_linux:/tcpip# gcc fork.c -o fork
root@my_linux:/tcpip# ./fork
Child Proc: [13, 27]
Parent Proc: [9, 23]

실행 결과 자식, 부모 프로세스가 서로 완전히 분리된 메모리 구조를 지님을 보이고 있다. 해당 예제 하나로도 fork()함수를 이해하기에 충분하다.

 

 

#2. 프로세스 & 좀비(Zombie) 프로세스

파일은 여는 것 못지않게 닫는 것이 중요하다. 마찬가지로 프로세스도 생성 못지않게 소멸이 중요하다. 만약 대충(?) 소멸시킨다면 프로세스는 좀비가 되어서 컴퓨터 리소스를 좀먹을 지도 모른다.

 

좀비(Zombie) 프로세스

프로세스 세계에서 프로세스가 할일을 다하고 나면(main 함수의 실행 완료) 사라져야 하는데 사라지지 않고 좀비가 되어 시스템의 중요한 리소스를 차지하기도 한다. 이 상태에 있는 프로세스를 가리켜 '좀비 프로세스'라 하는데, 이는 시스템에 부담을 주는 원인이 되기도 한다.

 

좀비 프로세스의 생성이유

fork 함수의 호출로 생성된 자식 프로세스가 종료되는 상황을 두 가지를 예로 들 수 있다.

1. 인자를 전달하면서 exit를 호출하는 경우

2. main 함수에서 return문을 실행하면서 값을 반환하는 경우

 

exit 함수로 전달되는 인자 값과 main 함수의 return문에 의해 반환되는 값 모두 운영체제로 전달된다. 그리고 운영체제는 이 값이 자식 프로세스를 생성한 부모 프로세스에게 전달될 때까지 자식 프로세스를 소멸시키지 않는데, 바로 이상황에 놓여있는 프로세스를 가리켜 좀비 프로세스라 한다. 즉, 자식 프로세스를 좀비 프로세스로 만드는 주체는 운영체제이다.

 

그렇다면 이 좀비 프로세스가 언제 소멸될까?

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

그렇다면 어떻게 부모 프로세스에게 값을 전달할까? 이는 부모 프로세스의 적극적인 요청이 있어야(함수 호출) 운영체제는 값을 전달해 준다. 반대로 말하면 부모 프로세스가 자식 프로세스의 전달 값을 요청하지 않으면, 운영체제는 그 값을 계속해서 유지하게 되고 결국 자식 프로세스는 좀비의 상태로 머물러 있어야 한다.

 

[zombie.c]

/* linux */
#include <stdio.h>
#include <unistd.h>

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

    if (pid == 0) 
    {
        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;
}

1. sleep 함수를 호출하면서 부모 프로세스가 블로킹이 되었고, 자식 프로세스는 그대로 진행되어 return하게 되었다.

2. 그러나 자식 프로세스의 ID가 여전히 출력되는 것을 보면 자식 프로세스의 실행을 끝났으나, 아직 남아있는것을 볼 수 있다.

 

[실행화면]

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: 10427

해당 프로그램을 실행하고, 부모 프로세스가 블로킹 되어있는 동안 콘솔창을 하나 열자.

그다음에 ps au 라고 명령어를 입력하면 현재 어떠한 프로세스가 동작중인지 확인 가능하다. 

이 목록을 보면 10427에 해당하는 프로세스를 확인 가능할 것이다.

 

[※참고 후면처리(Background Processing)]

후면처리란 콘솔 창에서 명령어의 실행을 후면(뒤)에서 진행하도록 하는 방식이다. 앞서 보인 예제를 다음과 같이 실행하면 프로그램의 실행이 후면에서 이뤄진다.(&의 입력이 후면처리를 유도한다.) 한마디로 백그라운드에서 동작

root@my_linux:/tcpip# ./zombie &

그리고 이렇게 후면처리 방식으로 예제를 실행하면 프로그램의 실행과는 별도로 다음의 명령을 추가로 입력할 수 있기 때문에 좀비의 확인을 위해서 다른 콘솔 창으로 옮겨가지 않아도 된다.

root@my_linux:/tcpip# ps au

 

 

 

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

자식 프로세스의 소멸을 위해서는 부모 프로세스가 자식 프로세스의 전달 값을 요청해야 함을 알았으니 이제 요청을 위한 구체적인 방법을 알아보자.

#include <sys/wait.h>

pid_t wait(int* statloc);
-> 성공 시 종료된 자식 프로세스의 ID, 실패 시 -1 반환

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

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

- WEXITSTATUS : 자식 프로세스의 전달 값을 반환

 

즉, wait 함수의 인자로 변수 status의 주소 값이 전달되었다면, wait 함수의 호출 이후에는 다음과 같은 유형의 코드를 구성해야 한다.

pid_t pid = wait(&status);
if (WIFEXITED(status))
{
  puts("Normal termination!");
  printf("Child pass num: %d", WEXISTATUS(status)); // 그렇다면 반환 값은 3
}

 

[wait.c]

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(int argc, char* argv[])
{
	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", WEXISTATUS(status));

			wait(&status);
			if (WIFEXITED(status))
				printf("Child send two: %d \n", WEXISTATUS(status));
			sleep(30); // Sleep 30 sec
		}
	}
	return 0;
}

1. 2개의 자식 프로세스를 생성해서 main 종료와 exit 함수 호출을 하여 자식 프로세스를 종료시킨다.

2. wait 함수를 호출하여 종료된 프로세스 관련 정보는 status에 담기고, 해당 정보의 프로세스는 완전히 소멸한다.

3. WIFEXITED 매크로 함수를 통해 자식 프로세스의 정상종료 여부를 확인하고, 정상종료에 한해서 WEXITSTATUS 매크로 함수를 호출하여 자식 프로세스가 전달한 값을 출력하고 있다.

4. sleep함수를 호출하여 부모 프로세스를 블로킹하고 자식 프로세스가 정상적으로 종료되었는지 확인해 본다.

 

[실행 결과]

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

실행 후 자식 프로세스를 확인해 보면 wait함수의 호출로 인해서 완전히 사라졌음을 확인 할 수 있다. 그리고 두 자식 프로세스가 종료되면서 전달한 값 3과 7이 부모 프로세스에게 전달되었음도 확인 가능하다.

그러나 wait함수를 쓸때는 주의해야 한다. wait함수를 호출하는 시점에 종료된 자식 프로세스가 없다면, 임의의 자식 프로세스가 종료될 때까지 블로킹(Blocking)상태에 놓인다.

 

좀비 프로세스의 소멸2: 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을 반환하면서 함수를 빠져 나온다.

 

 

[waitpid.c]

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

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

	if (pid == 0)
	{
		sleep(15);
		return 24;
	}
	else
	{
		while (!waitpid(pid, &status, WNOHANG))
		{
			sleep(3);
			puts("sleep 3sec");
		}
		
		if (WIFEXITED(status))
			printf("Child send %d \n", WEXITSTATUS(status));
	}
	return 0;
}

- 자식 프로세스의 종료를 늦추기 위해서 sleep 함수를 호출하고 있다. 이로인해서 약 15초간의 지연이 생긴다.

- while문 내에서 waitpid 함수를 호출하고 있다. 세 번째 인자로 WNOHANG을 전달하면, 종료된 자식 프로세스가 없을 때 0을 리턴한다.

 

[실행결과]

root@my_linux:/tcpip# gcc waitpid.c -o waitpid
root@my_linux:/tcpip# .;/waitpid
sleep 3sec.
sleep 3sec.
sleep 3sec.
sleep 3sec.
sleep 3sec.
Child send 24

결과를 통해서 waitpid에 WNOHANG인자를 주면 블로킹(Blocking)되지 않음을 증명하였다.

 

 

#3. 시그널 핸들링

지금까지 프로세스의 생성, 소멸에 대한 방법을 알아봤는데, 아직 해결하지 못한 문제가 있다.

"도대체 자식 프로세스가 언제 종료될 줄 알고 waitpid 함수를 계속 호출하고 앉아있으란 말인가?"

부모 프로세스도 자식프로세스 못지 않게 바쁘기 때문에 자식 프로세스가 끝나기만을 기다릴 수 있는 처지가 못된다.

 

 

운영체제야! 네가 좀 알려줘!!!!!

자식 프로세스 종료의 인식 주체는 운영체제이다. 그래서 이렇게 하면 이 문제를 해결 할 수 있다. 

"어이, 부모 프로세스야! 네가 생성한 자식 프로세스 종료되었어!"

그러면 부모 프로세스는 하던 일을 멈추고, 자식 프로세스의 종료와 관련된 일을 처리하면 된다. 굉장히 우아한데!! 이러한 처리법으로  "시그널 핸들링(Signal Handling)"이 존재한다.

여기서 "시그널"은 특정상황이 발생했음을 알리기 위해 운영체제가 프로세스에게 전달하는 메시지를 의미한다. 그리고 그 메시지에 반응해서 메시지와 연관된, 미리 정의된 작업이 진행되는 것을 가리켜 "핸들링" 또는 "시그널 핸들링"이라 한다.

 

시그널과 Singal 함수

다음은 시그널 핸들링의 이해를 돕기 위한 프로세스, 운영체제간의 대화 내용이다.

- 프로세스 : 야. 운영체제야! 내가 생성한 자식 프로세스가 종료되면 zombie_handlier라는 이름의 함수 좀 호출해 주라!

- 운영체제 : 그래! 그럼 네가 생성한 자식 프로세스가 종료되면, 네가 말한 zombie_handler라는 이름의 함수를 내가 대신 호출해줄 테니, 그 상황에서 실행해야 할 문장들을 그 함수에 잘 묶어둬!

 

#include <signal.h>

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

- 함수 이름 : signal

- 매개변수 선언 : int signo, void(*func)(int)

- 반환형 : 매개변수 int, 반환형 void인 함수 포인터

 

위 함수를 호출하면서 첫 번째 인자로 특정 상황에 대한 정보를, 두 번째 인자로 특정 상황에서 호출될 함수의 주소 값(포인터)을 전달한다. 그러면 첫 번째 인자를 통해 명시된 상황 발생 시, 두 번째 인자로 전달 된 주소 값의 함수가 호출된다.

참고로 signal 함수를 통해서 등록 가능한 특정 상황과 그 상황에 할당된 상수 몇몇을 정리해보면 다음과 같다.

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

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

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

그러면 이제 다음 요청에 해당하는 signal 함수의 호출문장을 만들어 보겠다.

"자식 프로세스가 종료되면 mychild 함수를 호출해 달라"

 

이때 mychild 함수는 매개변수형이 int이고 반환형이 void이어야 한다. 그래야 signal 함수의 두 번째 전달인자가 될 수 있다.  그리고 자식 프로세스가 종료된 상황은 상우 SIGCHLD로 정의되어 있으니, 이것이 signal 함수의 첫 번째 인자가 되어야 한다. 즉, signal 함수의 호출문장은 다음과 같이 구성하면 된다. 

signal(SIGCHLD, mychild);

 

응용해서 다음 signal 함수의 호출 문장도 만들어 보자.

"alarm 함수호출을 통해서 등록된 시간이 지나면 timeout 함수를 호출해 달라."

signal(SIGALRM, timeout);

 

"CTRL+C가 입력되면 keycontrol 함수를 호출해 달라"

signal(SIGINT, keycontrol);

#include <unistd.h>
unsigned int alarm(unsigned int seconds);
-> 0 또는 SIGALRM 시그널이 발생하기까지 남아있는 시간을 초 단위로 반환

위 alarm 함수를 호출하면 양의 정수로 전달된 인자의 수에 해당하는 시간(초 단위)이 지나서 SIGALRM 시그널이 발생한다. 그리고 0을 인자로 전달하면 이전에 설정된 SIGALRM 시그널 발생의 예약이 취소된다. 그런데 위의 함수호출을 통해서 시그널의 발생을 예약만 해놓고, 이 시그널이 발생했을 때 호출되어야 할 함수를 지정하지 않으면(signal 함수호출을 통해서) 프로세스가 그냥 종료되어 버리니 주의해야 한다.

 

 

[signal.c]

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

void timeout(int sig)
{
	if (sig == 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;
}

1. 시그널이 발생했을 때 호출되어야 할 함수가 각각 정의되어 있다. 이러한 유형의 함수를 가리켜 시그널 핸들러(Handler)라고 한다.

2. 2초 간격으로 SIGALRM을 시그널을 반복 발생시키기 위해 시그널 핸들러 내에서 alarm 함수를 호출하고 있다.

3.main문에서 시그널 SIGALRM, SIGINT에 대한 시그널 핸들러를 등록하고 있;다.

4. 시그널의 발생과 시그널 핸들러의 실행을 확인하기 위해서 100초간 3회의 대기시간을 갖도록 반복문 내에서 sleep 함수를 호출하고 있다. 그런데 실제 실행시간을 보면 10초도 걸리지 않는다. 만약 CTRL+C를 연속해서 누르면 더 짧은 시간에 종료될 것이다.

 

[실행결과]

root@my_linux:/tcpip# gcc signal.c -o signal
root@my_linux:/tcpip# ./signal
wait...
Time out!
wait...
Time out!
wait...
Time out!

실행해보면 실제 이렇게 출력되고, 끝나버린다. 여기서 알아야 할 중요한 사실이 있다.

"시그널이 발생하면 sleep 함수의 호출로 블로킹 상태에 있던 프로세스가 깨어난다."

 

 

sigaction 함수를 이용한 시그널 핸들링

지금까지 설명한 내용만 가지고도 좀비 프로세스의 생성을 막는 코드를 충분히 만들어 낼 수 있다. 그러나 함수를 하나 더 소개하고자 한다. 이 sigaction함수는 signal함수와 

유사하다. 오히려 signal함수보다 더 안정적으로 동작한다.

"signal 함수는 유닉스 계열의 운영체제 별로 동작방식에 있어서 약간의 차이를 보일 수 있지만, sigaction 함수는 차이를 보이지 않는다."

(요즘은 signal 함수를 사용해서 프로그램을 작성하지 않는다. 이 함수는 과거 프로그램과의 호환성을 위해서 유지만 되고 있을 뿐이다.)

#include <signal.h>
int sigaction(int signo, const struct sigaction* act, struct sigaction* oldact);
-> 성공 시 0, 실패 시 -1반환

- signo : signal 함수와 마찬가지로 시그널의 정보를 인자로 전다.ㄹ

- act : 첫 번째 인자로 전달된 상수에 해당하는 시그널 발생시 호출될 함수(시그널 핸들러)의 정보 전달.

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

 

[sigaction 구조체 구조]

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

- sa_handler : 시그널 핸들러의 함수 포인터 값(주소 값)을 저장

- sa_mask : 모든 비트를 0으로 초기화

- sa_flags : 모든 비트를 0으로 초기화

이 두 옵션은 시그널 관련 옵션 및 특성 지정에 사용됨.

 

[sigaction.c]

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

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, timeout, 0);

	alarm(2);

	for (i = 0; i < 3; ++i)
	{
		puts("wait....");
		sleep(100);
	}

	return 0;
}

 

 

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

 

[remove_zombie.c]

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>

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

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) /* 자식 프로세스 실행영역 */
	{
		puts("Hi! I`m child process");
		sleep(10);
		return 12;
	}
	else          /* 부모 프로세스 실행영역 */
	{
		printf("Child proc id: %d \n", pid);
		pid = fork();
		if (pid == 0)
		{
			puts("Hi! I`m child process");
			sleep(10);
			return 24;
		}
		else
		{
			int i;
			printf("Child proc id: %d \n", pid);
			for (i = 0; i < 5; ++i)
			{
				puts("wait......");
				sleep(5);
			}
		}
	}

	return 0;
}

 

[실행결과]

root@my_linux:/tcpip# gcc remove_zombie.c -o zombie
root@my_linux:/tcpip# ./zombie
Hi! I`m child process
Child proc id: 9555
Hi! I`m child process
Child proc id: 9556
wait...
wait...
Removed proc id: 9555
Child send: 12
Removed proc id: 9556
Child send: 24

 

 

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

 

프로세스 기반의 다중접속 서버의 구현 모델

이전에 구현했던 에코 서버는 한번에 하나의 클라이언트에게만 서비스를 제공할 수 있었다. 즉, 동시에 둘 이상의 클라이언트에게 서비스를 제공하지 못하는 구조였다. 따라서 이번에 동시에 둘 이상의 클라리언트에게 서비스를 제공하는 형태로 에코 서버를 확장해 보겠다.

 

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

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

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

(그런데 사실상 자식 프로세스는 부모 프로세스가 소유하고 있는 것을 전부 복사하기 때문에 별도로 파일 디스크립터를 넘길 필요는 없다.)

 

 

다중접속 에코 서버의 구현

[echo_mpserv.c]

 

/* linux */
#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_adr, (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 error_handling(char* message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

void read_childproc(int sig)
{
	pid_t pid;
	int status;
	pid = waitpid(-1, &status, WNOHANG);
	printf("removed proc id: %d \n", pid);
}

 

 

1. 이부분의 코드는 좀비 프로세스를 막기 위한 코드이다.

act.sa_handler = read_childproc;
sigemptyset(act.sa_mask);
act.sa_flags = 0;
state = sigaction(SIGCHLD, &act, 0);

 

2. accept 함수 호출 이후 fork 함수를 바로 호출하고 있다. 클라이언트의 연결요청 수락과정에서 반환된 파일 디스크립터를 부모와 자식 프로세스가 동시에 하나씩 갖게 된다.

		clnt_sock = accept(serv_adr, (struct sockaddr*)&clnt_adr, &adr_sz);
		if (clnt_sock == -1)
			continue;
		else
			puts("new client connected....");
		pid = fork();
		if (pid == -1)

 

3. 자식 프로세스의 실행 영역으로 이 부분에 의해서 클라이언트에게 에코 서비스가 제공된다. 

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

(지금 자식 프로세스쪽에서 서버 소켓을 닫고 있는데 추가적인 설명은 이후에.....)

 

4. accept 함수호출을 통해서 만들어진 소켓의 파일 디스크립터가 자식 프로세스에게 복사되었으니, 서버는 자신이 소유하고 있는 파일 디스크립터를 소멸시켜야 한다.

		else
			close(clnt_sock);

 

[실행결과 echo_mpserv.c]

root@my_linux:/tcpip# gcc echo_mpserv.c -o mpserv
root@my_linux:/tcpip# ./mpserv 9190
new client connected....
new client connected....
client disconnected...
removed proc id: 7012
new client connected....
removed proc id: 7026

 

[실행결과 echo_client.c one]

root@my_linux:/home/swyoon/tcpip# ./client 127.0.0.1 9190
Connected............
Input message(Q to quit): Hi I`m first client.
Message from server: Hi I`m first client.
Input message(Q to quit): Oh my friend go away~
Message from server: Oh my friend go away~
Input message(Q to quit): Good bye~
Message from server: Good bye~
Input message(Q to quit): Q

 

[실행결과 echo_client.c two]

root@my_linux:/home/swyoon/tcpip# ./client 127.0.0.1 9190
Connected............
Input message(Q to quit): Hi I`m second client.
Message from server: Hi I`m second client.
Input message(Q to quit): Good bye~
Message from server: Good bye~
Input message(Q to quit): Q

이렇듯 다수의 클라이언트가 에코 서비스를 동시에 받는 것을 확인 할 수 있다.

 

 

fork 함수호출을 통한 파일 디스크립터의 복사

예제 echo_mpserv.c에서는 fork 함수호출을 통한 파일 디스크립터의 복사를 보여준다.

부모 프로세스가 지니고 있던 두 소켓(하나는 서버 소켓, 또 하나는 클라이언트와 연결된 소켓)의 파일 디스크립터가 자식 프로세스에게 복사되었다.)

여기서 궁금한 점이 생긴다.

"파일 디스크립터만 복사된걸까? 아니면 소켓도 같이 복사된걸까?"

사실 파일 디스크립터의 복사는 다소 이해하기 힘든 부분이 있다. fork 함수가 호출되면 부모 프로세스의 모든 것이 복사되니 소켓도 함께 복사되었을 거라고 생각할 수 있다. 그러나 소켓은 프로세스의 소유가 아니다. 엄밀히 말해서 소켓은 운영체제의 소유이다. 다만 해당 소켓을 의미하는 파일 디스크립터만이 프로세스의 소유인 것이다. 그런데 굳이 이렇게 이해하지 않아도 소켓이 복사된다는 것은 다음의 이유로도 이치에 맞지 않는다.

즉, 예제의 결과 fork 함수의 호출 결과는 소켓을 복사 할 때 디스크립터가 복사되어 새롭게 할당된다.

 

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

ex) 부모 프로세스에서는 클라이언트 소켓 파일 디스크립터, 자식 프로세스에서는 서버 소켓 파일 디스크립터

 

 

#5. TCP의 입출력 루틴(Routine) 분할

입출력 루틴 분할의 의미와 이점

지금까지 구현한 에코 클라이언트의 데이터 에코 방식

"서버로 데이터를 전송한다! 그리고는 데이터가 에코되어 돌아올 때까지 기다린다. 무조건 기다린다.

그리고 에코되어 돌아온 데이터를 수신하고 나서야 비로소 데이터를 추가로 전송할 수 있다. "

 

자 한번의 데이터를 전송하면 에코 되어 돌아오는 데이터를 수신할 때까지 마냥 기다려야 했다. 왜? 프로그램 코드의 흐름이 read와 write를 반복하는 구조였기 때문이다. 그런데 이렇게밖에 구현할 수 없었던 이유는 하나의 프로세스를 기반으로 프로그램이 동작했기 때문이다. 그런데 이제 둘 이상의 프로세스를 생성할 수 있기 때문에, 이를 바탕으로 데이터의 송신과 수신을 분리해 보자.

 

[※참고 에코 클라이언트에서 입출력 루틴을 분리할 필요는 없다]

사실 에코 클라이언트는 입출력 루틴을 분리시킬만한 특별한 근거를 지니고 있지 않다. 오히려 입출력 루틴의 분리로 인해서 더 복잡하게만 느껴질 수 있다. 다만 입출력 루틴의 분리를 설명하기 위한 대상으로 에코 클라이언트를 삼았을 뿐이다.

 

참고로 해당 프로세스를 생성하여 입출력 루틴을 분리 할 경우 데이터의 수신여부에 상관없이 데이터 전송이 가능하기 때문에 연속해서 데이터 전송이 가능하다. 따라서 동일한 시간 내에서의 데이터 송수신 분량이 상대적으로 많을 수밖에 없다.

 

 

[echo_mpclient.c]

#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(aoit());

	if (connect(sock, (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 \n", 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);
}

1. 각 routine에 맞는 역할을 수행한다.

2. 서버로의 EOF 전달을 위해서 shutdown 함수가 호출되었다. return을 하기 때문에 이후 close함수를 통해 EOF의 전달을 기대할 수 있지만, fork함수를 통해 파일 디스크립터가 복사된 상황이므로 이러한 상황에서는 한번의 close 함수호출로 EOF의 전달을 기대할 수 없다. 따라서 명시적으로 shutdown 함수 호출을 통해 EOF를 명시적으로 전달하도록 한다.

 

[내용 확인문제]

01. 다음 중 프로세스에 대한 설명으로 옳지 않은 것을 모두 고르면?

a. 프로세스는 운영체제의 관점에서 프로그램의 실행 단위가 된다.

b. 프로세스도 생성방식에 따라서 부모와 자식의 관계를 갖는다.

c. 프로세스는 다른 프로세스를 포함할 수 있다. 즉, 하나의 프로세스는 자신의 메모리 영역에 또 다른 프로세스를 포함할 수 있다. 

d. 자식 프로세스는 또 다른 자식 프로세스를 생성할 수 있고, 이렇게 생성된 자식 프로세스 역시 또 다른 자식 프로세스를 생성할 수 있지만, 이들은 모두 하나의 프로세스와만 부모 자식의 관계를 형성한다.

답)

c. 프로세스는 독립적인 존재이다. 따라서 한 프로세스가 다른 프로세스를 포함 할 수 없다.

d. 부모 자식의 관계는 상대적인 개념이다. 프로세스를 생성한 프로세스는 생성된 프로세스의 부모 프로세스이며, 이로인해 생겨난 프로세스는 자식 프로세스가 된다. 

마찬가지로 생성된 프로세스가 또 다른 프로세스를 생성한다면, 새롭게 생성된 프로세스의 부모 프로세스가 된다.

 

02. fork 함수가 호출되면 자식 프로세스가 생성되는데, 이 자식 프로세스의 특징으로 옳지 않은 것을 모두 고르면?

a. 부모 프로세스가 소멸되면 자식 프로세스도 소멸된다.

b. 자식 프로세스는 부모 프로세스의 모든 것을 복사해서 생성되는 프로세스이다.

c. 부모 프로세스와 자식 프로세스는 전역으로 선언되는 변수를 공유한다.

d. fork 함수 호출로 생성된 자식 프로세스는 부모 프로세스가 실행한 코드를 처음부터 fork 함수가 호출된 위치까지 실행해 온다.

답)

c. 공유하지 않는다. 자식 프로세스는 부모 프로세스가 갖고있는 모든 변수를 복사하여 독립적으로 갖는다.

d. fork 함수를 호출하면 호출되고 반환이후부터 실행되며, 그전까지 생성된 지역 변수들을 복사하여 독립적으로 갖는다.

 

03. 자식 프로세스가 생성되면 부모 프로세스의 모든 것을 복사하는데, 이때 복사의 대상으로는 소켓의 파일 디스크립터도 포함이 된다. 그렇다면 복사된 파일 디스크립터의 정수 값은 원본 파일 디스크립터의 정수 값과 동일한지 확인하기 위한 프로그램을 작성해 보자.
답)

/* linux */
#include <stdio.h>
#include <unistd.h>
#include <sys/socket.h>

int main(int argc, char* argv[])
{
	pid_t pid;
	int sock = socket(PF_INET, SOCK_STREAM, 0);
	pid = fork();

	if (pid == 0)
	{
		printf("child sock file discripter : %d \n", sock);
	}
	else
	{
		printf("parent sock file discripter : %d \n", sock);
	}
	close(sock);
	return 0;
}

 

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

답)

좀비가 되는 대상은 자식 프로세스이다. 좀비 프로세스의 생성을 막기에 앞서 자식 프로세스는 두 가지의 상황에 따라 종료된다.

1. 인자를 전달하면서 exit 호출하는 경우

2. main 함수에서 return문을 실행하면서 값을 반환하는 경우

두 값(인자, 반환값) 모두 운영체제로 넘기는데 운영체제는 이 값이 부모 프로세스에게 전달될 때까지 자식 프로세스를 소멸시키지 않는다. 즉, 부모 프로세스에서 이값을 받기 전까지 자식 프로세스는 좀비 프로세스로 남게된다. 

 

따라서 좀비 프로세스가 생기지 않게 방지하려면 자식 프로세스가 종료될 때의 값을 운영체제가 부모 프로세스에게 넘겨야 한다.

 

wait, waitpid함수를 호출하는 것이다. 해당 함수를 호출하면 일반적으로 호출한 지점에 block이 걸려 자식 프로세스의 종료를 부모 프로세스가 기다리게 된다.

추가적으로 좀더 프로그램을 유연하게 하고 싶다면 시그널 핸들링을 이용하면 좋다. signal, sigaction함수를 호출할 때, SIGCHLD상수값으로 인자를 주면, 자식 프로세스 종료 시 시그널을 운영체제로 보내 핸들러로 등록된 함수를 대신 호출해준다. 이때 이 함수안에 자식 프로세스의 반환(인자)값을 처리하기 위한 waitpid, wait 함수를 호출하여 좀비 프로세스를 방지한다. 

 

05. SIGINT에 대한 핸들러를 등록하지 않은 상태에서 Ctrl+C 키가 입력되면, 운영체제가 지정해 놓은 디폴트 이벤트 핸들러에 의해서 프로그램이 종료되어 버린다. 그러나 Ctrl+C 키에 대한 핸들러를 직접 등록하면 프로그램은 종료되지 않고 프로그래머가 지정한 이벤트 핸들러가 대신 호출된다. 그렇다면 일반적인 프로그램에서 다음과 같이 동작하도록 이벤트 핸들러 등록을 위한 코드를 구성해 보자.

"Ctrl+C 키가 눌리면, 정말로 프로그램을 종료할 것인지 묻고, 이에 대한 대답으로 "Y"가 입력되면 프로그램을 종료한다."

 

그리고 간단히 문자열을 1초당 한번 정도 반복 출력하는 프로그램을 작성해서 위의 이벤트 핸들러 등록 코드를 적용시켜보자.

답)

/* linux */
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <signal.h>

#define BUF_SIZE			255

void signal_exit(int sig)
{
	char msg[10];
	if (sig == SIGINT)
	{
		fputs("do you want to exit(press y/Y) program?", stdout);
		fgets(msg, 10, stdin);
		if (!strcmp(msg, "y\n") || !strcmp(msg, "Y\n"))
			exit(-1);
	}
}

int main(int argc, char* argv[])
{
	char buf[BUF_SIZE];

	struct sigaction act;
	act.sa_handler = signal_exit;
	sigemptyset(&act.sa_mask);
	act.sa_flags = 0;
	sigaction(SIGINT, &act, 0);

	while (1)
	{
		puts("do you want exit?? press your key Ctrl+C!! hahaha!!!!");
		sleep(1);
	}

	return 0;
}