본문 바로가기

[병행성 #3] 컨디션 변수 본문

Fundamentals/OS

[병행성 #3] 컨디션 변수

SJ_Repsect 2025. 7. 6. 17:08

쓰레드가 계속 진행하기 전에 어떤 조건이 참인지를 검사해야 하는 경우가 많이 있다.
예를 들어 부모 쓰레드가 작을 시작하기 전에 자식 쓰레드가 작업을 끝냈는지를 검사하기를 원할 수 있다
(이 과정을 보통 join()이라 부른다). 그런 대기문은 어떻게 구현해야 할까?

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;
}
  • 위 처럼 회전 기반의 방법으로 자식을 기다릴 수 있다.
  • 하지만 조건이 참이 될 때까지 회전하며 기다리는 것은 비효율적일 뿐만 아니라 CPU 사이클을 낭비한다.

컨디션 변수

조건이 참이 될 때까지 기다리기 위해 **컨디션 변수(conditional variable)**를 활용할 수 있다.

  • 컨디션 변수는 일종의 큐 자료 구조
  • 조건이 참이 되기를 기다리며 쓰레드가 대기하는 큐
  • 다른 쓰레드가 상태를 변경시켰을 때, 대기 중이던 쓰레드를 조건에 따라 시그널을 보내어 깨운다.

wait()/signal()

pthread_cond_wait(pthread_cond_t *c, pthread_mutex_t *m);
pthread_cond_signal(pthread_cond_t *c);
  • wait() 은 락을 해제하고 스스로를 잠재움
    • 반드시 wait() 에서 리턴하기 전에 락을 재 획득해야한다. → 깨어나더라도 락을 획득하지 못하면 다시 sleep으로 돌아감
  • signal()은 잠자고 있던 쓰레드를 깨우는 것
int done = 0;
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t c = PTHREAD_COND_INITALIZER;

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("praent: begin\\n");
	pthread_t p;
	Pthread_create(&p, NULL, child, NULL);
	thr_join();
	printf("parent: end\\n");
	return 0;
}

시나리오 1

  1. 부모 쓰레드가 자식 쓰레드를 생성하고 계속 thr_join()을 호출하고 자식 쓰레드가 끝나기를 기다린다.
    1. 부모 쓰레드가 락을 획득하고, 자식이 끝났는지 자식이 끝났는지 검사한 후에 자식이 끝나지 않으면 wait()을 호출하여 스스로 잠재운다.
  2. 자식 쓰레드가 추후에 실행되어 child라는 메시지를 출력하고 thr_exit()을 호출하여 부모 쓰레드를 깨운다.
  3. done을 1로 설정하고, 부모 쓰레드에 시그널을 보내어 꺠어나도록 한다.
  4. 부모 쓰레드가 락을 획득한 채로 리턴하여 부모 쓰레드가 실행될 것이며 락을 해제하고 parent: end를 출력한다.

시나리오 2

  1. 자식 쓰레드가 생성되고 즉시 실행되고 done을 1로 변경하고 자고있는 쓰레드를 꺠우기 위해 시그널을 보낸다.
  2. 자고있는 쓰레드가 없기 때문에 단순히 리턴한다.
  3. 부모 쓰레드가 실행하고 thr_join()을 호출하고 done 변수가 1이므로 바로 리턴한다.

질문

1. 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);
}
  • 위 처럼 구현할 경우 시나리오 2번에서는 done이 없으므로 부모쓰레드는 바로 wait()을 호출하고 아무도 꺠우지 않을 것이다.

2. 시그널을 주거나 대기할 때 만약 lock을 획득이 필요 없다면?

void thr_exit() {
	done = 1;
	Pthread_cond_signal(&c);
}

void thr_join() {
	if (done == 0)
		Pthread_cond_wait(&c, &m);
}
  • 만약 부모 쓰레드가 thr_join을 호출하고, done 변수의 값이 0인 것을 확인한 후 잠자려고 한다.
  • wait을 호출하기 직전에 인터럽트에 걸린 후, 자식 쓰레드가 실행 되었다면 ? 
  • 현재 자고있는 쓰레드가 없으므로 부모 쓰레드는 계속 잠잔다.

생산자/소비자 문제

  • Dijkstra가 제시한 생산자/소비자(producer/consumer) 문제이다.
  • 여러 생산자 쓰레드와 소비자 쓰레드가 있다고 가정
  • 생산자는 데이터를 만들어 버퍼에 넣는다.
  • 소비자는 버퍼에서 데이터를 꺼내어 사용한다.
  • 멀티 쓰레드 웹 서버의 경우 HTTP 요청 작업을 큐에 넣고, 소비즈 쓰레드는 이 큐에서 요청을 꺼내어 처리한다.
  • 버퍼는 공유 자원이다. 경쟁 조건의 발생을 방지하기 위해 동기화가 필요하다.
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;
}

