Part1_멀티태스킹 기반의 서버 구현(1)

·2023년 11월 21일
0

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

리눅스 기반에서의 다중 접속 서버 구현 방법

  • 프로세스 생성을 통한 멀티태스킹 서버의 구현
  • select 함수에 의한 멀티플렉싱 서버의 구현
  • 쓰레드를 기반으로 하는 멀티쓰레딩 서버의 구현

[프로세스 ID]

ps -u 커맨드를 사용하면 확인 가능 (1은 init 프로세스에 할당됨)

[실행 결과]

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

#include <sys/types.h>
#include <unistd.h>

pid_t fork(void);

// 성공 시 프로세스 ID, 실패 시 -1을 리턴
// 호출한 프로세스의 복사본 프로세스를 생성함
// 성공 시 
// (1) 원본 프로세스(부모 프로세스)와 복사본 프로세스(자식 프로세스)에게 전달되는 리턴 값이 달라짐
// (2) 메모리 공간(데이터, 힙, 스택)을 그대로 복사함 -> 부모 프로세스와 자식 프로세스 간의 데이터 공유가 일어나지 않음
// fork.c

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

int main(int argc, char** argv)
{
	pid_t pid;
	int data = 10;
	pid = fork();
	if(pid == -1)
		printf("fork 실패, 프로세스 id : %d \n", pid);
	else
		printf("fork 성공, 프로세스 id : %d \n", pid);

	if(pid == 0) // 자식 프로세스라면 (리턴값이 0이라면)
		data += 10;
	else         // 부모 프로세스라면
		data -= 10;

	printf("data : %d \n", data);
	return 0;
}

[실행 결과]

[프로세스 & 좀비 프로세스]

[좀비 프로세스]

프로세스가 생성되고 나서 할 일을 다 했음에도 불구하고 사라지지 않고 중요한 리소스를 차지해서 성능을 저하시키는 원인이 되는 프로세스

[좀비 프로세스가 생성되는 이유]

fork 함수의 호출로 자식 프로세스가 생성이 되고 그 자식 프로세스가 exit 함수나 return 문을 이용하여 값을 반환하는 경우

반환된 값은 커널로 넘어감

커널은 자식 프로세스의 실행이 끝났더라도 리턴 값을 부모 프로세스에게 넘겨줄 때까지 자식 프로세스를 소멸시키지 않음

⇒ 리턴 값이 부모 프로세스에게 전달되도록 해야 자식 프로세스가 소멸되지 않고 좀비 프로세스가 되는 것을 막을 수 있음

커널은 부모 프로세스가 가만히 있는데 자식 프로세스의 리턴 값을 전달해 주지는 않음

⇒ 부모 프로세스가 커널에게 자식 프로세스가 리턴한 값을 전달해 달라고 요청해야 함

[좀비 프로세스 예제]

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

int main(int argc, char** argv)
{
	pid_t pid;
	int data = 10;

	pid = fork(); // 자식 프로세스 생성

	if(pid < 0)
		data += 10; // 자식 프로세스에 의해 실행됨
	else
	{
		data -= 10; // 부모 프로세스에 의해 실행됨
		sleep(20);  // 20초 동안 대기 상태로 둔 이유는 그 사이에 자식 프로세스가 좀비가 된 것을 확인해 보기 위해
	}

	print("data : %d \n", data);
	return 0;
}

[실행 결과]

ps -u가 실행되기 전 자식 프로세스는 0을 리턴하면서 종료하게 되고, 부모 프로세스는 20초간의 정지 상태에 들어가게 됨

부모 프로세스가 자식 프로세스의 리턴 값을 읽어 들이지 않았으므로 자식 프로세스는 좀비 프로세스가 됨

[wait 함수의 사용]

자식 프로세스가 소멸되기 위해서는 부모 프로세스가 자식 프로세스의 리턴 값을 읽어 들여야 함

#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int* status)

// 성공 시 종료된 자식 프로세스 ID 실패 시 -1 리턴

// wait 함수가 호출되었을 때, 이미 종료 된 자식 프로세스가 있다면, 
// 그 프로세스가 리턴한 값을 함수 호출 시 전달되는 포인터를 통해 읽어들임
// 그러나 wait 함수가 호출된 시점에서 종료된 자식이 없다면 임의의 자식 프로세스가 종료될 때 까지
// 블로킹 상태에 놓이게 되며, 자식 프로세스 중 하나가 종료되어야만 리턴 값을 읽어 들이고 빠져 나오게 됨

[종료 상태를 확인할 수 있는 매크로 함수]

status 포인터가 가리키는 변수에 저장된 값을 통해서 원하는 정보만 리턴받을 수 있도록 구현되어 있는 매크로 함수들

[wait 함수와 매크로 함수의 사용 예]

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

