OSTEP 27 - Thread API

JiYun·2025년 3월 15일

OSTEP

목록 보기
16/21

핵심 질문: 쓰레드를 생성하고 제어하는 방법
운영체제가 쓰레드를 생성하고 제어하는데 어떤 인터페이스를 제공해야 할까? 어떻게 이 인터페이스를 설계해야 쉽고 유용하게 사용할 수 있을까?

1. 쓰레드 생성

멀티 쓰레드 프로그램 작성시, 가장 먼저할 일은 새로운 쓰레드의 생성이다. POSIX를 통해 쉽게 할 수 있다.

POSIX(Portable Operating System Interface)는 운영체제 간에 이식성(Portability)을 높이기 위한 API(Application Programming Interface)와 명령어 셋 등을 정의하는 표준입니다.

주로 UNIX와 UNIX-like 시스템(예: Linux, macOS)에서 사용되며, 이 표준을 따르는 운영체제에서는 동일한 또는 유사한 프로그래밍 인터페이스를 제공함으로써, 소프트웨어의 이식성을 높입니다.

POSIX 표준에는 파일 시스템, 프로세스 관리, 스레드 관리, 입출력, 메모리 관리 등 다양한 부분이 포함되어 있습니다. 여기서 pthread_create 함수는 POSIX 스레드(POSIX Threads, 또는 Pthreads)를 생성하기 위한 C 라이브러리 함수 중 하나입니다. 이 함수를 사용하면 운영체제가 지원하는 스레드를 생성하고 관리할 수 있습니다.

#include <pthread.h>
int pthread_create(
			pthread_t*      thread,
	  const pthread_attr_t* attr,
			void*           (*start_routine)(void*),
			void*           arg);

threadpthread_t 타입 구조체를 가르키는 포인터이다. 이 구조체가 쓰레드와 상호작용하는데 사용되기 때문에 쓰레드 초기화 시 pthread_create()에 이 구조체를 전달한다.

attr은 쓰레드의 속성을 지정하는데 사용된다. 스택의 크기, 쓰레드의 스케줄링 우선순위 같은 정보를 지정하기 위해 사용될 수 있다. 대부분 NULL로 지정하며 디폴드 값으로 사용한다.

(*start_routine)(void*) 는 이 쓰레드가 실행할 함수이다. C언어의 함수 포인터를 통해 전달된다. void* 타입을 인자로 받고 void* 타입을 리턴하는데, 어떤 타입의 결과도 반환할 수 있기 때문이다.

arg는 실행할 함수에게 전달할 인자를 나타낸다.

#include <stdio.h>
#include <pthread.h>

typedef struct {
	int a;
	int b;
} myarg_t;

void *mythread(void *arg) {
	myarg_t *args = (myarg_t *) arg;
	printf("%d %d\n", args->a, args->b);
	return NULL;
}

int main(int argc, char *argv[]) {
	pthread_t p;
	myarg_t args = { 10, 20 };
	int rc = pthread_create(&p, NULL, mythread, &args);
    ...

두 개의 인자를 전달받는 새로운 쓰레드를 생성한다. 두 인자는 myarg_t 타입으로 묶여지고, 생성된 쓰레드에서 전달받은 인자를 타입 변환을 통해 얻을 수 있다.

2. 쓰레드 종료

다른 쓰레드가 작업을 완료할 때가지 기다려야 한다면 어떻게 해야할까?

POSIX 쓰레드에서는 pthread_join()을 부르면 된다.

int pthread_join(pthread_t thread, void **value_ptr);

이 루틴은 두개의 인자를 받는데, 첫번째는 pthread_t 값을 받아 어떤 쓰레드를 기다릴지 명시한다. 두번째는 반환 값에 대한 포인터이다(void *).

#include <stdio.h>
#include <pthread.h>
#include <assert.h>
#include <stdlib.h>

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); **// 스레드 p가 끝날때까지 대기한다.**
	printf("returned %d %d\n", rvals->x, rvals->y);
	free(rvals);
	return 0;
}
void *mythread(void *arg) {
	myarg_t *args = (myarg_t *) arg;
	printf("%d %d\n", args->a, args->b);
	myret_t oops; // ALLOCATED ON STACK: BAD!
	oops.x = 1;
	oops.y = 2;
	return (void *) &oops;
}

쓰레드에서 값이 어떻게 변경되는지를 유의해야한다. 특히, 쓰레드의 콜 스택에 할당된 값을 가르키는 포인터를 반환하면 안된다. 위 코드에서 myret_t oops 에 할당된 값은, 쓰레드가 리턴될 때 자동으로 해제된다. 현재 해제된 포인터를 가르키는 것은 좋지 않다.

위위 코드 처럼 pthread_create()로 쓰레드를 생성하고, 그 직후에 pthread_join()을 호출하는 것은 이상한 방식이다. procedure call을 사용하면 더 쉽게 이 작업을 할 수 있다. 일반적으로는 여러개의 쓰레드를 생성해 놓고 쓰레드가 끝나기를 기다린다.

