LAB/TCP_IP

TCP / IP 소켓 프로그래밍 6

it-lab-0130 2024. 9. 5. 14:27
18. 멀티쓰레드 기반의 서버구현
18-1. 쓰레드의 이론적 이해 
  • 프로세스 : 운영체제 관점에서 별도의 실행흐름을 구성하는 단위
  • 쓰레드 : 프로세스 관점에서 별도의 실행흐름을 구성하는 단위

즉, 프로세스가 하나의 운영체제 안에서 둘 이상의 실행흐름을 형성하기 위한 도구라면, 쓰레드는 하나의 프로세스 내에서 둘 이상의 실행흐름을 형성하기 위한 도구로 이해할 수 있다.

 

18-2. 쓰레드의 생성 및 실행

** 쓰레드는 별도의 실행흐름을 갖기 때문에 쓰레드만의 main함수를 별도로 정의해야한다.

 

pthread_create() : 별도의 실행흐름을 형성해 줄 것을 운영체제에게 요청하는 함수

#include <pthread.h>

int pthread_create( 
	pthread_t *restrict thread, const pthread_attr_t *restrict attr, 
    void *(*start_routine)(void*), void *restrict arg);
//성공 시 0, 실패 시 0 이외의 값 반환

thread : 생성할 쓰레드의 ID 저장을 위한 변수의 주소값 전달, 참고로 쓰레드는 프로세스와 마찬가지로 쓰레드의 구분을 위한 ID가 부여된다.

attr : 쓰레드에 부여할 특성 정보의 전달을 위한 매개변수, NULL 전달 시 기본적인 특성의 쓰레드가 생성된다.

start_routine : 쓰레드의 main 함수 역할을 하는 , 별도 실행흐름의 시작이 되는 함수의 주소값 (함수 포인터) 전달.

arg : 세번째 매개변수를 통한 등록된 함수가 호출될 때 전달할 인자의 정보를 담고있는 변수의 주소 값 전달.

 

pthread_join() : 첫번째 매개변수가 전달되는 ID의 쓰레드가 종료될 때까지, 이 함수를 호출한 프로세스(또는 쓰레드)를 대기상태에 둔다.

#include <pthread.h>

int pthread_join(pthread_t thread, void **stattus);
//성공 시 0, 실패 시 0 이외의 값 반환

thread : 이 매개변수에 전달되느 ID의 쓰레드가 종료될 때까지 함수는 반환하지 않는다.

status : 쓰레드의 main함수가 반환하는 값이 저장될 포인터 변수의 주소 값을 전달한다.

18-3. 쓰레드의 문제점과 임계영역(Critical Section)

*** 쓰레드의 문제점은 하나의 변수에 둘 이상의 쓰레드가 동시에 접근하는것이 문제

  • 두 쓰레드가 동시에 Thread_inc 함수를 실행하는 경우
  • 두 쓰레드가 동시에 Tread_des 함수를 실행하는 경우
  • 두 쓰레드가 각각 Tread_inc 함수와 Tread_des 함수를 동시에 실행하는 경우 

임계영역은 서로 다른 두 문장이 각각 다른 쓰레드에 의해서 동시에 실행되는 상황에서 만들어 질 수 있다. 

 

임계영역 이란?

임계 영역이란 한 순간 반드시 프로세스 하나만 진입해야 하는데, 프로그램에서 임계 자원을 이용하는 부분으로 공유 자원의 독점을 보장하는 코드 영역을 의미한다. 임계 구역은 지정된 시간이 지난 후 종료된다.

 

[임계 영역을 해결하기 위한 방법]

  • 뮤텍스, 세마포어, 모니터 등이 있다.
  • 상호 배제, 한정 대기, 융통성이라는 조건을 만족한다.
  1. 상호 배제 : 한 프로세스가 임계 영역에 들어갔을 때 다른 프로세스는 들어갈 수 없음.
  2. 한정 대기 : 특정 프로세스가 영원히 임계 영역에 들어가지 못하면 안 됨.
  3. 진행 : 임계 구역에 들어간 프로세스가 없는 상태에서, 들어가려고 하는 프로세스가 여러 개 있다면 어느 것이 들어갈지를 적절히 결정해주어야 한다.
  4. 융퉁성 : 한 프로세스가 다른 프로세스의 일을 방해해서는 안됨.
18-4. 쓰레드 동기화

[동기화가 필요한 상황]

  • 동일한 메모리 영역으로의 동시접근이 발생하는 상황
  • 동일한 메모리 영역에 접근하는 쓰레드의 실행순서를 지정해야 하는 상황 (실행순서 컨트롤)

