[소켓 #10] 멀티프로세스 기반의 서버 구현

이석환·2023년 4월 18일

Socket Programming

목록 보기
11/18

1. 지금까지 서버의 문제점

지금까지 공부하고 실습했던 코드들은 모두 서버와 클라이언트가 1대1의 관계를 가진다.
즉, 연결요청의 순서를 따라서 첫 번째 클라이언트부터 100번째 클라이언트까지 순차적으로 연결을 허용해서 서비스를 제공하는 서버였다.
하지만 이러한 서버는 실제로는 쓰이질 못한다.
예를 들어 설명해보겠다. 1초만에 서비스를 제공하는 서버가 있다.
순위권에 든 클라이언트는 만족할 수도 있다. 하지만 100번째 1000번째 클라이언트라면 ?
아무리 서비스 시간이 빠르다고 해도 대기시간이 길어지면 메리트가 없을 것이다.
반면에 3초만에 서비스를 제공하는 서버가 있다.
하지만 모든 클라이언트는 접속대기시간이 1초를 넘지 않는다.
당연히 후자의 서버를 고르지 않겠는가 ?
이번 챕터에서 모든 클라이언트의 만족도를 올릴 수 있는 다중 서버에 대해서 배워보자.

  • 네트워크 프로그래밍은 CPU의 연산을 필요치 않는 데이터의 송수신 시간이 큰 비중을 차지한다.
    그렇기 때문에 둘 이상의 클라이언트에게 동시에 서비스를 제공하는 것이 CPU를 보다 효율적이게 사용하는 것이라고 보면 된다.

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

다음은 대표적인 다중접속 서버의 구현 모델 및 구현 방법이다.

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

해당 챕터에서는 우선 멀티프로세스 기반 서버에 대해 배워보도록 하겠다.

2. 프로세스(Process)의 이해

  • 프로세스
    "메모리 공간을 차지한 상태에서 실행중인 프로그램"
    간단하게 말하면 하드디스크에 저장된 프로그램을 실행하면 메인 메모리에 올라가서 실행이 된다.
    그 시점부터 프로세스라고 부를 수 있다. 실행되는 수만큼 프로세스는 생성된다.
    그리고 그만큼 메모리 공간을 차지한다.
    즉, 실행중인 프로그램에 관련된 메모리, 리소스 등을 총칭하는 의미다.
    멀티프로세스 OS는 둘 이상의 프로세스를 동시에 생성 가능하다.

간단한 예를 들어보자
필자가 IntelliJ를 통해 개발을 하고 있다고 가정하자.
필자는 음악을 들으면서 하는 것을 작업하는 것을 좋아한다. 그리고 Notion을 통해 명세서를 본다고 가정하자.
그렇다면 필자는 총 3개의 프로세스를 동시에 생성하는 것이다.
이렇듯 프로세스는 운영체제의 관점에서 프로그램 흐름의 기본 단위가 되며, 여러 개의 프로세스가 생성되면 이들은 동시에 실행이 된다.
그러나 하나의 프로그램이 실행되는 과정에서 여러 개의 프로세스가 생성되기도 한다.
지금부터 구현할 멀티프로세스 기반의 서버가 대표적인 예이다.

  • CPU의 코어 수와 프로세스 수
    두 개의 연산장치가 존재하는 CPU를 가리켜 Dual 코어 CPU라고 하고, 4개는 Quad라고 한다.
    이렇듯 CPU는 실제 연산장치에 해당하는 코어가 둘 이상 존재할 수 있으며, 코어의 수만큼 프로세스는 동시 실행이 가능하다.
    반면 코어의 수를 넘어서는 개수의 프로세스가 생성되면, 프로세스 별로 코어에 할당되는 시간을 나눠서 가진다.
    하지만 CPU가 워낙 고속으로 프로세스를 실행하기 때문에 프로세스가 동시에 실행되는 것처럼 느끼게 된다.
    물론 코어의 수가 많을수록 그 느낌을 더 크게 받을 수 있다.
    그래서 코어가 많이 달린 CPU가 비싼 것이다.

2-1. 프로세스 ID (PID)

모든 프로세스는 생성되는 형태에 상관없이 OS로부터 ID를 부여 받는다.
이를 가리켜 PID라고 부르고 2 이상의 정수 형태를 가진다.
숫자 1은 OS가 시작되자마자 실행되는(OS를 돕는)프로세스에게 할당되기 때문에 프로세스는 1의 값은 받을 수 없다.

3. Fork()

fork 함수는 호출한 프로세스의 복사본을 생성한다.
즉, 전혀 새로운 프로세스를 만드는 것이 아니라, 실행중인 프로세스의 복사본을 만드는 것이다.
시점은 fork 함수를 호출했을 때, 그 이후로의 문장을 실행하게 된다.
완전히 동일한 프로세스로, 메모리 영역까지 복사한다.
하지만 fork() 된 이후에는 개별적인 메모리를 가지게 된다.
그래서 프로그램을 만들 때는 부모 프로세스와 자식 프로세스의 pid 반환 값이 다르다는 점을 이용해서 개별적으로 프로그램을 구현한다.

#include <unistd.h>

pid_t fork(void);

// 성공 시 프로세스 ID, 실패시 -1반환

/*
부모 프로세스 : fork 함수 반환값은 자식 프로세스 ID
자식 프로세스 : fork 함수 반환값은 0
*/

여기서 부모 프로세스는 원본 프로세스, 즉 fork를 호출한 주체다.
자식 프로세스는 fork 함수 호출을 통해서 복사된 프로세스이다.

위에 그림은 fork 함수의 동작 과정을 보여준다.
그러면 다음은 코드를 보며 설명하겠다.

#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)
		gval+=2, lval+=2;
	else
		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;
}

