Chapter 18 - 멀티쓰레드 기반의 서버구현

18-1 : 쓰레드의 이론적 이해

멀티프로세스 기반은 프로세스 생성자체에 큰 부하가있고, 프로세스 사이의 데이터 교환을 위해 별도의 IPC 기법을 적용해야한다는 단점이 존재함
무엇보다도 컨텍스트 스위칭(Context Switching)으로 인한 비효율이 크게 발생
따라서 이러한 단점을 극복하기 위해 쓰레드(Thread)가 등장

  • 쓰레드의 생성 및 컨텍스트 스위칭은 프로세스보다 빠름
  • 쓰레드 사이의 데이터 교환에는 특별한 기법이 필요하지 않음

프로세스는 데이터/힙/스택 영역을 각각 별도로 가짐
반면 쓰레드는 한 프로세스 내에서 스택 영역만을 독립적으로 유지함


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

쓰레드는 별도의 실행흐름을 갖기 때문에 쓰레드만의 main() 함수를 별도로 정의해야함
운영체제에게 쓰레드의 생성을 요청하는데는 pthread_create() 함수를 사용(UNIX 계열 기준)

#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 이외의 값 반환

pthread_create() 함수 사용 예제

// thread1.cpp

#include <iostream>
#include <unistd.h>
#include <pthread.h>

using namespace std;

void*	thread_main(void *arg);

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

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

	sleep(10);
	cout << "end of main\n";
	return 0;
}

void*	thread_main(void *arg)
{
	int	cnt = *((int*)arg);
	for (int i = 0; i < cnt; i++)
	{
		sleep(1);
		cout << "running thread\n";
	}
	return NULL;
}


위의 예제와 같이 sleep() 함수를 사용한 프로세스의 흐름 예측은 사실상 불가능하기 때문에 대신 pthread_join() 함수를 활용함

#include <pthread.h>

int pthread_join(pthread_t thread, void **status);
// 성공시 0, 실패시 0 이외의 값 반환
  • 첫 번째 인자로 전달되는 tid의 쓰레드가 종료될 때까지 함수를 호출한 프로세스(또는 쓰레드)를 대기상태로 둠
  • 두 번째 인자에는 쓰레드의 main() 함수가 반환하는 값이 저장될 포인터 변수의 주소 값을 지정

pthread_join() 함수 사용 예제

// thread2.cpp

#include <iostream>
#include <unistd.h>
#include <pthread.h>

using namespace std;

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)
	{
		cout << "pthread_create() error\n";
		return -1;
	}

	if (pthread_join(t_id, &thr_ret) != 0)
	{
		cout << "pthread_join() error\n";
		return -1;
	}

	cout << "Thread return message : " << (char*)thr_ret << endl;
	free(thr_ret);
	return 0;
}

void*	thread_main(void *arg)
{
	int		cnt = *((int*)arg);
	char	*msg = (char*)malloc(sizeof(char) * 50);

	strcpy(msg, "Hello, I'm thread~");

	for (int i = 0; i < cnt; i++)
	{
		sleep(1);
		cout << "running thread\n";
	}
	return (void*)msg;
}


쓰레드가 여러 쓰레드에 의해서 동시 호출 및 실행될시의 안정성 여부에 따라 쓰레드에 안전한 함수(Thread-safe function)과 쓰레드에 불안전한 함수(Thread-unsafe function)으로 구분

  • 대부분의 표준함수들은 쓰레드에 안전
  • 안전하지 못한 함수가 정의되어있는 경우 같은 기능을 가진 안전한 함수가 따로 정의되어있음(gethostbyname()을 대체하는 gethostbyname_r() 등)
  • 헤더파일 선언 이전에 매크로 _REENTRANT를 정의하거나 컴파일시 -D_REENTRANT 옵션을 추가하면 위와 같은 함수들의 대체가 자동적으로 이루어짐


워커 쓰레드(Worker thread) 모델 예시

// thread3.cpp

#include <iostream>
#include <unistd.h>
#include <pthread.h>

using namespace std;

void*	thread_summation(void *arg);
int		sum = 0;

int main(int argc, char *argv[])
{
	pthread_t	id_t1, id_t2;
	int			range1[] = {1, 5};
	int			range2[] = {6, 10};

	pthread_create(&id_t1, NULL, thread_summation, (void*)range1);
	pthread_create(&id_t2, NULL, thread_summation, (void*)range2);

	pthread_join(id_t1, NULL);
	pthread_join(id_t2, NULL);
	cout << "result : " << sum << endl;

	return 0;
}

