[OS] 27. Interlude: Thread API

급식·2022년 5월 19일
0

OSTEP

목록 보기
19/24
post-thumbnail

앞의 Process API에서 공부했던 것처럼, Process 내부의 독립적인 실행 단위인 Thread를 사용할 수 있도록 OS가 제공해주는 여러 API들에 대해 공부해볼 것이다.


27.1. Thread Creation

#include <pthread.h>

int
pthread_create(pthread_t      *thread,
         const pthread_attr_t *attr,
               void           *(*start_routine)(void*),
               void           *arg);

위의 코드는 POSIX에서 제공하는 Thread 생성 API의 signature이다.
interface의 반환 타입과 매개 변수를 하나씩 뜯어보자.

  • pthread_t* thread
    생성된 thread의 상태를 확인하고 조작하는 등의 상호 작용을 위해 선언한 pthread_t 객체를 넘겨주면 된다.
  • pthread_attr_t* attr
    thread의 속성을 지정하기 위해 넘겨주는 변수인데, 이전 예제에서 그랬듯 NULL을 넘겨주면 OS가 기본값으로 알아서 설정해준다.
  • void* (*start_routine)(void*)
    어휴 이게뭐람! 하고 생각할 수도 있지만, 이전에 살펴본 예제를 살펴보면 쉽게 이해할 수 있다.
    ...
    void *mythread(void *arg) {
      printf("%s\n", (char *) arg);
      return NULL;
    }
    ...
    int main(int argc, char *argv[]) {
    ...
    	Pthread_create(&p1, NULL, mythread, "A");
    ...
    	return 0;
    }
    그냥 mythread라는 함수의 이름을 넘겨줬는데, 함수의 이름은 곧 주소 공간 내에서 해당 함수의 첫 명령어의 주소쯤 된다고 생각해도 된다고 전에 얘기했었다(function pointer).
  • void* arg
    위 예제의 "A"에 해당되는 매개변수인데, thread에서 실행할 함수에 전달할 인자를 의미한다.
    역시 void * 타입으로 되어 있어 start_routine 내부에서 적절히 변환해서 사용하면 된다!

이전 예제가 간단하다보니 이렇게만 보면 이해가 더딜 것 같다. 책에 나와 있는 예제로 다시 살펴보자.

#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);
  ...
}

&p, NULL, mythread는 이전과 같은데 &args 부분이 이전과 조금 다르다.
위의 mythread쪽 코드를 보면 알겠지만, 매개변수로 전달하려는 값의 주소를 넘겨서 myarg_t 타입으로 변환해주고, args->a, args->b와 같이 꺼내다가 쓰고 있다는 점을 기억해두자. 신기해라!

이렇게 pthread_create 인터페이스를 통해 thread를 하나 만들어야 비로소 실제 thread가 생성되어(pthread_t 객체의 선언 시점이 아니다!) 각각의 stack을 가지게 되는 것이다.


27.2. Thread Completion

thread의 생성 방법을 알았으니 반대로 종료 방법도 알고 있어야겠지?

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

POSIX에서는 위와 같이 thread를 종료할 수 있는 기능을 제공한다.
마찬가지로 인자를 살펴보자.

  • pthread_t thread
    위에서 thread와 상호작용하기 위한 객체로 pthread_t 객체를 넘겨줬던 것처럼, 종료할 thread를 명시하기 위해 인자로 전달해주는 값이다.

  • void **value_ptr
    이게 좀 특이한데, pthread_t 반환 값을 받아올 포인터를 전달하는 것이다.
    엥? 반환받는건데 왜 인자를 전달해주지? 할 수도 있는데, 여기 전달하는 인자는 값이 아니라 주소이기 때문에 만약 해당 thread에서 실행한 routine에 반환 값이 있는 경우 이 포인터가 가리키는 위치에 값이 쓰여지는 것이라고 이해하면 될 것 같다. 뭐가 들어올지 모르니까 마찬가지로 자료형이 void **인 것이고!

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을 사용하는 간단한 예제이다. Phtread_create에서 thread를 생성하는데, 이때 myret_t 타입의 구조체가 저장된 주소를 가리키는 포인터를 &args로 넘겨 p에 넘겨주는 것까지는 동일하다.
mythread 내부가 달라졌는데, 해당 thread의 routine 내부에서 *rvals를 위한 메모리 공간을 heap에 할당해서 구조체의 attribute x, y의 값을 각각 1, 2로 초기화해준 다음 void* 타입으로 반환해주고 있다. 또! malloc으로 heap 공간의 메모리를 할당해주었으니, main에서 free(rvals);로 할당된 메모리를 해제해주었다.