결과값이 어떻게 되겠는가 ?
fork 이전에 gval++, 와 lval+=5를 통해 각 변수의 값은 11, 25가 된다.
그 후 fork()를 통해 부모 프로세스와 자식 프로세스로 나뉘게 되며 분기문을 통해 각 다른 증감식을 겪게 된다.
실행결과는 fork 함수 이후에 부모와 자식이 서로 완전히 분리된 메모리 구조를 지님을 보이고 있다.
쉬운 예제이니 직접 한 번 해보길 바란다.

  • 결과

4. 좀비 프로세스

흔히 아는 좀비와 똑같다.
프로세스를 분명 종료되었는데 죽지 않고 살아있어서 리소스가 메모리 공간을 차지하고 있는 상태를 의미한다.
프로세스는 실행되고 나서 할 일을 다 하면 사라져야 하는데 사라지지 않고 좀비가 되어 시스템의 중요한 리소스를 차지하기도 한다.
이 상태에 있는 프로세스를 가리켜 '좀비 프로세스'라고 부르며 이는 시스템에 부담을 주는 원인이다. 그렇기 때문에 우리는 좀비 프로세스를 소멸시켜야 한다.

4-1. 생성 원인

좀비 프로세스는 왜 생성되는 걸까 ?
우선 자식 프로세스가 종료되는 상황을 알 필요가 있다. 다음과 같은 상황에서 종료된다.

  • 인자를 전달하면서 exit를 호출하는 경우
  • main 함수에서 return문을 실행하면서 값을 반환하는 경우
    exit 함수로 전달되는 인자 값과 main 함수의 return문에 의해 반환되는 값 모두 OS에 전달된다.
    그리고 OS는 이 값들을 부모 프로세스에게 전달될 때까지 자식 프로세스를 소멸시키지 않는다.
    이 때 이러한 상황에 놓인 자식 프로세스는 좀비가 된다.
    즉, 자식 프로세스를 좀비로 만드는 주체는 OS이다.

    좀비 프로세스는 언제 소멸이 될까 ?
    "해당 자식 프로세스를 생성한 부모 프로세스에게 위에 값들이 전달되면 없어진다"
    하지만 부모 프로세스가 가만히 있는데 OS는 이를 전달해주지 않는다.
    부모 프로세스에서 함수 호출이 있어야 OS는 이 값들을 전달해 준다.
    즉, 부모 프로세스가 자식 프로세스의 전달 값을 요청하지 않으면, OS는 계속해서 유지하게 되고 결국 자식 프로세스는 좀비의 상태로 남아있는다.
  • 좀비 프로세스를 직접 만들어보고 확인해보자
