[운영체제 아주 쉬운 세 가지 이야기 - Concurrency] 27. Thread API
이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.
개요
운영체제가 쓰레드를 생성하고 제어하는 데 어떤 인터페이스를 제공해야 할까? 어떻게 이 운영체제를 설계해야 쉽고 유용하게 사용할 수 있을까?
쓰레드 생성
멀티 쓰레드 프로그램을 작성 시, 가장 먼저 할 일은 새로운 쓰레드의 생성이다. 쓰레드 생성을 위해서는 해당 인터페이스가 존재해야 한다. POSIX에서는 쉽게 할 수 있다.
함수 포인터를 사용해보지 않았다면 복잡하게 보이지만 실제로는 그리 어렵지 않다.
thread, attr, start_routine 그리고 arg라는 4개의 인자가 있다. thread는 pthread_t 타입 구조체를 가리키는 포인터이다. 이 구조가 쓰레드와 상호작용하는 데 사용되기 때문이 쓰레드 초기화 시 pthread_create()에 이 구조체를 전달한다.
두 번째 인자 attr은 쓰레드의 속성을 지정하는 데 사용한다. 스택의 크기와 쓰레드의 스케줄링 우선순위 같은 정보를 지정하기 위해서 사용될 수 있다. 개별 속성은 pthread_attr_init() 함수를 호출하여 초기화한다. 대부분의 경우에 디폴트 값을 지정하면 충분하고 간단히 NULL을 전달하면 된다.
세 번째 인자는 인자들 중에 가장 복잡해 보인다. 이 쓰레드가 실행할 함수를 나타낸다. C언어에서는 이를 함수 포인터라고 부르고, 다음과 같은 정보가 필요하다고 알려준다. 이 함수는 void* 타입의 인자 한 개를 전달받고 void* 타입의 값을 반환한다.
마지막으로 네 번째 인자인 arg는 실행할 함수에게 전달할 인자를 나타낸다. 왜 void 포인터 타입이 필요하지라고 질문할 수도 있다. 그 이유는 간단한다. void 포인터를 start_routine 함수의 인자로 사용하면, 어떤 데이터 타입도 인자로 전달할 수 있고, 반환 값의 타입으로 사용하면 쓰레드는 어떤 타입의 결과도 반환할 수 있다.
그림 30.1의 예제를 보면 두 개의 인자를 전달받는 새로운 쓰레드를 생성하는데 두 인자는 우리가 정의한 myarg_t 타입으로 묶여진다. 쓰레드가 생성되면 전달받은 인자의 타입을 예상하고 있는 타입으로 변환할 수 있고, 원하는 대로 인자를 풀어낼 수 있다.
쓰레드 종료
위의 예제는 쓰레드를 생성하는 법을 보았다. 다른 쓰레드가 작업을 완료할 떄까지 기다려야 한다면 어떻게 해야 할까? 다른 쓰레드의 완료를 기다리기 위해서는 뭔가 특별한 조치를 해야 한다. POSIX 쓰레드에서는 pthread_join()를 호출하는 것이다.
1
int pthread_join(pthread_t thread, void **value_ptr);
이 루틴은 두 개의 인자를 받는다. pthread_t 타입은 인자를 어떤 쓰레드를 기다리려고 하는지 명시한다. 이 변수는 쓰레드 생성 루틴에 의해 초기화된다. (자료구조에 대한 포인터를 pthread_create()의 인자로 전달하여) 이 구조체를 보관해 놓으면, 그 쓰레드가 끝나기를 기다릴 때 사용할 수 있다.
두 번째 인자는 반환 값에 대한 포인터이다. 루틴이 임의의 데이터 타입을 반환할 수 있기 때문에 void 포인터 타입으로 정의한다. pthread_join() 루틴은 전달된 인자의 값을 변경하기 때문에 값을 전달하는 것이 아니라 그 값에 대한 포인터를 전달해야 한다.
그림 30.2에 있는 또 다른 예제를 살펴보자. 이 코드에서는 이전 예제와 마찬가지로 쓰레드를 생성하여 myarg_t 데이터 타입을 통하여 2개의 인자를 전달했다. 반환 값으로 myret_t 타입이 사용된다. 메인 쓰레드는 pthread_join() 루틴 안에서 기다리는 중이다. 쓰레드가 실행을 마치면 메인 쓰레드가 리턴하고, 종료한 쓰레드가 반환한 값, 즉 myret_t 안에 들어 있는 무슨 값이든 접근할 수 있게 된다.
이 예제서 주목할 몇 가지 사항이 있는데 먼저 여러 인자를 한 번에 전달하기 위해 묶고 해체하는 불편한 과정을 항상 해야 하는 것은 아니다. 예를 들면 인자가 없는 쓰레드를 생성할 때에는 NULL을 전달하여 쓰레드를 생성할 수도 있다. 비슷한 예로 반환 값이 필요 없다면 pthread_join()에 NULL을 전달할 수도 있다.
두 번째는 값 하나만 전달해야 한다면 인자를 전달하기 위해 묶을 필요가 없다. 그림 30.3에 그 예시가 있고, 이 경우에 인자와 반환 값을 구조체로 묶을 필요가 없기 때문에 간단하게 할 수 있다.
세 번째로 유의해야 할 것은 쓰레드에서 값이 어떻게 반환되는지이다. 특히, 쓰레드의 콜 스택에 할당된 값을 가리키는 포인터를 반환하지 마라. 이런 포인터를 반환한다면 위험할 것이다.
이 코드에서 mythread의 스택에 변수 r이 할당되어 있다. 이 값은 쓰레드가 리턴할 때 자동적으로 해제되는데, 현재 해제된 변수를 가리키는 포인터를 반환하는 것은 온갖 종류의 좋지 않은 결과를 가져온다. 분명 반환받았다고 생각하는 값을 출력해 보면 (꼭 그런건 아니지만) 놀라게 될 것이다.
마지막으로, pthread_create()를 사용하여 쓰레드를 생성하고 직후에 pthread_join()를 호출한다는 것은 쓰레드를 생성하는 아주 이상한 방법이다. 사실 작업을 똑같이 할 수 있는 더 쉬운 방법이 이싿. 이를 프로시저 호출(procedure call)이라고 부른다. 여러 개의 쓰레드를 생성하 놓고 쓰레드가 끝나기를 기다리는 것이 보통일 것이다. 그렇지 않다면 쓰레드를 사용할 이유가 없을 테니 말이다.
모든 멀티 쓰레드 코드가 조인 루틴을 사용하지는 않는다. 예를 들면 멀티 쓰레드 웹서버의 경우 여러 개의 작업자 쓰레드를 생성하고 메인 쓰레드를 이용하여 사용자 요청을 받아 작업자에게 전달하는 작업을 무한히 할 것이다. 이런 프로그램은 join을 사용할 필요가 없다. 하지만 특정 작업을 병렬적으로 실행하기 위해 쓰레드를 생성하는 병렬 프로그램의 경우에는 종료 전 혹은 계산의 다음 단계로 넘어가기 전에 병렬 수행 작업이 모두 완료되었다는 것을 확인하기 위해 join을 사용한다.
락
쓰레드의 생성과 조인 다음으로 POSIX 쓰레드 라이브러리가 제공하는 가장 유용한 함수는 락(lock)을 통한 임계 영역에 대한 상호 배제 기법이다. 이러한 목적을 위하여 사용되는 가장 기본적인 루틴은 다음과 같이 쌍으로 이루어져 있다.
1
2
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
이 루틴은 이해하고 사용하기 쉬어야 한다. 그래야 임계 영역을 원하는 방식으로 동작하도록 보호할 때 사용할 수 있다. 다음과 같은 코드를 예상할 수 있다.
1
2
3
4
pthread_mutex_t lock;
pthread_mutex_lock(&lock);
x = x + 1;
pthread_mutex_unlock(&lock);
이 코드가 하고자 하는 바는 pthread_mutex_lock()가 호출되었을 때 다른 어떤 쓰레드도 락을 가지고 있지 않다면 이 쓰레드가 락을 얻어 임계 영역에 진입한다. 만약 다른 쓰레드가 락을 가지고 있다면, 락 획득을 시도하는 쓰레드는 락을 얻을 떄까지 호출에서 리턴하지 않는다. (리턴했다면 락을 가지고 있던 쓰레드가 언락을 호출하여 락을 양도했다는 것을 의미한다) 많은 쓰레드들이 락 획득 함수에서 대기 중일 수 있다. 락을 획득한 쓰레드만이 언락을 호출해야 한다.
안타깝게도 이 코드는 두 가지 측면에서 올바르게 동작하지 않는다. 첫 번째, 초기화를 하지 않았다. 올바른 값을 가지고 시작했다는 것을 보장하기 위해 모든 락은 올바르게 초기화되어야 한다. 그래야 락과 언락을 호출하였을 때 의도한 대로 동작할 수 있다.
POSIX 쓰레드를 사용할 때 락를 초기화하는 방법은 두 가지이다. 한 가지 방법은 PTHREAD_MUTEX_INITIALIZER를 사용하는 것이다.
1
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
위 연산은 락을 디폴트 값으로 설정한다. 실행 중 동적으로 초기화하는 방법은 다음과 같이 pthread_mutex_init()를 호출하는 것이다.
1
2
int rc = pthread_mutex_init(&lock, NULL);
assert(rc == 0); // 성공했는지 꼭 확인
이 루틴의 첫 번째 인자는 락 자체의 주소이고, 반면에 두 번째 인자는 선택 가능한 속성이며 NULL을 전달하면 디폴트 값을 사용한다. 정적, 동적 두 방법 모두 사용할 수 있지만 우리는 후자인 동적 방법을 주로 사용한다. 락 사용이 끝났다면 초기화 API와 상응하는 pthread_mutex_destroy()도 호출해야 한다는 것을 주의하라.
위 코드의 두 번째 문제는 락과 언락을 호출할 때 에러 코드를 확인하지 않는다는 것이다. Unix 시스템에서 호출하는 거의 모든 라이브러리 루틴과 마찬가지로 이 루틴들도 실패할 수 있다! 당신의 코드가 제대로 에러 코드를 검사하지 않는다면, 코드는 조용히 실패하게 된다. 이 경우 여러 쓰레드가 동시에 임계 영역으로 들어갈 수 있다. 최소한 래퍼 함수를 사용하여 해당 루틴이 성공적으로 처리되었는지 확인해야 한다. (ex: 그림 30.4) 실제 복잡한 프로그램에서, 락이나 언락이 실패한 경우 그냥 종료하면 안되고 적절한 대응을 해야 한다.
락과 언락 루틴 외에 pthread 라이브러리에서 락 관련 루틴들이 더 존재한다.
1
2
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_timedlock(pthread_mutex_t *mutex, struct timespec *abs_timeout);
이 두 함수는 락을 획득하는 데 사용된다. trylock은 락이 이미 사용 중이라면 실패 코드를 반환한다. timedlock은 타임 아웃이 끝나거나 락을 획득하거나의 두 조건 중 하나가 발생하면 리턴한다. timedlock의 타임 아웃을 0으로 설정하면 trylock과 동일하게 동작한다. 이 두 함수는 사용하지 않는 것이 좋지만, 락 획득 루틴에서 무한정 대기하는 상황을 피하기 위해 사용되기도 한다. 이러한 경우를 앞으로 교착 상태를 공부할 때 다시 보게 될 것이다.
컨디션 변수
쓰레드 라이브러리가 POSIX 쓰레드의 경우에는 확실히, 제공하는 주요한 구성 요소로 컨디션 변수(condition variable)가 있다. 한 쓰레드가 계속 진행하기 전에 다른 쓰레드가 무언가를 해야 쓰레드 간에 일종의 시그널 교환 매커니즘이 필요하다. 이런 경우 컨디션 변수가 사용된다. 두 개의 기본 루틴은 다음과 같다.
1
2
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
int pthread_cond_signal(pthread_cond_t *cond);
컨디션 변수 사용을 위해서는 이 컨디션 변수와 연결된 락이 “반드시” 존재해야 한다. 위 루틴 중 하나를 호출하기 위해서는 그 락을 갖고 있어야 한다.
첫 번째 루틴 pthread_cond_wait()는 호출 쓰레드를 수면(sleep) 상태로 만들고 다른 쓰레드들로부터의 시그널을 대기한다. 현재 수면 중인 쓰레드가 관심 있는 무언가가 변경되면 시그널을 보낸다. 전형적인 용례는 다음과 같다.
1
2
3
4
5
6
7
8
9
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인지 검사한다. ready 변수 값이 0이라면, 다른 쓰레드가 깨워줄 떄까지 잠들기 위해 대기 루틴을 호출한다. 다른 쓰레드에서 실행될 잠자는 쓰레드를 깨우는 코드를 다음과 같다.
1
2
3
4
pthread_mutex_lock(&lock);
ready = 1;
pthread_cond_signal(&cond);
pthread_mutex_unlock(&lock);
이 코드에서 유의할 몇 가지가 있다. 첫 번째, 시그널을 보내고 전역 변수 ready를 수정할 때 반드시 락을 가지고 있어야 한다. 이를 통해 경쟁 조건이 발생하지 않는다는 것을 보장한다.
두 번째, 시그널 대기 함수에서는 락을 두 번째 인자로 받고 있지만, 시그널 보내기 함수에서는 조건만을 인자로 받는 것에 유의해야 한다. 이런 차이의 이유는 시그널 대기 함수는 호출 쓰레드를 재우는 것 외에 락도 반납(release)해야 하기 때문이다. 락을 반납하지 않았다고 상상해 보자. 어떻게 다른 쓰레드가 락을 얻어 이 쓰레드를 꺠우기 위한 시그널을 보낼 수 있겠는가?
pthread_cond_wait()는 깨어나서 리턴하기 직전에 락을 다시 획득한다. 처음 락을 획득한 때부터 마지막에 락을 반납할 때까지 pthread_cond_wait()를 실행한 쓰레드들은 항상 락을 획득한 상태로 실행된다는 것을 보장한다.
마지막으로 매우 중요한 특이 사항이 있다. 대기하는 쓰레드가 조건을 검사할 때 if문을 사용하는 대신 while문을 사용한다는 것이다. 이 주제는 컨디션 변수를 다루는 장에서 자세히 다룰 것이다. while 문을 사용하는 것이 일반적으로 간단하고 안전하다. pthread 라이브러리에서 변수를 제대로 갱신하지 않고 대기하던 쓰레드를 깨울 수 있다. 이런 경우 재검사를 하지 않는다면 대기하던 쓰레드는 조건이 변경되지 않았더라도 변경되었다고 생각할 것이다. 때문에 시그널의 도착은 변경 사실을 알리는 것이 아니라, 변경된 것 같으니 검사해보라는 정도의 힌트로 간주하는 것이 더 안전하다.
두 쓰레드 간에 시그널을 주고 받아야 할 때, 락과 컨디션 변수를 사용하는 대신 간단한 플래그를 사용하여 구현하고 싶을 것이다. 예를 들어 앞에서의 대기 코드를 다음과 같이 다시 코딩을 할 수 있다.
while (ready == 0); // 회전
이 코드에 상응하는 시그널 보내기 코드는 다음과 같을 것이다.
ready = 1;
절대로 하지 마라. 이유는 다음과 같다. 첫째, 많은 경우에 이 코드는 성능이 좋지 않다. 조건 검사를 위해 오랫동안 반복문을 실행하여 검사하는 것은 CPU 사이클의 낭비를 초래한다. 둘째, 오류가 발생하기 쉽다. 쓰레드 간에 동기화를 하기 위해 플래그를 사용할 때 실수하기 쉽다. 컨디션 변수를 사용하지 않고도 할 수 있다고 생각하더라도, 꼭 컨디션 변수를 사용하기 바란다.
컴파일과 실행
이 장에서 사용한 예제 코드들은 상대적으로 작동시키기 쉽다. 예제들은 컴팡리 하기 위해서 pthread.h 헤더를 포함시켜야 한다. -pthread 플래그를 명령어 링크 옵션 부분에 추가하여 사용하여 pthread 라이브러리와 링크할 수 있도록 명시해야 한다.
예를 들어 간단한 멀티 쓰레드 프로그램을 컴파일하기 위하여 당신이 해야 할 일은 다음 명령어를 입력하는 것 뿐이다.
1
gcc -o main main.c -Wall -pthread
참고