씁-하! 뭔가 그냥 넘어가기엔 걸고 늘어질게 좀 많은 것 같지 않은가? 이걸로 괜찮겠는가?!
main thread에서 Pthread_create로 새 thread를 생성해줄 때 넘겨주는 args는 stack에 값을 선언해서 그 주소를 넘겨줬는데, 왜 mythread에서는 굳이! 굳~~이! malloc을 사용해서 값을 넘겨주었을까?

앞의 글에서 아래와 같이 설명했었다.

프로세스 각각이 서로 독립적인 단위이므로 프로세스끼리 상태를 주고 받기 위해 별도의 외부 요소가 필요했다면, 프로세스에 소속되는 쓰레드들은 각각의 PC, 스택 영역들은 각자 가지고 있지만 힙 영역을 공유할 수 있으므로 별도의 요소 없이 데이터를 공유할 수 있다.

말을 좀 애매하게 써놨는데, 중요한건 heap 영역을 각 thread들이 별도의 장치 없이 같이 접근할 수 있다는 점과 각각의 thread가 독립적인 stack 영역을 가진다는 것이다.

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;
}

thread p에 주어진 routine인 mythread가 종료되는 것은 해당 thread에 주어진 stack 영역이 사라지는 것과 같으므로, 만약 위와 같이 stack 영역에 myret_t를 선언해서 그 주소를 결과로 반환해준다면? 그 위치는 더이상 사용하지 않는 메모리 영역이게 되므로 매우 위험하다. 일반적으론 가능하지도 않겠지만!

흠흠, 이제 Pthread_join이 남았는데, 이건 child thread를 만든 parent thread가 자식 스레드가 종료(반환)될 때까지 더이상 실행하지 않고 기다리겠다는 의미이다.

여기 예제에서는 Pthread_create를 호출하자마자 냅다 join을 호출하는데 이건 예시를 들기 위해 저렇게 쓴 것이고, 일반적으로는 병렬적으로 실행해야 하는 긴 루틴을 보통 multi-thread로 구현하나보다. 나중에 배우겠지만 다음 명령어로 넘어가기 이전에 반드시 child thread의 작업이 완료됨이 보장되어야 하는 경우에 보통 사용한다.

join이 없으면 자식 스레드가 끝이 나지 않는가? 그건 또 아니다!
예를 들어 웹 서버의 경우, 클라이언트로 들어오는 각각의 요청을 처리하는 로직에 전달하는 작업을 계속해서 쭉쭉쭉 실행할 것이기 때문에 굳이 main thread에서 이 작업들을 기다릴(join) 필요가 없다.


27.3. Locks

Lock! 앞으로 이 lock 때문에 아주 고생할 것이다. 내가 고생하고 있거든,,,,
여하튼 이 Lock은 OS가 Mutual exclusion, 그러니까 한가지 자원에 동시에 접근하는 Race condition을 막기 위해 제공하는 기법의 일종이다.

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

잠갔으면(lock) 당연히 풀 수도(unlock) 있어야겠지? OS는 Lock 뿐만 아니라 Unlock을 쌍으로 제공하여 아래와 같이 사용할 수 있도록 지원한다.

pthread_mutex_t lock;

pthread_mutex_lock(&lock);
x = x + 1; // or whatever your critical section is
pthread_mutex_unlock(&lock);

가벼운 마음으로 읽어보자. 일단 pthread_mutex_t 타입의 lock을 하나 선언하고, lock을 걸어 놓은 다음(acquire 한다고 주로 표현하는 것 같다), Atomic하게 수행하고 싶은 작업인 x = x + 1;다른 thread의 간섭 없이 실행해주고, 다시 lock을 해제한다! 끝!