#include <stdio.h>
#include <unistd.h>

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

	pid = fork();
	if(pid==0)
		puts("Hi I'm a child process");
	else{
		printf("Child Process ID: %d \n",pid);
		sleep(30);
	}

	if(pid==0)
		puts("End child process");

	else
		puts("End parent process");
	return 0;
}

위 코드를 분석해보면 부모 프로세스는 우선 fork를 통해 자식을 만든다.
자식 프로세스는 pid 값으로 0을 가지기 때문에 (fork 반환값은 0) 분기문에 의하여 해당 코드들을 수행한다.
하지만 부모 프로세스는 sleep 함수를 통해 30초 동안 잠들게 된다.
즉, 자식 프로세스들은 실행이 되고 나서 프로그램이 종료되었다. 그리고 return 0을 통해 프로그램이 끝났음을 OS에게 전달하였다.
그러나 부모 프로세스는 잠들어있는 상태. 즉, 아직 종료되지 않았다. 그리고 자식 프로세스가 종료된 지 모른다.
이러한 상황에서 자식 프로세스는 좀비가 된다는 것이다.
좀비 상태인지 ps 명령어를 통해 확인해보자


위에 ps 명령어를 통해 확인 하였을 때는 자식 프로세스는 죽고 부모 프로세스는 sleep 상태이다.
확인해보면 Z+ (좀비) 상태임을 알 수 있다.
30초가 지난 후 부모 프로세스가 종료되고 다시 ps 명령어를 통해 확인해보면 두 프로세스 모두 정상적으로 종료되었음을 알 수 있다.

  • defunct 프로세스
    실행은 완료했지만, 부모 프로세스에게 완료 상태를 전달하지 못한 프로세스
  • 넘어가기 전에 하나 더
    필자는 해당 부분을 공부하면서 이해가 되지 않은 부분이 있었다.
    fork 함수로 만든 자식 프로세스는 어차피 개별적인 프로세스이기 때문에 분명 return 0으로 종료시키는데 그냥 종료되어야 하는 거 아닌가 ? 라고 생각했다.
    OS가 그냥 자식 프로세스의 종료됨을 알고 있고 이걸 부모에게 말해줘야 완전한 종료임을 알고 넘어갔다.
    결국 OS가 프로세스의 생명을 좌우한다고 생각하자.
    부모와 자식의 관계는 컴퓨터도 피해가지 못 하는 것 같다.
    이름 참 잘 지은 것 같다.

4-2. 좀비 프로세스의 소멸1 : wait 함수

위에서 좀비 프로세스의 소멸을 위해서 부모 프로세스가 자식 프로세스의 전달값을 요청해야 한다는 것을 알았으니 소멸시키는 방법을 알아보자.

#include <sys/wait.h>

pid_t wait(int * statloc);

// 성공 시 자식 프로세스의 ID, 실패 시 -1 반환

위 함수가 호출되었을 때, 이미 종료된 자식 프로세스가 있다면, 자식 프로세스가 종료되면서 전달한 값(exit 함수의 인자값, main 함수의 return에 의한 반환 값)이 매개 변수로 전달된 주소의 변수(statloc)에 저장된다.

이 변수에는 자식 프로세스가 종료되면서 전달된 값 이외에도 다른 정보가 함께 포함되어 있으니, 다음 매크로 함수를 통해서 값의 분리 과정을 거쳐야 한다.

  • WIFEEXITED : 자식 프로세스가 정상 종료한 경우 '참(TRUE)'을 반환한다.
  • WEXITSTATUS : 자식 프로세스의 전달 값을 반환한다.
  • 쉽게 외우는 방법 끊어서 읽어보자.

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

if(WIFEXITED(status))        // 정상 종료되었는가 ?
{
	puts("Normal termination!");
    printf("Child pass num : %d", WEXITSTATUS(status)); // 반환 값
}

아래는 위 함수를 이용한 코드를 보여주겠다.

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

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

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

	}
	return 0;
}

실행결과