int main(int argc, char** argv)
{
	pid_t pid, child;
	int data = 10;
	int state;

	pid = fork(); // 자식 프로세스 생성
	
	if(pid < 0)
		printf("fork 실패 프로세스 id : %d \n", pid);

	printf("fork 성공 프로세스 id : %d \n", pid);

	if(pid == 0)
		data += 10;
	else
	{
		// 부모 프로세스에 의해서 실행됨
		data -= 10;
		child = wait(&state); // wait 함수를 호출하면서 자식 프로세스가 종료되기를 기다림
		printf("자식 프로세스 ID = %d \n", child);
		printf("리턴 값 = %d \n", WEXITSTATUS(state));
		sleep(20); // 20초 동안 정지 상태에 들어갔을 때는 종료된 자식 프로세스가 소멸된 상태임 -> 프로세스 상태 확인을 위해 대기 시킴
	}

	printf("data : %d \n", data);
	return 0;
}

[실행 결과]

20초 정지 상태에 있을 때 프로세스의 상태를 확인 해 보면 자식 프로세스에 대한 정보가 없음

⇒ 부모 프로세스가 생성했던 자식 프로세스가 완전히 사라짐

[waitpid 함수의 사용]

wait 함수는 경우에 따라서 적절하지 못할 수 있음

→ wait 함수를 호출한 시점에서 종료된 자식 프로세스가 존재하지 않으면 종료된 자식 프로세스가 생길 때 까지 무한 블로킹 상태에 있게 되기 때문

⇒ 좀비가 존재하는지 확신할 수 없는 상태에서 호출했을 경우 문제가 되기도 함

기본적인 동작은 wait 함수와 동일하지만, 함수 호출 시 전달되는 인자에 따라서 블로킹 문제를 해결해 주는 함수가 있음

#include <sys/types.h>
#include <sys/wait.h>

pid_t waitpid(pid_t pid, int* status, int options)

// 성공 시 종료된 자식 프로세스 ID (경우에 따라 0), 실패 시 -1 리턴
// pid : 종료 확인을 원하는 자식 프로세스의 ID
// status : wait 함수의 status와 같은 역할을 함
// options : sys/wait.h에 정의되어 있는 'WNOHANG' 상수를 인자로 전달하게 되면 이미 종료된 자식 프로세스가 없는 경우
// 대기 상태로 들어가지 않고 바로 리턴하게 됨 -> 무한정 블로킹 상태에 빠지지 않음 => 이 때 리턴되는 값이 0

[waitpid 예제]

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

int main(int argc, char** argv)
{
	pid_t pid, child;
	int data = 10;
	int state;

	pid = fork();

	if(pid < 0)
		printf("fork 실패, 프로세스 id : %d \n", pid);

	printf("fork 성공, 프로세스 id : %d \n", pid);

	if(pid == 0)
	{
		data += 10; 
		sleep(10); // 자식 프로세스의 종료를 지연시키기 위해 10초간 정지 상태에 들어감
	}
	else
	{
		data -= 10;
		
		// 3초 간격으로 waitpid 함수를 호출하면서, 자식 프로세스의 소멸을 확인함
		// 종료된 자식 프로세스가 없다면 계속해서 do ~ while문을 반복하게 됨
		do
		{
			sleep(3);
			puts("3초 대기");
			child = waitpid(-1, &state, WNOHANG);
		}while(chiild == 0);

		printf("Child process id = %d, return value = %d \n\n", child, WEXITSTATUS(state));

	}
	
	printf("data : %d \n", data);
	return 0;
}

[실행 결과]

3초 대기라는 메시지가 출력되고 있음

→ waitpid 함수 호출 시 종료된 자식 프로세스가 없다면, 바로 0을 리턴하면서 함수를 빠져 나온다는 것을 보여줌

[시그널 핸들링 & 좀비 프로세스]

프로세스를 생성하는 방법과 완전히 소멸하는 방법에 대해서 알았지만 waitpid 함수를 언제 호출할건지에 대한 문제가 남아있음

블로킹 문제가 해결된다 하더라도, 이전 예제처럼 계속해서 루프를 돌면서 확인해 볼 수는 없음

→ 가장 이상적인 방법은 자식 프로세스가 종료되는 순간에 커널이 부모 프로세스에게 알려줘서 이를 처리하는 것임

⇒ 시그널 핸들링을 사용하면 이런 구현이 가능함

(참고)

이처럼 커널에 의해서 메시지가 전달되는 방식을 비동기(Asynchronous) 방식이라고 함

[시그널 핸들링]

  • 시그널 시스템에 특정 상황이 발생했을 때, 이를 알리기 위해 운영체제가 전달하는 메시지
  • 시그널 핸들러 시그널을 처리하는 함수 모듈
  • 시그널 핸들링 시그널이 발생했을 때, 이에 대한 시그널 핸들러를 실행시키는 행위

[특정 상황과 시그널]

[signal 함수를 이용한 시그널 핸들링]

#include <signal.h>

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

// signum : 프로세스가 가로채고자 하는 시그널 상수
// 프로세스가 가로챈다의 의미는
// 특정 상황이 발생하여 운영체제가 이를 알리기 위해서 시그널을 프로세스에게 전달하는 것
// func : 시그널을 처리할 함수의 포인터