void*	thread_summation(void *arg)
{
	int	start = ((int*)arg)[0];
	int	end = ((int*)arg)[1];

	while (start <= end)
	{
		sum += start;
		start++;
	}

	return NULL;
}


임계영역 오류가 발생하는 경우의 예시

// thread4.cpp

#include <iostream>
#include <unistd.h>
#include <pthread.h>

using namespace std;
#define NUM_THREAD 100

void*	thread_inc(void *arg);
void*	thread_des(void *arg);
long long	num = 0;

int main(int argc, char *argv[])
{
	pthread_t	thread_id[NUM_THREAD];

	cout << "sizeof long long : " << sizeof(long long) << endl;
	for (int i = 0; i < NUM_THREAD; i++)
	{
		if (i % 2)
			pthread_create(&(thread_id[i]), NULL, thread_inc, NULL);
		else
			pthread_create(&(thread_id[i]), NULL, thread_des, NULL);
	}

	for (int i = 0; i < NUM_THREAD; i++)
		pthread_join(thread_id[i], NULL);

	cout << "result : " << num << endl;
	return 0;
}

void*	thread_inc(void *arg)
{
	for (int i = 0; i < 50000000; i++)
		num += 1;
	return NULL;
}

void*	thread_des(void *arg)
{
	for (int i = 0; i < 50000000; i++)
		num -= 1;
	return NULL;
}

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

동일한 메모리 공간에 대해 여러 쓰레드가 동시적으로 접근할 경우 동기화(Synchronization) 문제가 발생
임계영역은 함수 내에 둘 이상의 쓰레드가 동시에 실행하면 문제를 일으키는 코드블록을 뜻함


18-4 : 쓰레드 동기화

동기화 문제는 동일한 메모리 영역으로의 동시접근이 발생하거나, 동일한 메모리 영역에 접근하는 쓰레드의 실행순서를 지정해야 하는 상황에 발생
이 때 사용하는 동기화 기법으로는 뮤텍스(Mutex)와 세마포어(Semaphore)가 존재


뮤텍스란 ‘Mutual Exclusion’의 줄임말로 쓰레드의 동시접근을 허용하지 않음
임계영역에 대한 자물쇠를 채워놓는 개념으로, 뮤텍스를 다루는 여러 함수들이 존재

#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 이외의 값 반환

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

pthread_mutex_lock() 함수 호출시 해당 쓰레드가 pthread_mutex_unlock() 함수를 호출하기 전까지 반환하지 않고 블로킹 상태에 놓이게 됨
즉, lock()unlock() 함수로 임계영역을 감싸서 쓰레드가 동시 접근하는 것을 허용하지 않음
unlock() 함수가 호출되지 않아 영원히 블로킹 상태에 놓이는 데드락(Deadlock) 상태가 발생하지 않도록 유의해야함

뮤텍스를 사용한 예시

// mutex.cpp

#include <iostream>
#include <unistd.h>
#include <pthread.h>

using namespace std;
#define NUM_THREAD 100

void*	thread_inc(void *arg);
void*	thread_des(void *arg);
long long		num = 0;
pthread_mutex_t	mutex1;

int main(int argc, char *argv[])
{
	pthread_t	thread_id[NUM_THREAD];

	pthread_mutex_init(&mutex1, NULL);

	cout << "sizeof long long : " << sizeof(long long) << endl;
	for (int i = 0; i < NUM_THREAD; i++)
	{
		if (i % 2)
			pthread_create(&(thread_id[i]), NULL, thread_inc, NULL);
		else
			pthread_create(&(thread_id[i]), NULL, thread_des, NULL);
	}

	for (int i = 0; i < NUM_THREAD; i++)
		pthread_join(thread_id[i], NULL);

	cout << "result : " << num << endl;
	pthread_mutex_destroy(&mutex1);
	return 0;
}

void*	thread_inc(void *arg)
{
	pthread_mutex_lock(&mutex1);
	for (int i = 0; i < 50000000; i++)
		num += 1;
	pthread_mutex_unlock(&mutex1);
	return NULL;
}

void*	thread_des(void *arg)
{
	for (int i = 0; i < 50000000; i++)
	{
		pthread_mutex_lock(&mutex1);
		num -= 1;
		pthread_mutex_unlock(&mutex1);
	}
	return NULL;
}

뮤텍스의 lock(), unlock() 함수의 호출 수를 최소한으로 줄일수록 성능은 상승함
따라서 쓰레드의 대기시간이 문제가 되지 않는다면 임계영역을 넓게 잡아주는 것이 유리할 수 있음