위에 코드에서 부모 프로세스에 sleep(30)은 ps 명령어를 통해 자식 프로세스가 죽었는지 확인해보기 위해 넣은 것이다. 직접 확인해보자.
두 프로세스가 종료되면서 전달한 값 3과 7이 부모프로세스에게 전달되었음을 확인할 수 있다.

  • wait 함수는 호출된 시점에서 종료된 자식 프로세스가 없다면, 임의의 자식 프로세스가 종료될 때까지 블로킹(Blocking) 상태에 놓인다는 특징이 있다.
    즉, 종요될 자식 프로세스가 없는데 wait를 호출한다면 부모 프로세스는 종료를 하지 못하는 상황이 발생하기 때문에 함수 호출에 주의해야 한다.

4-3. 좀비 프로세스의 소멸2 : waitpid 함수

위에 wait 함수의 문제점을 보았다. 이 함수를 사용하면 블로킹 문제를 해결할 수 있다.
그리고 첫 번째 매개변수 PID를 통해 어떤 process를 멈추게할 지 정할 수 있다.

#include <sys/wait.h>

pid_t waitpid(pid_t pid, int * statloc, int options);

// 성공 시 종료된 자식 프로세스의 ID(또는 0), 실패 시 -1 반환

/*
pid : 종료를 확인하고자 하는 자식 프로세스의 ID 전달, 
	  이를 대신해서 -1을 전달하면 wait 함수와 마찬가지로 임의의 자식 프로세스가 종료되기를 기다린다.

staloc : wait 함수의 매개변수 staloc (wait 함수와 동일)

options : 헤더파일 sys/wait.h에 선언된 상수 WNOHANG을 인자로 전달하면, 
		  종료된 자식 프로세스가 존재하지 않아도 블로킹 상태에 있지 않고, 0을 반환하면서 함수를 빠져 나온다.
*/

아래는 위 함수를 이용한 코드를 보여주겠다.

#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(-1, &status, WNOHANG))
		{
			sleep(3);
			puts("sleep 3sec.");
		}

		if(WIFEXITED(status))
			printf("Child send %d \n", WEXITSTATUS(status));
	}
	return 0;
}

자식 프로세스의 종료를 늦추기 위해서 sleep 함수를 호출하였다.
그리고 부모 프로세스는 while문 내에서 waitpid 함수를 호출하고 있다.
세 번째 인자로 WNOHANG을 전달하였으니, 종료된 자식 프로세스가 없으면 0을 반환

  • 실행 결과

결과를 보면 while 반복문이 5회 실행되었음(자식 프로세스가 죽기 전까지)을 알 수 있다.
그리고 이는 waitpid 함수가 블로킹 되지 않음을 증명하는 결과이다.

5. Signal handling

지금까지 fork를 통해 여러 프로세스를 만들고 자식 프로세스가 좀비가 되는 것을 방지하는 방법까지 알아보았다.
하지만 문제점이 하나 남아있다.
"자식 프로세스가 언제 종료될 지 모르는데 waitpid 함수를 계속해서 호출하는 것은 맞지 않다!"

이 문제의 해결책을 한 번 알아보자

  • 위에서 계속해서 말하지만 자식 프로세스의 종료의 인식주체는 OS이다.
    그리고 그걸 부모 프로세스가 알아야 한다고도 말했다.
    즉, OS가 부모 프로세스에게 자식 프로세스가 종료되었다고 말해주면 더 효율적이지 않을까 ?
    그 때만 잠시 부모 프로세스는 하던 일을 멈추고 자식 프로세스의 종료와 관련된 일을 처리하면 된다.

이러한 구현을 위해서 시그널 핸들링이라는 것이 존재한다.

5-1. 시그널

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

예를 들어보면 이렇게 생각해보자

부모 프로세스 : OS야 내가 생성한 자식 프로세스가 종료되면 zombie_handler라는 이름의 함수를 호출해줘

OS : 걱정하지마. 자식 프로세스가 종료되면 내가 그 함수를 호출해줄게 그 대신에 실행해야 할 문장을 함수에다가 좀 만들어줘

이 상황에서 프로세스가 한 이야기가 '시그널 등록'에 속한다.
즉, 프로세스는 자식 프로세스의 종료라는 상황 발생시, 특정 함수의 호출을 OS에게 요구한다.
이 요구는 다음 함수의 호출을 통해서 이뤄진다.

#include <signal.h>

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

// 시그널 발생시 호출되도록 이전에 등록된 함수의 포인터 반환

