[OS] 21. Thread API

Park Yeongseo·2024년 1월 22일
1

OS

목록 보기
23/54
post-thumbnail

Operating Systems : Three Easy Pieces를 보고 번역 및 정리한 내용들입니다.

1. Thread Creation

멀티-스레드 프로그램을 만들기 위해서 가장 먼저 할 수 있어야 하는 일은 새로운 스레드들을 만드는 것이다. 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);

2. Thread Completion

스레드가 완료될 때까지 대기할 때에는 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을 전달하면 된다.
  1. 만약 단일 값을 전달해야하는 경우라면, 이 경우에도 인자를 packing하지 않아도 된다.
  2. 어떻게 스레드로부터 값들이 반환되는지에 대해 주의하자.
  • 구체적으로, 스레드의 콜 스택에 할당된 것을 참조하는 포인터를 반환하는 경우는 없어야 한다.
  • 해당 포인터는 함수가 종료되고 콜 스택에서 할당 해제됨에 따라 함께 할당 해제 된다.
  • 할당 해제된 변수를 가리키는 포인터를 이용하는 것은 좋지 않은 결과로 이어진다.
  1. pthread_create()를 호출하고 바로 pthread_join()을 호출하는 게 이상해보일 수도 있다. 사실 이와 같은 작업들을 하는 더 쉬운 방법(프로시저 콜, procedure call)이 있다.

모든 멀티-스레드 코드들이 조인 루틴을 사용하지는 않는다는 것에 주의하자. 예를 들어 멀티-스렏드 웹서버는 여러 작업자 스레드들을 만들고, 메인 스레드를 통해 요청을 받아 작업자들에게 전한다. 이렇게 오랫동안 돌아가는 프로그램들은 조인을 할 필요가 없다. 한편, 특정 작업을 실행하는 병렬 프로그램의 경우에는 다음 계산 단계로 넘어가기 전에 해당 작업들의 완료가 보장되어야 하므로 반드시 조인을 사용해야 한다.

3. Locks

스레드의 생성과 조인 외에, 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()이 호출되고, 스레드가 락의 소유권을 가져 임계 영역에 들어간다.
  • 만약 다른 한 스레드가 락의 소유권을 가지고 있다면, 락의 소유권을 얻으려 하는 스레드는 이 락을 얻을 때까지 콜에서 리턴하지 않는다.

많은 스레드들이 특정 시점에 락을 얻기 위해 기다리면서 멈춰 있을 것이고, 오직 락을 가지고 있는 스레드만이 언락을 호출할 수 있다.

불행히도, 위 코드는 두 가지 중요한 문제가 있다.

(1) 적절한 초기화의 부재

모든 락들은 제대로 사용되기 위해서는 초기화 되어야 한다. 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 커맨드)

(2) 에러 코드 확인에 실패

위 코드의 두 번째 문제는 락 및 언락 호출의 에러 코드를 확인하지 못하고 있다는 것이다. 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을 피하기 위한 몇몇 경우에는 유용하게 쓰일 수 있다.

4. Condition Variables

스레드 라이브러리의 다른 주요 구성 요소는 조건 변수(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);

이 코드 시퀀스에서 신경 써야할 점들이 몇 가지 더 있다.

  1. 시그널링을 할 때는 반드시 락을 가지고 있는지를 확인해야 한다.
  • 의도치 않게 코드 내에서 경쟁 상태를 만들어내는 일을 방지하기 위함이다.
  1. 대기 콜은 락을 두 번째 파라미터로 가지지만 시그널 콜은 조건만을 파라미터로 가진다.
  • 대기 콜의 경우에는 호출자 스레드를 sleep 상태로 바꾸는 것과 더불어, 호출자 스레드가 sleep 상태로 갈 때 락을 놓아주어야 하기 때문이다.
  • 다른 스레드들이 이 잠들어 있는 스레드를 깨우기 위해서는 락이 필요한데, 만약 잠든 스레드가 이렇게 락을 놓아주지 않는다면 어떤 스레드도 이 스레드를 깨워주지 못할 것이다.
  • 스레드는 깨어나서 반환을 하기 전까지 락을 다시 얻어, 코드 시퀀스의 마지막에 락을 풀어줄 때까지 가진다.
  1. 대기 중인 스레드는 if 문이 아니라 while 문에서 조건을 재확인 한다.
  • 이 이슈와 관련된 자세한 내용은 이후의 장에서 다루게 될 것이다.
  • 하지만 보편적으로는 while 루프를 사용하는 것이 가장 간단하고 안전한 방법이다.
  • 조건을 재확인하고 있기는 하지만, 특정 pthread 구현 중에는 대기 중인 스레드를 비정상적으로 깨울 수 있는 것들이 있다.
  • 이러한 경우 재확인이 없으면 대기 중인 스레드는 조건이 변하지 않았음에도 변했다고 생각할 것이다.

두 스레드 사이의 시그널링을 위해서 조견 변수와 락이 아닌, 간단한 플래그를 사용하고 싶을 수도 있다. 예를 들어 위의 대기 코드를 다음과 다음과 같이 변경했다고 하자.

while (ready == 0)
	; //spin

이와 관련한 시그널링 코드는 ready = 1이다. 하지만 절대 이렇게 해서는 안 된다. 그 이유는 다음과 같다.
1. 이와 같은 코드는 많은 경우 낮은 성능을 가진다.

  • 그냥 스피닝을 시키는 것은 CPU 사이클을 낭비할 뿐이고,
  1. 에러가 일어나기 쉽다.
  • 최근 연구에 따르면, 스레드 동기화를 위해 플래그들을 사용하는 것은 실수를 유발하기 쉽다.
  • 해당 연구에 따르면, 이렇게 플래그를 사용한 동기화의 거의 절반에 가까운 것들이 버그를 가지고 있다.

5. Compiling and Running

이 장의 모든 코드 예제들을 컴파일하기 위해서는 pthread.h 헤더가 코드에 포함되 어야 한다. 링크 라인에서는 -pthread 플래그를 통해 pthread 라이브러리를 명시적으로 링크해주어야 한다.

6. Summary

스레드 생성, 락을 통한 상호 배제, 조건 변수를 통한 시그널링과 대기 등, pthread 라이브러리의 기초들에 대해서 배웠다. 멀티-스레드 코드들을 작성하기 위한 여러 유용한 팁들과 함께 이 장을 마치도록 하자. 이 API들에 더 알고 싶다면 man -k pthread를 통해 전체 인터페이스를 확인해보도록 하자.

스레드 API 가이드라인

  • 최대한 간단하게 만들어라. 복잡한 스레드 상호작용은 버그들로 이어진다.
  • 스레드 상호작용을 최소화해라. 각 상호작용은 세심하게 설계되고 만들어져야 한다.
  • 락과 조건 변수를 초기화하라.
  • 리턴 코드를 확인해라.
  • 어떻게 스레드로 인자를 전달하고, 스레드로부터 반환값을 가져올지에 대해 주의하라.
  • 각 스레드는 저마다의 스택을 가진다는 것을 기억하자.
  • 스레드에 시그널을 보낼 때에는 항상 조건 변수를 사용하자. 간단한 플래그를 사용하고 싶을 수도 있지만, 절대 그러지 말라.
  • 매뉴얼 페이지를 사용해라. pthread 매뉴얼 페이지에는 더 자세한, 유용한 정보들이 많다.

0개의 댓글