세마포어는 뮤텍스와 유사하며, 그중에서도 바이너리 세마포어는 뮤텍스와 거의 같음

#include <semaphore.h>

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

int sem_post(sem_t *sem);
int sem_wait(sem_t *sem);
// 성공시 0, 실패시 0 이외의 값 반환
  • sem에는 세마포어의 참조 값 저장을 위한 변수의 주소 값 전달
  • pshared에 0 이외의 값 전달시 둘 이상의 프로세스에 의해 접근 가능한 세마포어가 생성됨
  • value로 세마포어 생성시의 초기값 지정

sem_init() 함수 호출시 세마포어 오브젝트가 생성되며 이 값은 sem_post 함수 호출시 1 증가, sem_wait() 함수 호출시 1 감소함
단, 세마포어 값은 0보다 작아질 수 없으므로 0인 상태에서 sem_wait() 함수 호출시 블로킹 상태에 놓이게 되며 이를 통해 임계영역을 동기화함

세마포어를 사용한 예시

// semaphore.cpp

#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <semaphore.h>

using namespace std;

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)
{
	for (int i = 0; i < 5; i++)
	{
		cout << "Input num : ";
		sem_wait(&sem_two);
		cout << "input\n";
		cin >> num;
		sem_post(&sem_one);
	}
	return NULL;
}

void*	accu(void *arg)
{
	int	sum = 0;
	for (int i = 0; i < 5; i++)
	{
		sem_wait(&sem_one);
		cout << "sum\n";
		sum += num;
		sem_post(&sem_two);
	}
	cout << "Result : " << sum << endl;
	return NULL;
}

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

pthread_join() 함수로 쓰레드를 소멸시킬 때에는 쓰레드가 종료될 때까지 블로킹된다는 단점이 존재
따라서 종료와 동시에 쓰레드를 소멸시키는 pthread_detach() 함수도 사용됨

#include <pthread.h>

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


멀티쓰레드 기반 다중접속 서버 구현 예시

// chat_server.cpp

#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <arpa/inet.h>
#include <sys/socket.h>

using namespace std;

#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;
	socklen_t			clnt_adr_sz;
	pthread_t			t_id;

	if(argc != 2)
	{
		cout << "Usage : " << argv[0] << " <port>\n";
		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, (sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
		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);
		cout << "Connected client IP : " << inet_ntoa(clnt_adr.sin_addr) << endl;
	}
	close(serv_sock);
	return 0;
}