네트워크 프로그래밍에 대해 배우고 있기 때문에 함수 포인터에 대한 설명은 넘어가겠다.

간단히 위에 함수를 설명하자면
함수 이름은 signal
매개 변수 선언은 int signo - 값, void(*func)(int) - 함수
반환형은 매개변수형이 int이고 반환형이 void인 함수 포인터

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

등록이 가능한 상수 값 몇 개를 예시로 보여주겠다.

그리고 시그널 등록의 예시도 보여주겠다. 여기서 두 번째 매개 변수는 사용자가 정의하는 함수이다.

이렇게 시그널이 등록되면, 등록된 시그널 발생시(등록된 상황 발생시), OS는 해당 시그널에 등록된 함수를 호출해준다.

#include <unistd.h>

unsigned int alarm(unsigned int seconds);

// 0 또는 SIGALRM 시그널이 발생하기까지 남아있는 시간을 초 단위로 반환

위 함수를 호출하면서 양의 정수를 전달하면, 전달된 수에 해당하는 시간(초 단위)이 지나서 SIGALRM 시그널이 발생한다.
그리고 0 을 인자로 전달하면 이전에 설정된 SIGALRM 시그널 발생의 예약이 취소된다.

여기서 주의할 점은 위의 함수 호출을 통해서 시그널의 발생을 예약만 해놓고, 이 시그널이 발생했을 때 호출되어야 할 함수를 지정하지 않으면 (signal 함수 호출을 통해서) 프로세스가 그냥 종료되어 버리니, 이를 조심하자 !

우선 코드를 보겠다.

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

위에 설명을 이해했다면 이 코드는 해석하기 굉장히 쉬울 것이다.
내 스타일대로 해석해보자면 알람이 울릴 때마다 timeout 함수를 실행시킨다.
그리고 CTRL+C를 누르면 keycontrol 함수를 실행시킨다.
여기서는 알람이 2초에 한 번 울리게 만들었다.
SIGALRM이나 SIGINT는 모두 상수값이고 전달된 매개변수도 상수값과 같은 값을 가진다는 것은 알 것이다. 그래서 해당 분기문에 코드를 실행시킨다.
그리고 timeout 함수에서는 계속해서 알람이 울리게 만들었다.
그리고 반복문을 보면 3번을 반복하는데 한 번 울릴 때마다 100초씩 쉬게 만들었다.
그러면 반복문 한 번 돌 때 분명 "Time out"은 50번이 출력될 것이다.
결과를 통해서 확인해보자.

  • 실행 결과

    위의 예제는 CTRL+C를 입력하지 않았을 때의 결과이다.
    어찌됐건 우리가 예상했던 것과 결과가 다르다.
    왜 그런 것일까 ?
    그 이유는
    "시그널이 발생하면 sleep 함수의 호출로 블로킹 상태에 있던 프로세스가 깨어난다."
    함수의 호출을 유도하는 것은 OS이지만, 그래도 프로세스가 잠들어 있는 상태에서 함수가 호출될 수는 없다.
    따라서 시그널이 발생하면, 시그널에 해당하는 시그널 핸들러의 호출을 위해서 블로킹 상태에 있던 프로세스는 깨어나게 된다.
    그리고 한 번 꺠어나면 다시 잠들지 않는다. sleep 함수의 호출문에서 요구하는 시간이 지나지 않아도 그렇다.
    그래서 위 예제에서 실행은 10초가 걸리지 않는 것이다.
    만약에 CTRL+C를 연속해서 입력했다면 실행은 훨씬 빨리 끝났을 것이다.

5-2. sigaction 함수

지금까지 설명한 내용으로도 좀비 프로세스의 생성을 막을 수 있다.
하지만 signal 함수에는 문제점이 하나 있다.
"signal 함수는 유닉스 계열의 OS별로 동작방식에 있어서 약간의 차이를 보일 수 있지만, sigaction 함수는 차이를 보이지 않는다."

실제로 요즘은 signal 함수를 사용해서 프로그램을 작성하지 않는다.
이 함수는 과거 프로그램과의 호환성을 위해서 유지만 되고 있다.
그래서 최근에는 이와 매우 유사한 sigaction 함수를 사용한다.
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이라는 이름의 구조체 변수를 선언 및 초기화해야 하는데, 이 구조체는 다음과 같이 정의되어 있다.
strutc sigaction
{
	void (*sa_handler)(int);
    sigset_t sa_mask;
    int sa_flags;
}

