본문 바로가기

시스템/네트워크

다중 접속 서버 구현 방법 (feat. IPC, Thread)

다중 접속 서버의 구현 방법

1) 멀티프로세스 기반 서버 : 다수의 프로세스 생성하는 방식

  • 프로세스 생성
  • 좀비프로세스 소멸
  • 시그널 함수 등록
  • 멀티프로세스 기반 다중 접속 서버

2) 멀티플렉싱 기반 서버 : 입출력 대상을 묶어서 관리하는 방식

 

3) 멀티쓰레딩 기반 서버 : 클라이언트 수만큼 쓰레드를 생성하는 방식

    • 동기(Motivation)
    • 방법(Method)
    • Thread-Safe, Thread-Nonsafe
    • Synchronization(mutex, semaphore
    • 멀티 쓰레딩 기반 다중 접속 서버

1) 멀티 프로세스 기반 서버

1-1) 프로세스 생성

- fork 함수 호출하는 순간 자식 프로세스가 복사되어, 각각의 fork 함수 환 값을 받게 된다.

  (자식 프로세스는 fork 함수가 호출된 위치까지 실행해온다)

- 부모 프로세스 : 자식 프로세스의 ID 값 반환 받음

- 자식 프로세스 : 0값 반환 받음

- 부모와 자식은 완전히 분리된 메모리 구조를 가진다

- 부모 프로세스는 자식 프로세스 종료 후 정리 해 줄 의무가 있다(그렇지 않으면 운영체제는 자식 프로세스 종료 X)

#include <unistd.h>

pid_t fork(void);
pid_t getpid(); //현재 프로세스의 pid 반환
pid_t getppid(); //현재 프로세스의 부모 프로세스 pid 반환

 

※ 좀비 프로세스 생성 이유 

- 자식 프로세스 종료 단계 

자식 프로세스의 (exit함수 호출 or 리턴) --> (자식 프로세스 exit 함수 인자, main 리턴 ) 영 체제 전달 -->

운영체제는 해당 값을 부모 프로세스에 전달 --> 부모 프로세스 수신

 

- 부모 프로세스 수신 전까지 자식 프로세스는 소멸 전이다. 이때 부모 프로세스가 자식 프로세스 종료를 요청하지 않으면, 자식 프로세스는 좀비 상태에 놓이게 된다(커널 프로세스 테이블에 남아 있는 상태-->시스템 리소스 장악)

 

부모 프로세스 소멸되면, 자식 프로세스도 같이 소멸된다.

부모 프로세스와 자식 프로세스는 소켓은 공유하지 않는다(소켓은 운영체제 소유). 전역 변수는 공유한다.

 

1-2) 좀비 프로세스의 소멸

- wait 함수 : 임의의 자식 프로세스가 종료될 때 까지 블로킹(Blocking) 상태에 놓이게 된다(단점)

#include <sys/wait.h>

pid_t wait(int *statloc);

//ex) wait(status) <-- 해당 라인에서 Blocking

WIFEXITED(state) : 자식 프로세스가 정상 종료되면 true 반환한다.

※ WEXITSTATUS(state) : 자식 프로세스의 전달 값을 반환한다.

더보기
// 메크로
WIFEXITED(wstatus)   //종료 여부
WEXITSTATUS(wstatus) //종료 값. 자식프로세스의 exit 반환 값 혹은 리턴 값
WIFSIGNALED(wstatus) 
WTERMSIG(wstatus)    //프로세스를 종료 시킨 시그널 번호 반환
WCOREDUMP(wstatus)    
WIFSTOPPED(wstatus)
WSTOPSIG(wstatus)
WIFCONTINUED(wstatus)

 

- waitpid 함수 : waitpid 함수보다는 능동적

  - pid : 종료되길 기다리는 자식 프로세스 ID, -1이면 임의의 자식 프로세스가 종료되길 기다린다.

  - options: WNOHANG을 인자로 전달하면, 종료된 자식 프로세스가 없어도 블로킹 상태에 놓이지 않는다. 이때 0 반환.

