티스토리 뷰

반응형

안녕하세요! Pingu입니다!

 

지난 글에서는 Concurrency(동시성)을 구현할 때 사용하는 thread(스레드)와 스레드를 사용할 때 해결해야 하는 문제점에 대해 알아봤습니다. 이번 글에서는 C언어에서 스레드를 사용하기 위해 제공하는 API들을 살펴보며 앞으로 해결할 문제점들이 코드적으로는 어떻게 해결되는지에 대해 알아보려고 합니다. 제가 공부할 때 참고하고 있는 OSTEP 책에서는 Chapter 27 - Thread API 부분 입니다.

Interlude: Thread API

이번 글에서는 아까 말했듯 스레드 API에 대해서 알아볼 예정입니다. 하지만 간단하게 짚고 넘어갈 것이며 이후 글에서 자세히 다룰 예정입니다. 이 글에서 알아보는 API는 POSIX에서 사용되는 API들입니다.

Thread Creation

멀티 스레드 프로그램을 구현하기 위해서 가장 중요한 것은 스레드를 만드는 일이니 스레드를 만드는 인터페이스부터 알아보겠습니다.

#include <pthread.h>
int pthread_create(pthread_t      *thread,    // 스레드에 대한 정보
             const pthread_attr_t *attr,      // 스레드의 속성
                   void           *(*start_routine)(void *), // 스레드가 할 일
                   void           *arg);      // arguments

스레드를 만드는 함수는 위와 같습니다. 사실 이름만 봐도 뭔가 스레드를 만들어 줄 것처럼 생겼으니 이름은 쉽게 이해할 수 있다고 보고 중요한 것은 매개변수들입니다.

 

첫 번째 매개변수인 *thread는 pthread_t 구조체입니다. 이 구조체를 사용하여 스레드와 상호 작용하기 때문에 스레드를 만들 때 필요한 정보입니다.

두 번째 매개변수 attr은 스레드가 가질 수 있는 속성을 지정하는 데 사용합니다. 스레드 별 스택의 크기, 스레드의 스케줄링 우선순위 등이 여기 포함될 수 있는 속성들이죠!

세 번째 매개변수는 제일 복잡하게 생겼지만 스레드가 어떤 함수에서 실행을 시작하면 되는지, 즉 스레드가 할 일이라고 보면 됩니다. 물론 수행하려는 함수의 반환 타입에 따라 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);
    
    return 0;
}

위와 같이 간단하게 스레드를 만들 수 있습니다! 그리고 이렇게 만들었다는 것은 스케줄링 정책에 의해 곧 수행될 스레드라는 것을 의미합니다.

Thread Completion

그럼 스레드를 만들 줄 알게 되었으니 스레드가 실행 완료되기를 기다리는 방법에 대해 알아보겠습니다. 완료되기를 기다리기 위해서는 pthread_join() 함수를 사용하면 됩니다. 이 함수는 두 개의 매개변수를 갖는데요, 첫 번째는 pthread_t 구조체 타입이며 여기서 받은 스레드의 실행 완료를 기다리게 됩니다. 두 번째 매개변수는 스레드의 실행 결과로 예상되는 반환 값에 대한 포인터입니다. 어떠한 것도 반환할 수 있기 때문에 void에 대한 포인터를 반환하게 되어있습니다.

 

그럼 예를 한 번 보도록 하겠습니다~

#include <stdio.h>
#include <pthread.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);
    // 반환 된 값 출력
    printf("returned %d %d\n", rvals->x, rvals->y);
    free(rvals);
    return 0;
}

만약 위의 코드에서 pthread_join 함수를 호출하지 않아서, 즉 스레드의 실행 완료를 기다리지 않는다면 어떻게 될까요? 스케줄링이 어떻게 되느냐에 따라 다르겠지만 원하는 값을 리턴 받지 못하고 그냥 main 함수가 끝나버리는 경우도 있을 것입니다. 실제로 실험을 해보면..