[Signal 함수 예제]

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

void handler(int sig);

int main(int argc, char** argv)
{
	int state;
	int num = 0;

	// 시그널이 발생하는 경우 handler 함수가 호출되도록 설정
	signal(SIGINT, handler);
	while(1)
	{
		printf("%d : 대기중 \n", num++);
		sleep(2);
		if(num > 5) break;
	}

	return 0;
}

void handler(int sig)
{
	signal(SIGINT, handler);
	printf("전달된 시그널은 %d \n", sig);
}

[실행 결과]

실행 중 Ctrl + C(인터럽트 발생)를 누르면 프로그램에서 정의한 시그널 핸들러가 실행되면서 메시지를 콘솔에 출력함

SIGINT 시그널에 대해 핸들러 설정을 해 주지 않았다면 SIGINT 시그널이 발생하지 않을까?

→ signal 함수의 호출을 주석 처리하고 실행하면 Ctrl + C 키를 누르자 마자 프로그램이 바로 종료됨

즉, 핸들러를 설정해 주지 않았다고 해서 시그널이 발생하지 않는 것은 아님

시그널은 발생됨

→ 우리가 직접 핸들러를 설정해 주지 않으면, 운영체제 차원에서 지니고 있는 기본적인 핸들러가 동작하게 됨

⇒ 운영체제에서 디폴트로 제공하는 SIGINT 시그널에 대한 핸들러는 프로세스를 그냥 종료 시켜버림

(핸들러를 설정 해 주면 운영체제 핸들러 대신 내가 설정한 시그널 핸들러가 호출됨)

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

#include <signal.h>
int sigaction(int signum, const struct sigaction* act, struct sigaction* oldact);

// 리턴 값은 성공 시 0을, 실패 시 -1을 리턴함
// signum : signal 함수와 마찬가지로 가로 채고자 하는 시그널의 종류를 인자로 전달함
// act : 새로 등록할 시그널 핸들러 정보로 초기화된 sigaction 구조체 변수의 포인터를 인자로 전달함
// oldact : 이전에 등록되었던 시그널 핸들러의 포인터를 얻고자 할 때 사용함

[sigaction 구조체]

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

// sa_handler : 함수 포인터 (시그널을 처리하는 시그널 핸들러의 포인터 대입)
// 시그널은 쌓이지 않음
// 즉, 동일한 이벤트가 연이어서 다섯 번 발생했다고 해서, 프로세스에게 동일한 시그널을 다섯 번 전달해 주지 않음
// sa_mask에 설정된 시그널들은 동일한 이벤트가 연이어서 다섯 번 발생하는 경우 순차적으로 시그널을 발생시킴
// 즉 첫 번째 시그널이 처리되는 동안 나머지 시그널들은 블로킹 상태에 있게 됨

// sa_mask : 시그널 핸들러 함수가 실행되는 동안에 블로킹될 시그널들을 설정하는 요소
// 주로 모든 비트를 0으로 masking 함

// sa_flags : 시그널을 핸들링하는데 있어서 필요한 옵션을 설정하는 경우에 사용 (기본적으로 0으로 설정)

[sigaction 함수 예제]

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

void handler(int sig);

int main(void)
{
	int state;
	int num = 0;

	struct sigaction act;
	act.sa_handler = handler;
	sigemptyset(&act.sa_mask);
	act.sa_flags = 0;

	state = sigaction(SIGINT, &act, 0);
	if(state != 0)
	{
		puts("sigaction() error ");
		exit(1);
	}

	while(1)
	{
		printf("%d : 대기중 \n", num++);
		sleep(2);
		if(num > 5) break;
	}
	return 0;
}

void handler(int sig)
{
	printf("전달된 시그널은 %d \n", sig);
}

[실행 결과]

[Sigalrm 시그널 핸들링 예제]

#include <unistd.h>

unsigned int alarm(unsigned int seconds);

// 0 혹은 SIGALRM 시그널이 발생 하기까지 남아 있는 초 단위 시간을 리턴

// seconds : SIGALRM 시그널 발생을 초 단위로 예약함
// 이전에 이미 설정되어 있던 SIGALRM 시그널 예약을 취소하는 목적으로 0 전달도 가능
// 즉, 인자로 0이 전달 되면 예약 되어 있던 시그널은 취소됨
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>

void timer(int sig);

int main(int argc, char** argv)
{
	int state;
	struct sigaction act;
	act.sa_handler=timer;
	sigemptyset(&act.sa_mask);
	act.sa_flags = 0;

	state = sigaction(SIGALRM, &act, 0);
	if(state != 0)
	{
		puts("sigaction() error ");
		exit(1);
	}

	alarm(5);

	while(1)
	{
		puts("대기중");
		sleep(2);
	}
	return 0;
}

void timer(int sig)
{
	puts("예약하신 시간이 되었습니다!! \n");
	exit(0);
}

[실행 결과]

0개의 댓글