운영체제 스터디 11주차

낚시하는 곰·2025년 11월 18일

운영체제 스터디

목록 보기
10/10

참고

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()함수에서 주의할 점

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 해버려서 그런건가?

생산자 / 소비자 문제 해결하기

문제

  • 여러 개의 생산자(Producer) 쓰레드와 소비자(Consumer) 쓰레드가 있습니다.
  • 생산자는 데이터를 만들어 버퍼에 집어넣습니다.
  • 소비자는 버퍼에서 데이터를 꺼내 사용합니다.
  • 버퍼는 유한한 크기를 가집니다.
  • 버퍼가 가득 차면 생산자는 기다려야 하고, 비어있으면 소비자가 기다려야 합니다.

여러 쓰레드가 버퍼라는 공유 자원에 동시에 접근하므로, 경쟁 조건을 피하기 위해 동기화가 필요하다. 어떻게 해결할 수 있을까?

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

put()과 get()을 이용한 해결법

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과 동시에 접근하게 되면 문제가 생길 수 있다는건가?

put()과 get()을 이용한 해결법의 문제점

대기 루프의 if문과 관련이 있습니다.

소비자 쓰레드가 2개(Tc1, Tc2) 있고 생산자 쓰레드가 1개(Tp) 있다고 가정해 보겠습니다.

  1. 먼저 소비자 Tc1이 실행됩니다. 락을 획득하고(c1) 버퍼를 소비할 수 있는지 검사합니다(c2). 현재 버퍼가 비어있으므로 Tc1은 wait()을 호출하여(c3) 락을 해제하고 sleep합니다.
  2. 이제 생산자 Tp가 실행됩니다. 락을 획득하고(p1) 버퍼가 비었음을 확인합니다(p2). 데이터를 버퍼에 넣고(p4) 소비자에게 시그널을 보냅니다(p5). 이때 대기 중이던 Tc1이 깨어나 실행 가능한 상태가 됩니다.
  3. 하지만 Tc1이 바로 실행되는 것이 아니라 단지 실행 가능한 상태일 뿐입니다. 그 사이에 생산자 Tp는 계속 실행 중이며 다시 버퍼 상태를 검사합니다. 버퍼가 차 있으므로 이번엔 자신이 wait()을 호출하고(p3) 잠듭니다.
  4. 이제 문제가 발생합니다. Tc1 대신 다른 소비자 Tc2가 먼저 실행될 수 있습니다. Tc2는 버퍼에서 데이터를 꺼내 가버립니다(c1-c6).
  5. 이제 Tc1이 비로소 실행됩니다. 그러나 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()을 호출하여 잠듭니다.

조건을 한 번만 검사하는 것이 아니라 여러 번 검사하게 함으로써 위의 문제를 방지한 듯

세마포어

목적

  1. 세마포어란?
  2. 락과 조건 변수 대신에 세마포어를 사용하는 방법은 무엇인가?
  3. 세마포어를 사용하는 방법?
  4. 락과 조건 변수를 사용하여 세마포어를 구현하는 것이 가능한가? 그 반대로 세마포어를 사용하여 락과 조건 변수를 구현하는 것이 가능한가?

세마포어란?

세마포어는 음이 아닌 정수 값을 갖는 객체로서 두 가지 연산(wait와 post)을 통해 조작할 수 있습니다. 세마포어는 초기값에 따라 동작이 결정되기 때문에 사용 전에 반드시 초기화를 해야 합니다.

int sem_wait(sem_t *s) {
    // 세마포어 s의 값을 1 감소
    // 세마포어 s의 값이 음수가 되면 해당 쓰레드는 블록됨
}

int sem_post(sem_t *s) {
    // 세마포어 s의 값을 1 증가
    // 블록된 쓰레드가 있다면 그 중 하나를 깨움
}

sem_wait() 함수는 세마포어의 값이 양수일 때 즉시 리턴하고, 그렇지 않으면 세마포어의 값이 양수가 될 때까지 호출한 쓰레드를 블록시킵니다. 

sem_post() 함수는 세마포어의 값을 증가시키고 블록된 쓰레드가 있다면 그 중 하나를 깨웁니다. 

그러니까 세마포어가 양수일 때만 접근할 수 있다는거군.

질문 3개

  1. 왜 락만으로는 제대로 된 병행 프로그램을 작성할 수 가 없을까요?
    답변 : 락은 "한 번에 한 쓰레드만 실행"을 보장하지만, "특정 조건이 만족될 때까지 효율적으로 대기"하는 메커니즘은 제공하지 않습니다. 컨디션 변수는 쓰레드를 sleep 상태로 전환하여 CPU를 낭비하지 않고 조건이 만족될 때까지 기다릴 수 있게 해줍니다.
  2. 조건 검사에 while문 사용을 권장하는 이유가 무엇인가?
    답변 : 시그널로 깨어났다고 해서 조건이 반드시 만족된다는 보장이 없기 때문입니다. 따라서 깨어난 후 반드시 조건을 재검사해야 합니다.
  3. 컨디션 변수는 어떤 자료구조로 구현되나요? 그리고 왜 이런 자료구조를 사용할까요?
    답변 : 큐 자료구조를 사용합니다. 큐 자료구조를 사용하는 이유는 조건이 충족될 때까지 CPU 시간을 낭비하는 스핀 대기(Spin-waiting) 대신, 스레드를 효율적으로 재울 수 있습니다. 조건이 충족되면 signal을 통해 특정 스레드만 선택적으로 깨울 수 있습니다.
profile
취업 준비생 낚곰입니다!! 반갑습니다!!

0개의 댓글