[뮤텍스(Mutex)]

Mutual Exclusion 의 줄임말로써 쓰레드의 동시접근을 허용시키지 않는다는 의미가 있다.

 

뮤텍스 쓰레드 함수 

 

pthread_mutex_init() : 뮤텍스 자물쇠 시스템 생성 함수 

pthread_mutex_destroy() : 뮤텍스 자물쇠 시스템 소멸 함수

#include <pthread.h>

int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
//성공 시 0, 실패 시 0 이외의 값 반환

mutext : 뮤텍스 생성시에는 뮤텍스의 참조 값 저장을 위한 변수의 주소 값 전달, 그리고 뮤텍스 소멸 시에는 소멸하고자 하는 뮤텍스의 참조 값을 저장하고 있는 변수의 주소값 전달.

attr : 생성하는 뮤텍스의 특성정보를 담고 있는 변수의 주소 값 전달, 별도의 특성을 지정하지 않을 경우에는 NULL전달.

 

***attr 에 NULL 전달하는 경우 매크로 PTHREAD_MUTEX_INITIALIZER을 이용해서 다음과 같이 초기화 가능.

= pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

가급적이면 pthread_mutex_init함수를 이용한 초기화를 추천한다. 왜냐하면 매크로를 이용한 초기화는 오류발생에 대한 확인이 어렵다.

 

[임계영역 뮤텍스 설정]

#include <pthread.h>

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
//성공 시 0, 실패 시 0 이외의 값 반환

 

임계영역에 들어가기에 앞서 호출하는 함수 pthread_mutex_lock()

[형식]

pthread_mutex_lock(&mutex);
//임계영역 시작

// ....

//임계영역 끝
pthread_mutex_unlock(&mutex);

** 둘 이상의 쓰레드 접근을 허용하지 않게된다.

 

세마포어

뮤텍스를 바탕으로 세마포어는 0과1만을 사용하는 쓰레드의 '실행순서 컨트롤'중심의 동기화

#include <semaphore.h>

int sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_destroy(sem_t *sem);
//성공 시 0, 실패 시 0 이외의 값 반환

sem : 세마포어 생성시에는 세마포어의 참조 값 저장을 위한 변수의 주소 값 전달, 그리고 세마포어 소멸 시에는 소멸하고자 하는 세마포어 소멸 시에는 소멸하고자 하는 세마포어의 참조 값을 저장하고 있는 변수의 주소값 전달.

pshared : 0이외의 값 전달 시, 둘 이상의 프로세스에 의해 접근 가능한 세마포어 생성, 0전달시 하나의 프로세스 내에서만 접근 가능한 세마포어 생성, 우리는 하나의 프로세스 내에 존재하는 쓰레드의 동기화가 목적이므로 0을 전달한다.

value : 생성되는 세마포어의 초기 값 지정.

 

[mutex의 lock, unlock함수에 해당하는 세마포어 관련함수]

#include <semaphore.h>

int sem_post(sem_t *sem);
int sem_wait(sem_t *sem);
//성공 시 0, 실패 시 0 이외의 값 반환

sem : 세마포어의 참조 값을 저장하고 있는 변수의 주소 값 전달, sem_post에 전달되면 세마포어의 값은 하나 증가, sem_wait에 전달되면 세마포어의 값은 하나 감소.

 

sem_init함수가 호출되면 운영체제에 의해서 세마포어 오브젝트라는 것이 만들어 지는데, 이곳에는 "세마포어 값(Semaphore Value)"이라 불리는 정수가 하나 기록된다.

sem_post함수를 호출하면 세마포어의 값이 1이 되므로 , 이 1을 0으로 감소시키면서 블로킹 상태에서 빠져나가게 된다.

이런한 특징을 이용해서 임계영역을 동기화 시킨다.

[형식]

sem_wait(&sem);//세마포어 값을 0으로...
// 임계영역의 시작
// ....
// 임계영역의 끝
sem_post(&sem);//세마포어 값을 1로...

 

18-5. 쓰레드의 소멸과 멀티쓰레드 기반의 다중접속 서버의 구현

 

[쓰레드를 소멸하는 두가지 방법]

  • pthread_join 함수호출
  • pthread_detach 함수호출
#include <pthread.h>

int pthread_detach(pthread_t thread);
//성공 시 0, 실패 시 0 이외의 값 반환

thread : 종료와 동시에 소멸 시킬 쓰레드의 ID 정보 전달.

 

[멀티쓰레드 기반의 다중접속 서버의 구현 예제]

chat_server.c

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

#define BUF_SIZE 100
#define MAX_CLNT 256