lock의 자세한 작동 메커니즘은 뒤에서 배우겠지만, 지금 간단하게 언급하자면 아래와 같이 비유할 수 있을 것 같다.

  • 화장실에 A가 들어가서 문을 걸어 잠근다.
  • B도 갑자기 화장실이 급해져서, A가 들어가 있는 화장실을 열고 일을(...) 보려고 했는데 문이 잠겨있다!
  • 그럼 B는 A가 문을 열어줄 때까지 앞에서 일정 주기로 계속 노크를 하거나(1번 옵션), A가 문을 열어줄 때까지 절전모드로 ㅋㅋㅋ 대기하고 있을 수도(2번 옵션) 있다.
  • A가 일을 다 보고 나오면? 그제서야 잠긴 문이 열리고 노크를 하든 절전모드로 대기하고 있든 쭉 대기하고 있었던 B가 드디어 화장실에 진입해 일을 볼 수 있게 된다.

한참 지나도 잊지 않으려고 쓰다 보니까 비유가 쪼끔 드러운데, 중요한건 B가 A와 같은 관심사에 동시에 접근할 수 없도록 제한했기 때문에 앞에서 계속 대기하고 있다는 점이다. 공부하다가 막히면 한 번씩 떠올려보자.

대강 lock/unlock이 어떤 역할을 하는지 이해했으니, 조금 더 구체적인 사용 방법을 알아보려고 한다.

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

위의 예제에서는 lock의 초기화를 제대로 안 해주었는데, Pseudo code려니~~ 하고 그냥 넘어갔을 수도 있을 것 같다. 나는 C언어를 잘 몰라서 와닿지는 않는데, 대문자로 뚜씨뚜씨 나와 있는걸 보니까 뭔가 컴파일 시점에(정적으로) 상수처럼 Lock이 초기화될 것 같은 느낌이 든다. 실제로도 그런 것 같고.

int rc = pthread_mutex_init(&lock, NULL);
assert(rc == 0); // always check success!

또는 위와 같이 동적으로 lock을 초기화해줄 수도 있다. 위의 pthread 객체처럼 lock은 OS의 lock과 상호작용하기 위해 pthread_mutex_init의 첫번째 인자로 넘겨주었고, 두번째 인자는 뭐, 이제껏 그래왔던 것처럼 initializing 과정에서 사용할 수 있는 여러 옵션들을 싹 기본값으로 사용한다는 의미이다.

아참! pthread_mutex_init에서 lock을 성공적으로 초기화하면 값 0을 반환하는데, assert문을 통해 제대로 lock이 초기화되었음을 꼭 확인해 주어야 한다. Assert가 뭔지 잘 모르겠다면 꼭 공부해봤으면 좋겠다. 나도 알기 전에는 딱히 필요성을 못 느꼈는데, 알고 나니까 실수가 확 줄었다.

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

위 두 함수는 강의 중에 직접적으로 다루지는 않았는데, 읽어보니 꽤 흥미로워서 남겨 놓는다.

pthread_mutex_trylock이미 Lock이 걸려 있으면 실패 코드를 반환한다.
pthread_mutex_timedlocktimeout이 끝나거나, Lock을 획득하면 반환한다.

이게 왜 흥미롭냐고? 뒤쪽에서 Multi-thread programming을 구현하며 발생할 수 있는 Deadlock 등의 문제를 해결할 때 이런 함수를 사용하면 다양한 문제들을 예방할 수 있기 때문이다. 나중에 다시 한 번 와서 감탄하고 넘어가자.


27.4. Condition Variables

이름에 Condition이 들어가서 뭔가 조건,, 어쩌구,, 할 것 같은데, 조건식 내부에서 사용되는 어떤 값처럼 이해하기 보다는 대기열 정도로 이해하면 적절할 것 같다.

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

Condition variable을 일종의 대기열로 생각한다면, 무엇을 위해 대기한다는 것일까?

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);

바로 Lock이다! pthread_mutex_t와 마찬가지로 pthread_cond_t도 정적으로 초기화해준 다음, (lock을 얻었다는 가정 하에) while문으로 진입해 만약 준비가 안되었으면(ready ==0) Pthread_cond_wait이 호출되어 누군가 깨워줄 때까지 잠들게 된다. 준비가 되었으면?(ready==1) while문의 조건식이 거짓이 되므로 그냥 Pthread_mutex_unlock으로 이동하면 되는거고.

아 참고로 잠들지 말지 결정하는 과정을 lock을 통해 Critical section으로 만들어준 것은 이 작업 역시 Atomic하게 실행되어야 하기 때문이다. ready 값을 메모리에서 읽고 wait하기로 결정했는데 context switching이 발생해서 다른 thread가 이 값을 바꿔주면? 예상한대로 나오지 않겠지?