void *producer(void *arg) {
	int i;
	int loops = (int) arg;
	for (i = 0; i < loops; i++) {
		put(i);
	}
}

void *consumer(void *arg) {
	int i ;
	while (1) {
		int tep = get();
		printf("%d\\n", tmp);
	}
}
  • 매우 간단한 형태의 생산/소비 루틴이다. (버퍼에 1개만 저장할 수 있다고 설정)
  • put()은 버퍼가 비어있다 가정하고 값을 넣은 후 count를 1로 설정하여 가득 찼다고 표시한다.
  • get()은 버퍼가 찼는지 확인하고, 값을 꺼낸 후 count를 0으로 설정하여 비었다고 설정한다.
  • 이 작업은 생산자 T1 , 소비자 T2 쓰레드에 의해 수행될 것이다.
  • T1은 loop 횟수 만큼 put()을 호출, T2는 무한히 get()을 호출
  • 이 코드가 제대로 동작하지 않을 것이다 → count와 buffer에 경쟁 조건이 발생하기 떄문이다.

불완전한 해답

  • 컨디션 변수와 mutex락으로 해결이 가능할까 ?
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)
  • 생산자와 소비자가 각 하나씩인 경우에는 코드가 동작한다.
  • Tc1 Tc2 라는 두개의 소비자가 있고 Tp 라는 생산자가 하나가 있다고 하면?

문제점 1 (If 문)

  • Tc1이 먼저 실행한다. 락을 획득하고 버퍼를 가져올 수 있는지 검사하고, 비어있음을 확인하고 확인한 후 대기하며 락을 해제한다.
  • Tp가 실행된다. 락을 획득하고 버퍼가 비어있으므로 버퍼를 채우고 시그널을 보낸다.
  • 대기중인 첫째 소비자는 꺠어나 준비 큐로 이동한다. Tc1은 이제 실행할 수 있는 상태지만 아직 실행하지 않는다.
  • Tp를 실행하고, 버퍼가 차 있으므로 대기 상태로 존재한다.
  • 여기서 Tc2가 끼어들어서 실행된다면 ? 다음 Tc1은 .. 데이터가 없어요
  • 문제 원인은 Tc1이 꺠어나서 실행되기 까지 사이에 버퍼의 상태가 변경이 되었다.
  • 깨어난 쓰레드는 실제 실행되는 시점에도 그 상태가 유지된다는 보장이 없다. (Mesa semantic)

이 문제는 if 문 대신 while문을 사용하면 해결할 수 있다.

소비자 Tc1 이 깨어나서 즉시 count 상태를 재확인한다.

만약 이 시점에 버퍼가 비어 있다면, 소비자는 대기 상태로 돌아간다

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

 

 

문제점 2 (컨디션 변수가 하나)

  • Tc1과 Tc2가 먼저 실행 한 후 소비한 후에 둘다 대기 상태에 있다면 ?
  • Tc1는 이제 어떤 쓰레드를 깨울 것인가?
  • 만약 Tc2를 깨운다면 버퍼가 비어있는 것을 확인한 후에 다시 대기상태로 들어간다. 그러면 세 개의 쓰레드가 모두 대기상태이다..
  • signal 을 보내야 하지만, 대상이 명확해야한다. (소비자 ↔ 생산자)

단일 버퍼 생산자/소비자 해법

  • 두개의 컨디션 변수를 사용해야만 쓰레드에게 시그널을 제대로 전달한다.
  • empty 일때 fill에 대한 시그널을 보내고, fill 에 대해서 대기하고  empty에 대해서 시그널을 발생시켜
  • 소비자가 다른 소비자를 꺠울 수 없도록, 생산자가 생산자를 꺠울 수 없도록 한다.
cond_t empty, fill;
mutex_t mutex;

void *producer(void *arg) {
	int i;
	for (i = 0; i < loops; i++) {
		Pthread_mutex_lock(&mutex);        
		while (count == 1)                     
			Pthread_cond_wait(&empty, &mutex); 
		put(i);                           
		Pthread_cond_signal(&fill);       
		Pthread_mutex_unlock(&mutex);      
	}
}

void *consumer(void *arg) {
	int i ;
	for (i = 0; i < loops; i++) {
		Pthread_mutex_lock(&mutex);       
		while (count == 0)                   
			Pthread_cond_wait(&fill, &mutex); 
		int tmp = get();                  
		Pthread_cond_signal(&empty);      
		Pthread_mutex_unlock(&mutex);     
		printf("%d\\n", tmp);  
	}
}

int buffer[Max];
int fill = 0;
int use = 0;
int count = 0;

void put(int value) {
	buffer[fill] = value;
	fill = (fill + 1 ) % MAX ;
	count++;
}

int get() {
	int tmp = buffer[use];
	use = (use + 1) % MAX;
	count--;
	return tmp;
}

 

출처 : 운영체제 : 아주 쉬운 세 가지 이야기

Comments