/*
sa_handler : 시그널 핸들러의 함수 포인터 값(주소 값)을 저장
sa_mask : 모든 비트를 0으로 초기화
sa_flags : 0으로 초기화
*/

sa_mask와 sa_flags는 시그널 관련 옵션 및 특성의 지정에 사용되는데, 우리의 목적은 좀비 프로세스의 생성을 막는데 있으므로 현재 챕터에서는 생략하겠다.

다음은 예제를 직접 보도록 하자.

#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, &act, 0);

	alarm(2);

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

시그널 발생시 호출될 함수의 등록을 위해서 이렇게 sigaction 구조체 변수를 선언해서 멤버 sa_handler에 함수 포인터 값을 저장해야 한다.
sigemptyset은 모든 bit를 0으로 만드는 것이고 sa_flags 역시 signal 함수를 대신하기 위해서 필요한 멤버가 아니기 때문에 0으로 초기화한다.
sigaction 함수를 통해 시그널 SIGALRM에 대한 핸들러를 지정했다.
그리고 alarm 함수 호출을 통해서 2초 뒤에 시그널 SIGALRM의 발생을 예약했다.

  • 실행 결과

    역시 마찬가지로 sleep으로 process를 100초간 잠들게 했지만 SIGALRM의 발생으로 바로 깨어난다.

6. 좀비 죽이기

드디어 좀비를 죽일 차례가 왔다.
자식 프로세스가 종료된 상황에 대한 시그널 이름이 SIGCHILD라는 것을 생각해 두고 코드를 한 번 작성해보자

#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);
			exit(24);
		}
		else
		{
			int i;
			printf("Child proc id: %d \n", pid);
			for(i=0; i<5; i++)
			{
				puts("wait...");
				sleep(5);
			}
		}
	}
	return 0;
}

main 함수 내에서 시그널 SIGCHLD에 대한 시그널 핸들러의 등록 과정을 보인다.
이로써 자식 프로세스가 종료되면 read_childproc로 정의된 함수가 호출된다.
그리고 이 함수 내에서 waitpid 함수 호출로 인해 프로세스는 좀비가 되지 않고 소멸된다.

부모 프로세스는 총 두 개의 자식 프로세스를 생성한다.

시그널 SIGCHLD의 발생을 대기하기 위해서 부모 프로세스는 5초간 5회 멈춰 놓았다.
물론 시그널이 발생하면 부모 프로세스는 깨어나기 때문에 실제 멈춰있는 시간은 25초는 되지 않는다.

  • 실행 결과

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

드디어 우리가 원하는 목표에 도달헀다.
fork 함수를 통해 멀티 프로레스를 만들고 시그널 함수를 통해 좀비 프로세스를 막을 수 있다.
서버다운 서버를 한 번 구현해보자

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

이전에 구현했던 에코 서버는 한 번에 하나의 클라이언트에게 서비스를 제공했다.
이번에는 동시에 둘 이상의 클라이언트에게 서비스를 제공하는 형태로 에코 서버를 확장해보자
다음 그림은 멀티프로세스 기반의 다중접속 에코 서버의 구현 모델을 보이고 있다.

여기서 포인트는 클라이언트의 서비스 요청(연결 요청)이 있을 때마다 에코 서버는 자식 프로세스를 생성해서 서비스를 제공한다.

즉, 서비스를 요청하는 클라이언트의 수가 다섯이라면 에코 서버는 추가로 다섯 개의 자식 프로세스를 생성해서 서비스를 제공한다.

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

  • 여기서 자식 프로세스에게 소켓의 파일 디스크립터를 어떻게 넘길까 ?
    코드를 보면 바로 알 수 있겠지만 별로 걱정하지 않아도 된다.
    fork() 함수를 사용하면 자식 프로세스는 부모 프로세스의 모든 걸 복사하기 때문이다.
    즉, 사실상 파일 디스크립터를 넘기는 과정은 별도로 구현할 필요가 없다.

코드를 먼저 보자.
client는 앞서 챕터4에서 구현한 clinet 코드를 사용하겠다.

  • Server
