v1.1.1
POSIX 쓰레드로 멀티 쓰레드 프로그래밍하기
옮긴이: 차현진(terminus@kldp.org)
원 본: http://users.actcom.co.il/~choo/lupg/tutorials/multi-thread/multi-thread.html
차례
- 시작하기 전에(Before We Start)...
- 쓰레드가 뭔데 그걸 쓰죠?(What Is a Thread? Why Use Threads)
- 쓰레드 만들고 없애기(Creating And Destroying Threads)
- 뮤텍스로 쓰레드 동기화하기(Synchronizing Threads With Mutexes)
- 뮤텍스가 뭐죠?(What Is A Mutex?)
- 뮤텍스 만들고 초기화하기(Creating And Initializing A Mutex)
- 뮤텍스 걸고 풀기(Locking And Unlocking A Mutex)
- 뮤텍스 없애기(Destroying A Mutex)
- 뮤텍스 사용법 - 완전한 예제(Using A Mutex - A Complete Example)
- 굶어죽기와 데드락(Starvation And Deadlock Situations)
- 세련된 동기화 - 조건 변수(Refined Synchronization - Condition Variables)
- 조건 변수가 뭐죠?(What Is A Condition Variable?)
- 조건 변수 만들고 초기화하기(Creating And Initializing A Condition Variable)
- 조건 변수에 시그널 보내기(Signaling A Condition Variable)
- 조건 변수 기다리기(Waiting On A Condition Variable)
- 조건 변수 없애기(Destroying A Condition Variable)
- 실제 상황의 조건 변수(A Real Condition For A Condition Variable)
- 조건 변수 사용법 - 완전한 예제(Using A Condition Variable - A Complete Example)
- "Private" thread data - Thread-Specific Data
- Overview Of Thread-Specific Data Support
- Allocating Thread-Specific Data Block
- Accessing Thread-Specific Data
- Deleting Thread-Specific Data Block
- A Complete Example
- 쓰레드 취소와 끝내기(Thread Cancellation And Termination)
- 쓰레드 취소하기(Canceling A Thread)
- 쓰레드 취소 상태 설정하기(Setting Thread Cancellation State)
- 취소 위치(Cancellation Points)
- 쓰레드 청소 함수 설정하기(Setting Thread Cleanup Functions)
- 쓰레드 끝내기 동기화(Synchronizing On Threads Exiting)
- 쓰레드 떼어내기(Detaching A Thread)
- 쓰레드 취소 - 완전한 예제(Threads Cancellation - A Complete Example)
- 쓰레드를 이용한 사용자 인터페이스 프로그래밍(Using Threads For Responsive User Interface Programming)
- 사용자 인터페이스 - 완전한 예제(User Interaction - A Complete Example)
- 멀티 쓰레드 어플리케이션에서 비시스템 라이브러리 쓰기(Using 3rd-Party Libraries In A Multi-Threaded Application)
- 쓰레드를 지원하는 디버거 쓰기(Using A Threads-Aware Debugger)
시작하기 전에(Before We Start)...
이 튜토리얼은 여러분에게 POSIX 쓰레드(pthread)를 이용한 멀티 쓰레드 프로그램에 익숙해지게 하고 쓰레드의 특징들이 실제 프로그램에서 어떻게 쓰이는지 보여줄 것입니다. 라이브러리가 정의해 놓은 여러가지 툴들을 설명하고 그것들을 어떻게 쓰는지, 또한 프로그래밍 문제를 해결하기위해 실제로 어떻게 적용시키는지를 보여줄 것입니다. 이 글을 읽으려면 병렬 프로그래밍(혹은 멀티 프로세스) 개념을 알고 있어야 합니다. 안 그러면 개념 잡기가 약간 힘들 것입니다. 각 튜토리얼은 "직렬" 프로그래밍에만 익숙한 독자들을 위해 이론적 배경 지식과 용어들을 설명 하면서 시작할 것입니다.
독자들이 X나 모티프 같은 비동기적인 프로그래밍 환경에 익숙하다고 가정을 하고 진행하겠습니다. 이런 환경에 익숙하다면 멀티 쓰레드 프로그래밍 개념을 이해하기 쉽습니다.
POSIX 쓰레드를 말할 때 항상 나오는 질문은 "어떤 POSIX 쓰레드 표준안을 써야 할 것인가?"입니다. 쓰레드 표준은 지난 몇 년간 계속 수정중이기 때문에 서로 다른 함수들, 서로 다른 디폴트 값, 서로 다른 뉘앙스의 여러 구현들이 있습니다. 본 튜토리얼은 리눅스 시스템의 커널 레벨 LinuxThreads 라이브러리 0.5 버전을 사용했기 때문에 다른 시스템, 다른 버전의 pthread를 쓰는 프로그래머들은 문제 발생시 해당 시스템의 매뉴얼을 참고해야 할 것입니다. 몇몇 예제들은 블러킹 시스템 콜을 쓰기 때문에 유저 레벨 쓰레드 라이브러리에서는 동작하지 않을 것입니다 (더 많은 정보를 보려면 우리 웹 사이트의 parallel programming theory tutorial을 참고하세요).
앞에서 얘기 했듯이 여기 나오는 예제들은 리눅스 이외의 다른 시스템에서도 동작하도록 노력을 했습니다(솔라리스 2.5).
쓰레드가 뭔데 그걸 쓰죠?(What Is a Thread? Why Use Threads)
쓰레드는 프로세스와 비슷합니다. 자신의 스택을 가지고 주어진 코드를 실행합니다. 하지만 진짜 프로세스와는 다르게 메모리를 다른 쓰레드와 공유합니다(프로세스는 자신만의 메모리 공간을 가지고 있습니다). 쓰레드 그룹은 한 프로세스 안에서 실행되는 모든 쓰레드를 나타내고, 메모리를 공유하기 때문에 전역 변수와 힙 메모리, 파일 디스크립터 등등을 공유합니다. 또한 같은 쓰레드 그룹의 쓰레드들은 병렬적으로 실행됩니다(즉, 시간을 잘라서 사용을 하는데 프로세서가 여러개라면 진짜 병렬로 동작합니다).
보통의 순차적인 프로그램 대신 쓰레드 그룹을 사용하면 몇 가지 일을 동시에 할 수 있는 장점이 있습니다. 따라서 어떤 이벤트에 대해 즉각적으로 반응을 할 수 있습니다 (예를 들면, 한 쓰레드는 사용자 인터페이스를 처리하고 다른 쓰레드는 데이타베이스 쿼리를 처리한다고 하면, 아주 엄청난 양의 쿼리가 들어와 바쁜 경우에도 사용자 입력에 대해 반응하고 처리할 수가 있습니다).
프로세스 그룹대신 쓰레드 그룹을 사용했을 때의 장점으로는 쓰레드간 컨택스트 스위치(context switching)가 프로세스간 컨택스트 스위치보다 훨씬 빠르다는 것입니다(컨택스트 스위칭이란 현재 돌고 있는 쓰레드나 프로세스에서 다른 쓰레드나 프로세스로 옮겨 가는 것을 말합니다). 또한, 보통 두 쓰레드간 통신을 두 프로세스간 통신보다 빠르고 쉽게 구현 할 수 있습니다.
다른 한 편으로는 한 그룹안의 모든 쓰레드들은 같은 메모리 영역을 사용하기 때문에 한 쓰레드가 메모리를 잘 못 건드리면 다른 쓰레드들에 영향이 미칠 수 있습니다. 프로세스에서는 운영체제가 프로세스를 다른 프로세스로부터 보호해 주기 때문에 쓰레드같은 영향은 없습니다. 프로세스의 다른 장점으로, 서로 다른 프로세스는 서로 다른 시스템(머신)에서 각각 돌 수 있다는 것입니다. 쓰레드는 보통 한 시스템에서 돌아야 합니다.
쓰레드 만들고 없애기(Creating And Destroying Threads)
멀티 쓰레드 프로그램이 실행을 시작하면 main()을 실행시키는 하나의 쓰레드만이 존재하게 됩니다. 이 완전한 쓰레드는 자신의 쓰레드 ID를 갖습니다. 새 쓰레드를 만들려면 pthread_create()
함수를 써야 됩니다. 어떻게 쓰는지 보시죠.
#include <stdio.h> /* 표준 I/O 루틴 */
#include <pthread.h> /* pthread 함수와 데이타 스트럭쳐 */
/* 새 쓰레드가 실행시킬 함수 */
void*
do_loop(void* data)
{
int i;
int i; /* 숫자를 찍을 카운터 */
int j; /* 지연용 카운터 */
int me = *((int*)data); /* 쓰레드 구분 숫자 */
for (i=0; i<10; i++) {
for (j=0; j<500000; j++) /* 지연 루프 */
;
printf("'%d' - Got '%d'\n", me, i);
}
/* 쓰레드 없애기 */
pthread_exit(NULL);
}
/* 보통의 C 프로그램처럼 main에서 시작합니다. */
int
main(int argc, char* argv[])
{
int thr_id; /* 새 쓰레드용 쓰레드 ID */
pthread_t p_thread; /* 쓰레드 구조체 */
int a = 1; /* 1번 쓰레드 구분 숫자 */
int b = 2; /* 2번 쓰레드 구분 숫자 */
/* 'do_loop()를 실행시킬 새 쓰레드 만들기 */
thr_id = pthread_create(&p_thread, NULL, do_loop, (void*)&a);
/* main()함수에서도 'do_loop()' 실행시키기 */
do_loop((void*)&b);
/* NOT REACHED */
return 0;
}
위 프로그램에서 몇 가지를 살펴보겠습니다.
- 메인 프로그램 자체도 쓰레드이기 때문에
do_loop()
는 자신이 새로 실행시킨 쓰레드가 실행시킨 do_loop()
와 병렬로 동작합니다.
pthread_create()
는 4개의 파라미터를 받습니다. 첫 번째는 쓰레드에 대한 정보를 제공하기 위해서 쓰입니다. 두 번째는 새 쓰레드에 속성을 주기 위해서 쓰이는데 우리는 NULL 포인터를 넘겨 줘서 기본값을 쓰게 했습니다. 세 번째 파라미터는 어떤 함수에서 쓰레드가 시작할 것인지를 알려주는 것이고 네 번째는 그 함수로 넘겨줄 아규먼트를 나타냅니다. 여기서 'void*'로 캐스팅 한 것은 이것이 비록 ANSI-C 문법에서는 불필요하지만 좀 더 명확하게 하기 위해서 쓰인 것입니다.
- 지연 루프는 병렬로 실행되는 쓰레드를 확실히 보여주기 위해서 쓰였습니다. CPU가 너무 빨라서 한 쓰레드가 모두 출력된 다음 다른 쓰레드의 출력이 나온다면 지연값을 증가시키기 바랍니다.
pthread_exit()
는 현재 쓰레드를 종료 시키고 자신이 갖고 있던 자신만의 쓰레드 리소스들을 놓아 줍니다. 쓰레드의 첫 함수 마지막에서 꼭 이 함수를 불러야 할 필요는 없습니다. 그 함수에서 리턴을 하게 되면 자동으로 종료가 됩니다. 쓰레드 중간에서 쓰레드를 종료하고 싶은 경우가 생길 때, 유용하게 쓰일 수 있습니다.
멀티 쓰레드 프로그램을 gcc
로 컴파일 하려면 pthread 라이브러리 를 링크시켜줘야 합니다. 이미 여러분의 시스템에 이 라이브러리가 설치되어 있다고 가정하고 어떻게 컴파일 하는지를 보여 드리겠습니다.
gcc pthread_create.c -o pthread_create -lpthread
앞으로 나올 몇몇 프로그램들은 제대로 컴파일 하기 위해서 '-D_GNU_SOURCE' 를 줘서 컴파일 해야 할지도 모릅니다. 주의하세요.
이 프로그램의 소스 코드는 pthread_create.c를 보세요.
뮤텍스로 쓰레드 동기화하기
여러개의 쓰레드를 동시에 돌릴 때 발생하는 기본적인 문제점 중의 하나는 같은 메모리 영역을 쓰기 때문에 "서로의 상태에 신경 쓰도록" 하는 것입니다. 그래서 여기서는 두 개의 쓰레드가 동일한 데이타 구조에 접근할 때 생기는 문제점을 살펴 보도록 하겠습니다.
예를 들어서, 두 쓰레드가 두 변수를 업데이트 하려고 하는 상황을 생각해 봅시다. 한 쓰레드는 두 변수를 0으로 세트하려고 하고, 다른 쓰레드는 두 변수를 1로 세트 하려고 합니다. 만약에 두 쓰레드가 동시에 이 일을 하려고 한다면 한 변수는 1로, 다른 한 변수는 0으로 세트된 상황이 생길 수도 있습니다. 이런 일이 생기는 이유는 첫번째 쓰레드가 첫번째 변수를 0으로 만들고 나서 바로 컨택스트 스위치(context switching-이제 이게 뭔지 아시죠?)가 일어나고, 두번째 쓰레드가 두 변수를 1로 세트를 한 다음 다시 첫번째 쓰레드가 동작을 하면 두 번째 변수만을 0으로 만들기 때문에 결과적으로 첫 번째 변수는 1로, 두 번째 변수는 0으로 됩니다.
뮤텍스(mutex)가 뭐죠?
이 문제를 해결하기 위해서 pthread 라이브러리가 제공하는 기본 메카니즘을 뮤텍스라 부릅니다. 뮤텍스는 다음 세가지를 보장해주는 잠금 장치입니다. (역주: mutex - MUTual EXclusion - 상호 배타성)
- 원자성(Atomicity) - 뮤텍스를 걸었을 경우 다른 쓰레드가 동시에 뮤텍스가 걸린 영역으로 들어오지 못하게 보장해주는 원자적 동작입니다.
- 유일성(Singularity) - 한 쓰레드가 뮤텍스를 걸었을 경우 자신이 풀기 전에는 다른 쓰레드가 다시 뮤텍스를 걸지 못하게 해 줍니다.
- Non-Busy Wait - A라는 쓰레드가 이미 뮤텍스가 걸린 B 쓰레드를 걸려고 한다면 A 쓰레드는 B 쓰레드가 뮤텍스를 풀 때까지 서스펜드(suspend)됩니다 (CPU 리소스를 전혀 사용하지 않습니다). B가 뮤텍스를 풀면 A는 깨어나고 자신이 뮤텍스를 걸고 실행을 계속해 나갑니다.
이 세가지에서 볼 수 있듯이 어떻게 뮤텍스가 변수(혹은 코드의 임계 부분)에 대해서 배타적 접근을 확실하게 해 주는지 알 수 있습니다. 앞에서 설명했던 두 변수를 업데이트 해주는 가상 코드를 살펴 보죠. 다음은 첫 번째 쓰레드입니다.
'X1' 뮤텍스를 잠근다.
첫번째 변수를 '0'으로 세팅.
두번째 변수를 '0'으로 세팅.
'X1' 뮤텍스를 푼다.
두번째 쓰레드는 이렇게 되겠죠.
'X1' 뮤텍스를 잠근다.
첫번째 변수를 '1'로 세팅.
두번째 변수를 '1'로 세팅.
'X1' 뮤텍스를 푼다.
두 쓰레드가 같은 뮤텍스를 쓰고 동시에 돌았다고 하면 두 변수 모두 '0'으로 세트되어 있던지 '1'로 세트되어 있을 겁니다. 프로그래머가 주의할 일이 조금 있습니다. 만약에 세번째 쓰레드가 코드의 다른 부분에서 'X1' 뮤텍스 없이 이 두 변수에 접근을 한다면 역시나 변수 내용이 뒤죽박죽 될 가능성이 있습니다. 따라서 이 변수에 접근하는 모든 코드들을 조그만 함수로 만들어 놓고 이 변수들에 접근 할 때는 이 함수만 쓰도록 해야 합니다.
뮤텍스 만들고 초기화하기
뮤텍스를 만들려면 먼저 pthread_mutex_t
형의 변수를 선언하고 초기화 해야 합니다. 가장 간단한 방법은 PTHREAD_MUTEX_INITIALIZER
상수를 할당하는 것입니다. 따라서 다음같은 코드를 쓰면 되겠습니다.
pthread_mutex_t a_mutex = PTHREAD_MUTEX_INITIALIZER;
주의 할 점이 하나 있는데, 이런 형태의 초기화는 '빠른 뮤텍스(fast mutex)'라는 뮤텍스를 만들어 줍니다. 무슨 뜻이냐면, 만약에 쓰레드가 뮤텍스를 잠근 뒤에 또, 그 뮤텍스를 잠그려고 하면, 그냥 멈춰버릴 것입니다. - 데드락(deadlock)이 걸린다는 뜻입니다.
'재귀적 뮤텍스(recursive mutex)'란 다른 형태도 있는데 한 번 잠근 뒤에도 몇 번이고 더 잠글 수 있게 해주는 뮤텍스입니다. 이 뮤텍스는 위에서 말한 데드락 상황이 안 걸리게 해 줍니다(하지만 이 뮤텍스를 풀려고 하는 다른 쓰레드는 멈출 것입니다). 걸었던 뮤텍스를 풀 때, 걸었던 만큼 풀지 않는 한 뮤텍스가 계속 걸려 있을 겁니다. 이 방법은 현대적인 문잠금 장치에서 문을 잠글 때는 시계 방향으로 두 번 돌리고, 풀 때는 반시계 방향으로 두 번 돌리는 것과 비슷합니다. 이런 뮤텍스를 만들려면 PTHREAD_RECURSIVE_MUTEX_INITIALIZER_NP
를 할당해 주면 됩니다.
뮤텍스 걸고 풀기(Locking And Unlocking A Mutex)
뮤텍스를 걸때는 pthread_mutex_lock()
함수를 씁니다. 뮤텍스를 걸려고 하는데, 이미 다른 쓰레드가 그 뮤텍스를 걸어놨다면 자신의 쓰레드를 멈추게 합니다. 이렇게 멈췄을 경우에는 뮤텍스를 걸었던 프로세스가 뮤텍스를 풀면 이 함수는 다시 뮤텍스를 걸고 리턴을 합니다. 미리 초기화 했다고 가정하고 어떻게 뮤텍스를 거는지 보여 드리죠.
int rc = pthread_mutex_lock(&a_mutex);
if (rc) { /* 에러 발생 */
perror("pthread_mutex_lock");
pthread_exit(NULL);
}
/* 뮤텍스가 걸렸습니다. 필요한 일을 하세요. */
.
.
쓰레드는 자신이 할 일(변수나 데이타 구조의 값을 바꾼다거나 파일을 처리하는등)을 하고 나면 다음처럼 pthread_mutex_unlock()
함수를 써서 뮤텍스를 풀어 줘야 합니다.
rc = pthread_mutex_unlock(&a_mutex);
if (rc) {
perror("pthread_mutex_unlock");
pthread_exit(NULL);
}
뮤텍스 없애기(Destroying A Mutex)
뮤텍스로 할 일을 다 했다면 이젠 없앨 차례입니다. 할 일을 다 했다는 얘기는 어떤 쓰레드도 그 뮤텍스가 필요없어졌다는 뜻입니다. 만약에 한 쓰레드만 뮤텍스로 할 일을 끝마쳤다면 이 때는 없애면 안 됩니다. 다른 쓰레드가 그 뮤텍스를 쓸지도 모르기 때문입니다. 모든 쓰레드가 확실히 뮤텍스를 쓸 일이 없다면 마지막 쓰레드가 pthread_mutex_destroy()
함수로 그 뮤텍스를 없앨 수 있습니다.
rc = pthread_mutex_destroy(&a_mutex);
이 함수를 부르고 나면 a_mutex 변수는 다시 초기화 되지 않는 한 더 이상 뮤텍스로 쓰일 수가 없습니다. 따라서 만약에 한 쓰레드가 너무 일찍 뮤텍스를 없앴을 경우에, 다른 쓰레드에서 잠그거나 풀려고 한다면 잠그고 푸는 함수는
EINVAL
에러 코드를 만나게 됩니다.
뮤텍스 사용법 - 완전한 예제(Using A Mutex - A Complete Example)
뮤텍스의 탄생부터 죽음까지 모두 알아봤기 때문에 이제는 예제를 살펴보겠습니다. 이 예제는 영광스러운 "종업원상"을 타기 위해 다투는 두 종업원을 시뮬레이션합니다. 빠르게 시뮬레이션하기 위해서 3개의 쓰레드를 쓰겠습니다. 하나는 Danny를 "종업원상"에 올리고 두번째 쓰레드는 Moshe를 올립니다. 세번째 쓰레드는 "종업원상"의 내용이 일치하는 지를 보여줍니다(즉, 정확하게 한 종업원의 데이타가 들어있음).
두 개의 프로그램이 있는데 하나는 뮤텍스를 쓰는 것이고 다른 하나는 쓰지 않는 것입니다. 둘 다 해보고 차이점을 알아본 다음, 멀티 쓰레드 환경에서 뮤텍스가 꼭 필요한 이유를 마음으로 느껴 보세요.
이 프로그램들은 파일 형태로 제공됩니다. 뮤텍스를 쓰는 것은 employee-with-mutex.c이고, 뮤텍스를 안 쓰는 것은 employee-without-mutex.c입니다. 소스에 있는 주석을 잘 읽어서 어떻게 동작하는지에 대해서 더 잘 이해하시기 바랍니다.
굶어죽기와 데드락 상황(Starvation And Deadlock Situations)
다시 기억을 되살려 보죠.
pthread_mutex_lock()
는 이미 잠겨 있는 뮤텍스에 대해서는 알 수 없는 시간 동안 멈춰 있을 수 있습니다. 만약에 그 잠김이 영원하다면 우리의 불쌍한 쓰레드는 "굶어(starved)" 죽습니다. 리소스를 얻으려 하지만 영원히 얻지 못하게 되는 것입니다. 이런 굶어 죽기( starvation)가 발생하지 않도록 하는 것은 프로그래머에게 달려 있습니다. pthread 라이브러리는 어떤 도움도 줄 수가 없습니다.
그렇지만, pthread 라이브러리는 "데드락(deadlock)"은 해결 할 수도 있습니다. 데드락이란 모두 같은 상태인 몇몇 쓰레드가 다른 쓰레드가 갖고 있는 리소스를 기다리는 상황입니다.(A deadlock is a situation in which a set of threads are all waiting for resources taken by other threads, all in the same set.) 당연히 모든 쓰레드가 뮤텍스를 기다리면서 멈춰있다면 아무도 다시 돌 수는 없을 것입니다. pthread 라이브러리는 이런 상황을 추적하다가 마지막 쓰레드가 pthread_mutex_lock()
를 부르면 실패를 리턴하면서 EDEADLK
에러를 발생시킵니다. 프로그래머는 이런 값을 확인해서 데드락을 피할 방법을 찾아야 합니다.
세련된 동기화 - 조건 변수(Refined Synchronization - Condition Variables)
지금까지 살펴본 뮤텍스는 리소스에 대한 배타적 접근이라는 간단한 동기화를 제공합니다만, 가끔은 진짜 동기화가 필요할 경우가 있습니다.
- 서버에서, 한 쓰레드는 클라이언트의 요청을 읽어들이고 그 요청을 해석해서 여러 쓰레드에게 처리를 넘깁니다. 이 처리 쓰레드들은 처리할 데이타가 생길 경우에 그 사실을 알아야 할 필요가 있습니다. 그렇지 않다면 CPU 시간을 쓰지 않으면서 기다려야 합니다.
- GUI(Graphical User Interface) 어플리케이션에서 한 쓰레드는 사용자 입력을 읽어 들이고 한 쓰레드는 그래픽 출력을 담당하며, 한 쓰레드는 서버에 요청을 보내고 그 응답을 처리합니다. 서버쪽을 담당하는 쓰레드는 서버에서 응답이 왔을 때 그래픽을 담당하는 쓰레드에게 알려줄 수가 있어야 합니다. 그래야 사용자에게 즉시 보여줄 수 있기 때문입니다. 사용자 입력 담당 쓰레드는 예를 들면 서버 담당 쓰레드가 아주 긴 동작중이더라도 사용자가 그것을 취소 시킬 수 있게 해주는 상황처럼 사용자의 요청에 항상 빠르게 응답해야 할 필요가 있습니다.
이 상황들은 모두, 쓰레드는 서로 어떤 사건에 대해서 상대방에게 통보할 수 있는 능력이 필요합니다. 이것이 바로 조건 변수가 탄생한 이유입니다.
조건 변수가 뭐죠?(What Is A Condition Variable?)
조건 변수는 어떤 일이 발생할 때까지 CPU 사이클을 낭비하지 않고 기다릴 수 있도록 해 주는 메카니즘입니다. 몇개의 쓰레드가 조건 변수를 기다리고 있고, 다른 쓰레드가 그 조건 변수에 대해서 시그널을 날려주면(사건을 통지) 기다리던 쓰레드중의 하나가 깨어나서 그 사건에 대해 반응을 하게 됩니다. 또한 그 조건 변수를 기다리고 있던 모든 쓰레드를 깨울 수 있게 브로드캐스트 할 수 있는 방법도 있습니다.
주의할 점은 조건 변수는 잠금을 지원하지 않는다는 것입니다. 따라서 조건 변수에 접근을 하려면 뮤텍스와 같이 사용을 해야 합니다.
조건 변수 만들고 초기화하기(Creating And Initializing A Condition Variable)
조건 변수를 만들려면 pthread_cond_t
형의 변수를 선언하고 알맞게 초기화 시켜줘야 합니다. 초기화는 간단하게 PTHREAD_COND_INITIALIZER
라는 매크로를 쓰던지, pthread_cond_init()
함수를 쓰면 됩니다. 매크로를 쓰는 예제를 살펴 보겠습니다.
pthread_cond_t got_request = PTHREAD_COND_INITIALIZER;
'got_request'라는 조건 변수를 선언하고 초기화 합니다.
주의사항: PTHREAD_COND_INITIALIZER
는 실제로 구조체이기 때문에 조건 변수가 선언 될 때에만 쓰일 수 있습니다. 실행 시간에 초기화를 해야 한다면 pthread_cond_init()
함수를 쓰기 바랍니다.
조건 변수 시그널 날리기(Signaling A Condition Variable)
조건 변수에 시그널을 날리는 방법은 두 가지가 있습니다. 하나는 pthread_cond_signal()
함수를 부르는 것이고(이 변수를 기다리고 있는 하나의 쓰레드만을 깨울 때), 또 하나는 pthread_cond_broadcast()
함수를 부르는 것입니다(이 변수를 기다리고 있는 모든 쓰레드를 깨울 때). 'got_request'가 적당히 초기화 됐다고 가정하고 예제를 살펴보도록 하죠.
int rc = pthread_cond_signal(&got_request);
혹은 브로드캐스트 함수를 써서,
int rc = pthread_cond_broadcast(&got_request);
두 함수 모두 성공했을 때는 'rc'를 0으로, 실패했을 때는 0이 아닌 값으로 세팅합니다. 실패 했을 경우에는 리턴값은 에러 이유를 나타냅니다(파라미터가 조건 변수가 아닐 때는 EINVAL
를, 시스템 메모리가 부족할 때는 ENOMEM
를 나타냅니다).
주의 사항: 시그널이 성공했다고 해서 어떤 쓰레드가 깨어났다는 뜻은 아닙니다. 그 조건 변수를 기다리던 쓰레드가 하나도 없었다면 아무일도 아닌 것이죠(즉, 시그널을 잃어버리는 것입니다).
그리고 시그널을 저장해놨다가 쓸 수도 없습니다. 만약에 시그널 함수가 리턴한 다음에 어떤 쓰레드가 그 조건 변수를 기다리기 시작한다면 그 쓰레드는 다른 시그널이 발생해야 깨어날 수 있습니다.
조건 변수 기다리기(Waiting On A Condition Variable)
어떤 쓰레드가 조건 변수에 시그널을 날리길 다른 쓰레드가 기다리려고 한다면 다음 두 함수 중에 한 함수를 쓰면 됩니다. pthread_cond_wait()
, pthread_cond_timedwait()
. 각 함수는 조건 변수와 뮤텍스(기다리기 전에 뮤텍스를 걸지도 모르기 때문에)를 넘겨 받아서 뮤텍스를 푼 다음에 조건 변수에 시그널이 들어올 때까지 잠들어 버립니다. 앞에서 살펴 봤던 pthread_cond_signal()
에 의해서 시그널이 발생해, 깨어 나게 된다면 뮤텍스는 자동으로 다시 잠기고 리턴하게 됩니다.
두 함수가 다른 점은
pthread_cond_timedwait()
에 기다릴 시간을 알려준다는 것인데 ETIMEDOUT의 에러값을 갖고 리턴을 해서 조건 변수가 시그널을 받은 것이 아니라 시간이 지나서 리턴했다는 것을 알려준다는 것입니다.
pthread_cond_wait()
는 시그널을 받기 전에는 영원히 기다릴 것입니다.
두 함수를 어떻게 쓰는지 보여드리죠. 'got_request'는 적당한 조건 변수로 초기화 됐고 역시 'request_mutex'도 적당한 뮤텍스로 초기화 됐다고 가정합니다. 먼저 pthread_cond_wait()
함수를 봅시다.
/* 뮤텍스를 먼저 걸고 */
int rc = pthread_mutex_lock(&a_mutex);
if (rc) { /* 에러 났음 */
perror("pthread_mutex_lock");
pthread_exit(NULL);
}
/* 이제 뮤텍스가 걸렸고, 조건 변수를 기다린다. */
/* pthread_cond_wait이 실행되는 동안 뮤텍스는 풀립니다. */
rc = pthread_cond_wait(&got_request, &request_mutex);
if (rc == 0) { /* 조건 변수가 시그널을 받아서 깨어났습니다. */
/* pthread_cond_wait()가 뮤텍스를 다시 걸어 줍니다. */
/* 할 일을 하세요... */
.
}
/* 끝으로 뮤텍스를 풀어 줍시다. */
pthread_mutex_unlock(&request_mutex);
다음은
pthread_cond_timedwait()
함수를 쓰는 예제입니다.
#include <sys/time.h> /* struct timeval 정의 */
#include <unistd.h> /* gettimeofday() 선언 */
struct timeval now; /* 기다리기 시작하는 시각 */
struct timespec timeout; /* 대기 함수에서 쓸 타임아웃값 */
int done; /* 다 기다렸나요? */
/* 뮤텍스를 먼저 걸고 */
int rc = pthread_mutex_lock(&a_mutex);
if (rc) { /* 에러 났음 */
perror("pthread_mutex_lock");
pthread_exit(NULL);
}
/* 이제 뮤텍스가 걸렸음. */
/* 지금 시각을 얻는다. */
gettimeofday(&now);
/* 타임아웃값을 세팅 */
timeout.tv_sec = now.tv_sec + 5
timeout.tv_nsec = now.tv_usec * 1000; /* timeval은 마이크로(micro)초를 씁니다. */
/* timespec은 나노(nano)초를 씁니다. */
/* 1 나노초 = 1000 마이크로초 */
/* 조건 변수를 기다림 */
/* 유닉스 시그널이 타임아웃 전에 대기 상태를 멈추게 할 수 있기 때문에 루프를 써서 피하겠습니다. */
done = 0;
while (!done) {
/* pthread_cond_timedwait()은 함수 시작부분에서 뮤텍스를 푼다는 것을 기억하세요. */
rc = pthread_cond_timedwait(&got_request, &request_mutex, &timeout);
switch(rc) {
case 0: /* 조건 변수가 시그널을 받아서 깨어 났음 */
/* pthread_cond_timedwait가 뮤텍스를 다시 걸어줍니다. */
/* 할 일을 하시고... */
.
.
done = 0;
break;
case ETIMEDOUT: /* 시간이 다 됐네요 */
done = 0;
break;
default: /* 에러가 났습니다.(즉, 유닉스 시그널을 받았습니다.) */
break; /* swithc문을 빠져나가지만 다시 while 루프를 돕니다. */
}
}
/* 자, 끝으로 뮤텍스를 풀어 줍시다. */
pthread_mutex_unlock(&request_mutex);
보는바와 같이 타임아웃을 쓰는 버전이 더 복잡합니다. 따라서 필요할 때마다 코드를 만들지 말고 래퍼 함수등을 쓰는게 훨씬 좋을 것입니다.
주의사항: 두 개 이상의 쓰레드가 기다리고 있는 조건 변수가 시그널을 아주 많이 받는다고 할 때, 기다리던 쓰레드 중의 하나는 영원히 깨어 나지 못 할 수도 있습니다. 조건 변수가 시그널을 받았을 때 기다리던 쓰레드중 어떤 쓰레드가 깨어날 지에 대해서 알 수가 없기 때문입니다. 방금 깨어난 쓰레드가 대기 상태로 다시 들어가자마자 시그널이 다시 발생해 그 쓰레드가 다시 깨어나는 식의 동작이 계속 될 수 있기 때문입니다. 이럴 경우에 계속 깨어나지 못하는 쓰레드를 가르켜 "굶어죽었다(starvation)"라고 부릅니다. 이렇게 원치 않는 동작이 일어날 가능성이 있는 상황을 피하는 것은 전적으로 프로그래머의 책임입니다. 하지만 앞에서 봤던 서버 예제에서는 요청이 아주 늦게 들어오고, 서비스 응답을 처리할 쓰레드는 많을 것이기 때문에 아주 바람직한 상황입니다. 즉, 이 경우에는 요청이 발생하자마자 바로바로 처리될 것이기 때문입니다.
주의사항 2: 뮤텍스가 pthread_cond_broadcast로 브로드캐스트를 받았을 때, 그 뮤텍스를 기다리던 모든 쓰레드가 동시에 실행되는것은 아닙니다. 기다리던 각각은 자신의 대기 함수가 리턴하기 전에 뮤텍스를 다시 걸려고 시도를 하기 때문에 하나씩 실행이 됩니다. 즉, 뮤텍스를 걸고, 자기 할 일을 하고, 뮤텍스를 풀고하는 식으로 차례차례 실행이 됩니다.
조건 변수 없애기(Destroying A Condition Variable)
조건 변수를 다 썼다면 없애야겠죠. 이래야 조건 변수가 갖고 있던 시스템 리소스를 반환할테니까요. pthread_cond_destroy()
로 이 일을 합니다. 제대로 동작하려면 이 조건 변수를 기다리는 쓰레드가 하나도 없어야 합니다. 사용법을 보여드릴텐데, 역시 'got_request'가 이미 조건 변수로 초기화 되어 있었다고 가정합니다.
int rc = pthread_cond_destroy(&got_request);
if (rc == EBUSY) { /* 이 조건 변수를 기다리는 쓰레드가 있군요. */
/* 잘 처리하세요... */
.
.
}
어떤 쓰레드가 여전히 조건 변수를 기다리고 있다면, 상황에 따라 다르겠지만, 이 조건 변수의 사용에 어떤 허점이 있었을 수도 있고 적당한 쓰레드 종료 코드가 빠졌을 수도 있습니다. 최소한 디버깅 단계에서는 이 상황을 프로그래머에게 알려주는게 좋습니다. 아무 것도 아닐 수도 있고 아주 중대한 결함일 수도 있으니까요.
실제 상황에서의 조건 변수(A Real Condition For A Condition Variable)
조건 변수에 대해서 하나 짚고 가야겠습니다. 이것과 관련된 실제 조건에 대한 확인들이 없다면 조건 변수는 거의 쓸모가 없습니다. 확실히 하기 위해서 앞에서 소개했던 서버 예제를 잠깐 살펴보도록 하죠. 'got_request' 조건 변수가 처리할 새 요청이 들어왔을 때 시그널을 받는다고 가정하고 사용을 했습니다. 이들은 또한 어떤 요청 큐에 들어 있을 것입니다. 그 조건 변수가 시그널을 받았을 때, 기다리던 쓰레드가 있다면 그 쓰레드는 깨어나고 응답을 처리할 것이라는 것을 확신할 수 있습니다.
하지만, 새 요청이 들어온 순간에 모든 쓰레드가 바로 전 응답을 처리하느라 바쁘다면 어떻게 될까요? 이 순간에는 모든 쓰레드는 조건 변수를 기다리고 있지 않고 자기 일을 하고 있었기 때문에 그 조건 변수가 받은 시그널은 무시될 겁니다. 또한 각 쓰레드가 자기 일을 마치고 조건 변수를 기다리는 상태가 됐을 경우, 그 무시됐던 시그널이 다시 발생하지도 않습니다(또다른 새 요청이 없다고 가정하면). 따라서, 모든 쓰레드가 시그널을 기다리느라 멈춰있는 동안 최소한 한 개의 요청이 처리되지 못 하고 남아 있게 됩니다.
이 문제를 해결하기 위해서 요청이 미처리 된 갯수를 정수 변수에 갖고 있겠습니다. 그리고 각 쓰레드는 조건 변수를 기다리기 전에 그 값을 확인해서 그 값이 양수이면 (미처리 된 요청이 있다), 멈추지 않고 그 응답을 처리할 겁니다. 또한, 요청을 처리한 쓰레드는 이 변수를 하나씩 감소시켜야 하는데 이렇게 해야 숫자가 정확해 질것입니다.
이런 고려 사항들이 위에서 봤던 코드를 어떻게 바꾸는지 봅시다.
/* 미처리된 요청, 0으로 초기화 */
int num_requests = 0;
.
.
/* 먼저, 뮤텍스를 잠급시다. */
int rc = pthread_mutex_lock(&a_mutex);
if (rc) { /* 에러 있음 */
perror("pthread_mutex_lock");
pthread_exit(NULL);
}
/* 이제 뮤텍스는 잠겼고, 조건 변수를 기다립니다. */
/* 처리할 요청이 없다면 */
rc = 0;
if (num_requests == 0)
rc = pthread_cond_wait(&got_request, &request_mutex);
if (num_requests > 0 && rc == 0) { /* 미처리 요청이 있네용 */
/* 할 일을 합시다. */
.
.
/* 미처리 요청수를 하나 줄입니다. */
num_requests--;
}
}
/* 마지막으로, 뮤텍스를 풀어줘야죠 */
pthread_mutex_unlock(&request_mutex);
조건 변수 사용법 - 완전한 예제(Using A Condition Variable - A Complete Example)
조건 변수의 실질적인 사용법을 보여주기 위해서 앞에서 설명했던 서버를 시뮬레이션하는 프로그램을 소개하겠습니다. 한 쓰레드는 수신자로서, 클라이언트의 요청을 받아 들여서 링크드 리스트에 요청을 집어 넣습니다. 핸들러 쓰레드는 이 요청을 처리하게 됩니다. 간단하게 하기 위해서 수신자 쓰레드는 실제 클라이언트에서 요청을 받아들이지 않고 자신이 요청을 만들어 내게 할 것입니다.
소스는 thread-pool-server.c에서 볼 수 있습니다. 소스안에 아주 자세한 주석이 달려 있으니까 소스를 먼저 읽어 본 다음에 밑에 나오는 설명을 참고하세요.
- 'main' 함수는 먼저 핸들러 쓰레드를 만들고, 자신의 메인 루프를 통해 수신자 쓰레드의 역할을 짊어집니다.
- 한 개의 뮤텍스로, 조건 변수와 요청을 기다릴 링크드 리스트, 두 개를 보호하는데 씁니다. 이렇게 하면 전체 설계를 간단하게 할 수 있습니다. 연습문제 하나 내죠. 이 예제를 두 개의 뮤텍스를 쓰는 방식으로 바꿔보세요.
- 여기서 쓰이는 뮤텍스는 재귀적 뮤텍스"여야" 합니다. 왜 그런가는 소스 코드중, 'handle_requests_loop' 함수를 보세요. 보면, 먼저 뮤텍스를 걸고, 'get_request' 함수를 부르는데, 여기서도 뮤텍스를 또 거는군요. 만약에 재귀적 뮤텍스를 안 썼다면 이 'get_request' 함수에서 뮤텍스를 거는 순간 영원히 멈춰버릴 것입니다.
'get_request' 함수에서 뮤텍스 거는 부분을 빼서 두 번 거는 문제를 풀 수 있지 않겠냐라고 할 지도 모르겠지만 이렇게 하면 결함이 있는 설계가 돼 버립니다. 아주 큰 프로그램에서 'get_request'를 다른 코드상에서 부를 수도 있기 때문입니다. 따라서 매번 쓸 때마다 뮤텍스가 적절하게 잠겼는지 확인할 필요가 있습니다.
- 일반적으로, 재귀적 뮤텍스를 쓸 때에는, 뮤텍스를 잠그고 푸는 것을 한 함수 안에서 하도록 해야 합니다. 안 그러면, 잠근 수만큼 풀기가 아주 어려워 지고 결국 데드락이 발생하게 될 겁니다.
pthread_cond_wait()
함수가 내부적으로 뮤텍스를 풀었다 다시 거는게 처음에는 헷갈릴 수도 있습니다. 제일 좋은 방법은 코드상에 이런 동작에 대해 주석으로 달아서, 다른 사람이 쓸데없이 뮤텍스를 또 걸지 않게 해 줄 수 있습니다.
개인적인 쓰레드 데이타 - 쓰레드만의 데이타("Private" thread data - Thread-Specific Data)
보통의 쓰레드 하나짜리 프로그램에서 가끔 전역 변수를 써야 할 때가 있습니다. 맞습니다. 나이 드신 훌륭한 선생님께서는 전역 변수를 쓰는게 아주 나쁜 습관이라고 말씀하셨습니다. 하지만 가끔 이게 편할 때가 있습니다. 특히나 한 파일 안에서만 보이는 정적 변수라면 더욱 그렇죠.
멀티 쓰레드 프로그램에서도 이런 전역 변수를 써야 할 경우가 있습니다. 모든 쓰레드에서 접근 가능한 하나의 변수에 대해서는 약간의 오버헤드를 갖는 뮤텍스를 써서 보호해야 한다는 것에 주의하시기 바랍니다. 게다가, 특정한 쓰레드에서만 쓰일 "전역" 변수가 필요할 수도 있고, 똑같은 "전역" 변수이나 다른 쓰레드에서는 다른 값을 가져야 할 때도 있습니다. 예를 들어, 각 쓰레드에서 전역적으로 접근할 수 있는 하나의 연결 리스트(그러나 같지 않은)가 필요하다고 가정해 보죠. 더군다나, 모든 쓰레드가 실행할 코드는 동일해야 합니다. 이런 경우에, 리스트의 시작을 나타내는 전역 포인터는 각 쓰레드에서 서로 다른 위치를 가르키고 있어야 합니다.
이런 포인터를 가지려면 메모리상의 위치가 다른 동일한 전역 변수가 있어야 합니다. 이것이 바로 쓰레드만의 데이타(thread-specific data) 메카니즘이 필요한 이유입니다.
쓰레드만의 데이타 지원 개요(Overview Of Thread-Specific Data Support)
쓰레드만의 데이타(TSD) 메카니즘에서는 키와 값이라는 개념이 필요합니다. 각 키는 이름을 갖고 있고 어떤 메모리 영역을 가르킵니다. 두 개의 서로 다른 쓰레드에서 이름이 같은 키를 갖고 있다면 항상 서로 다른 메모리 위치를 나타냅니다. 이 키를 가지고 접근할 수 있는 메모리 블럭을 할당해 주는 라이브러리 함수들이 이것을 처리해 줍니다. 키를 만들어 주는 함수(전체 프로세스에서 한 키에 대해서 한 번만 실행), 메모리를 할당해 주는 함수(각 쓰레드에서 실행), 특정 쓰레드에서 이 메모리를 다시 반환해 주는 함수, 전체 프로세스에서 그 키를 없애주는 함수등이 있습니다. 또, 키가 가르키는 데이타에 접근하는 함수와 그 값을 세팅하거나 값을 알아내는 함수도 있습니다.
쓰레드만의 데이타 블럭 할당하기(Allocating Thread-Specific Data Block)
pthread_key_create()
함수는 새로운 키를 만들어 내려고 할 때 쓰입니다. 이 키는 전체 프로세스의 모든 쓰레드에서 유효합니다. 키가 생성 됐을 때, 기본으로 NULL을 가르키게 됩니다. 다음에 각 쓰레드들은 자신이 원하는 값으로 이 복사본을 변경하게 됩니다. 사용법을 보여드리죠.
/* rc 는 pthread 함수의 리턴값을 저장하는데 쓰입니다. */
int rc;
/* 키를 갖고 있을 변수 정의. */
pthread_key_t list_key;
/* cleanup_list 는 데이타를 청소해 주는 함수입니다. */
/* 이것은 우리 프로그램에서 만들어 주는 것이지 TSD 자체의 것이 아닙니다. */
extern void* cleanup_list(void*);
/* 삭제시 불릴 함수를 넘겨서 키를 만듭니다. */
rc = pthread_key_create(&list_key, cleanup_list);
몇 가지 주의사항:
pthread_key_create()
가 리턴한 후에는 'list_key' 변수는 새롭게 생성된 키를 가르키게 됩니다.
pthread_key_create()
의 두번째 인자로 넘겨진 함수 포인터는 쓰레드 종료시, pthread 라이브러리에 의해서 키 값의 포인터를 인자로 받아서 불리게 됩니다. 함수 포인터에 NULL 포인터를 넘길 수도 있는데 이렇게 하면 종료시 해당 키에 대해서는 아무 함수도 실행 되지 않습니다. 주의할 점은, 이 키가 한 쓰레드에서 한 번만 생성됐다고 하더라도, 각 쓰레드가 종료할 때마다 실행된다는 것입니다.
만약에 키를 여러개 생성했다면 키 생성 순서와는 상관없이 해당 종료 함수가 실행 될 것입니다.
pthread_key_create()
함수는 성공시 0을, 실패시 에러 코드를 리턴합니다.
PTHREAD_KEYS_MAX
만큼의 키 값 제한이 있습니다. PTHREAD_KEYS_MAX
가 넘어가게 되면 pthread_key_create()
함수에서 EAGAIN 에러 값을 받게 될 것입니다.
쓰레드만의 데이타에 접근하기(Accessing Thread-Specific Data)
키를 생성한 다음에는 pthread 함수를 써서 접근할 수 있습니다: pthread_getspecific()
와 pthread_setspecific()
. 첫번째 함수는 주어진 키에 대해서 그 값을 알아내는 데 쓰이고, 두번째 함수는 주어진 키에 데이타를 세트하는데 쓰입니다. 키 값은 간단하게 void 포인터(void *)이기 때문에, 아무것이나 저장할 수 있습니다. 사용법을 살펴보도록 하죠. 'a_key'는 pthread_key_t
타입으로서, 이미 적당히 초기화된 키 변수라고 가정합니다.
/* 이 변수는 pthread 함수의 리턴 코드값을 저장하는데 쓰입니다. */
int rc;
/* 데이타를 저장할 변수를 정의합니다. 여기서는 integer 라고 하죠. */
int* p_num = (int*)malloc(sizeof(int));
if (!p_num) {
fprintf(stderr, "malloc: out of memory\n";
exit(1);
}
/* 변수를 아무 값으로 초기화 합니다. */
(*p_num) = 4;
/* 이제 이 값을 TSD 키에 저장합니다. */
/* 주의할 것은 'p_num' 을 저장하는게 아니라 */
/* p_num이 가르키는 값을 저장한다는 것입니다. */
rc = pthread_setspecific(a_key, (void*)p_num);
.
.
/* 어쩌구 저쩌구... */
.
.
/* 'a_key' 키의 값을 얻어서 출력. */
{
int* p_keyval = (int*)pthread_getspecific(a_key);
if (p_keyval != NULL) {
printf("value of 'a_key' is: %d\n", *p_keyval);
}
}
한 쓰레드에서 키 값을 세트한 후, 다른 쓰레드에서 그 값을 읽어보시기 바랍니다. NULL 을 얻게 될텐데 이 키 값은 쓰레드마다 서로 다르기 때문에 그렇습니다.
pthread_getspecific()
이 NULL을 리턴하는 두 가지 경우를 알아보겠습니다:
- 주어진 키가 유효하지 않다(즉, 키가 생성이 안 됐다).
- 키 값이 NULL이다. 이는 초기화가 안 됐거나 그 전에
pthread_setspecific()
에 의해서 강제로 NULL로 세트됐을 경우중 하나이다.
쓰레드만의 데이타 블럭을 지우기(Deleting Thread-Specific Data Block)
pthread_key_delete()
함수는 키를 지울 때 쓰입니다만, 함수 이름 때문에 헷갈리지 말아야 할 것이 하나 있습니다. 이 함수는 해당 키가 갖고 있는 메모리를 지우지도 않고, 키 생성시 등록된 청소 함수를 부르지도 않습니다. 그러므로, 실행중에 이 메모리를 프리시켜야 한다면 직접 해 줘야 합니다. 하지만 보통, 전역 변수(쓰레드만의 데이타 역시)를 사용한다는 것은 쓰레드가 종료할 때까지 프리시킬 필요가 없을 테고, 이럴 경우에는 쓰레드 라이브러리가 종료함수를 불러줄 것입니다.
이 함수 사용법은 간단합니다. list_key를 알맞게 생성된 키를 가르키는 pthread_key_t
변수라고 가정하면 이런식으로 쓰면 됩니다:
int rc = pthread_key_delete(key);
성공시에는 0을 리턴하고, 주어진 변수가 유효한 TSD 키를 가르키지 않을 경우에는 EINVAL을 리턴합니다.
완전한 예제(A Complete Example)
아직 없습니다. 생각할 시간이 좀 필요하네요. 죄송합니다. 지금 당장 제가 생각할 수 있는 것은 '전역 변수는 아주 나쁘다'라는 것입니다. 앞으로 좋은 예제를 찾아보도록 하겠습니다. 혹시 좋은 예제가 있다면 제게 알려주시기 바랍니다.
쓰레드 취소와 끝내기
쓰레드를 만들었으니 끝내는것도 생각해 볼까요? 몇 가지를 살펴보죠. 쓰레드를 깨끗하게 끝낼 수 있어야 하겠죠. 그리고 아주 고약한 방법인 시그널을 사용하는 프로세스 포크(fork)와는 달리 pthread 라이브러리는 좀 더 신중하게 디자인 돼서 쓰레드를 취소한다든지 끝난 다음의 청소 작업등에 대한 완전한 시스템을 제공합니다. 한 번 살펴보죠.
Canceling A Thread
쓰레드를 끝내려고 할 때는 pthread_cancel
를 쓰면 됩니다. 이 함수는 쓰레드 ID를 파라미터로 받아 그 쓰레드 ID로 취소 요청을 보냅니다. 이 요청에 대해 그 쓰레드가 어떻게 할 지는 그 쓰레드의 상태에 달려 있습니다. 즉시 취소될 수도 있고, 취소 위치(뒤에서 설명합니다)에 다다랐을때 취소될 수도 있고, 아예 무시해 버릴 수도 있습니다. 어떻게 쓰레드의 상태를 설정하며 취소 요청에 대해 어떻게 동작하는지에 대한 설정등에 대해서는 뒤에서 살펴보도록 하죠. 일단은 취소 함수를 어떻게 쓰는지 보겠습니다. 'thr_id'는 돌고 있는 쓰레드의 pthread_id
를 갖고 있는 변수라고 합시다.
pthread_cancel(thr_id);
pthread_cancel()
은 0을 리턴하기 때문에 성공여부를 알 수가 없습니다.
쓰레드 취소 상태 설정하기
쓰레드의 취소 상태는 여러가지 방법으로 바꿀 수 있습니다. 첫번째는 pthread_setcancelstate()
함수를 쓰는 것입니다. 이 함수는 취소 요청을 받아 들일 것인지 아닌지를 결정합니다. 두 개의 파라미터가 필요한데, 하나는 새로운 취소 상태가 설정되어 있어야 하고 하나는 이전 취소 상태가 담겨질 변수입니다. 어떻게 쓰는지 보세요.
int old_cancel_state;
pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &old_cancel_state);
이 함수를 부른 쓰레드는 취소될 수가 없습니다. 취소될 수 있도록 하려면 다음처럼 하면 됩니다.
int old_cancel_state;
pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, &old_cancel_state);
두 번째 파라미터를 NULL로 넘겨주게 되면 예전 취소 상태에 대해서 알 수가 없습니다.
비슷하게 pthread_setcanceltype()
이란 함수가 있는데 이 함수는 취소 요청에 대한 반응을 결정합니다. 이 때 이 쓰레드는 취소될 수 있다고 가정합니다. 가능한 반응으로는 취소 요청을 즉시(비동기적으로) 처리하는것과 취소 위치에 도착하기 전까지 취소를 미루는 것입니다. 다음은 비동기적 취소 방법 입니다.
int old_cancel_type;
pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, &old_cancel_type);
취소 위치까지 취소를 미루는 것은 다음처럼 하면 됩니다.
int old_cancel_type;
pthread_setcanceltype(PTHREAD_CANCEL_DEFERRED, &old_cancel_type);
두 번째 파라미터를 NULL로 이 함수를 부르면 예전 취소 상태에 대해 알 수 없습니다.
"취소 상태랑 타입을 설정 안 하면 어떻게 되나요?"라고 묻는다면 pthread_create()
가 자동으로 PTHREAD_CANCEL_ENABLE
(취소 요청 처리)과 PTHREAD_CANCEL_DEFERRED
(취소 미룸)를 설정해 준다라고 대답해 드리죠.
취소 위치
지금까지 살펴본 것처럼, 쓰레드는 취소 요청을 즉시 처리하지 않을 수 있습니다. 대신 취소 위치에 도착할 때까지 그 요청을 미룰 수가 있습니다. 그럼 도대체 취소 위치란게 뭘까요?
보통, 쓰레드 실행을 오랫동안 정지시키는 함수는 취소 위치가 될 수 있습니다. 실제로는 특정 구현에 따라 달라지지고 얼마나 POSIX 표준을 따르냐(어느 버전의 표준)에 따라 달라집니다만 다음 함수들은 취소 위치입니다.
pthread_join()
pthread_cond_wait()
pthread_cond_timedwait()
pthread_testcancel()
sem_wait()
sigwait()
무슨 소리냐면 쓰레드가 이 중 한 함수를 실행중일 때, 뒤로 미룰 취소 요청이 있는지 확인하고 취소 요청이 들어와 있으면 자기가 끝난 다음에 취소 작업을 실행한 뒤 종료하게 됩니다. 이런 함수들이 실행중이 아니라면 방법은 한 가지 밖에 없는데,
pthread_testcancel()
를 쓰는 것입니다. 이 함수는 현재 쓰레드에서 대기중인 취소 요청이 있는지 확인해서 있다면 취소 작업을 실행하고, 없다면 그냥 리턴합니다. 보통 취소 상태로 들어가지 않고 긴 작업을 수행하는 쓰레드에서 쓰일 수 있습니다.
주의사항: 실제 pthread 표준에 일치하는 구현에서는, 프로세스를 블럭시키는 read()
, select()
, wait()
등등의 시스템 콜들도 역시 취소 위치가 됩니다. 또한, 이 시스템 콜을 쓰는 표준 C 라이브러리들도 역시 마찬가지입니다(예를 들면 다양한 버전의 printf 함수들).
쓰레드 청소 함수 세팅하기(Setting Thread Cleanup Functions)
pthead 라이브러리가 제공해주는 기능중에, 자신이 종료하기 전에 자기 자신이 쓰던 리소스를 깨끗히 정리해주는 것이 있습니다. 이는 pthread 라이브러리에 의해서 자동으로 관련 함수가 불리거나 필요해 의해 스스로 부를 수 있기 때문에 가능해 집니다(즉, 자신이 pthread_exit()
를 부르거나, 다른 쓰레드에 의해 취소될 때).
이를 위해 두 개의 함수가 제공됩니다. 하나는 pthread_cleanup_push()
함수로서 현재 쓰레드용 청소 함수 집합에 새로운 청소 함수를 추가해 줍니다. pthread_cleanup_pop()
함수는 pthread_cleanup_push()
에 의해 추가된 마지막 함수를 제거해 줍니다. 쓰레드가 종료될 때는, 해당 청소 함수들은 등록됐던 반대 순서롤 불리게 됩니다. 즉, 마지막에 등록된 청소 함수가 제일 처음 불리게 됩니다.
pthread_cleanup_push()
함수의 두 번째 파라미터로 넘긴 변수가 청소 함수의 파라미터로 넘겨져서 불리게 됩니다. 이것들이 어떻게 쓰이는지를 살펴보도록 하죠. 여기 예제에서는 쓰레드가 시작할 때 할당받았던 메모리를 반환하는데 이 함수들을 적용시켜 보겠습니다.
/* 등록할 청소 함수 */
/* 할당된 메로리의 포인터를 받고 프리시켜 줌 */
void
cleanup_after_malloc(void* allocated_memory)
{
if (allocated_memory)
free(allocated_memory);
}
/* 쓰레드 함수 */
/* thread-pool 서버 예제에서 썼던 함수 그대로... */
void*
handle_requests_loop(void* data)
{
.
.
/* 이 변수는 나중에 쓰일 겁니다. 그냥 읽어 나가세요.. */
int old_cancel_type;
/* 지금 이 쓰레드의 시작 시각을 기억하기 위해서 약간의 메모리를 할당 받습니다. */
/* MAX_TIME_LEN 은 앞에서 미리 정의된 매크로라고 가정합니다. */
char* start_time = (char*)malloc(MAX_TIME_LEN);
/* 청소 함수를 등록합니다. */
pthread_cleanup_push(cleanup_after_malloc, (void*)start_time);
.
.
/* 쓰레드의 메인 루프입니다. 어떤 일들을 하겠죠... */
.
.
.
/* 그리고 끝으로, 청소 핸들러를 제거할텐데 이 방법이 좀 이상해 보이겠지만 */
/* 밑의 주석을 잘 읽어 보세요. */
/* 현재 쓰레드를 취소 미룸 상태에 둡니다. */
pthread_setcanceltype(PTHREAD_CANCEL_DEFERRED, &old_cancel_type);
/* '1'을 넘기면, 청소 핸들러 집합에서 지워버리기 전에 청소 핸들러를 실행 시킵니다. */
/* '0'을 넘기면, 청소 핸들러를 실행하지 않습니다. */
pthread_cleanup_pop(1);
/* 쓰레드를 이전 취소 상태로 다시 되돌려 놓습니다. */
pthread_setcanceltype(old_cancel_type, NULL);
}
여기서 볼 수 있듯이, 메모리를 약간 할당한 뒤, 이 메모리를 쓰레드 종료 시점에서 프리 시키도록 청소 핸들러를 등록시킵니다. 메인 루프가 다 돌고 나면 청소 핸들러를 제거시키게 되는데, 이 때 등록한 함수, 같은 블럭에서 제거 시켜야 합니다. 왜냐하면, pthread_cleanup_push()
와 pthread_cleanup_pop()
(역주 : 원문은 pthread_cleanup_pop()과 pthread_cleanup_pop(), 오타임)이 실제로는 '{'와 '}'를 나타내는 매크로이기 때문입니다.
청소 함수를 제거할 때 이렇게 복잡한 코드를 쓰는 이유는 청소 함수 내에서 쓰레드가 취소되지 않게 하기 위해서입니다. 쓰레드가 비동기 취소 상태에 있을 수도 있기 때문에, 확실히 하기 위해서 취소 미룸 상태로 바꾼 다음에, 청소 함수를 제거하고, 마지막으로 이전 취소 상태로 되돌려 놓는 것입니다. 주의 할 점은, 쓰레드가 pthread_cleanup_pop()
자체내에서 취소 되지 않는다는 것인데, pthread_cleanup_pop()
가 취소 위치가 아니기 때문입니다.
쓰레드 종료 동기화 하기(Synchronizing On Threads Exiting)
가끔은 다른 쓰레드가 끝나길 기다려야 하는 경우가 있습니다. pthread_join()
함수로 이 일을 할 수 있습니다. 이 함수는 두 개의 파라미터가 필요한데, 조인(join)될 쓰레드를 나타내는 pthread_t
타입의 변수와 해당 쓰레드의 종료 코드값이 담길(취소 됐다면 PTHREAD_CANCELED
) void *
의 주소를 나타내는 변수입니다. pthread_join()
함수는 이 함수를 부르는 쓰레드를, 조인될 쓰레드가 끝날 때까지 중지시킵니다.
예를 들어 앞에서 살펴봤던 thread-pool 서버 예제를 생각해 보죠. 코드의 끝 부분을 보면, sleep()
을 불러서 프로세스가 끝나길 기다리고 있는 것을 볼 수 있습니다. 이렇게 한 이유는 메인 쓰레드가 다른 쓰레드의 지연된 처리가 끝났는지 어떤지를 알 수 있는 방법이 없기 때문입니다. 해결 방법은 이것이 비록 바쁜 루프가 되긴 하겠지만 지연된 요청이 없을 때까지 메인 쓰레드가 루프를 돌게 하는 것입니다.
지금까지 살펴 본 것들을 깔끔하게 구현하려면 다음 세가지 변경사항을 추가시키면 됩니다.
- 요청이 다 만들어지면, 플래그를 이용해서 핸들러 쓰레드에게 알려줍니다.
- 요청 큐가 비어 있을 때마다 더 이상 만들 요청이 있는지 없는지를 확인하게 합니다. 더 만들어질 요청이 없다면 쓰레드를 종료시킵니다.
- 메인 쓰레드는 자신이 만든 쓰레드들이 끝날 때까지 기다립니다.
앞에 두 가지 변경사항은 좀 쉽습니다. 'done_creating_requests'란 전역 변수를 하나 만들고 '0'으로 초기화를 시킨 다음, 각 쓰레드들은 조건 변수를 기다리기 전에 (즉, 요청 큐가 비어있을 때), 이 전역 변수 값을 확인합니다.
메인 쓰레드는 자신이 모든 요청을 다 만들어 낸 다음, 이 변수를 '1'로 세팅합니다. 그리고, 조건 변수에 브로드캐스트를 날려 혹시 조건 변수를 기다리고 있는 쓰레드가 확실히 'done_creating_requests' 플래그를 다시 확인 할 수 있게 해줍니다.
마지막 세번째 변경사항은 pthread_join()
루프로 처리할 수 있습니다. 각 핸들러 쓰레드마다 한 번씩 pthread_join()
를 불러주면 됩니다. 이렇게 하면 모든 핸들러 쓰레드가 종료된 다음에 이 루프가 끝나게 됩니다. 따라서 전체 프로세스를 안전하게 종료할 수 있습니다. 만약에 이 루프를 쓰지 않는다면 핸들러 쓰레드가 요청을 처리하고 있는 중간에 전체 프로세스를 끝낼 가능성이 있습니다.
변경된 프로그램은 thread-pool-server-with-join.c 에서 볼 수 있습니다. 세 가지 변경 사항은 소스에서 'CHANGE'(대문자)란 곳을 찾아 보면 됩니다.
쓰레드 떼어내기(Detaching A Thread)
지금까지 pthread_join()
함수를 써서 쓰레드가 어떻게 조인 되는지를 살펴 봤습니다. 사실 조인가능한(join-able) 상태에 있는 쓰레드는 꼭 다른 쓰레드에 의해서 조인되어야 합니다. 그렇지 않다면 그 쓰레드가 갖고 있던 메모리 리소스가 완전하게 제거되지 않을 것입니다. 이는, 부모 프로세스가 자식 프로세스를 거둬들이지 않는 상황과 비슷합니다('고아'나 '좀비'프로세스라고 부르죠).
만약에 어떤 쓰레드가 다른 쓰레드에 조인이 필요없이 아무때나 종료하고 싶다면, 그 쓰레드는 떨어진(detached) 상태에 있어야 합니다. 이렇게 하려면, pthread_create()
에 적당한 플래그를 줘서 쓰레드를 만들어 내던지, pthread_detach()
함수를 쓰면 됩니다. 여기서는 두 번째 방법을 살펴 보겠습니다.
pthread_detach()
함수는 파라미터가 한 개 필요합니다. 파라미터는 pthread_t
형으로서 떨어진(detached) 상태로 놓을 쓰레드를 나타냅니다. 예를 들면, 다음 코드처럼 쓰레드를 만들자마자 바로 떨어지게(detach) 할 수 있습니다.
pthread_t a_thread; /* 쓰레드 구조체를 담을 변수 */
int rc; /* pthread 함수의 리턴값을 위한 변수 */
extern void* thread_loop(void*); /* 쓰레드의 메인 함수를 선언 */
/* 새 쓰레드를 만드는데... */
rc = pthread_create(&a_thread, NULL, thread_loop, NULL);
/* 성공이라면 새 쓰레드를 떼어낸다. */
if (rc == 0) {
rc = pthread_detach(a_thread);
}
물론, 떨어진(detached) 상태의 쓰레드를 즉시 갖고 싶다면 첫번째 방법 (
pthread_create()
를 부를 때, 떨어진(detached) 상태를 세트해서 부름)을 쓰는 것이 더 효과적입니다.
쓰레드 취소 - 완전한 예제(Threads Cancellation - A Complete Example)
다음 예제는 지금까지 예제들보다 훨씬 큰 예제입니다. 이 예제는 C에서, 약간은 깔끔한 멀티 쓰레드 프로그램을 어떻게 만드는가를 보여줍니다. 앞에서 썼던 thread-pool 서버 예제를 사용하겠습니다. 이 예제는 두 가지 면에서 업그레이드 될 텐데, 하나는 요청의 부하에 따라 핸들러 쓰레드의 숫자를 조절하는 기능입니다. 요청 큐가 커지면 새 쓰레드가 만들어지고, 큐가 다시 줄어들면 필요없는 쓰레드는 취소 될 것입니다.
두 번째는, 더 이상 처리할 요청이 없을 때 서버의 종료 방법을 고칠 것입니다. 깔끔하지 못한 sleep()을 쓰는 대신, 각 핸들러 쓰레드들이 자신의 마지막 요청을 처리하고 종료할 때까지, 메인 쓰레드가 pthread_join()
을 써서 기다리도록 할 것입니다.
다음처럼 4개의 파일로 나눠서 구현됐습니다.
- requests_queue.c - 이 파일에는 요청 큐를 처리하는 함수들이 있습니다.
add_request()
와 get_request()
함수를 여기에 넣었는데, 앞에서 전역 변수로 정의됐던 큐 헤드용 포인터와, 요청 카운터, 큐용 뮤텍스, 조건 변수를 하나의 구조체로 묶어서 같이 넣었습니다. 이렇게 해서, 데이타에 대한 모든 조작이 한 파일 안에서 일어나게 되고, 이 파일안에 있는 모든 함수는 'requests_queue'라는 구조체에 대한 포인터를 받습니다.
- handler_thread.c - 이 파일은 각 핸들러 쓰레드가 실행 시킬 메인 루프를 돌리는 함수들이 있습니다. (이 버전에서의 'handle_requests_loop()' 함수와, 밑에서 설명할 몇 가지 지역 함수들). 각 쓰레드간에 주고 받을 데이타들을 위한 구조체를 정의하고,
pthread_create()
에 파라미터로 그 구조체의 포인터를 넘깁니다. 이렇게 해서 세련되지 못한 전역변수의 사용을 대신합니다. 이 구조체에는 쓰레드 ID, 요청 큐 구조체에 대한 포인터, 뮤텍스, 조건 변수가 들어 있습니다.
- handler_threads_pool.c - 여기서 쓰레드 풀(pool)의 추상화를 정의합니다. 여기에는 쓰레드를 만드는 함수, 취소시키는 함수, 프로그램 종료시 모든 활성화된 쓰레드를 없애는 함수들이 들어 있습니다. 요청큐에서처럼 구조체를 정의해서 쓰겠습니다. 이것들에 대해서는 메인 쓰레드 혼자만 접근하기 때문에 뮤텍스로 이것들을 막을 필요가 없습니다. 이렇게 하면 뮤텍스에 의한 약간의 오버헤드를 줄일 수 있는데, 비록 이런 오버헤드가 작을지라도, 아주 바쁜 서버에서는 큰 영향을 미치기 때문입니다.
- main.c - 그리고 마지막으로, 이 모든 것들을 묶고, 관리하는 메인 함수입니다. 이 함수는 요청큐와 쓰레드 풀(pool), 핸들러 쓰레드들을 만들고, 요청을 발생시킵니다. 그 요청이 큐로 들어간 다음에는 큐 크기와 현재 활성화된 쓰레드의 숫자를 확인해서 큐 크기에 맞게 쓰레드 수를 조절합니다. 수위(water-mark) 알고리즘을 사용하는데, 코드를 보면 알겠지만, 좀 더 세련되고 복잡한 알고리즘으로 쉽게 바꿀 수가 있습니다. 여기서 쓰인 수위(water-mark) 알고리즘은 간단합니다. 수위가 높아지면 큐를 빨리 비우기 위해서 쓰레드들을 새로 만들어 내고, 수위가 낮아지면 원래 핸들러 쓰레드를 제외한 나머지 쓰레드들은 취소 시킵니다.
원래 프로그램을 좀 더 다루기 쉽게 고친 다음에 우리가 새로 배운 pthread 함수들을 다음과 같이 적용시켰습니다.
- 각 핸들러 쓰레드는 취소 미룸 상태로 만들어 집니다. 이렇게 하면 이 쓰레드들이 취소가 됐을때, 현재 처리중인 요청을 다 처리한 다음에 종료할 수 있게 됩니다.
- 각 핸들러 쓰레드는 또한 청소 함수를 등록하는데, 각 쓰레드가 종료시 뮤텍스를 풀고 종료토록 하기 위해서입니다. 이는 아마 거의 대부분이 취소 상태인
pthread_cond_wait()
에서 취소 명령을 받을 것이기 때문에 정확히 동작 할 것입니다. 만약에 뮤텍스를 건 다음에 최소되거나 종료되면 다른 모든 쓰레드가 그 뮤텍스에 의해 '멈춰버릴' 것입니다. 따라서 청소 핸들러( pthread_cleanup_push()
함수로 등록함)에 뮤텍스를 풀어 주는 함수를 등록하는 것은 아주 확실한 해결책이 될 것입니다.
- 끝으로, 메인 쓰레드는 대충 종료하지 않고 아주 정확하게 종료되도록 세트됩니다. 종료할 시점이 되면, 'delete_handler_threads_pool()' 함수를 불러서 남아 있는 핸들러 쓰레드들을 기다리도록
pthread_join
을 부릅니다. 이렇게 함으로써, 모든 핸들러 쓰레드가 자신의 마지막 요청을 다 처리하고 난 다음에 이 'delete_handler_threads_pool()' 함수가 리턴하게 됩니다.
자, 이제 소스 코드를 통해 모든 것을 살펴보시기 바랍니다. 헤더 파일을 먼저 읽으면 전체 디자인을 이해하기 쉽습니다. 컴파일하려면, thread-pool-server-changes 디렉토리로 들어가 'gmake'라고 치면 됩니다.
연습문제 1 : 마지막 예제 프로그램에는 종료 시점에 약간의 경쟁 상태(race condition)가 존재합니다. 이 경쟁이 뭐에 대한 건지 알 수 있겠습니까? 이 문제에 대해서 완전한 해결책을 제시할 수 있습니까?(힌트 - 'delete_handler_thread()'함수를 써서 쓰레드를 없애려고 할 때 무슨 일이 생기는지 생각해 보세요)
연습문제 2 : 우리가 사용한 수위(water-mark) 알고리즘은 새 쓰레드를 만들어 낼 때, 너무 느리게 동작하는 것 같습니다. 요청들이 처리 되기전에 큐에서 기다리는 평균 시간을 줄일 수 있는 다른 알고리즘을 생각해 보세요. 그리고 이 시간을 잴 수 있는 코드를 넣어 보세요. 여러분의 "최적화된 풀(pool) 알고리즘"을 찾을 때까지 계속 실험을 해 보세요. 주의 사항 - 시간을 재는 것은 getrusage
, 시스템 콜로 할 수 있습니다. 정확한 측정값을 위해 각 알고리즘을 여러번 실행 시켜보시기 바랍니다.
쓰레드를 이용한 사용자 인터페이스 프로그래밍(Using Threads For Responsive User Interface Programming)
쓰레드가 아주 유용하게 쓰일 수 있는 분야로 유저 인터페이스(user interface)용 프로그램이 있습니다. 이런 프로그램들은 보통 한 곳에서 루프를 돌면서 사용자 입력을 읽고, 처리한 다음, 결과를 보여주는 식으로 되어 있습니다. 만약에 처리 부분이 시간을 아주 오래 잡아 먹고 있다면 사용자는 이 동작이 끝날 때까지 계속 기다려야 합니다. 이런 긴 처리 부분을 독립된 쓰레드로 돌리고, 다른 쓰레드에서는 사용자 입력을 받게 한다면, 그 프로그램은 좀 더 사용자의 반응에 민감하게 될 것입니다. 사용자는 그 긴 동작 중간에 취소를 시킬 수 있게 됩니다.
그래피컬한 프로그램에서는 이 문제가 더욱 심각해 집니다. 왜냐하면, 이런 프로그램은 자신의 윈도우를 다시 그리도록 윈도우 시스템에서 오는 메세지를 항상 기다리고 있어야 하기 때문입니다. 만약에 다른 일을 하느라고 너무 바쁘다면 자신의 윈도우는 텅 비어 있을 것입니다. 아주 안 좋아 보이죠. 이런 경우에, 한 쓰레드가 윈도우 시스템의 메세지를 처리하는 루프를 돌리면서, 다시 그리라는 요청에 항상 응답 할 수 있게 하는 것은 아주 좋은 방법입니다( 사용자 입력에 대해서도 마찬가지겠죠). 이렇게 오래 걸릴법한 동작이 필요하다 싶으면(최악의 경우에 0.2초 이상이라고 합시다), 독립된 쓰레드로 돌게 하십시요.
세번째 쓰레드를 쓰는 좀 더 좋은 방법이 있습니다. 이 세번째 쓰레드가 사용자 입력 쓰레드와 작업 수행 쓰레드의 제어와 동기화를 맏게 하는 것입니다. 사용자 입력 쓰레드가 사용자 입력을 받으면 제어 쓰레드에게 이 일을 처리하도록 요청하고, 작업 수행 쓰레드가 자신의 일 처리를 끝내면 결과를 사용자에게 보여주도록 제어 쓰레드에게 요청하게 하는 것입니다.
사용자 인터페이스 - 완전한 예제(User Interaction - A Complete Example)
사용자가 중간에 취소 시킬 수 있는, 파일에서 줄 수를 읽어들이는 간단한 문자 모드 프로그램을 작성해 보겠습니다.
메인 쓰레드는 줄 수를 세도록 쓰레드 하나를 만듭니다. 다음으로 사용자 입력을 확인하도록 두번째 쓰레드를 만듭니다. 그리고나서, 메인 쓰레드는 조건 변수를 기다립니다. 아무 쓰레드나 자신의 일을 마치면, 이 조건 변수에 시그널을 날려서 메인 쓰레드가 알게 합니다. 사용자의 취소 요청이 일어났는지 아닌지를 확인하기 위해서 전역 변수를 씁니다. '0'으로 초기화를 시키는데 만약에 사용자 쓰레드가 취소 요청을 받는다면(사용자가 'e'를 누른다면), 이 전역 변수를 '1'로 세팅하고 조건 변수에 시그널을 날리고 종료합니다. 줄 수를 세는 쓰레드는 자신의 계산이 다 끝났을 경우에만 조건 변수에 시그널을 날릴 것입니다.
프로그램을 읽기 전에 system()
함수의 사용법과 'stty' 유닉스 명령어에 대해서 설명드리겠습니다. system()
함수는 파라미터로 받아 들인 유닉스 명령어를 실행시킬 쉘을 하나 생성합니다. stty
유닉스 명령어는 터미널 모드 세팅을 바꾸는데 쓰입니다. 우리는 터미널을 라인 버퍼 모드에서 캐릭터 모드(raw 모드라고도 하죠)로 바꾸는데 썼습니다. 이렇게 하면, 사용자 입력 쓰레드에서 getchar()
를 부를 때, 사용자가 키를 누르자마자 즉시 리턴하도록 해줍니다. 만약에 이렇게 하지 않는다면, 사용자가 엔터(ENTER) 키를 누를 때까지 사용자의 입력을 버퍼에 저장해 놓을 것입니다. 끝으로, 이 캐릭터 모드는 별로 쓸모가 없기 때문에 프로그램이 종료하고 쉘 프롬프트를 다시 받으면, 사용자 입력 쓰레드는 원래의 터미널 모드(라인 버퍼 모드)로 돌아가도록 청소 함수를 등록시킵니다. 더 자세한 내용은 stty 매뉴얼을 참고하세요.
프로그램 소스는 line-count.c에서 받을 수 있습니다. 이 프로그램이 읽을 파일 이름은 'very_large_data_file'이라고 하드 코드 되어 있습니다. 이 이름의 파일을 하나 만드셔도 되고(충분한 시간동안 동작이 이뤄지도록 크게 만드세요), 저희가 제공하는 'very_large_data_file.Z' 파일을 받으셔서 압축을 풀어 사용하셔도 됩니다. 압축 푸는 명령어는 다음처럼 하시면 됩니다.
uncompress very_large_data_file.Z
압축이 풀리면 5메가(!) 짜리 'very_large_data_file'이 생기니까, 압축을 풀기 전에 디스크 용량이 충분한지 확인하시기 바랍니다.
멀티 쓰레드 어플리케이션에서 비시스템 라이브러리 쓰기(Using 3rd-Party Libraries In A Multi-Threaded Application)
멀티 쓰레드를 프로그램에 적용하려는 프로그래머에게 아주 중요한 것 하나만 더 말씀드리겠습니다. 멀티 쓰레드 프로그램은 동시에 똑같은 함수를 실행시킬 수도 있기 때문에, 한 쓰레드이상에서 동시에 실행 될지도 모르는 함수는 꼭 MT-safe(Multi-Thread Safe:멀티 쓰레드에 안전)해야 합니다. MT-safe한 함수 내부의 구조체나 다른 공유 리소스에 대한 접근이 뮤텍스로 보호된다는 뜻입니다.
멀티 쓰레드 프로그램에서 MT-safe하지 않는 라이브러리를 쓸 수 있는 가능한 방법은 두 가지가 있습니다.
- 오직 한 쓰레드에서만 이 라이브러리를 쓰기. 이 방법은 이 라이브러리 함수가 서로 다른 쓰레드에서 동시에 실행되지 않게 해줍니다. 하지만 이 방법은 문제가 있는데, 전체 설계에 제한 사항을 줄 수도 있다는 것입니다. 또한, 다른 쓰레드가 이 라이브러리의 함수를 쓰려고 할 가능성이 있기 때문에 쓰레드간 통신에 좀 더 신경을 써야 할 지도 모릅니다.
- 그 라이브러리 함수를 부를 때는 뮤텍스를 써서 보호할 것. 어느 쓰레드에서건 이 라이브러리의 함수를 부를 때는 하나의 뮤텍스를 쓰라는 뜻입니다. 뮤텍스를 걸고, 함수를 부르고, 뮤텍스를 푸는 순서로 사용하면 되겠습니다. 여기서 생길 수 있는 문제는 잠금이 그렇게 깔끔하게 이루어지지 않는다는 것입니다. 같은 라이브러리의 서로 다른 두 개의 함수가 서로 간섭하지 않는 독립된 함수임에도, 서로 다른 쓰레드에서 동시에 불릴 수 없을 수 있습니다. 두 번째 쓰레드는 첫번째 쓰레드가 함수 실행을 끝낼 때까지 뮤텍스에 걸려 있을 것입니다. 관련 없는 함수들에 대해서 서로 다른 두 개의 뮤텍스로 처리할 수도 있겠지만, 그 라이브러리가 실제로 어떻게 동작하는지 알 방법이 없기 때문에 어떤 함수끼리를 묶어야 하는지 알수가 없습니다. 거기다가, 혹시 안다고 할 지라도 새 버전의 라이브러리가 나왔을 때, 예전 버전과 다르게 동작할수도 있기 때문에 잠금 시스템 전체를 고쳐야 할 지도 모릅니다.
보시다시피, MT-safe하지 않은 라이브러리는 특별한 주의가 필요하기 때문에, 가능하면 비슷한 기능을 가진 MT-safe한 라이브러리를 찾아 쓰는 것이 제일 좋습니다.
쓰레드를 지원하는 디버거 쓰기(Using A Threads-Aware Debugger)
마지막 주의 사항입니다. 멀티 쓰레드 어플리케이션을 디버깅 할 때, 쓰레드를 "인식"하는 디버거가 필요합니다. 상용 개발 환경의 거의 대부분의 최신 디버거들은 모두 쓰레드를 처리할 수 있습니다. 리눅스에서, 거의 대부분의 배포판에 들어 있는 gdb는 쓰레드를 인식하지 못합니다. 'SmargGDB'라는 프로젝트가 있는데, gdb에 쓰레드 지원과, 그래픽 사용자 인터페이스(멀티 쓰레드 어플리케이션을 디버깅 할 때만 가능한)를 추가하는 프로젝트입니다. 어쨌든, 이것으로 다양한 사용자 레벨의 쓰레드 라이브러리를 쓰는 쓰레드 프로그램 에만 쓰이고, LinuxThreads를 디버깅하려면 커널 패치가 필요한데, 2.1.X 대 버전에서만 가능합니다. 더 자세히 알고 싶다면 http://hegel.ittc.ukans.edu/projects/smartgdb/를 찾아보시기 바랍니다. 또한 커널 2.0.32에 패치하는 법과 gdb 4.17을 쓰는 것에 대한 정보도 있는데, LinuxThreads homepage에서 찾아보시기 바랍니다.
Side-Notes
- 수위(water-mark) 알고리즘
- 버퍼나 큐를 처리할 때 주로 쓰이는 알고리즘입니다. 버퍼나 큐에 데이타를 채워넣다가, 크기가 상위 한계를 넘으면 큐에 넣는 것을 멈춥니다( 혹은 비우는 것을 좀 더 빠르게 합니다). 이 상태를 하위 한계 이하로 떨어질 때까지 계속 유지하다가 떨어지면, 큐에 채워넣기를 계속하게 됩니다( 혹은 비우는 속도를 원래 속도로 되돌려 놓습니다).