Operating Systems : Three Easy Pieces를 보고 번역 및 정리한 내용들입니다.
멀티-스레드 프로그램을 만들기 위해서 가장 먼저 할 수 있어야 하는 일은 새로운 스레드들을 만드는 것이다. POSIX에서는 다음의 인터페이스를 사용한다.
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routione)(void*), void * arg);
이 선언은 복잡해 보이지만, 사실 그렇지는 않다. 함수의 네 파라미터들에 대해 알아보자.
thread
pthread_t
타입 구조체를 가리키는 포인터다. 이 구조체는 스레드와 상호작용하기 위해 사용하므로, 이를 초기화해 사용하려면 pthread_create()
에 넘겨주어야 한다. attr
pthread_attr_init()
을 호출함으로써 초기화된다.NULL
을 전달하면 된다.start_routine
void *
타입의 값을 반환한다.arg
//만약 루틴이 정수 인자를 가지게 하려 한다면 다음과 같다.
int pthread_create(..., void *(*start_routine)(int));
//만약 루틴이 보이드 포인터를 인자로 가지고, 정수를 반환한다면 다음과 같이 된다.
int pthread_create(..., int (*start_routine)(void *), void *arg);
스레드가 완료될 때까지 대기할 때에는 pthread_join()
을 사용한다. 이 루틴은 두 개의 파라미터를 가진다.
int pthread_join(pthread_t thread, void **value_ptr);
thread
value_ptr
void *
타입을 가지고 있다.pthread_join()
루틴이 전달된 인자의 값을 바꾸기 때문에, 값 그 자체가 아니라 값의 포인터를 전달해야 한다.아래의 예시를 보자. 이 코드에서는 한 스레드가 새로 만들어지고, 두 인자가 구조체로 묶여 전달된다. 반환값으로도 구조체가 사용되고 있다.
스레드의 실행이 완료되고 나면, pthread_join()
내부의 루틴이 종료되기를 기다리던 메인 스레드에서 비로소 해당 루틴의 반환값들에 접근한다.
typedef struct { int a; int b; } myarg_t;
typedef struct { int x; int y; } myret_t;
void *mythread(void *arg) {
myret_t *rvals = Malloc(sizeof(myret_t));
rvals->x = 1;
rvals->y = 2;
return (void *) rvals;
}
int main(int argc, char *argv[]) {
pthread_t p;
myret_t *rvals;
myarg_t args = { 10, 20 };
Pthread_create(&p, NULL, mythread, &args);
Pthread_join(p, (void **) &rvals);
printf("returned %d %d\n", rvals->x, rvals->y);
free(rvals);
return 0;
}
다만 눈여겨 볼 점들이 몇 가지 있다.
1. 이렇게 인자들을 packing, unpacking하는 과정을 거치지 않아도 될 때가 많다.
NULL
을 전달하면 된다.NULL
을 전달하면 된다.pthread_create()
를 호출하고 바로 pthread_join()
을 호출하는 게 이상해보일 수도 있다. 사실 이와 같은 작업들을 하는 더 쉬운 방법(프로시저 콜, procedure call)이 있다. 모든 멀티-스레드 코드들이 조인 루틴을 사용하지는 않는다는 것에 주의하자. 예를 들어 멀티-스렏드 웹서버는 여러 작업자 스레드들을 만들고, 메인 스레드를 통해 요청을 받아 작업자들에게 전한다. 이렇게 오랫동안 돌아가는 프로그램들은 조인을 할 필요가 없다. 한편, 특정 작업을 실행하는 병렬 프로그램의 경우에는 다음 계산 단계로 넘어가기 전에 해당 작업들의 완료가 보장되어야 하므로 반드시 조인을 사용해야 한다.
스레드의 생성과 조인 외에, POSIX 스레드 라이브러리에서 제공하는 유용한 함수 집합에는 락(lock)을 통해 임계 영역으로의 상호 배제를 제공하는 것들이 있다. 이를 위해 사용되는 가장 기본적인 루틴 쌍은 다음과 같다.
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
임계 영역에 해당하는 코드 영역이 있어, 올바른 연산을 보장하기 위해 보호될 필요가 있는 경우, 락은 유용하다.
pthread_mutex_t lock;
pthread_mutex_lock(&lock);
x = x + 1; // 임계 영역에 해당하는 코드들
pthread_mutex_unlock(&lock);
위 코드에서 의도하는 바는 다음과 같다.
pthread_mutex_lock()
이 호출되고, 스레드가 락의 소유권을 가져 임계 영역에 들어간다.많은 스레드들이 특정 시점에 락을 얻기 위해 기다리면서 멈춰 있을 것이고, 오직 락을 가지고 있는 스레드만이 언락을 호출할 수 있다.
불행히도, 위 코드는 두 가지 중요한 문제가 있다.
모든 락들은 제대로 사용되기 위해서는 초기화 되어야 한다. POSIX 스레드에서는 락을 초기화하기 위한 방법이 두 가지 있는데, 그 중 하나는 다음과 같이 PTHREAD_MUTEX_INITIALIZER
를 사용하는 것이다. 이렇게 하면 락을 기본 값으로 설정하고 사용할 수 있게 된다.
pthread_mutex_t lock = PTHREAD_MUTEX_INIITIALIZER;
이를 동적으로, 즉 런타임에 실행하는 방법은 다음과 같이 pthread_mutex_init()
을 호출하는 것이다.
int rc = pthread_mutex_init(&lock, NULL);
assert(rc == 0); //꼭 잘됐는지 확인하기
이 루틴의 첫 번째 인자는 락의 주소이고, 두 번째 인자는 추가적인 속성 설정을 위해서 쓰인다.
위 두 방법은 모두 잘 작동하지만, 보통은 후자의 메서드를 사용한다. 자세한 내용은 매뉴얼을 확인하자(man
커맨드)
위 코드의 두 번째 문제는 락 및 언락 호출의 에러 코드를 확인하지 못하고 있다는 것이다. UNIX 시스템에서 호출하는 거의 모든 라이브러리 함수들과 마찬가지로, 이 함수들도 실패할 수 있다. 만약 코드에서 이러한 에러 코드들이 제대로 확인되지 않는다면, 이 오류들 또한 조용하게 일어나서 여러 스레드들이 임계 영역에 진입하는 일을 막지 못할 수 있다. 적어도 다음과 같이 루틴이 성공했는지를 확인하는 래퍼를 이용하도록 하자.
void Pthread_mutex_lock(pthread_mutex_t *mutex){
int rc = pthread_mutex_lock(mutex);
assert(rc == 0);
}
락과 언락 루틴이 락과 상호 작용하는 pthread
라이브러리의 유이한 루틴들은 아니다. 다음과 같은 다른 루틴들도 있다.
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_timedlock(pthread_mutex_t *mutex,
struct timespec *abs_timeout);
이 두 콜은 락 획득과 관련해 쓰인다. trylock
은 이미 다른 스레드가 락을 소유하고 있는지를 확인하고, 만약 그렇다면 실패한다. timedlock
은 락을 얻거나 타임아웃이 발생하는 경우에 반환한다. 이 두 콜의 사용은 되도록이면 지양해야하지만, stuck을 피하기 위한 몇몇 경우에는 유용하게 쓰일 수 있다.
스레드 라이브러리의 다른 주요 구성 요소는 조건 변수(condition variable)다. 조건 변수는 스레드 간 시그널링이 일어나야 하는 경우에 유용하게 쓰인다. 예를 들면 한 스레드가 다른 스레드의 특정 작업을 기다리고, 해당 작업이 끝난 후에야 자신의 작업을 재개하는 경우가 그렇다. 이와 같은 상호작용을 위해 쓰이는 기본 루틴에는 다음과 같은 것들이 있다.
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
int pthread_cond_signal(pthread_cond_t *cond);
조건 변수를 사용하려면(위의 두 루틴 중 어떤 것을 사용하든) 이 조건과 관련된 락을 얻어야 한다.
첫 번째 pthread_cond_wait()
는 호출 스레드를 sleep 상태로 만들고 다른 스레드들이 시그널을 보낼 때까지 대기하도록 한다. 전형적인 용례는 다음과 같다.
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
Pthread_mutex_lock(&lock);
while (ready == 0)
Pthread_cond_wait(&cond, &lock);
Pthread_mutex_unlock(&lock);
관련된 락과 조건을 초기화한 후, 스레드는 변수 ready
가 0이 아닌 값으로 설정되어 있는지를 확인한다. 만약 0이 아니라면 스레드는 다른 스레드들이 깨워줄 때까지 sleep하기 위한 대기 루틴을 호출한다.
자고 있는 스레드를 깨우기 위해 다른 스레드들에서 실행될 코드는 다음과 같다.
Pthread_mutex_lock(&lock);
ready = 1;
Pthread_cond_signal(&cond);
Pthread_mutex_unlock(&lock);
이 코드 시퀀스에서 신경 써야할 점들이 몇 가지 더 있다.
두 스레드 사이의 시그널링을 위해서 조견 변수와 락이 아닌, 간단한 플래그를 사용하고 싶을 수도 있다. 예를 들어 위의 대기 코드를 다음과 다음과 같이 변경했다고 하자.
while (ready == 0)
; //spin
이와 관련한 시그널링 코드는 ready = 1
이다. 하지만 절대 이렇게 해서는 안 된다. 그 이유는 다음과 같다.
1. 이와 같은 코드는 많은 경우 낮은 성능을 가진다.
이 장의 모든 코드 예제들을 컴파일하기 위해서는 pthread.h
헤더가 코드에 포함되 어야 한다. 링크 라인에서는 -pthread
플래그를 통해 pthread 라이브러리를 명시적으로 링크해주어야 한다.
스레드 생성, 락을 통한 상호 배제, 조건 변수를 통한 시그널링과 대기 등, pthread 라이브러리의 기초들에 대해서 배웠다. 멀티-스레드 코드들을 작성하기 위한 여러 유용한 팁들과 함께 이 장을 마치도록 하자. 이 API들에 더 알고 싶다면 man -k pthread
를 통해 전체 인터페이스를 확인해보도록 하자.