Pthread_mutex_lock(&lock);
ready = 1;
Pthread_cond_signal(&cond);
Pthread_mutex_unlock(&lock);

잠드는 thread가 있다면 깨워주는 thread도 당연히 있어야 한다. 위의 wait하는 예제와 마찬가지로 ready 값의 전환과 잠든 thread를 깨워 주는 Pthread_cond_signal 역시 중간에 다른 thread에 의해 간섭받으면 안될 mutual exclusion을 만족해야 하므로 lock/unlock으로 감싸준 것이고, 그렇게 만들어진 critical section 안에서 준비되었음(ready=1)을 표시해준 다음 잠들어 있는 thread를 깨워주는(awake) 작업을 하고 있다.

그럼 이때 깨어나는 thread는 어느 부분에서 다시 시작되는지 궁금하지 않은가?
바로 Pthread_cond_wait 함수가 반환하는 부분에서 시작된다고 보면 된다. 위의 예제의 경우 깨어나는 부분이 while문에 갇혀 있기 때문에 다시 while문의 조건식을 검사하게 되고, 이번엔 조건식이 거짓(준비됨)이므로 while문 바깥의 unlock을 수행하게 되는 것이다.

if문을 쓰면 안되나? 하고 생각할 수도 있는데, 만약 if문을 사용한다면 재검사 과정 없이 그냥 일어났으니까 다음 작업을 실행하게 된다. 만약 조건식에서 사용할 변수를 다른 thread에서 갑자기 0으로 바꿔버리면? 문이 닫혀있는데(ready==0) 그으냥 들이받는거지.

또! wait/signal 함수의 signature를 유심히 보았거나, 잠드는 thread가 lock을 걸어 놓았는데 어떻게 깨우는 thread가 실행될 수 있는지 고민해보았다면 알겠지만 wait에서는 인자로 cond, lock을 모두 받는 반면 signal에서는 cond만 받고 있다.

이는 signal과 달리 wait은 thread가 잠들면서 lock도 같이 내려 놓아야 하기 때문이다.
lock을 쥔 상태로 잠들어버리면? 어우 화장실 줄이 막 100m 되는데 화장실 들어가 있는 사람이 문을 걸어 잠그고 잠들어버린 상황인 것이다. 문을 열 수 없으니 다른 사람들이 들어가서 깨워줄 수도 없고, 그냥 밑도 끝도 없이 기다리기만 하는 상황이라,,

즉, Pthread_cond_wait는 atomic하게 thread를 재우는 작업과 잠들 thread가 쥐고 있던 lock을 해제해 주는 작업을 수행한다고 정리할 수 있겠다.


아오! 뭔 sleep이니 awake니 상태가 자꾸 늘어나니까 생각하기 엄청 복잡하다.

while (ready == 0)
  ; // spin

그냥 기다리는 thread는 이렇게 ready가 1이 될 때까지 계속 아무것도 안하고 대기하고,

ready = 1;

깨우는 thread는 이렇게 값을 1로 바꿔주기만 하면 안되나?!

안된다. 인생사 쉽게 풀리는 것이 하나도 없다.

이런 상황을 교수님께서 Busy waiting이라고 부르셨는데, 말그대로 바쁘게 기다린다,, 는 의미이다. 아무것도 안하는데 CPU 스케줄에 배정되어서 그냥 뺑뺑뺑뺑 돌기만 하면 그만한 낭비도 없을 것이다. 또,, 어떤 연구에 의하면 thread syncrhonization 작업에 이렇게 flag 하나만 떨렁 사용하면 약 절반 정도는 꼭 버그가 발생했었다고 한다. 안그래도 시스템 상황에 따라서 버그가 발생했다, 안했다 하는데 까불지말고 잘 공부해두자.


마무리

thread의 생성, 종료, 상호 배제를 위한 lock, thread 간의 실행 흐름을 제어하기 위한 API를 공부했다. signature는 몰라도 각각의 함수가 어떻게 작동하고, 왜 필요한지 잘 알아두어야 이어질 불지옥에서 그나마 버틸 수 있다! 좀만 힘내라!

과제만 했는데 달력을 보니까 3주 있으면 또 시험이다. 이 무슨,,
바쁘다고 미루지 말고, 빠릿빠릿하게 미리 열심히 포스팅해야겠다. 화이팅,,,,,,

profile
뭐 먹고 살지.

0개의 댓글