void * handle_clnt(void * arg);
void send_msg(char * msg, int len);
void error_handling(char * msg);

int clnt_cnt=0;
int clnt_socks[MAX_CLNT];
pthread_mutex_t mutx;    //쓰레드 동기화 뮤텍스

int main(int argc, char *argv[])
{
	int serv_sock, clnt_sock;
	struct sockaddr_in serv_adr, clnt_adr;
	int clnt_adr_sz;
	pthread_t t_id; // 쓰레드 ID 변수명 선언
	if(argc!=2) {
		printf("Usage : %s <port>\n", argv[0]);
		exit(1);
	}
  
	pthread_mutex_init(&mutx, NULL); // 쓰레드 동기화 하기 전 초기화
	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) // 소켓에 IP, PORT 할당
		error_handling("bind() error");
	if(listen(serv_sock, 5)==-1) // 클라이언트와 연결 대기 상태 
		error_handling("listen() error");
	
	while(1)
	{
		clnt_adr_sz=sizeof(clnt_adr);
		clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr,&clnt_adr_sz);
		// 클라이언트 연결요청 수락한 클라이언트 소켓 생성
		pthread_mutex_lock(&mutx); // 쓰레드 임계영역 시작
		clnt_socks[clnt_cnt++]=clnt_sock;
		pthread_mutex_unlock(&mutx); // 쓰레드 임계영역 끝
	
		pthread_create(&t_id, NULL, handle_clnt, (void*)&clnt_sock); 쓰레드 생성
		pthread_detach(t_id);// 쓰레드 메모리에서 완전히 소멸되도록 실행 
		printf("Connected client IP: %s \n", inet_ntoa(clnt_adr.sin_addr));
	}
	close(serv_sock);
	return 0;
}
	
void * handle_clnt(void * arg)
{
	int clnt_sock=*((int*)arg); // 클라이언트 소켓 담을 변수 선언
	int str_len=0, i;
	char msg[BUF_SIZE]; // 메시지 문자열 버퍼 크기 선언
	
	while((str_len=read(clnt_sock, msg, sizeof(msg)))!=0)
		send_msg(msg, str_len);
	
	pthread_mutex_lock(&mutx);//임계영역 시작
	for(i=0; i<clnt_cnt; i++)   // remove disconnected client
	{
		if(clnt_sock==clnt_socks[i]) // 클라이언트 소켓 = 클라이언트 접근관련 배열 소켓 같으면
		{
			while(i++<clnt_cnt-1)     
				clnt_socks[i]=clnt_socks[i+1]; 
			break;
		}
	}
	clnt_cnt--;
	pthread_mutex_unlock(&mutx); // 임계영역 끝
	close(clnt_sock);
	return NULL;
}
void send_msg(char * msg, int len)   // send to all
{
	int i;
	pthread_mutex_lock(&mutx);
	for(i=0; i<clnt_cnt; i++)
		write(clnt_socks[i], msg, len);
	pthread_mutex_unlock(&mutx);
}
void error_handling(char * msg)
{
	fputs(msg, stderr);
	fputc('\n', stderr);
	exit(1);
}

 

chat_clnt.c

입력과 출력의 처리를 분리시키기 위해서 쓰레드를 생성하였다.

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

#define BUF_SIZE 100
#define NAME_SIZE 20
	
void * send_msg(void * arg);
void * recv_msg(void * arg);
void error_handling(char * msg);
	
char name[NAME_SIZE]="[DEFAULT]";
char msg[BUF_SIZE];
	
int main(int argc, char *argv[])
{
	int sock;
	struct sockaddr_in serv_addr;
	pthread_t snd_thread, rcv_thread;
	void * thread_return;
	if(argc!=4) {
		printf("Usage : %s <IP> <port> <name>\n", argv[0]);
		exit(1);
	 }
	
	sprintf(name, "[%s]", argv[3]);
	sock=socket(PF_INET, SOCK_STREAM, 0);
	
	memset(&serv_addr, 0, sizeof(serv_addr));
	serv_addr.sin_family=AF_INET;
	serv_addr.sin_addr.s_addr=inet_addr(argv[1]);
	serv_addr.sin_port=htons(atoi(argv[2]));
	  
	if(connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr))==-1)
		error_handling("connect() error");
	
	pthread_create(&snd_thread, NULL, send_msg, (void*)&sock);
	pthread_create(&rcv_thread, NULL, recv_msg, (void*)&sock);
	pthread_join(snd_thread, &thread_return);
	pthread_join(rcv_thread, &thread_return);
	close(sock);  
	return 0;
}
	