모든 멀티 쓰레드 코드가 조인 루틴을 사용하지는 않는다. 멀티 쓰레드 웹서버의 경우, 여러개의 작업 쓰레드를 생성하고 메인 쓰레드를 이용하여 사용자의 요청을 받아 작업자에게 전달하는 작업을 무한히 할 것이다. 이런 프로그램은 join이 필요없다.

특정 작업의 병렬적 실행에서 다음 단계로 넘어가기 전에 병렬 수행 작업의 완료를 확인하기 위해 join을 사용한다.

3. 락

쓰레드의 생성과 조인 다음으로 가장 유용한 함수는 락(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_mutext_t lock = PTHREAD_MUTEX_INITALIZER;

이 방식은 락을 디폴트 값으로 설정한다. 동적으로 호출하려면 어떡하띾?

int rc = pthread_mutex_init(&lock, NULL);
assert(rc == 0); // 성공했는지 꼭 확인해야 한다.

첫번째 인자는 락 자체의 주소이고, 정상적으로 획득했는지를 확인하는 작업이 필요하다. 락 사용이 끝났다면, pthread_mutex_destory()를 호출해주어야 한다.

락과 언락 루틴 외에도 락 관련 루틴들이 더 존재한다.

int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_timedlock(pthread_mutex_t *mutex,
							struct timespec *abs_timeout);

trylock은 락이 이미 사용중이라면 실패코드를 변환한다. timedlock은 타임아웃이 끝나거나 락을 획득하거나의 두 조건 중 하나가 발생하면 리턴한다.

두 함수를 사용하지 않는 것이 좋다. 그러나 락 획득 루틴에서 무한정 대기를 피하기 위해 사용되기도 한다. 특히, 이후 교착 상태를 공부할 때 보게될 것이다.

4. 컨디션 변수

한 쓰레드가 계속 진행하기 전에 다른 쓰레드가 무언가를 하는 것을 기다릴 때, 쓰레드 간에 일종의 시그널 교환 메커니즘이 필요하다.

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
int pthread_cond_signal(pthread_cond_t *cond);

컨디션 변수의 사용을 위해서는 연결된 락이 “반드시” 존재해야 한다. 위 루틴 중 하나를 호출하기 위해서는 그 락을 갖고 있어야 한다.

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

// 스레드 1
pthread_mutex_lock(&lock);
while (ready == 0) {
	pthread_cond_wait(&cond, &lock);
}
pthread_mutex_unlock(&lock);

// 스레드 2
pthread_mutex_lock(&lock);
ready = 1; // while (ready == 0) 탈출
pthread_cond_signal(&cond);
pthread_mutex_unlock(&lock);

pthread_cond_wait() 는 호출 쓰레드를 수면(sleep) 상태로 만들고 다른 쓰레드로부터 시그널을 대기한다. pthread_cond_signal() 을 통해 잠자는 쓰레드를 깨울 수 있다.

시그널을 보내고 전역 변수 ready를 수정할 때 반드시 락을 가지고 있어야한다. 이를 통해 경쟁 조건의 발생을 방지한다.

시그널 대기 함수에서는 락을 두번째 인자로 받고 있지만, 시그널 보내기 함수에서는 cond만 인자로 받는다. 이러한 차이의 이유는, 시그널 대기 함수에서는 호출 쓰레드를 재우고 락을 반납(release)해야하기 때문이다.

pthread_cond_wait()는 깨어나서 리턴하기 직전에 락을 다시 획득한다. 처음 락을 획득한 때부터 마지막에 락을 반납할 때까지 pthread_cond_wait()를 실행한 쓰레드들은 항상 락을 획득한 상태로 실행된다는 것을 보장한다.

두 쓰레드 간 시그널을 주고 받을 때, 락과 컨디션이 없이 간단히 플래그를 사용하고 싶을 수도 있다.

// 스레드 1
while (ready == 0); // spinlock

// 스레드 2
ready = 1;

절대로 하지 마라. 일단 성능이 좋지 않고, 오류가 발생하기 쉽다.

5. 컴파일과 실행

pthread플래그를 명령어 링크 옵션 부분에 추가하여 사용하여 pthread 라이브러리와 링크할 수 있도록 명시해야 한다.

prompt> gcc −o main main.c −Wall −pthread

6. 요약

쓰레드 생성과 락을 통한 상호 배제의 구현, 컨디션 변수를 이용한 시그널과 대기 등 pthread 라이브러리의 기본 지식을 소개하였다. 강인하고 효율적인 멀티 쓰레드 코드를 작성하기 위해서는 인내와 세심한 주의가 답이다.

멀티 쓰레드 코드 작성을 위한 몇가지 팁이다.

  • 간단하게 작성하라
  • 쓰레드 간의 상호동작을 최소로 해라
  • 락과 컨디션 변수를 초기화하라
  • 반환 코드를 확인하라
  • 쓰레드 간에 인자를 전달하고 반환받을 때는 조심해야 한다
  • 각 쓰레드는 개별적인 스택을 가진다
  • 쓰레드 간에 시그널을 보내기 위해 항상 컨디션 변수를 사용하라
  • 메뉴얼을 사용하라
profile
고수가 되고싶다

0개의 댓글