https://os2024.jeju.ai/week12/threads-cv.html
‘락’ 하나만 가지고는 제대로 병행 프로그램을 작성할 수 없습니다. 쓰레드가 계속 진행하기 전에 특정 조건이 만족되었는지 검사가 필요한 경우가 있습니다. 예를 들어 자식 스레드가 작업을 끝냈는 지 확인할 필요가 있습니다.
volatile int done = 0;
void *child(void *arg) {
printf("child\n");
done = 1;
return NULL;
}
int main(int argc, char *argv[]) {
printf("parent: begin\n");
pthread_t c;
Pthread_create(&c, NULL, child, NULL);
while (done == 0)
; // spin
printf("parent: end\n");
return 0;
}
이렇게 공유 변수를 사용해서 구현할 수 있습니다. 하지만 부모 스레드가 spin을 돌면서 자원을 낭비하고 있는 문제가 발생합니다.
부모 스레드의 자원 낭비를 막기 위해서는 특정 조건이 될 때까지 재워두는 것입니다.
조건이 참이 될 때까지 기다리기 위해 컨디션 변수(Condition Variable)를 활용할 수 있습니다. 컨디션 변수는 일종의 큐 자료 구조로서, 어떤 실행의 상태 (또는 어떤 조건)가 원하는 것과 다를 때 참이 되기를 기다리며 스레드가 대기할 수 있는 큐입니다.
컨디션 변수에는 두 가지 연산이 존재합니다.
wait() : 쓰레드가 스스로를 잠재우기 위해 호출하는 함수signal() : 쓰레드가 무엇인가를 변경했기 때문에 조건이 참이 되기를 기다리며 잠자고 있던 쓰레드를 깨울 때 호출하는 함수wait()에서 주의할 점은 mutex를 매개변수로 사용한다는 것입니다. wait()가 호출될 때 mutex는 잠겨있었다고 가정합니다. wait()는 락을 해제하고 호출한 쓰레드를 재운 후, 어떤 다른 쓰레드가 시그널을 보내 쓰레드가 깨어나면 wait()에서 리턴하기 전에 락을 다시 획득해야 합니다.
즉, 조건이 만족되어 잠에서 깨어났더라도 락을 획득하지 못하면 다시 잠에 드는 것입니다. 이렇게 복잡한 이유는 쓰레드가 스스로 잠들려고 할 때 경쟁 조건(Race Condition)의 발생을 방지하기 위해서입니다.
wait()에서 리턴하기 전에 락을 다시 획득해야 되는 이유?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()를 호출하여 자식 스레드가 끝나기를 기다림 → 부모 쓰레드는 락을 획득하고 자식이 끝났는지 검사(while (done == 0))한 후, 자식이 아직 끝나지 않았으므로 wait()을 호출하여 락을 해제하고 스스로를 잠재움 → 나중에 자식 쓰레드가 실행되어 thr_exit()을 호출하면 부모 쓰레드가 깨어나고, wait()에서 락을 다시 획득한 채로 리턴하여 부모 쓰레드가 계속 실행되고 락을 해제한 후 종료
문제가 되는 부분은 여기…
자식 스레드가 생성되자마자 즉시 실행되어 done을 1로 만들고, 자고 있는 스레드를 깨우기 위해서 시그널을 보냄 → 하지만 아직은 자고 있는 스레드가 없기 때문에 아무런 효과가 없음 → 이후 부모 스레드가 실행되어 thr_join() 호출 → 하지만 이미 done이 1이므로 while을 건너뛰고 바로 락을 해제하고 return하는 문제가 발생
문제가 자식 스레드가 작업을 끝낼 때까지 부모 스레드가 대기하고 있어야 하는데 자식 스레드의 작업이 끝나기도 전에 return 해버려서 그런건가?
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);
}
}
int buffer;
int count = 0;
void put(int value) {
assert(count == 0);
count = 1;
buffer = value;
}
int get() {
assert(count == 1);
count = 0;
return buffer;
}
count == 1) 기다립니다.count == 0) 기다립니다.생산자와 소비자가 각각 1명씩만 있을 때는 문제가 없지만 2 이상일 때는 문제가 생긴다.
여기서 문제가 생긴다는게 생산자 1과 2가 각각 생산품을 1개씩 생산을 해도 소비자 1이 생산품을 가져간 후에 생산품이 생산되기도 전에 소비자 1과 2과 동시에 접근하게 되면 문제가 생길 수 있다는건가?
대기 루프의 if문과 관련이 있습니다.
소비자 쓰레드가 2개(Tc1, Tc2) 있고 생산자 쓰레드가 1개(Tp) 있다고 가정해 보겠습니다.
wait()을 호출하여(c3) 락을 해제하고 sleep합니다.wait()을 호출하고(p3) 잠듭니다.get()을 호출했을 때(c4) 버퍼는 이미 비어있습니다! Tc2가 먼저 데이터를 가져갔기 때문입니다.여기서 핵심은 Tc1이 시그널에 의해 깨어났을 때 조건이 여전히 만족된다는 보장이 없다는 것입니다. 시그널은 쓰레드를 깨우기만 할 뿐, 깨어난 쓰레드가 즉시 실행된다는 보장은 없습니다.
이해가 안되는데 Tc1이 버퍼에서 값을 못 가져갈 경우가 왜 문제가 되는거지?
이 문제는 대기 루프의 if문을 while문으로 바꾸면 해결됩니다.
cond_t cond;
mutex_t mutex;
void *producer(void *arg) {
int i;
for (i = 0; i < loops; i++) {
Pthread_mutex_lock(&mutex); // p1
while (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
while (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);
}
}
이제 Tc1이 시그널에 의해 깨어나면(lock을 획득한 상태로) 즉시 조건을 다시 검사합니다(while (count == 0)). 만약 그 사이에 Tc2가 데이터를 가져갔다면 Tc1은 다시 wait()을 호출하여 잠듭니다.
조건을 한 번만 검사하는 것이 아니라 여러 번 검사하게 함으로써 위의 문제를 방지한 듯
세마포어는 음이 아닌 정수 값을 갖는 객체로서 두 가지 연산(wait와 post)을 통해 조작할 수 있습니다. 세마포어는 초기값에 따라 동작이 결정되기 때문에 사용 전에 반드시 초기화를 해야 합니다.
int sem_wait(sem_t *s) {
// 세마포어 s의 값을 1 감소
// 세마포어 s의 값이 음수가 되면 해당 쓰레드는 블록됨
}
int sem_post(sem_t *s) {
// 세마포어 s의 값을 1 증가
// 블록된 쓰레드가 있다면 그 중 하나를 깨움
}
sem_wait() 함수는 세마포어의 값이 양수일 때 즉시 리턴하고, 그렇지 않으면 세마포어의 값이 양수가 될 때까지 호출한 쓰레드를 블록시킵니다.
sem_post() 함수는 세마포어의 값을 증가시키고 블록된 쓰레드가 있다면 그 중 하나를 깨웁니다.
그러니까 세마포어가 양수일 때만 접근할 수 있다는거군.
signal을 통해 특정 스레드만 선택적으로 깨울 수 있습니다.