위의 결과는 pthread_join을 사용해서 스레드가 완료되는 것을 기다렸을 때의 결과입니다. 원하는 대로 1, 2가 잘 출력된 것을 볼 수 있습니다. 그럼 없을 때는 어떻게 해야 할까요? 우선 반환 값을 받을 수 없으니 스레드 자체에서 값을 출력시키는 코드로 수정해보겠습니다.

#include <stdio.h>
#include <pthread.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;
    // 스레드에서 출력
    printf("returned %d %d\n", rvals->x, rvals->y);
    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);

    free(rvals);
    return 0;
}

결과는 위와 같은데 이와 같이 스레드에서 출력하기 전에 main 함수가 먼저 종료되어 출력되는 것을 볼 수 없게 되는 것이죠.

 

pthread_join 함수를 사용할 때 주의할 점이 또 하나 있는데요, 바로 값이 반환되는 방식입니다. 스레드는 스택을 개별적으로 가진다고 했었죠? 따라서 스레드에서 사용하는 정보들을 스택에 저장해두면 스레드가 종료될 때 스택도 함께 사라지게 됩니다. 스택에 들어가는 정보들은 지역변수와 같은 것들인데, 이를 반환시키려고 하면 문제가 발생하는 것입니다. 실제로 한 번 스레드에 할당된 스택의 값을 반환해보겠습니다.

void *mythread(void *arg) {
    // 구조체 변수를 지역변수로 선언
    myret_t stack;
    stack.x = 1;
    stack.y = 2;
    return (void *) &stack;
}

이렇게 하고 동일하게 수행하면 아래와 같은 오류를 발생시킵니다.

스택에서 반환하지 말라고 경고를 주네요.

 

사실 이렇게 pthread_create로 스레드를 만들어서 실행해놓고 바로 pthread_join()을 호출하는 것은 스레드를 사용하는 이상한 방법인 것을 알 수 있습니다. 이와 동일하지만 더욱 정확하고 쉽게 동작하도록 하는 방법이 있는데요, 이를 procedure call(절차 호출)이라고 합니다. 하지만 모든 스레드에 이러한 join을 사용하는 것은 아닙니다. 하지만 작업을 병렬로 수행할 때 특정 작업을 모두 마치고 다음 단계로 넘어가게 하기 위해 join을 사용하곤 합니다.

Locks

스레드를 생성하고 조인하는 것 외에도 유용한 함수는 lock을 통해 critical section(임계 영역)에 mutual exclusion(상호 배제)를 제공하는 기능입니다. 이를 위한 함수는 아래와 같습니다.

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

이름을 보면 lock, unlock인 게 한 쌍인 것을 쉽게 알 수 있습니다. 간단하게 사용하는 코드를 보면 아래와 같습니다.

pthread_mutex_t lock;
pthread_mutex_lock(&lock);
x = x + 1; // pthread_mutex_unlock(&lock);이 호출되기 전까지 접근 불가

위와 같이 lock을 하나 만들고 이를 실행하면 해당 스레드가 lock을 가지고 있지 않다면 현재 스레드가 lock을 가지고 임계 영역에 접근합니다. lock을 해제하기 전까진 스레드가 반환되지도 않고 다른 스레드들은 임계 영역에 접근할 수도 없습니다.

 

이 코드는 문제가 있는데, 이는 lack of proper initialization(적절한 초기화가 없다.)입니다. 모든 lock은 생성되기에 적절한 값을 가지고 있기 때문에 lock, unlock이 호출될 때 원하는 대로 작동하기 위해 적절하게 초기화되어야 합니다. POSIX 스레드를 사용할 땐 lock을 초기화하는 방법이 두 가지가 있습니다. 한 가지 방법은 아래와 같습니다.

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

이렇게 하면 lock이 default 값으로 설정되어 사용할 수 있게 됩니다. 또 다른 방법은 아래와 같이 동적으로 생성하는 것입니다.