#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함수를 통해 만들어진 소켓(클라이언트의 연결 요청 수락 과정에서 만들어진)의 파일 디스크립터를 부모 프로세스와 자식 프로세스가 동시에 하나씩 갖게 된다.

  • 반복문 안에 if(pid ==0) 부터 return까지 자식 프로세스에 의해 실행되는 영역이다.
    이 부분에 의해서 클라이언트에게 에코 서비스가 제공된다.
    그런데 시작하자마자 처음에 만들었던 서버 소켓을 닫고 있다.
    이는 자식 프로세스로 서버 소켓의 파일 디스크립터까지 복사되기 때문이다.
    이는 잠시 후 별도로 설명하겠다.

  • 부모 프로세스는 accept 함수호출을 통해서 만들어진 소켓의 파일 디스크립터가 자식 프로세스에게 복사되었으니, 서버는 자신이 소유하고 있는 파일 디스크립터를 소멸시킨다.
    이에 대해서도 잠시 후 별도로 설명하겠다.

  • Client

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
void error_handling(char *message);

int main(int argc, char *argv[])
{
	int sock;
	char message[BUF_SIZE];
	int str_len;
	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);   
	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, (struct sockaddr*)&serv_adr, sizeof(serv_adr))==-1)
		error_handling("connect() error!");
	else
		puts("Connected...........");
	
	while(1) 
	{
		fputs("Input message(Q to quit): ", stdout);
		fgets(message, BUF_SIZE, stdin);
		
		if(!strcmp(message,"q\n") || !strcmp(message,"Q\n"))
			break;

		write(sock, message, strlen(message));
		str_len=read(sock, message, BUF_SIZE-1);
		message[str_len]=0;
		printf("Message from server: %s", message);
	}
	
	close(sock);
	return 0;
}

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

실행결과를 먼저 보고 설명하겠다.

  • 실행결과

    서버를 실행시킨 후 두 개의 클라이언트로 접속했다.
    다수의 클라이언트에게 에코 서버가 서비스를 제공함을 확인할 수 있다.

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

위에 server 코드에서는 fork 함수 호출을 통한 파일 디스크립터의 복사를 보여줬다.
부모 프로세스가 지니고 있던 두 소켓(서버 소켓, 클라이언트와 연결된 소켓)의 파일 디스크립터가
자식 프로세스에게 복사 되었다.

먼저 간단하게 결론부터 말하자면
자식 프로세스에서 서버소켓을 닫은 이유는 fork() 수행 후 자식프로세스에게 serv_sock
즉, 서버 소켓의 파일 디스크립터가 복사되기 때문이다.
그리고 부모 프로세스에서 클라이언트소켓을 닫는 이유는 부모 프로세스는 accept만 수행하기 때문에 클라이언트 소켓의 파일 디스크립터가 필요가 없다.

자세하게 설명해보도록 하겠다.
fork 함수 호출 후 자식 프로세스에게 복사되는 건 소켓이 아니라 파일 디스크립터다.
소켓은 프로세스의 소유가 아니라 OS의 소유이다.
다만 해당 소켓을 의미하는 파일 디스크립터만 이 프로세스의 소유이다.
결정적으로

"소켓이 복사되면 동일한 PORT에 할당된 소켓이 둘 이상이 된다."

즉, 예제 server 코드에서 fork 함수의 호출 결과는 다음과 같다.
fork 함수 호출 이후에 하나의 소켓에 두 개의 파일 디스크립터가 할당된 모습이다.


이와 같이 하나의 소켓에 두 개의 파일 디스크립터가 존재하는 경우, 두 개의 파일 디스크립터가 모두 종료되어야 소켓은 소멸된다.

때문에 위와 같은 형태를 유지하면 자식 프로세스가 클라이언트와 연결되어 있는 소켓을 종료시키려고 해도 소멸되지않고 계속 남아있게 된다.
이는 서버 소켓도 마찬가지이다.

그래서 fork 함수 호출 후에는 다음 그림과 같이 서로에게 상관이 없는 소켓의 파일 디스크립터를 닫아야 한다.


위 그림과 같이 파일 디스크립터를 정리하기 위해서
자식 프로세스에서는 서버 소켓을 닫아주고
부모 프로세스는 클라이언트 소켓을 닫는 것이다.

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