#include <sys/wait.h>

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

//ex) while(!waitpid(-1, &status, WHOHANG)) <-- 자식 프로세스 종료 아니면 빠져 나온다.

pid_t wait4(pid_t pid, int *statloc, int options, struct rusage *rusage);
//rusage는 리소스 사용량

 

1-3) 시그널 등록 함수

좀비 프로세스가 발생하면, 프로세스가 매번 확인하는 것이 아니라 OS로 하여금 알려주게끔 하기 위해서 시그널 핸들러 함수를 등록한다.

- 함수이름 : signal

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

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

 

<함수 등록 가능한 상황>

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

SIGINT : CNTL + C 가 입력된 상황

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

#include <signal.h>

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

 

특정 상황이 발생하면 시그널을 보내도록 요청하는 것이, 임베디드의 인터럽트 서비스 함수 등록 과정과 유사하다.

시그널 핸들러를 등록하지 않으면, 디폴트 이벤트 핸들러가 호출된다.

(시그널 핸들러 = 서비스 함수 = 서비스 핸들러)

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

 

※ sigaction으로 시그널 함수를 등록하는 것이 조금 더 안정적이다.

#include <signal.h>

int sigaction(int signo, const strut sigaction *act, struct sigaction *oldact);

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

예제)

더보기
#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;
}

Child proc id: 4770
Hi! I'm child process
Child proc id: 4771
Hi! I'm child process
wait...
wait...
Removed proc id: 4770
Child send: 12
wait...
Removed proc id: 4771
Child send: 24
wait...
wait...

특이점) 자식 프로세스가 10초 뒤에 동시에 끝나지만, 부모 프로세스 sleep(5) 이 한번 더 수행되고, 미뤄졌던 시그널 함수가 호출된다.

 

1-4) 멀티 태스킹(프로세스) 기반 다중 접속 서버

이전에 구현했던 에코 서버는 한번에 하나의 클라이언트에게만 서비스를 제공할 수 있었다. 동시에 둘 이상의 클라이언트에게 서비스를 제공하지 못하는 구조였다. 멀티프로세서 기술을 활용해 다중접속 에코 서버 구현이 가능하다. 

 

멀티 프로세서 기술을 사용하면 구체적으로 다음이 가능하다.

서버

    - 다중 접속 에코 클라이언트 요청이 있을 때 마다 에코 서버를 fork 하여 각각 클라이언트를 담당하게 가능.

      (이때 부모 프로세스는 클라이언트 연결 소켓을 끊어주고, 자식 프로세스는 서버 연결 소켓을 끊어줘야 한다)

클라이언트

    - 클라이언트 부모 프로세스는 데이터 수신(READ), 자식 프로세스는 데이터 송신(WRITE)를 담당하게 분리

    - TCP의 입출력 루틴 분할

    - 입출력 루틴이 분할된 클라이언트는 데이터 수신 여부에 상관 없이 데이터 (연속) 전송이 가능하다.

 

 

2) 멀티 플렉싱 기반 서버

(작성중)

 

3) 멀티 쓰레딩 기반 서버

3-1) 동기

- 멀티 프로세스를 구성하기 위해서 프로세스 메모리 통으로 복사는 부담

- (하나의 코어일 경우) 프로세스간 작업을 처리하기 위해 잦은 문맥전환(Context Switching)은 부담

 

3-2) 방법

- 하나의 프로세스 내에서 둘 이상의 실행 흐름을 구성. 어떻게?

- 데이터 영역과 힙 영역을 공유, 스택 영역만 따로 구성

 

 

스레드 생성시 전달 인자는 (void *)으로 하여 int, struct 어떤 형태든 쓰레드에 전달이 가능하다.

#include <pthread.h>
int pthread_create(IN pthread_t *restrict thread, IN const pthread_attr_t *restrict attr, IN void *(*start_routine)(void*), IN void *restrict arg);