void*	handle_clnt(void *arg)
{
	int		clnt_sock = *((int*)arg);
	int		str_len = 0;
	char	msg[BUF_SIZE];

	while ((str_len = read(clnt_sock, msg, sizeof(msg))) != 0)
		send_msg(msg, str_len);

	pthread_mutex_lock(&mutx);
	for (int 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
{
	pthread_mutex_lock(&mutx);
	for (int i = 0; i < clnt_cnt; i++)
		write(clnt_socks[i], msg, len);
	pthread_mutex_unlock(&mutx);
}

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

멀티쓰레드 기반 다중접속 클라이언트 구현 예시

// chat_clnt.cpp

#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <arpa/inet.h>
#include <sys/socket.h>

using namespace std;

#define BUF_SIZE 100
#define NAME_SIZE 256

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)
	{
		cout << "Usage : " << argv[0] << " <IP> <port> <name>\n";
		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(&snd_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 *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

내용 확인문제

  1. 하나의 CPU를 기반으로 어떻게 둘 이상의 프로세스가 동시에 실행되는지 설명해보자. 그리고 그 과정에서 발생하는 컨텍스트 스위칭이 무엇인지도 함께 설명해보자.

    CPU의 실행시간을 프로세스가 고속으로 나누기 때문에 여러 프로세스가 동시에 실행 가능
    그 과정에서 메모리 공간에 프로세스들이 올라가고 내려오는 작업을 컨텍스트 스위칭이라고 부름

  2. 쓰레드의 컨텍스트 스위칭이 빠른 이유는 어디에 있는가? 그리고 쓰레드간의 데이터 교환에는 IPC와 같은 별도의 기법이 불필요한 이유는 무엇인가?

    각 쓰레드는 스택 영역만 고유한 메모리로 가지기 때문에 프로세스에 비해 이동할 데이터의 크기가 작기 때문
    또한 쓰레드들은 스택을 제외한 나머지 영역을 공유하기 때문에 별도의 통신 기법이 필요하지 않음

  3. 실행흐름의 관점에서 프로세스와 쓰레드를 구분하여라.

    프로세스는 운영체제 관점에서의 실행흐름, 쓰레드는 프로세스 내에서 구분되는 실행흐름을 뜻함

  4. 다음 중에서 임계영역과 관련해서 잘못 설명하고 있는 것을 모두 고르면?
    • 임계영역은 둘 이상의 쓰레드가 동시에 접근(실행)하는 경우에 문제가 발생하는 영역을 의미한다. (O)
    • 쓰레드에 안전한 함수는 임계영역이 존재하지 않아서 둘 이상의 쓰레드가 동시에 호출해도 문제가 발생하지 않는 함수를 의미한다. (X)
    • 하나의 임계영역은 하나의 코드블록으로만 구성된다. 하나의 임계영역이 둘 이상의 코드블록으로 구성되는 경우는 없다. 즉, A 쓰레드가 실행하는 코드블록 A와 B 쓰레드가 실행하는 코드블록 B 사이에는 절대 임계영역이 구성되지 않는다. (X)
    • 임계영역은 전역변수의 접근코드로 구성된다. 이외의 변수에서는 문제가 발생하지 않는다. (X)
  5. 다음 중에서 쓰레드의 동기화와 관련해서 잘못 설명하고 있는 것을 모두 고르면?
    • 쓰레드의 동기화는 임계영역으로의 접근을 제한하는 것이다. (O)
    • 쓰레드의 동기화에는 쓰레드의 실행순서를 컨트롤한다는 의미도 있다. (O)
    • 뮤텍스와 세마포어는 대표적인 동기화 기법인다. (O)
    • 쓰레드의 동기화는 프로세스의 IPC를 대체하는 기법이다. (X)
  6. 리눅스의 쓰레드를 완전히 소멸시키는 방법 두가지를 설명하여라!

    pthread_join() 함수와 pthread_detach() 함수를 사용

  7. 에코 서버를 멀티쓰레드 기반으로 구현해보자. 단, 클라이언트가 전송하는 메시지의 저장을 목적으로 선언되는 메모리 공간(char형 배열)을 모든 쓰레드가 공유하도록 하자. 참고로 이는 간단하게나마 본문에서 보인 동기화 기법의 적용을 유도하기 위한 것일 뿐, 이렇게 구현하면 다소 이치에 맞지 않는 에코 서버가 만들어진다.

     // echo_mtserv.cpp
     // 클라이언트는 이전의 echo.client.cpp를 사용
    
     #include <iostream>
     #include <unistd.h>
     #include <pthread.h>
     #include <arpa/inet.h>
     #include <sys/socket.h>
    
     using namespace std;
    
     #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);
    
     char	msg[BUF_SIZE];
     pthread_mutex_t	mutx;
    
     int	main(int argc, char *argv[])
     {
         int					serv_sock, clnt_sock;
         struct sockaddr_in	serv_adr, clnt_adr;
         socklen_t			clnt_adr_sz;
         pthread_t			t_id;
    
         if(argc != 2)
         {
             cout << "Usage : " << argv[0] << " <port>\n";
             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, (sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
             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_create(&t_id, NULL, handle_clnt, (void*)&clnt_sock);
             pthread_detach(t_id);
             cout << "Connected client IP : " << inet_ntoa(clnt_adr.sin_addr) << endl;
         }
         close(serv_sock);
         return 0;
     }
    
     void*	handle_clnt(void *arg)
     {
         int		clnt_sock = *((int*)arg);
         int		str_len = 0;
    
         while (1)
         {
             pthread_mutex_lock(&mutx);
             str_len = read(clnt_sock, msg, sizeof(msg));
             if (str_len <= 0)
                 break;
             else
                 write(clnt_sock, msg, str_len);
             pthread_mutex_unlock(&mutx);
         }
    
         close(clnt_sock);
         return NULL;
     }
    
     void	error_handling(char *message)
     {
         fputs(message, stderr);
         fputc('\n', stderr);
         exit(1);
     }
    
  8. 위의 문제 7에서는 에코 메시지의 송수신에 사용할 메모리 공간을 모든 쓰레드가 공유할 것을 요구하고있다. 이렇게 구현을 하면 동기화를 해도, 안해도 문제가 발생하는데 각각의 경우에 따라서 어떠한 문제가 발생하는지 설명해보자.

    동기화를 하지 않을시 data race 문제가 발생
    동기화를 할시 임계영역에 포함된 read() 함수의 호출으로 인해 하나의 클라이언트에서 계속 락이 걸려있기 때문에 다른 클라이언트들이 임계영역에 접근하지 못하고 계속 대기하는 문제가 발생