void * send_msg(void * arg)   // send thread main
{
	int sock=*((int*)arg);
	char name_msg[NAME_SIZE+BUF_SIZE];
	while(1) 
	{
		fgets(msg, BUF_SIZE, stdin);
		if(!strcmp(msg,"q\n")||!strcmp(msg,"Q\n")) 
		{
			close(sock);
			exit(0);
		}
		sprintf(name_msg,"%s %s", name, msg);
		write(sock, name_msg, strlen(name_msg));
	}
	return NULL;
}
	
void * recv_msg(void * arg)   // read thread main
{
	int sock=*((int*)arg);
	char name_msg[NAME_SIZE+BUF_SIZE];
	int str_len;
	while(1)
	{
		str_len=read(sock, name_msg, NAME_SIZE+BUF_SIZE-1);
		if(str_len==-1) 
			return (void*)-1;
		name_msg[str_len]=0;
		fputs(name_msg, stdout);
	}
	return NULL;
}
	
void error_handling(char *msg)
{
	fputs(msg, stderr);
	fputc('\n', stderr);
	exit(1);
}

 

int argc와 char *argv[]란?

 

main 함수가 int main(int argc, char **argv){} 다음과 같이 구성되어있었다.

정확히 argc와 argv가 어떤 인자인지 알기 위해 쓰는 글이다. main함수를 포함한 모든 함수에는 인수(파라미터)를 지정할 수 있다. main 함수의 매개변수는 보통 아무것도 사용하지 않지만 다음 모양과 같은 모양으로 쓰일 수 있다.

 

int main(int argc, char *argv[]) 함수에서는 윈도우나 리눅스 같은 OS 명령 프롬포트를 이용해 인자를 전달해서 작동할 수 있도록 한다. c언어에서 main 함수는 프로그램이 최초로 실행되는 곳이다. 또한 매개변수는 함수를 호출할 때 전달되는 데이터를 의미하는데 우리는 main()함수의 매개변수를 넘겨줌으로써 원하는 실행결과를 도출하고자 한다 . 

1. int argc

- argc = argument count

- argc는 운영체제가 이 프로그램을 실행했을 때 전달되는 인수의 갯수이다. 

- 즉, main()함수에 전달되는 데이터의 갯수를 의미한다.

 

2. char *argv[]

- argv = argument variable

- char *argv[]: 문자열의 주소를 저장하는 포인터 배열

- argv[0]은 프로그램의 실행경로이다. 

- argv[1], argv[2] ... 에는 순서대로 사용자가 입력한 argument가 저장된다. 

 

예를 들어, int main(int argc, char *argv[])에 ./tiny 8000 aaa이라는 입력을 준다면,

argc2개일 것이고, argv[0]에는 실행경로인 ./tiny가 들어가고, argv[1]에는 8000이 들어가고, argv[2]에는 aaa가 들어갈 것이다. 

=> argv의 각 인자는 띄어쓰기로 구분된다. 

 

 

 

3. int main(int argc, char *argv[]) VS int main(int argc, char **argv)

위 함수의 차이점은 무엇인가? ? !

결론부터 말하면 두 함수는 똑같다. 

 

기본적으로 char *argv[]char 포인터 배열을 의미하고, char **argvchar 포인터에 대한 포인터를 의미한다. 

※ 더블 포인터(포인터의 포인터)는 포인터 변수를 가리키는 또 다른 포인터 변수를 의미한다.

 

다시 예를 들어 int main(int argc, char **argv)에 ./tiny 8000 aaa를 인자로 넣어줬다고 가정하면,

여기서 argv는 문자열의 시작 주소를 나타내는 3개(argc)의 char*의 배열을 가리키는 포인터이다. 

첫번째 문자열의 시작 주소를 나타내는 argv[0]은 첫 번째 문자열에 대한 포인터이고, *argv[0]은 첫 번재 문자열의 첫 번째 문자를 나타낸다. 그렇기에 **argv도 첫 번째 문자열의 첫 번째 문자를 나타낸다.

 

1. argv는 현재 처리해야할 문자열에 대한 포인터이다.

2. *argv는 현재 처리해야 할 문자열이다.

3. **argv는 현재 처리해야 할 문자열의 첫 글자이다. 

4. *argv + 1은 현재 처리해야할 문자열의 두 번째 문자에 대한 포인터이다. &argv[0][1]과 같다.

 

 

결론)

main()함수의 argument는 여러가지 파라미터에 따른 시뮬레이션을 해야할 경우에 유용하게 사용된다.

입력이 여러 개 있을 경우(예: 8000 aaa bbbb...)에는 문자열들이 여러개 있기 때문에 더블(2) 포인터가 필요하다.