프로세스가 종료되면, 해당 프로세스에서 생성된 쓰레드도 함께 소멸된다.

 

pthread_join은 쓰레드 종료 될 때까지 프로세스가 대기해준다. \

#include <pthread.h>
int pthread_join(pthread_t thread, void **status); //★ status는 이중 포인터. 보통은 void 포인터 주소 넘긴다.

int pthread_detach(pthread_t thread); //쓰레드의 소멸

중요한 예제)

- 쓰레드가 내부에서 메모리를 할당해서 프로세스에 전달해도 프로세스가 참조할 수 있는 이유는 Heap 영역을 공유하기 때문이다.

 

더보기
#include <stdio.h>
#include <pthread.h>

void *thread_main(void *arg);

int main(int argc, char *argv[])
{
    pthread_t t_id;
    int thread_param = 5;
    void *thr_ret;

    if(pthread_create(&t_id, NULL, thread_main, (void*)&thread_param) != 0)
    {
        puts("pthread_create() error"); return -1;
    }

    if(pthread_join(t_id, &thr_ret) != 0) //★ 포인터의 주소를 인자로 넘긴다.
    {
        puts("pthread_join() error");  return -1;
    }

    printf("Thread return message : %s \n", (char *)thr_ret);
    free(thr_ret);
    return 0;
}

void *thread_main(void *arg)
{
    int cnt = *((int *) arg);
    char *msg = (char *)malloc(sizeof(char *) * 100);
    strcpy(msg, "Hello ~ I'm thread \n");

    while(cnt--)
        sleep(1); puts("running thread");

    return (void *)arg;
}

3-3) Thread-safe vs Thread-nonsafe

- 쓰레드 불안전한 함수 여부가 임계영역 유무가 아니다. 쓰레드에 안전한 함수도 임계영역이 존재할 수 있다.

- 다만, 둘 이상의 쓰레드가 접근해도 문제가 없도록 적절한 조치가 취해져 있다.

 

ex) struct hostent *gethostbyname_r 은 임계영역이 존재하지만 thread_safe 하도록 조치가 취해져 있다.

(struct hostent *gethostbyname 함수는 thread_safe 하지 않았다)

 

코드를 전부 다 바꿔줄 순 없고...

-D_REENTRANT 옵션을 주면 컴파일시 넣어주면 thread_safe 함수로 자동 호출 한다.

 

일반적으로 임계영역은 쓰레드에 의해서 실행되는 함수 내에 존재한다.

 

3-4) Thread Synchronization

- 하나의 변수(메모리 영역)에 둘 이상의 쓰레드가 동시 접근하면 문제가 생길 수 있다.

- ex) 쓰레드 A가 var=100을 가져가서 1을 증가시키고 다시 가져다 놓기 전에 쓰레드 B가 var를 참조한다.

 

#include <pthread.h>

int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

임계 영역을 넓게 잡으면 경우에 따라선 mutex의 호출 횟수를 줄여 수행 시간을 줄일 수 있지만, 동시에 멀티 쓰레드 장점을 발휘하지 못할 수 도 있다. 그래도 mutex의 호출 횟수를 줄이는 것이 일반적으로 유리하다.

 

#include <semaphore.h>

int sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_destroy(sem_t *sem);

int sem_post(sem_t *sem);
int sem_wait(sem_t *sem);

 

※ Mutex vs Semaphore (임계영역 보호 관점)

- 뮤택스소유한 Task/Thread 만 그걸 다시 돌려줄 수 있다. Key 라고 생각하면 편하다.

- 세마포어는 동일한 키로 열수 있는 방 갯수라고 생각하면 된다. 즉, 접근할 수 있는 Task/Thread 수를 제한한다

 

Q) 그렇다면 (키가 1개만 있는) Binary Semaphore 와 Mutex는 동일한 것일까? 

