이전의 포스팅에서 Lock의 개념을 알게되었고, Lock을 구축하는 방법을 알아보았다.
하지만 Lock은 동시성 프로그램을 작성하는데 필요한 유일한 기본조건이 아니다. 특히, 스레드가 실행을 하기 전에 조건이 참인지 확인하는 경우,
예를 들어 부모스레드는 자식스레드가 완료되었는지 확인하고 실행하려하는 경우인 join()에 대기(wait)를 어떻게 구현해야할까?
1 void *child(void *arg) {
2 printf("child\n");
3 // XXX 완료되었음을 어떻게 표시할까요?
4 return NULL;
5 }
7 int main(int argc, char *argv[]) {
8 printf("parent: begin\n");
9 pthread_t c;
10 Pthread_create(&c, NULL, child, NULL); // 자식 스레드 생성
11 // XXX 자식 스레드를 어떻게 기다릴까요?
12 printf("parent: end\n");
13 return 0;
14 }
//원하는 출력
parent: begin
child
parent: end
이 상황을 해결하기 위해서 공유변수를 하나 둬서 체크할 수 있다.
1 volatile int done = 0;
2
3 void *child(void *arg) {
4 printf("child\n");
5 done = 1;
6 return NULL;
7 }
8
9 int main(int argc, char *argv[]) {
10 printf("parent: begin\n");
11 pthread_t c;
12 Pthread_create(&c, NULL, child, NULL); // child
13 while (done == 0)
14 ; // spin
15 printf("parent: end\n");
16 return 0;
17 }
하지만 이렇게 하면 spin하는 시간이 비효율적이고, 여러 스레드가 공유변수에 접근하는것은 위험하다고 이전에 배웠었다.
스레드는 조건변수(condition variable)을 사용할 수 있다. 조건변수는 실행상태가 원하는 상태가 아닐때, 스레드가 들어갈 수 있는 명시적인 대기열(explicit queue)이다. 다른 스레드는 상태를 변경할 때, 대기중인 스레드를 깨워 실행할 수 있게 한다. 이 아이디어는 Dijkstra의 세마포어사용에서 유래하였다.
조건변수를 선언하려면 pthread_cond_t c;를 작성한다. 조건변수는 두가지 작업이 있다. 첫 번째로 wait()호출은 스레드가 잠들고자 할 때 실행되며, signal()호출은 프로그램에서 무언가가 변경되었음을 알리고 이 조건을 기다리는 스레드를 깨우고자 할 때 실행된다. 예를 들어 POSIX에서는 다음과 같다.
pthread_cond_wait(pthread_cond_t *c, pthread_mutex_t *m);
pthread_cond_signal(pthread_cond_t *c);
여기서 mutex가 매개변수로 사용된다. wait()가 호출될 때 mutex가 잠겨있음을 가정한다. wait()호출은 lock을 해제하고 호출한 스레드를 원자적으로 잠들게 한다. 이렇게 하는 이유는 스레드가 잠들 떄 race condition이 발생하는 것을 방지하기 위함이다. 예시로 join 문제에 대한 해결책을 알아보자
int done = 0;
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t c = PTHREAD_COND_INITIALIZER;
void thr_exit() {
Pthread_mutex_lock(&m);
done = 1;
Pthread_cond_signal(&c);
Pthread_mutex_unlock(&m);
}
void *child(void *arg) {
printf("child\n");
thr_exit();
return NULL;
}
void thr_join() {
Pthread_mutex_lock(&m);
while (done == 0) Pthread_cond_wait(&c, &m);
Pthread_mutex_unlock(&m);
}
int main(int argc, char *argv[]) {
printf("parent: begin\n");
pthread_t p;
Pthread_create(&p, NULL, child, NULL);
thr_join();
printf("parent: end\n");
return 0;
}
위의 코드에서는 두가지 상황을 생각할 수 있다.
첫 번째, 부모 스레드는 자식 스레드를 생성하고 thr_join()을 호출하여 자식스레드가 완료되기를 기다린다. 부모 스레드는 락을 얻고 wait()을 호출하여 잠에 들면 자식스레드는 실행되고 부모스레드를 깨우기 위해 thr_exit()을 호출한다. 이 코드는 락을 얻고 done을 설정하고 부모를 깨운다. 부모 스레드는 실행(락을 가진채 wait()에서 반환)되고 락을 해제하고 parent: end를 출력한다.
두 번째, 자식스레드는 생성되자마자 실행되어 done을 1로 설정하고 신호를 보내고(아무도 없으므로 그냥 반환) 완료된다. 그 후 부모 스레드는 thr_join()을 호출하고 done이 1이므로 기다리지 않고 반환한다.
thr_exit()과 thr_join()의 중요성을 이해하기 위해 몇가지 대체 구현을 시도해보자 먼저 상태변수 done이 필요한지 궁금할 수 있다. 코드가 아래와 같다면 어떨까?
void thr_exit() {
Pthread_mutex_lock(&m);
Pthread_cond_signal(&c);
Pthread_mutex_unlock(&m);
}
void thr_join() {
Pthread_mutex_lock(&m);
Pthread_cond_wait(&c, &m);
Pthread_mutex_unlock(&m);
}
이 접근방식은 문제가 있다.자식 스레드가 즉시 실행되어 thr_exit()을 호출할 때 잠든 스레드가 없고, 그 후 부모스레드가 실행되면 wait을 호출되어 잠에 드는데, 그 이후 부모스레드를 깨우는 코드가 없어 영원히 잠들게 된다.
다음 예제는 신호(signal)와 대기(wait)를 위해 락이 필요없다고 가정한 것이다.
1 void thr_exit() {
2 done = 1;
3 Pthread_cond_signal(&c);
4 }
5
6 void thr_join() {
7 if (done == 0)
8 Pthread_cond_wait(&c);
9 }
락이 없으니 race condition이 생긴다. 부모 스레드가 done이 0인것을 확인하고 wait()을 호출하기 직전에 자식스레드가 실행된다면 부모스레드는 영원히 잠들게 될 것이다.
다음으로 다룰 동기화 문제는 생산자/소비자(Producer/Consumer) 문제로, 제한 버퍼(Bounded Buffer) 문제라고도 불린다.
하나 이상의 생산자 스레드와 소비자 스레드를 상상해보자 생산자는 데이터 항목을 생성해서 버퍼에 넣고 소비자는 이 항목을 버퍼에서 가져와서 소비한다. 이런 방식은 많은 실제 시스템에서 발생한다. 예를 들어 멀티 스레드 웹 서버에서 생산자는 HTTP 요청을 버퍼에 넣고 소비자 는 요청을 처리한다.
제한 버퍼는 한 프로그램의 출력을 다른 프로그램으로 파이프(pipe)할 때 사용된다. 예를 들어 grep foo file.txt | wc -l를 실행하면 두 프로세스가 동시에 실행된다. grep은 file.txt에서 foo문자열이 포함된 줄을 표준 출력으로 쓰고, UNIX 셸은 출력을 UNIX 파이프로 리디렉션한다. 이 파이프의 다른 끝은 wc프로세스의 표준입력에 연결되어있으며, 이는 입력 스트림의 줄 수를 세고 결과를 출력한다. 따라서 grep 프로세스는 생산자이고, wc 프로세스는 소비자이며, 그 사이에는 커널 내부의 제한 버퍼가 있다.
제한 버퍼는 공유자원이기 때문에 race condition이 발생하지 않도록 해야한다. 이 문제를 잘 이해하기 위해 예시를 살펴보자 공유버퍼로 간단히 정수 하나를 사용하고 값을 넣는 put()루틴과 값을 가져오는 get()루틴을 사용하자
int buffer;
int count = 0; // initially, empty
void put(int value) {
assert(count == 0);
count = 1;
buffer = value;
}
int get() {
assert(count == 1);
count = 0;
return buffer;
}
put()루틴은 버퍼가 비어있다고 가정하고(assert로 확인), 값을 공유버퍼에 넣고 count를 1로 설정하여 버퍼가 가득 찼음을 표시한다. get()루틴은 반대로 버퍼를 비우고(count=0) 값을 반환한다.
하나의 생산자와 하나의 소비자만 있다고 상상해보자 코드 주변에 락을 놓는것 만으로는 보호가 잘 되지 않는다. 이 때 필요한 것이 조건변수이다. 단일 조건변수와 락이 있는 예제를 살펴보자
int loops; // must initialize somewhere...
cond_t cond;
mutex_t mutex;
void *producer(void *arg) {
int i;
for (i = 0; i < loops; i++) {
Pthread_mutex_lock(&mutex); // p1
if (count == 1) // p2
Pthread_cond_wait(&cond, &mutex); // p3
put(i); // p4
Pthread_cond_signal(&cond); // p5
Pthread_mutex_unlock(&mutex); // p6
}
}
void *consumer(void *arg) {
int i;
for (i = 0; i < loops; i++) {
Pthread_mutex_lock(&mutex); // c1
if (count == 0) // c2
Pthread_cond_wait(&cond, &mutex); // c3
int tmp = get(); // c4
Pthread_cond_signal(&cond); // c5
Pthread_mutex_unlock(&mutex); // c6
printf("%d\n", tmp);
}
}
생산자가 버퍼를 채우려고 할 때, 버퍼가 비어있을 경우를 기다린다.(p1~p3)
소비자는 버퍼가 가득 참을 기다린다.(c1~c3)
이 코드는 생산자와 소비자가 하나 씩 있을때 만 동작한다.