int rc = pthread_mutex_init(&lock, NULL); 
assert(rc == 0);

pthread_mutex_init 함수의 첫 번째 매개변수는 lock의 주소이고 두 번째 매개변수는 lock의 속성들입니다. 위와 같이 사용하면 기본값으로 설정하게 됩니다. 보통은 위와 같은 방법을 사용하며 lock을 해제하고 싶다면 pthread_mutex_destory() 함수를 호출하면 됩니다.

 

아까 코드의 또 다른 문제는 lock, unlock을 할 때 오류 코드를 확인하지 못한다는 것입니다. lock, unlock 역시 실패할 수 있으며 왜 실패하는지를 모른다면 잘못된 줄도 모르고 스레드들의 임계 영역 접근을 모두 허용해버려 문제를 발생할 수 있습니다. 따라서 이를 보완한 함수들이 존재합니다.

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

둘 다 lock을 생성할 때 사용하는 함수이며 이름에서 느껴지듯 trylock은 lock을 시도해보고 실패하면 이를 알려줍니다. timedlock은 lock을 획득하려고 하다가 시간이 초과할 때 이를 그냥 반환해줍니다. 이는 나중에 배울 getting stuck (교착상태), deadlock에 대해 배울 때 사용하면 좋습니다.

Condition Variables

POSIX 스레드에는 condition variable(조건 변수)이라는 것이 존재합니다. 조건 변수는 스레드 간에 어떤 신호를 주고받아야 할 때 유용합니다. 예를 들어 어떤 스레드가 다른 스레드가 완료된 다음 수행되어야 할 때 사용할 수 있는데요, 이러한 상호 작용에는 두 가지 함수를 주로 사용합니다.

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

함수 이름을 보면 바로 이해가 되실 텐데요, 기다리는 함수와 신호를 주는 함수로 보입니다. 이를 사용하기 위해서는 이를 처리해줄 lock도 필요합니다. 간단히 설명하자면 어떤 스레드에 wait를 주게 되면 스레드는 어떤 신호를 받을 때까지 기다리게 됩니다. 그러다 signal 함수에 의해 어떤 신호를 전달받으면 다시 스레드가 실행됩니다.

 

간단하게 사용하는 예를 보며 이해해보겠습니다.

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; 
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

// lock을 건다
Pthread_mutex_lock(&lock);

while (ready == 0)
    Pthread_cond_wait(&cond, &lock);
    
Pthread_mutex_unlock(&lock);

위와 같이 어떤 스레드가 ready라는 변수가 0이 아닐 때까지 무한루프를 돌게 해 뒀습니다. 기다리고 있는 이 스레드를 다시 실행하기 위해서는 신호를 보내줘야 하는데요, 신호를 보내주는 코드는 아래와 같습니다.

Pthread_mutex_lock(&lock); 

ready = 1; 
Pthread_cond_signal(&cond); 

Pthread_mutex_unlock(&lock);

위와 같이 ready의 값을 변경해줘서 신호를 주게 되는 것이죠. 이렇게 신호를 주고받을 때는 항상 lock을 유지해야 합니다. 그렇지 않으면 경쟁 상태가 될 수 있기 때문에 원하지 않을 때 스레드에 신호를 보내게 될 수도 있습니다.

 

이후 글에서 이에 대해 자세히 다룰 예정이니 지금은 어떻게 사용되는지 흐름만 파악하면 될 것 같습니다!

Summary

이번 글에서는 스레드를 생성하고 완료되기를 기다리고, lock을 사용하여 mutual exclusion을 방지하고 조건 변수를 통한 신호, 대기 등을 사용할 수 있는 API에 대해 알아봤습니다. 이번 글에서 본 API들은 극히 일부의 API로 더 많은 API들이 존재하니 알아보는 것도 좋을 것 같습니다. 다음 글에서는 Lock에 대해 좀 더 자세히 알아보도록 하겠습니다.

 

감사합니다.

반응형
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함