A) 아니다. Binary Semaphore는 B가 Semaphore를 가지고, A에게 줄 수 있다. 심지어, 동일한 테스크가 Binary Semaphore를 주고 받는 경우는 잘 없다.

A2) Mutex는 임계영역 보호 목적이 크고, Binary Semaphore는 동기화 목적이 크다("Hey! It's done!")

 

https://stackoverflow.com/questions/62814/difference-between-binary-semaphore-and-mutex

 

참고)

아래 예시를 보듯, 하나의 스레드가 동일한 세마 포어 변수를 사용하는 것이 아니다.

하나의 세마포어 변수(read 레드의 sem_one)로 다른 쓰레드(accu 쓰레드)를 깨운다

더보기
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>

void * read(void * arg);
void * accu(void * arg);
static sem_t sem_one;
static sem_t sem_two;
static int num;

int main(int argc, char *argv[])
{
	pthread_t id_t1, id_t2;
	sem_init(&sem_one, 0, 0);
	sem_init(&sem_two, 0, 1);

	pthread_create(&id_t1, NULL, read, NULL);
	pthread_create(&id_t2, NULL, accu, NULL);

	pthread_join(id_t1, NULL);
	pthread_join(id_t2, NULL);

	sem_destroy(&sem_one);
	sem_destroy(&sem_two);
	return 0;
}

void * read(void * arg)
{
	int i;
	for(i=0; i<5; i++)
	{
		fputs("Input num: ", stdout);

		sem_wait(&sem_two); // 1) 먼저 시작
		scanf("%d", &num);
		sem_post(&sem_one); // 2) 다른 스레드 깨운다
	}
	return NULL;	
}
void * accu(void * arg)
{
	int sum=0, i;
	for(i=0; i<5; i++)
	{
		sem_wait(&sem_one); // 3) 시작
		sum+=num;
		sem_post(&sem_two); // 4) 다른 스레드 깨운다
	}
	printf("Result: %d \n", sum);
	return NULL;
}

 

3-5) 멀티 쓰레딩 기반 다중 접속 서버

방법

- 클라이언트와 연결되면, 쓰레드를 생성하면서 해당 쓰레드에 소켓을 전달한다. 쓰레드가 클라이언트에게 서비스 제공.

- 소켓 정보를 참조하는 코드를 동기화 시킨다. 소켓정보를 참조하는 동안 소켓의 추가 및 삭제(종료)를 막겠다는 의도.

 


▶ IPC 방법

1) 파이프 기반 IPC : Pipe

- 파이프는 운영체제가 마련해주는 메모리 공간.

- 부모 프로세스가 파이프 (송/수신) 파일 디스크립트를 가진 뒤에 fork 한다. 따라서, 자식도 파일 디스크립터 같게 된다.

  (같은 Pipe를 사용하게 되는 이유)

- 데이터 흐름을 관리하지는 않는다.

- Related Process 간에 사용 가능(ex. fork()로 생성한 자식 프로세스)

- Uni-directon. 따라서, Multi-direction을 하려면 Pipe 2개 생성한다.

 

Q. IPC에서 운영체제의 도움이 필요한 이유는 무엇인가?

A. IPC를 위해서는 두 프로세스가 동시 접근 가능한 메모리가 필요한데 이를 운영체제에서 제공해 줘야 하기 때문.

 

2) 메시지 기반 : POSOX Message Queue

- byte stream 아님. 메시지 덩어리가 움직임.

- Virtual 파일 시스템에 mount 되어 있음 (/dev/mqueue/)

 

 

Data Transfer Shared Memory Synchronization
Byte stream
- Pipe
- Socket stream
   
Message
- Posix Message Queue
   

출처 

- 윤성우, 열혈 TCP/IP 소켓 프로그래밍

반응형

'시스템 > 네트워크' 카테고리의 다른 글

네트워크 명령어  (0) 2021.08.17
HTTPS 조사  (0) 2021.06.10