12. 멀티쓰레드 기반의 서버구현
쓰레드의 등장배경
앞서 8. 멀티프로세스 기반의 서버구현 에서는 멀티프로세스 기반의 서버구현에 대해 살펴 보았다.
이 때 보인 프로세스의 생성은 seelct나 epoll에 비해서 확실히 구분되는 장점이 있다.
그러나 나름의 문제점도 있다. 일단 프로세스의 생성(복사)라는 작업자체가 운영체제 차원에서 상당히
부담되는 작업이다. 뿐만 아니라, 프로세스마다 완전히 독립된 메모리 공간을 유지하기 때문에 프로세스 사이에는
메세지를 주고받아야 하는 경우에는 그만큼 구현의 어려움을 겪기도 한다.
즉 멀티프로세스 기반의 단점은 다음과 같다.
● 프로세스 생성이라는 부담스러운 작업과정을 거친다.
● 두 프로세스 사이에서의 데이터 교환을 위해서는 별도의 IPC 기법을 적용해야 한다.
하지만 이 둘은 다음의 단점에 비하면 그나마 눈감아 줄만 하다.
● 초당 적게는 수십 번에서 많게는 수천 번까지 일어나는 컨텍스트 스위칭(Context Switching)에 따른 부담은
프로세스 생성방식의 가장 큰 부담
CPU가 하나뿐인 시스템에서도 둘 이상의 프로세스가 동시에 실행되지 않은가? 이는 실행중인 둘 이상의 프로세스들이
CPU의 할당시간을 매우 작은 크기로 쪼개서 서로 나누기 때문에 가능한 일이다. 그런데 CPU의 할당시간을 나누기 위해선
'컨텍스트 스위칭'이라는 과정을 거쳐야한다. 그럼 컨텍스트 스위칭이 무엇인지 살펴보자. 프로그램의 실행을 위해서는 해당 프로세스의 정보가 메인 메모리에 올라와야 한다. 때문에 현재 실행중인 A프로세스의 뒤를 이어서 B 프로세스를 실행시키려면 A 프로세스 관련 데이터를 메인 메모리에서 내리고 B 프로세스 관련 데이터를 메인 메모리로 이동시켜아한다. 그리고 이것이 바로 컨텍스트 스위칭이다.
그런데 이 때 A 프로세스 관련 데이터는 하드디스크로 이동하기 때문에 컨텍스트 스위칭에는 오랜 시간이 걸리고, 빨리 진행한다하더라도 한계가 있다.
※ 컨텍스트 스위칭
컴퓨터 구조 및 운영체제와 관련된 내용을 학습하면 컨텍스트 스위칭의 영역에 포함되는 작업이 어떻게 구성되는지 정확히 알 수 있다. 그런데 필자는 네트워크 프로그래밍의 학습을 위해서 최소한으로 개념적인 이해만 유도하였다. 정확한 이해를 위해선 CPU 내부에 존재하는 레지스트 중심으로 설명이 진행 되어야한다.
결국 멀티 프로세스의 특징을 유지하면서 단점을 어느 정도 극복하기 위해 "쓰레드(Thread)" 라는 것이 등장하였는데
이는 멀티프로세스의 여러 가지 단점을 최소화 하기 위해 (아주 없애는것은 아님) 설계된 일종의 '가벼워진 프로세스'이다.
쓰레드는 프로세스와 비교해서 다음의 장점을 지닌다.
● 쓰레드의 생성 및 컨텍스트 스위칭은 프로세스의 생성 및 컨텍스트 스위칭보다 빠르다.
● 쓰레드 사이에서의 데이터 교환에는 특별한 기법이 필요치 않다.
이 두 가지 장점에 대해선 서서히 이해하면 된다.
쓰레드와 프로세스의 차이점
쓰레드는 다음과 같은 고민에 의해 등장하였다.
"야!! 둘 이상의 실행흐름을 갖기 위해서 프로세스가 유지하고있는 메모리 영역을 통째로 복사한다는 것이 너무 부담스러워 나안해!"
프로세스의 메모리 구조는 전역변수가 할당되는 '데이터 영역' , malloc 함수등에 의해 동적 할당이 이뤄지는 '힙(Heap)' 그리고 함수의 실행에 사용되는 '스택(Stack)' 으로 이뤄진다. 그런데 프로세스들은 이를 완전히 별도로 유지한다.
때문에 프로세스 사이에서는 다음의 메모리 구조를 보인다.
그런데 둘 이상의 실행흐름을 갖는 것이 목적이라면, 위 그림처럼 완전히 메모리 구조를 분리시킬 것이 아니라 스택 영역만을
분리시킴으로써 다음의 장점을 얻을 수 있다.
● 컨텍스트 스위칭 시 데이터 영역과 힙은 올리고 내릴 필요가 없다.
● 데이터 영역과 힙을 이용해서 데이터를 교환할 수 있다.
그래서 등장한 것이 쓰레드이며, 지금 설명한 것처럼 모든 쓰레드는 별도의 실행흐름을 유지하기 위해서
스택 영역만 독립적으로 유지하기 때문에 다음의 메모리 구조를 가진다.
위 그림에서 보이듯이 데이터 영역과 힙 영역을 공유하는 구조로 쓰레드는 설계되어 있다.
그리고 이를 위해서 쓰레드는 프로세스 내에서 생성 및 실행되는 구조로 완성되었다.
즉 프로세스와 쓰레드는 다음과 같이 정의 할 수 있다.
● 프로세스 : 운영체제 관점에서 별도의 실행흐름을 구성하는 단위
● 쓰레드 : 프로세스 관점에서 별도의 실행흐름을 구성하는 단위
즉 프로세스가 하나의 운영체제 안에서 둘 이상의 실행흐름을 형성하기 위한 도구라면, 쓰레드는 하나의
프로세스 내에서 둘 이상의 실행흐름을 형성하기 위한 도구로 이해 할 수있다.
때문에 운영체제와 프로세스, 그리고 쓰레드의 관계는 다음과 같이 표현 가능하다.
이렇게 해서 쓰레드의 이론적인 설명을 어느 정도 진행하였는데, 쓰레드의 생성경험 없이 쓰레드를 이해하는 데는 한계가 있다.
따라서 아직 이해하지 못한 부분은 쓰레드 관련 코드를 공부하면서 이해하길 바란다.
쓰레드의 생성 및 실행
POSIX란 Portable Operating System Inerface for Computer Environment의 약자로써 UNIX 계열 운영체제간에 이식성을 높이기 위한 표준 API 규격을 뜻한다. 그리고 쓰레드의 생성방법은 POSIX에 정의된 표준을 근거로 한다. 때문에 리눅스 뿐만아니라 유닉스 계열의 운영체제에서도 대부분 적용 가능하다.
쓰레드의 생성과 실행흐름의 구성
쓰레드는 별도의 실행흐름을 갖기 때문에 쓰레드만의 main 함수를 별도로 정의해야한다. 그리고 이함수를 시작으로 별도의
실행흐름을 형성해 줄 것을 운영체제에게 요청해야하는데, 이를 목적으로 호출하는 함수는 다음과 같다.
#include <pthread.h>
int pthread_create(
pthread_t *restict 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 : 세번째 인자를 통해 등록된 함수가 호출될 때 전달할 인자의 정보를 담고 있는 변수의 주소 값 전달
사실 위 함수의 매개변수를 정확히 이해하려면 키워드 restict와 함수 포인터 관련 문법을 잘 이해하고 있어야한다.
하지만 사용방법 위주로 공부하면 생각보다 이 함수를 활용할 수 있다.
그럼 간단한 예제를 통해서 이 함수의 기능을 보이겠다.
thread1
#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;
if(pthread_create(&t_id, NULL, thread_main, (void*)&thread_param)!=0)
{
puts("pthread_create() error");
return -1;
};
sleep(10); puts("end of main");
return 0;
}
void* thread_main(void *arg)
{
int i;
int cnt=*((int*)arg);
for(i=0; i<cnt; i++)
{
sleep(1); puts("running thread");
}
return NULL;
}
if(pthread_create(&t_id, NULL, thread_main, (void*)&thread_param)!=0)
thread_main 함수의 호출을 시작으로 별도의 실행흐름을 구성하는 쓰레드의 생성을 요청하고 있다.
더불어 thread_main 함수호출 시 인자로 변수 thread_param의 주소 값을 전달하고 있다.
sleep(10); puts("end of main");
sleep 함수의 호출을 통해서 main 함수의 실행을 10초간 중지시키고 있다. 이는 프로세스의 종료시가를 늦추기 위함이다.
16행의 return 문이 실행되면 프로세스는 종료된다. 그리고 프로세스의 종료는 그 안에서 생성된 쓰레드의 종료로 이어진다.
따라서 쓰레드의 실행을 보장하기 위해서 이 문장이 삽입되었다.
void* thread_main(void *arg)
{
int i;
int cnt=*((int*)arg);
매개변수 arg로 전달되는 것은 10행에서 호출한 pthread_create함수의 네번째 전달인자이다.
실행결과
위 실행결과에서 보이듯이 쓰레드 관련 코드는 컴파일 시 -lpthread 옵션을 추가해서 쓰레드 라이브러리의 링크를 별도로 지시해야한다. 그래야 헤더파일 pthread.h에 선언된 함수들을 호출할 수 있다.
참고로 위 예제의 실행형태를 그림으로 표현하면 다음과 같다.
위 그림에서 점선은 실행흐름을 의미한다. 위에서 아래로 실행 흐름을 표현하였고, 중간에 함수의 호출도 표현해 놓았다.
어려운 기호가 아니니 예제와 비교해서 쉽게 파악이 가능할 것이다. 그럼 이번에는 위 예제의 15행에 있는 sleep 함수의 호출문을 다음과 같이 변경해서 실행해보자
"sleep(2);"
싱행해 보았는가? 그렇다면 문자열 "running thread" 의 출력이 코드상에서 명시하고 있는 대로 5회 출력되지 않음을 확인할 수 있었을 텐데, 이는 다음 그림에서 보이듯이 , main 함수의 종료로 인해서 프로세스 전체가 소멸되었기 때문이다.
따라서 위 예제에선 sleep 함수 호출을 통해서 쓰레드가 실행되기에 넉넉한 시간을 확보하고있다.
" 그럼 쓰레드 기반 프로그래밍에서는 적절한 sleep 함수 호출이 필수겠군요?"
그렇지 않다! sleep 함수 호출을 통해서 쓰레드의 실행을 관리한다는 것은 프로그램의 흐름을 예측한다는 뜻인데
이는 사실상 불가능한 일이다. 그리고 잘못된 구현은 프로그램의 흐름을 방해하는 결과로 이어질 수 있다.
예를 들어서 위 예쩨에서 정의하고 있는 thread_main 함수의 실행시간을 정확히 예측해서 main 함수가 딱 필요한 만큼만
대기하도록 할 수 있겠는가? 이러헌 문제점 때문에 sleep 함수보다는 다음 함수를 이용해서 쓰레드의 실행 흐름을 조절한다,
즉 다음 함수를 사용하면 지금 이야기 하고 있는 문제를 쉽고 효율적으로 해결할 수 있다.
참고로 이 함수를 통해서 쓰레드의 ID가 어떠한 경우에 사용되는지도 함께 확인 할 수있다.
#include <pthread.h>
int pthread_join(pthread_t thread, void **status);
// 성공시 0 실패시 0이외의 값 반환
thread : 이 매개변수에 전될되는 ID의 쓰레드가 종료될 때까지 함수는 반환하지 않는다
status : 쓰레드의 main 함수가 반환하는 값이 저장될 포인터 변수의 주소 값을 전달한다.
간단히 말해서 , 위 함수는 첫 번째 인자로 전달되는 ID의 쓰레드가 종료될 때까지, 이 함수를 호출한 프로세스(또는 쓰레드)를
대기상태에 둔다. 뿐만 아니라, 쓰레드의 main 함수가 반환하는 값까지 얻을 수 있으니, 그만큼 유용한 함수이다.
그럼 다음 예제를 통해 위 함수의 기능을 확인해보자
tread2
#include <stdio.h>
#include <stdlib.h>
#include <string.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 i;
int cnt=*((int*)arg);
char * msg=(char *)malloc(sizeof(char)*50);
strcpy(msg, "Hello, I'am thread~ \n");
for(i=0; i<cnt; i++)
{
sleep(1); puts("running thread");
}
return (void*)msg;
}
if(pthread_join(t_id, &thr_ret)!=0)
main 함수에서 13 행에서 생성한 쓰레드를 대상으로 pthread_join 함수를 호출하고 있다.
때문에 main 함수는 변수 t_id에 저장된 ID의 쓰레드가 종료될 때까지 대기하게 된다.
if(pthread_join(t_id, &thr_ret)!=0)
if(pthread_join(t_id, &thr_ret)!=0)
return (void*)msg;
11, 19 , 41 행 이 세문장을 통해서 반환하는 값이 참조되는 방법을 이해하길 바란다. 간단히 설명하면 41행에 의해서 반환되는 값은
19행의 두 번째 인자로 전달된 변수 thr_ref에 저장된다. 그리고 이 반환 값은 thread_main 함수 내에서 동적으로 할당된 메모리
공간의 주소 값이라는 사실에도 관심을 두기 바란다.
실행결과
끝으로 예제의 이해를 돕기 위해서 위 예제의 실행흐름을 그림으로 정리해 보겠다.
이 그림에서 주목할 부분은 실행이 일시적으로 정지되었다가 쓰레드가 종료되면서(쓰레드의 main 함수가 반환하면서) 다시 실행이 이어지고 있는 부분이다.
임계영역 내에서 호출이 가능한 함수
앞서 보인 예제에서는 쓰레드를 하나만 생성했었다. 그러나 이제부터는 동시에 둘 이상의 쓰레드를 생성해 볼 것이다. 물론 쓰레드를 하나 생성하건 둘을 생성하건 그 방법에 있어서 차이를 보이진 않는다. 그러나 쓰레드의 실행과 관련해서 주의 해야 할 사항이 하나 있다. 함수 중에는 둘 이상의 쓰레드가 동시에 호출(실행)하면 문제를 일으키는 함수가 있다.
이는 함수 내에 '임계영역(Critical Section)' 이라 불리는 둘 이상의 쓰레드가 동시에 실행하면 문제를 일으키는 문장이 하나 이상 존재하는 함수이다.
어떠한 코드가 임계영역이 되는지, 그리고 둘 이상의 쓰레드가 동시에 임계영역을 실행하면 어떠한 문제가 발생하는지, 잠시 후에 이야기 하도록 하고 일단은 둘 이상의 쓰레드가 동시에 실행하면 문제를 일으키는 코드블록을 가리켜 임계영역이라 한다는 사실만 기억하자. 이러한 임계여역의 문제와 관련해서 함수는 다음 두 가지 종료로 구분이 된다.
● 쓰레드에 안전한 함수(Thread - safe function)
● 쓰레드에 불안전한 함수(Thread - unsafe function)
여기서 쓰레드에 안전한 함수는 둘 이상의 쓰레드에 의해서 동시에 호출 및 실행되어도 문제를 일으키지 않는 함수를 뜻한다.
반대로 쓰레드에 불안전한 함수는 동시호출 시 문제가 발생할 수 있는 함수를 뜻한다.
하지만 이것이 임계영역의 유무를 뜻하는 것은 아니다. 즉 쓰레드에 안전한 함수도 임계영역이 존재 할 수 있다.
다만 이 영역을 둘 이상의 쓰레드가 동시에 접근해도 문제를 일으키지 않도록 적절한 조치가 이뤄져있어
쓰레드에 안전한 함수로 구분될 수 있는 것이다.
다행히 기본적으로 제공되는 대부분의 표준함수들은 쓰레드에 안전하다. 그러나 그보다 더 다행인 것은 쓰레드에 안전한 함수와 불안전한 함수의 구분을 우리가 직접 할 필요가 없다는데 있다(윈도우 기반도 마찬가지)
왜냐하면 쓰레드에 불안전한 함수가 정의되어 있는 경우, 같은 기능ㅇ을 갖는 쓰레드에 안전한 함수가 정의되어 있기 때문이다
예를 들어 우리가 도메인 이름과 인터넷 주소 에서 소개한 다음 함수는 쓰레드에 안전하지 못하다.
struct honest * gethostbyname(const char * hostname);
때문에 동일한 기능을 제공하면서 쓰레드에 안전한 다음 함수가 이미 정의되어 있다.
struct hostent *gethostbyname_r(
const char *name, struct hostent *result, char *buffer, intbuflen, int *h_errnop);
일반적으로 쓰레드에 안전한 형태로 재 구현된 함수의 이름에는 _r 이붙는다(윈도우는 다름) 그렇다면 둘 이상의 쓰레드가 동시에 접근 가능한 코드블록에는 gethostbyname 함수를 대신해서 gethostbyname_r 을 호출해야할까?
당연하다 하지만 이는 프로그래머에게 엄청난 수고를 요구하는 것이다. 그런데 다행히도 다음의 방법으로 이를 자동화 할 수 있다.
즉 다음과 같은 방법을 통해서 gethostbyname 함수의 호출문을 gethostbyname_r 로 변경할 수 있다. 그것도 자동으로 말이다.
"헤더파일 선언 이전에 매크로 _REENTRANT를 정의한다"
gethostbyname 함수와 gethostbyname_r 함수가 이름에서뿐만 아니라 매개변수 선언에서도 차이가 난다는 사실을 알았으니,
이것이 얼마나 매력적인지 알 수 있을 것이다. 그리고 위의 매크로 정의를 위해서 굳이 소스코드에 #define 문장을 추가할 필요는 없다. 다음과 같이 컴파일 시 -D_REENTRANT 옵션을 추가하는 방식으로도 매크로를 정의할 수 있기 때문이다.
gcc -D_REENTRANT mythread.c -o mthread -lpthread
따라서 앞으로 쓰레드 관련 코드가 삽입되어 있는 예제를 컴파일 할 때는 -D_REENTRANT 옵션을 항상 추가하도록 하겠다.
워커(Worker) 쓰레드 모델
지금까지는 쓰레드의 개념 및 생성방법의 이해를 목적으로 아주 간단히 예제를 작성했기 때문에 하나의 예제 안에서 둘 이상의 쓰레드를 생성해 보지 못했다.
따라서 이번에는 둘 이상의 쓰레드가 생성되는 예제를 작성해 보고자 한다.
1부터 10까지의 덧셈결과를 출력하는 예제를 만들어 보겠다. 그런데 main 함수에서 덧셈을 진행하는 것이 아니라, 두 개의 쓰레드를 생성해서 하나는 1부터 5까지 다른 하는 6부터 10까지 덧셈하도록 하고 main 함수에서는 단지 연산결과를 출력하는 형태로 작성해 보고자 한다.
참고로 이러한 유형의 프로그래밍 모델을 가리켜 '워커 쓰레드(Worker thread) 모델' 1부터 5까지 그리고 6부터 10까지 덧셈을 진행하는 쓰레드가 main 함수가 관리하는 일꾼의 형태를 띠기 때문이다. 그럼 마지막으로 예제를 보이기에 앞서 예제의 흐름을 그림으로 먼저 정리해 보겠다.
앞서 몇 차례 이러한 유형의 그림을 보였으니, 그림에서 보이고자 하는 내용을 쉽게 파악할 수 있을 것이다.
그리고 쓰레드 관련 코드는 실행의 흐름을 파악하기가 상대적으로 복잡하기 떄문에, 이렇게 그림을 통해서
정리하는 습관을 들이는 것도 필요하다.
thread3
#include <stdio.h>
#include <pthread.h>
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);
printf("result: %d \n", sum);
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;
}
쓰레드가 호출하는 함수로의 인자전달 및 반환에 대해서는 앞서 설명했으니, 위 예제에서 쓰레드의 생성 및 실행에 대한 부분은 어렵지 않게 이해라 수 있을 것이다. 그런데 다음 사실에는 별도의 주목이 필요하다
" 두 쓰레드가 하나의 전역변수 sum에 직접 접근한다!"
sum+=start;
위 예제 28행을 통해서 이러한 결론을 내릴 수 있는데 코드상에서 보면 이는 매우 당연한 것처럼 보인다.
그러나 이는 전역변수가 저장되는 데이터 영역을 두 쓰레드가 함께 공유하기 때문에 가능한 것이다.
실행결과
실행 결과로 55가 출력되었다. 물론 실행결과는 정확하지만 예제 자체적으로는 문제가 있다.
앞서 간단히 소개한 임계영역과 관련해서 다소 문제가 있다. 때문에 예제 하나 더 제시하겠다.
이는 위 예제와 거의 비슷하다. 다만 앞서 설명한 임계영역과 관련해서 오류의 발생소지를 더 높였을 뿐이다. 이 정도 예제면
아무리 시스템의 성능이 좋아도 어렵지 않게 오류의 발생을 확인할 수 있을 것이다.
thread4
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>
#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];
int i;
printf("sizeof long long: %d \n", sizeof(long long));
for(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(i=0; i<NUM_THREAD; i++)
pthread_join(thread_id[i], NULL);
printf("result: %lld \n", num);
return 0;
}
void * thread_inc(void * arg)
{
int i;
for(i=0; i<50000000; i++)
num+=1;
return NULL;
}
void * thread_des(void * arg)
{
int i;
for(i=0; i<50000000; i++)
num-=1;
return NULL;
}
위 예제에서는 총 100개의 쓰레드를 생성해서 그 중반은 thread_inc를 쓰레드의 main 함수로 나머지 반은 thread_dos를 쓰레드의 main 함수로 호출하게 하고 있다. 이로써 전역변수 num에 저장된 값의 증가와 감소로 최종변수 num에는 0이 저장되어야 한다.
그럼 실제로 0이 저장되는지 수차례 실행을 해보자.
실행결과는 0이 아니다 뿐만 아니라 생행할 때마다 매번 그 결과도 다르다 . 우리는 그 이유를 아직 모르나 어찌되었든 이는 쓰레드를 활요하는데 있어서 큰 문제점임은 틀림없다.
쓰레드의 문제점과 임계영역(Critical Section)
우리는 아직 예제 thread4 에서 보인 문제점의 원인을 모르고있다. 따라서 이번에는 문제의 원인을 이해하고 그 해결책도 함께 고민해 보겠다.
하나의 변수에 둘 이상의 쓰레드가 동시에 접근하는 것이 문제!
thread4 의 문제점은 다음과 같아
"전역변수 num에 둘 이상의 쓰레드가 함께 접근하고있다"
여기서 말하는 접근이란 주로 값의 변경을 뜻한다. 그런데 보다 다양한 상황에서 문제가 발생할 수 있기 때문에 문제의
원인이 무엇인지 정확히 이해해야 한다. 그리고 예제에서는 접근의 대상이 전역변수였지만, 이는 전역변수였기 때문에 발생한 문제가 아니다.
어떠한 메모리 공간이라도 동시에 접근을 하면 문제가 발생할 수 있다.
" 쓰레드들은 CPU의 할당시간을 나눠서 실행하게 한단면서요? 그러면 실제로는 동시접근이 이뤄지지않는데 왜 뭐가 문제입니까?"
물론 여기서 말하는 동시접근은 여러분이 생각하는 동시접근과 약간의 차이가 있다. 그래서 하나의 예를 통해서 동시 접근이 무엇인지, 그리고 이것이 왜 문제가 되는지 설명하겠다. 먼저 변수에 저장된 값을 1씩증가시키는 연산을 두 개의 쓰레드가 진행하려는 상황이라고 가정해보자.
위 그림은 변수 num에 저장되어있는 값을 증가시키려는 두 개의 쓰레드가 존재하는 상황을 묘사한 것.
이 상황에서 thread1이 변수 num에 저장된 값을 100으로 증가시켜놓은 다음에, 이어서 thread2가 변수 num에 접근을 하면
우리의 예상대로 변수 num에는 101이 저장된다.
다음 그림은 thread1 이 변수 num에 저장된 값을 완전히 증가시킨 상황을 보여준다.
그런데 위 그림에서 한가지 주목할 사실이 있다. 그것은 증가 방식이다. 값으 증가는 CPU를 통한 연산이 필요한 작업이다.
따라서 그냥 변수 num에 저장된 값이, 변수 num에 저장된 상태로 증가하지는 않는다.
이 변수에 저장된 값은 thread1에 의해서 우선 참조가 된다. 그리고 thread1은 이 값을 CPU에 전달해서 1이 증가된 값
100을 얻는다. 마지막으로 연산이 완료된 값을 변수 num에 저장한다. 이렇게 해서 변수 num에 100이 저장되는 것이다.
그럼 이어서 thread2도 값을 증가시켜보도록 해보자.
이렇게 해서 변수 num에는 101이 저장된다. 그런데 이는 매우 이상적인 상황을 묘사한 것이다.
thread1이 변수 num에 저장된 값을 완전히 증가시키기 전에라도 얼마든지 thread2로 CPU의 실행이 넘어갈 수 있기 때문이다.
그럼 처음부터 다시 시작해보자. 다음 그림은 thread1이 변수 num에 저장된 값을 참조해서 값을 1 증가시키는 것까지 완료한 상황을 보여준다
단 변수 num엔 증가된 값을 저장하지 않았다.
이제 100이라는 값을 변수 num에 저장해야 하는데 이 작업이 진행되기 전에 thread2로 실행의 순서가 넘어가 버렸다.
그런데 다해히도(다행인지 아닌지는 조금 더 두고보자) thread2는 증가연산을 완전히 완료해서, 증가된 값을 변수 num에 저장했다고 가정하자
위 그림에서 보이듯이 변수 num에 저장된 값이 thread1에 의해서 100으로 증가된 상태가 아니기 때문에 thread2가 참조한 변수
num의 값은 99이다. 결국 thread2에 의해서 변수 num의 값은 100이 되었다 이제 남은 일은 무엇인가? thread1이 증가시킨 값을 변수 num에 저장하는 일만 남았다. 이 작업을 얼른 완료해보자
안타깝게도 이미 100으로 증가된 변수 num에 다시 100을 저장하는 일이 발생하였다.
결과적으로 변수 num은 100이된다. 비록 thread1과 thread2가 각각 1씩 증가시켰지만, 이렇게 전혀 엉뚱한 값이 저장될 수 있다는 것이다.
때문에 이러한 문제를 막기 위해서 한 쓰레드가 변수 num에 접근해서 연산을 완료할 때까지 다른쓰레드가 변수 num에 접근하지 못하도록 막아야한다.
바로 이것이 '동기화(Synchronization)' 이다 이제 멀티쓰레드 프로그래밍에서 동기화가 왜 필요한지 충분히 이해했으리 믿는다
그리고 예제 thread4의 실행결과도 이해할 수 있으리라 생각한다.
임계영역은 어디?
임계영역의 구분은 어렵지 않다 앞에서는 임계영역을 다음과 같이 정의하였으니 예제 thread4 에서 임계영역을 찾아보자
" 함수 내에 둘 이상의 쓰레드가 동시에 실행하면 문제를 일으키는 하나 이상의 문장으로 묶여있는 코드블록"
전역변수 num을 임계영역으로 보아야 할까? 아니다! 이는 문제를 일으키는 문장이 아니지 않는가?
뿐만 아니라 동시에 실행이 되는 문장도 아닌, 메모리의 할당을 요구하는 변수 선언일 뿐이다. 일반적으로 임계영역은 쓰레드에 의해서 실행되는 함수 내에 존재한다. 그럼 예제 thread4에서 보인 쓰레드의 두 main 함수를 살펴보자
void * thread_inc(void *arg)
{
int i;
for(i = 0; i<50000000; i++)
num += 1; // 임계영역
return NULL;
}
void * thread_des(void * arg)
{
int i;
for( i = 0; i < 50000000; i++)
num -= 1; // 임계영역
return NULL;
}
위 코드의 주석에서 말하듯이 변수 num이 아닌, 변수 num에 접근하는 두 문장이 임계영역에 해당한다.
이 두 문장은 둘 이상의 쓰레드에 의해 동시에 실행되도록 구현되어 있는, 문제를 일으키는 직접적인 원인이 되기 때문이다. 물론 문제가 발생하는 상황은 다음과 같이 세 가지 형태로 나눠서 정리할 수 있다.
● 두 쓰레드가 동시에 thread_inc 함수를 실행하는 경우
● 두 쓰레드가 동시에 thread_des 함수를 실행하는 경우
● 두 쓰레드가 각각 thread_inc 함수와 thread_des 함수를 동시에 실행하는 경우
여기서 마지막에 언급한 경우를 주목할 필요가 있다. 이는 다음의 경우에도 문제가 발생할 수 있음을 의미한다.
"쓰레드 1이 thread_inc 함수 num+=1을 실행할때, 동시에 쓰레드 2가 thread_des 함수의 문장 num-=1을 실행하는 상황"
이렇듯 임계영역은 서로 다른 두 문장이 각각 다른 쓰레드에 의해 동시에 실행되는 상황에서도 만들어질 수 있다.
바로 그 두 문장이 동일한 메모리 공간에 접근한다는 가정하에 말이다.
쓰레드 동기화
쓰레드는 실행결과를 저장하기전에 레지스터에 접근해서 값을 건드려서 우리가 원하지 않는 결과가 출력된다.
쓰레드가 지니는 문제점을 살펴보았으니, 해결책을 고민할 차례이다. 참고로 이 해결책을 가리켜 "쓰레드 동기화(Synchroniztion)'라 한다.
동기화의 두가지 측면
쓰레드의 동기화는 쓰레드의 접근 순서 때문에 발생하는 문제점의 해결책을 뜻한다. 그런데 동기화가 필요한 상황은 2가지 측면에서 생각해볼 수 있다.
● 동일한 메모리 영역으로의 동시접근이 발생하는 상황
● 동일한 메모리 영역에 접근하는 쓰레드의 실행순서를 지정해야하는 상황
첫번째 언급한 상황은 이미 충분히 설명되었으니, 두번째 언급한 상황에 대해서 알아보자.
이는 쓰레드의 '실행순서 컨트롤(Control)'에 관련된 내용이다.
예를들어 쓰레드 A, B가 있다고 가정해보자.
그런데 쓰레드 A는 메모리 공간에 값을 가져다 놓는(저장하는) 역할을 담당하고,
쓰레드 B는 이 값을 가져가는 역할을 담당한다고 가정해보자.
이러한 경우 쓰레드 A가 약속된 메모리 공간에 먼저 접근을해서 값을 저장해야 한다. 혹시라도 쓰레드 B가 먼저 접근을 해서 값을 가져가면, 잘못된 결과로 이어질 수 있다. 이렇듯 실행 순서의 컨트롤이 필요한 상황에서도 이어서 설명하는 동기화 기법이 활용된다.
우리는 '뮤텍스(Mutex)'와 '세마포어(Semaphore)' 라는 두 가지 동기화 기법에 대해 알아볼 것이다.
그런데 이 둘은 개념적으로 매우 유사하다. 따라서 뮤텍스를 이해하고나면 세마포어는 쉽게 이해할 수 있다.
뮤텍스(Mutex)
뮤텍스란 '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 이외의 값 반환
- mutex : 뮤텍스 생성시에는 뮤텍스의 참조 값 저장을 위한 변수의 주소 값 전달, 그리고 뮤텍스 소멸시에는 소멸하고자하는 뮤텍스의 참조 값을 저장하고 있는 변수의 주소 값 전달
- attr : 생성하는 뮤텍스의 특성정보를 담고 있는 변수의 주소 값 전달, 별도의 특성을 지정하지 않을 경우에는 NULL 전달.
위 함수들을 통해서도 확인할 수 있듯이, 자물쇠 시스템에 해당하는 뮤텍스의 생성을 위해서는 다음과 같이 pthread_mutex_t 형 변수가 하나 선언되어야한다.
pthread_mutex_t mutex;
그리고 이 변수의 주소 값은 pthread_mutex_init 함수 호출 시 인자로 전달되어서, 운영체제가 생성한 뮤텍스(자물쇠 시스템)의 참조에 사용된다. 때문에 pthread_mutex_destroy 함수 호출 시에도 인자로 사용되는 것이다.
참고로 뮤텍스 생성시 별도의 특성을 지정하지 않아서 두 번째 인자로 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 이외의 값 반환
함수의 이름에도 lock, unlock이 있으니 쉽게 의미하는 바를 알 수 있을 것이다.
임계영역에 들어가기에 앞서 호출하는 함수가 pthread_mutex_lock이다. 이 함수를 호출할 당시 다른 쓰레드가 이미 임계영역을 실행하고 있는 상황이라면, 이 쓰레드가 pthread_mutex_unlock 함수를 호출하면서 임계영역을 빠져나갈 때까지 pthread_mutex_lock 함수는 반환하지 않는다. 즉, 다른 쓰레드가 임계영역을 빠져나갈 때까지 블로킹 상태에 놓이게 된다.
자 그럼 임계영역을 보호하기 위한 코드의 구성을 간단히 정리해 보겠다. 이미 생성된 상태에서는 다음의 형태로 임계영역을 보호하게 된다.
pthread_mutex_lock(&mutex);
// 임계영역 시작
// ....
// 임계영역 끝
pthread_mutex_unlock(&mutex);
쉽게 말해서 lock, 그리고 unlock 함수를 이용해서 임계영역의 시작과 끝을 감싸는 것이다.
그러면 이것이 임계영역에 대한 자물쇠 역할을 하면서, 둘 이상의 쓰레드 접근을 허용하지 않게 된다.
한가지 더 기억해야 할 것은, 임계영역을 빠져나가는 쓰레드가 pthread_mutex_unlock 함수를 호출하지 않는다면,
임계영역으로의 진입을 위해 pthread_mutex_lock 함수는 블로킹 상태에서 빠져나가지 못하게 된다는 사실이다.
이를 두고 '데드락(Dead-lock)' 상태라 하는데, 이러한 상황이 발생하지 않도록 주의해야 한다.
그럼 이어서 뮤텍스를 이용해서 예제 thread4.c에서 보인 문제점을 해결해보자.
#include <thread의 헤더선언과 동일하다>
#define NUM_THREAD 100
void * thread_inc(void * arg);
void * thread_des(void * arg);
long long num = 0; // long long형은 64비트 정수 자료형
pthread_mutex_t mutex;
int main(int argc, char *argv[])
{
pthread_t thread_id[NUM_THREAD];
int i;
pthread_mutex_init(&mutex, NULL);
for(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(i = 0; i < NUM_THREAD; i++)
pthread_join(thread_id[i], NULL);
printf("result : %lld \n", num);
pthread_mutex_destroy(&mutex);
return 0;
}
void * thread_inc(void *arg)
{
int i;
pthread_mutex_lock(&mutex);
for(i = 0; i<50000000; i++)
num += 1;
pthread_mutex_unlock(&mutex);
return NULL;
}
void * thread_des(void * arg)
{
int i;
for(i = 0; i<50000000; i++)
{
pthread_mutex_lock(&mutex);
num -= 1;
pthread_mutex_unlock(&mutex);
}
return NULL;
}
pthread_mutex_t mutex;
뮤텍스의 참조 값 저장을 위한 변수가 선언되었다. 이렇게 전역변수로 선언된 이유는 뮤텍스의 접근이
thread_inc, thread_des 이렇게 두 개의 함수 내에서 이뤄지기 때문이다.
pthread_mutex_destroy(&mutex);
뮤텍스의 소멸을 보이고 있다. 이렇듯 뮤텍스는 필요가 없어지면 소멸해야한다.
pthread_mutex_lock(&mutex);
for(i = 0; i<50000000; i++)
num += 1;
pthread_mutex_unlock(&mutex);
실제 임계영역은 37행 하나이다. 그런데 여기서는 36행의 반복문까지 임계영역으로 포함해서 lock, unlock 함수를 호출하고 있다.
이와 관련해서는 이어서 별도로 논의를 한다.
pthread_mutex_lock(&mutex);
num -= 1;
pthread_mutex_unlock(&mutex);
임계영역에 해당하는 47행만 뮤텍스의 lock, unlock 함수로 감싸고있다.
실행결과
실행결과를 보면 예제 thread4에 있는 문제점이 해결되었음을 알 수 있다. 그런데 실행결과의 확인에는 오랜 시간이 걸린다.
왜냐하면 뮤텍스의 lock, unlcok 함수의 호출에는 생각보다 오랜 시간이 걸리기 떄문이다.
자! 그럼 먼저 thread_inc 함수의 동기화에 대해서 이야기 해보자
void * thread_inc(void *arg)
{
int i;
pthread_mutex_lock(&mutex);
for(i = 0; i < 50000000; i++)
{
num+=1;
pthread_mutex_unlock(&mutex);
return NULL;
}
이는 임계영역을 상대적으로 좀 넓게 잡은 경우이다. 그런데 이유없이 넓게 잡은 것은 아니고 다음의 장점을 생각한 결과이다.
"뮤텍스의 lock, unlock 함수의 호출 수를 최대한으로 제한한다"
위 예제의 thread_des 함수는 thread_inc 함수보다 뮤텍스의 lock, unlock 함수를 49,999,999회 더 호출하는 구조이다.
때문에 인간이 느끼고도 남을 정도의 큰 속도 차를 보인다. 따라서 쓰레드의 대기 시간이 문제 되지 않는 상황이라면, 위의 경우에는 임계영역을 넓게 잡아주는 것이 좋다. 하지만 변수 num의 값 증가가 50,000,000회 진행될 때까지 다른 쓰레드의 접근을 허용하지 않기 때문에 이는 단점으로 작용할 수 있다.
세마포어(Semaphore)
세마포어는 뮤텍스와 매우 유사하다. 따라서 뮤텍스에서 이해한 내용을 바탕으로 쉽게 세마포어를 이해 할 수 있다.
참고로 여기서는 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 : 생성되는 세마포어의 초기 값 지정.
위의 함수에서 매개변수 pshared는 우리의 관심영역 밖이므로 0을 전달하기로 하자. 그리고 매개변수 value에 의해 초기화되는 세마포어의 값이 무엇인지는 잠시 후에 알게 될 것이다.
그럼 이어서 뮤텍스의 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)'라 불리는 정수가 하나 기록된다.
그리고 이 값은 sum_post 함수가 호출되면 1 증가하고, sem_wait 함수가 호출되면 1 감소한다.
단, 세마포어의 값은 0보다 작아질 수 없기 때문에 현재 0인 상태에서 sem_wait 함수를 호출하면, 호출한 쓰레드는 함수가 반환되지 않아서 블로킹 상태에 놓이게 된다. 물론 다른 쓰레드가 sem_post 함수를 호출하면 세마포어의 값이 1이 되므로, 이 1을 0으로 감소시키면서 블로킹 상태에 빠져나가게 된다.
바로 이러한 특징을 이용해서 임계영역을 동기화 시키게 된다. 즉, 다음의 형태로 임계영역을 동시화 시킬 수 있다.
(이때 세마포어의 초기 값이 1이라 가정한다)
sem_wait(&sem) // 세마포어 값을 0으로..
// 임계영역 시작
// ...
// 임계영역 끝
sem_post(&sem) // 세마포어 값을 1로...
위와 같이 코드를 구성하면, sem_wait 함수를 호출하면서 임계영역에 진입한 쓰레드가 sem_post 함수를 호출하기 전까지는 다른 쓰레드에 의해서 임계영역의 진입이 허용되지 않는다. 그리고 세마포어 값은 0과 1을 오가게 되는데, 이러한 특징 때문에 위와 같은 구성을 가리켜 바이너리 세마포어라 하는 것이다.
그럼 이어서 세마포어 관련 예를 보이겠다. 그런데 이번에는 동시접근 동기화가 아닌, 접근 순서의 동시과화 관련된 예제를 작성해보이겠다. 예제의 시나리오는 다음과 같다.
"쓰레드 A가 프로그램 사용자로부터 값을 입력 받아서 전역 변수 num에 저장하면,
쓰레드 B는 이 값을 가져다가 누적해 나간다. 이 과정은 총 5회 진행되고, 진행이 완료되면 총 누적금액을 출력하면서 프로그램은 종료된다. "
위의 시나리오대로 프로그램을 구현하려면 변수 num의 접근은 쓰레드 A, 쓰레드 B의 순으로 이뤄져야 한다.
그리고 이를 위해서는 동기화가 필요하다. 예제를 작성해보자
참고로 이 예제는 여러분이 나름대로 분석하는데 시간이 다소 걸릴 수 있다.
semaphore
#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);
scanf("%d", &num);
sem_post(&sem_one);
}
return NULL;
}
void * accu(void * arg)
{
int sum=0, i;
for(i=0; i<5; i++)
{
sem_wait(&sem_one);
sum+=num;
sem_post(&sem_two);
}
printf("Result: %d \n", sum);
return NULL;
}
sem_init(&sem_one, 0, 0);
sem_init(&sem_two, 0, 1);
세마포어를 두 개 생성하고 있다. 하나는 세마포어 값이 0이고 다른 하나는 1이다. 이 두 개의 세마포어가 필요한 이유를 잘 이해해야한다.
sem_wait(&sem_two);
sem_post(&sem_two);
세마포어 변수 sem_two를 이용한 wait과 post 함수의 호출이다. 이는 accu 함수를 호출하는 쓰레드가 값을 가져가지도 않았는데 read 함수 호출하는 쓰레드가 값을 다시 가져다 놓는 (이전값을 덮어써버리는) 상황을 막기 위한 것이다.
sem_post(&sem_one);
sem_wait(&sem_one);
세마포어 변수 sem_one을 이용한 wai과 post 함수의 호출이다. 이는 read 함수를 호출하는 쓰레드가 새로운 값을 가져다 놓기도 전에 accu 함수가 값을 가져가 버리는(이전 값을 다시 가져가는) 상황을 막기 위한 것이다.
실행결과
혹시 위 예제에서 두 개의 세마포어 오브젝트가 필요한 이유를 잘 모르겠다면, 세마포어 관련된 코드의 일부를 주석처리 했을 때 실행 결과를 확인하기 바란다. 그러면 이해에 많은 도움이 될 것이다. 이로써 쓰레드와 관련된 이론적인 이야기를 마무리 하고
지금까지 공부한 쓰레드를 바탕으로 서버 프로그램을 작성해 보겠다.
쓰레드의 소멸과 멀티쓰레드 기반의 다중접속 서버의 구현
지금까지는 쓰레드의 생성과 컨트롤에 대해서만 언급하였다. 하지만 이것 못지않게 중요한 것이 쓰레드의 소멸이다.
그래서 먼저 쓰레드의 소멸에 대해 이야기하고 그 다음에 멀티쓰레드 기반으로 서버를 구현해보겠다.
쓰레드를 소멸하는 두 가지 방
리눅스의 쓰레드는 처음 호출하는, 쓰레드는 main 함수를 반환했다고 해서 자동으로 소멸되지 않는다.
때문에 다음 두 가지 방법 중 하나를 선택해서 쓰레드의 소멸을 직접적으로 명시해야한다. 그렇지 않으면 쓰레드에 의해서 할당된 메모리 공간이 계속해서 남아있게 된다.
● pthread_join 함수의 호출
● pthread_detach 함수의 호출
pthread_join은 앞서 우리가 호출했던 함수이다. 이렇듯 이 함수가 호출되면, 쓰레드의 종료를 대기할 뿐만 아니라, 쓰레드의 소멸까지 유도가 된다. 하지만 이 함수의 문제점은 쓰레드가 종료될 때까지 블로킹 상태에 놓이게 된다는 점이다. 따라서 일반적으로는 다음 함수의 호출을 통해서 쓰레드의 소멸을 유도한다.
#include <pthread.h>
int pthread_detach(pthread_t thread);
// 성공 시 0, 실패 시 0 이외의 값 반환
thread : 종료와 동시에 소멸시킬 쓰레드의 ID 정보 전달
위 함수를 호출했다고 해서 종료되지 않은 쓰레드가 종료되거나, 블로킹 상태에 놓이지는 않는다. 따라서 이 함수를 통해서 쓰레드에게 할당된 메모리의 소멸을 유도할 수 있다. 그리고 이 함수가 호출된 이후에는 해당 쓰레드를 대상으로 pthread_join 함수의 호출이 불가능하니, 이점에 주의해야한다. 참고로 쓰레드를 생성할 때 소멸의 시기를 결정하는 방법도 있으나, pthread_detach 함수를 호출하는 방법과 결과적으로 차이가 없어서 여기서는 소개하지 않았다.
그럼 이어서 작성할 멀티쓰레드 기반의 다중접속 서버에서는 쓰레드의 소멸과 관련된 부분도 신경써보자.
멀티쓰레드 기반의 다중접속 서버의 구현
이번에는 에코 서버가 아닌, 서버에 접속한 클라이언트 사이에서 메시지를 주고받을 수 있는 간단한 채팅 프로그램을 만들어보자. 이 예제를 통해 쓰레드가 어떻게 사용되는지, 그리고 동기화는 어떠한 방식으로 처리하는지 확인해보자,
chat_server
#include <"헤더파일 선언은 밑에 써놓겠다">
#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;
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)
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);
}
#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>
헤더파일
int clnt_cnt=0;
int clnt_socks[MAX_CLNT];
서버에 접속한 클라이언트의 소켓 관리를 위한 변수와 배열이다. 이 둘의 접근과 관련있는 코드가 임계영역을
구성하게 됨에 주목하자
clnt_socks[clnt_cnt++]=clnt_sock;
새로운 연결이 형성될 때마다 변수 clnt_cnt와 배열 clnt_socks에 해당 정보를 등록한다.
pthread_create(&t_id, NULL, handle_clnt, (void*)&clnt_sock);
추가된 클라이언트에게 서비스를 제공하기 위한 쓰레드를 생성하고 있다. 그리고 이 쓰레드에 의해서 53행에 정의된 함수가 실행된다.
pthread_detach(t_id);
pthread_detach 함수호출을 통해 종료된 쓰레드가 메모리에서 완전히 소멸되도록 하고 있다.
void send_msg(char * msg, int len) // send to all
이 함수는 연결된 모든 클라이언트에게 메세지를 전송하는 기능을 제공한다.
위 예제를 통해 넘어가야할 것은 채팅 서버의 구현 방식이아닌, 임계 영역의 구성형태이다.
위 예제는 임계영역과 관련해서 다음의 특징을 보인다.
"전역변수 clnt_cnt와 배열 clnt_socks에 접근하는 코드는 하나의 임계영역을 구성한다."
클라이언트가 새로 추가 및 삭제되면 변수 clnt_cnt와 배열 clnt_socks에는 동시에 변화가 생긴다. 때문에 다음과 같은 상황은 모두 데이터의 불일치를 유도해서 심각한 오류상황으로 이어질 수 있다.
● A 쓰레드는 배열 clnt_socks에서 소켓 정보 삭제, 동시에 B 쓰레드는 변수 clnt_cnt 참조
● A 쓰레드는 변수 clnt_cnt 참조, 동시에 B 쓰레드는 배열 clnt_socks에 소켓 정보 추가
따라서 위 예제에서 보이듯이 변수 clnt_cnt, 그리고 배열 clnt_socks의 접근 관련 코드는 묶어서 하나의 임계영역으로 구성해야 한다. 방금 말했던 접근은 주로 값의 변경을 뜻하고, 보다 다양한 상황에서 문제가 발생할 수 있기 때문에 문제의 원인을 정확히 이해해야 한다는 글이 공감될 것이다.
이어서 채팅 클라이언트 코드는 작성해보자. 이 예제는 입출력의 처리를 분리시키기위해 쓰레드를 생성하였다.
chat_clnt
#include<"헤더파일 따로 정리">
#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);
}
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <pthread.h>
헤더파일
실행결과