지금까지 구현한 에코 클라이언트의 방식은 다음과 같다.

1. 서버에 데이터를 전송한다.
2. 데이터가 에코되어 돌아올 때까지 기다린다.
3. 무조건 기다린다.
4. 그리고 에코되어 돌아온 데이터를 수신한다.
5. 데이터를 추가로 전송한다.

즉, 한번 데이터를 전송하면 에코 되어 돌아오는 데이터를 수신할 때까지 마냥 기다려야 했다.
이유가 무엇일까 ?

프로그램 코드의 흐름이 read와 write를 반복하는 구조였기 때문이다.
이렇게 했던 이유는 지금까지 하나의 프로세스를 기반으로 프로그램이 동작했기 때문이다.
하지만 우리는 이제 둘 이상의 프로세스를 생성할 수 있다.
이를 바탕으로 데이터의 송신과 수신을 분리해보자


위 그림과 같이 클라이언트의 부모는 데이터의 수신을 담당
클라이언트의 자식은 데이터의 송신을 담당한다.
그리고 이렇게 구현해 놓으면 입력과 출력을 담당하는 프로세스가 각각 다르기 때문에
서버로부터의 데이터 수신 여부에 상관없이 데이터를 전송할 수 있다.

  • 그렇다면 에코 클라이언트의 장점은 무엇일까 ?
    프로그램의 구현이 한결 수월해진다.
    프로세스를 하나 더 만드는 것이 뭐가 수월해지냐고 물어볼 수 있다.
    하지만 이렇게 송신과 수신을 구분해두면 프로세스 생성 이후에 부모 프로세스는 수신
    자식 프로세스는 송신에만 관련해서 코드를 작성하면 되기 때문에 구현이 훨씬 수월하다.
    이러한 차이는 프로그램이 복잡할수록 더 극명하게 들어난다.

또 다른 장점으로는 데이터의 송수신이 잦은 프로그램에서 성능향상을 기대할 수 있다.
데이터 수신 여부에 상관없이 송신할 수 있기 때문이다.


위 그림의 왼쪽은 이전 에코 클라이언트의 데이터 송수신 방식
오른쪽은 입출력 루틴을 분리시킨 에코 클라이언트의 데이터 송수신 방식이다.
서버에는 차이가 없다. 차이가 나는 부분은 클라이언트 영역이다.
입출력 루틴이 분리된 클라이언트는 데이터의 수신 여부에 상관없이 데이터 전송이 가능하기 때문에
연속해서 데이터의 전송이 가능하다.
따라서 동일한 시간 내에서의 데이터 송수신 분량이 상대적으로 많을 수 밖에 없다.
이러한 성능적 차이는 데이터의 전송속도가 느린 환경에서 더 확실히 드러난다.

물론 에코 클라이언트에서는 입출력 루틴을 분리할 필요는 없다.
어차피 내가 송신한 걸 다시 수신받기 때문이다.
설명을 위해 해당 예제에서 에코 클라이언트를 삼았을 뿐이다.

  • Client
#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);
}
  • 자식 프로세스에서 호출하는 write_routine 함수에는 데이터 출력에 관련된 코드만 존재한다.
    그리고 부모 프로세스에서 호출하는 read_routine 함수에는 데이터 입력에 관련된 코드만 존재한다.
    이렇듯 입력 출력 루틴을 구분해서 각각의 함수로 정의하는 것은 구현의 편의성을 준다.

  • 서버로의 EOF 전달을 위해서 shutdown 함수를 호출했다.
    write_routine 함수호출의 return문 실행 이후에 close 함수 호출을 통해 EOF의 전달을 기대할 수 있지만, 현재 fork 함수 호출에 의해 파일 디스크립터가 복사된 상황이다.
    그리고 이러한 상황에서는 한 번의 close 함수 호출로 EOF의 전달을 기대할 수 없다.
    따라서 반드시 shutdown 함수 호출을 통해서 EOF의 전달을 별도로 명시해야 한다.
  • 실행 결과

참고 : 윤성우의 열혈 TCP/IP 소켓 프로그래밍
Git : https://github.com/im2sh/Socket_Programming/tree/main/lab07

profile
반갑습니다.

0개의 댓글