빙응의 공부 블로그
[OSTEP 병행성(concurrency)]컨디션 변수 본문
📝서론
지금까지 락의 개념을 학습하면서 하드웨어와 운영체제의 적절한 지원을 통해 제대로된 락을 만드는 법을 알아보았다.
쓰레드가 계속 진행하기 전에 어떤 조건이 참인지 검사해야 하는 경우가 많다.
예를 들어 부모 쓰레드가 작업을 시작하기 전에 자식 쓰레드가 작업을 끝냈는지 검사하기를 원할 수 있다.
이런 경우 부모 쓰레드가 특정 조건이 참이 될때까지 잠자면서 기다리는 방법을 선택한다.
그렇다면 조건을 기다리는 법은 무엇일까?
조건이 참이 될 때까지 회전을 하며 기다리는 것이 간단하지만 CPU 사이클 낭비가 심하다.
📝정의와 루틴들
조건이 참이 될 때까지 기다리기 위해 컨디션 변수를 활용할 수 있다.
컨디션 변수는 일종의 큐 자료 구조로서, 어떤 실행의 상태가 원하는 것과 다를 때 조건이 참이 되기를 기다리는 쓰레드 대기 큐이다.
다른 쓰레드가 상태를 변경시켰을 때, 대기 중이던 쓰레드를 깨우고(신호를 보낸다) 계속 진행할 수 있도록 하는 것이다.
그렇다면 신호를 어떻게 보낼까?
pthread_cond_t는 컨디션 변수를 선언한느 것이다.
컨디션 변수는 wait()와 signal()이라는 두가지 연산이 있다.
- wait() : 스스로를 잠재우기 위해서 호출
- signal() : 쓰레드가 무엇인가를 변경했기에 조건이 참이 되기를 기다리며 잠자고 있던 쓰레드를 깨울 때 호출
pthread_cond_wait(pthread_cond_t*c, pthread_mutex_t*m);
pthread_cond_signal(pthread_cond_t*c);
각각 유의할 점이 있다.
바로 wait()을 호출할 때 mutex는 잠겨있었다고 가정한다. wait()의 역할은 락을 해제하고 호출한 쓰레드를 재우는 것이다.
어떤 다른 스레드가 시그널을 보내어 쓰레드가 깨어나면, wait()에서 리턴하기 전에 락을 재획득해야한다.
그 이유는 경쟁 조건의 발생을 방지하기 위해서이다.
wait()가 호출될 때 mutex는 잠겨있었다고 가정한다.
wait()의 역할은 락을 해제하고 호출한 쓰레드를 재우는 것이다.
어떤 다른 쓰레드가 시그널을 보내어 쓰레드가 깨어나면, wait()에서 리턴하기 전에 락을 재획득해야 한다.
📝생산자/소비자 문제
다음으로 살펴볼 동기화 문제는 다익스트라가 제시한 생산자/소비자 문제(유한 버퍼)이다.
다익스트라와 동료들이 락이나 컨디션 변수 대신하여 사용할 수 있는 일반화된 세마포어를 발명하게 된 이유는 이 문제 때문이다.
쉽게 설명해보면
- 생산자 쓰레드
- 데이터를 만들어 버퍼에 넣는 쓰레드
- 소비자 쓰레드
- 데이터를 버퍼에서 꺼내서 사용하는 쓰레드
이러한 관계는 실제 시스템에서 자주 일어난다.
이렇게 쓰레드들이 한 유한 버퍼를 공유하는 것이며 경쟁 조건의 방지하기 위해 동기화가 필요하다.
간단한 예시를 들어보자
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;
}
- put()은 값을 공유 버퍼에 넣고 count를 1로 설정한다.
- get()은 공유 버퍼가 찼는지 확인하고, 값을 꺼낸다. 읽은 값은 리턴한다.
이 작업들은 생산자 쓰레드, 소비자 쓰레드에서 수행된다. 위 코드를 본다면 분명 제대로 동작하지 않는다는 것을 알 것이다.
공유 자원이기 때문이다.
동작은 다음과 같이한다고 하자
void *producer(void *arg) {
int i;
int loop = (int) arg;
for (i = 0; i < loops; i++) {
put(i);
}
}
void *consumer(void *arg) {
int i;
while (1) {
int tmp = get();
printf("%d\n", tmp);
}
}
- 생산자는 loop 횟수만큼 공유 버퍼에 데이터를 넣는다.
- 소비자는 무한루프를 돌며 계속해서 데이터를 공유 버퍼에서 꺼낸다.
불완전한 보완
생산자와 소비자가 각 하나씩 있다고 가정한다. 당연히 put()과 get() 루틴은 임계 영역에 있다.
코드에 락을 추가한것만으로는 제대로된 종작을 할 수 없다. 그렇기에 우리는 컨디션 변수가 하나 연결된 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)
하지만 이 방법에도 문제가 발생한다.
생산자와 소비자가 각각 하나씩인 경우 그림은 꽤 괜찮게 동작한다. 그러나 두개 이상 같은 종류의 쓰레드가 있다면 어떨까?
첫 번째 문제점을 생각해보자, 대기 명령 전의 if 문과 관련되어 있다. 공유 변수로 락을 진행하기에 다른 쓰레드가 끼어들 가능성이 있다.
시그널은 단순히 쓰레드를 깨우는 역할만 수행할 뿐, 그 상태가 그대로 유지된다는 보장을 하지 않는다.
시그널은 '조건이 바뀌었을 수 있다'는 힌트일 뿐이며, 실제로 쓰레드가 실행될 때에는 조건이 다시 변경되었을 가능성이 있기 때문에, 반드시 조건을 while 루프 안에서 다시 확인해야 한다.
다음 보완 if 대신 while로 조건을 반복 확인.
이 문제는 쉽게 해결할 수 있다. if 문을 while 문으로 바꾸면 된다.
시그널이 상태를 유지하지 않기에 자기가 실행될 차례가 되었을 때 한번 더 검사를 하면 되는 것이다.
컨디션 변수 사용의 가장 기본적인 법칙은 언제나 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);
}
}
하지만 아직도 문제가 있다.
소비자 1, 소비자 2가 대기할 때 생산자가 버퍼를 다채웠다면 시그널을 통해서 wait을 해제할 것이다.
그런데 어떤 쓰레드가 일어날까? 그것은 명확하지 않다.
문제가 발생하는 상황을 가정하면 소비자 1, 소비자 2가 대기상태일 때 만약 소비자 1이 깨어나 버퍼를 비우면
소비자 2와 생산자가 대기 중일 것이다. 이때 누굴 깨울지 몰라 소비자 2가 깨어나면 문제가 발생한다.
소비자 2는 버퍼에 값이 없어 대기하면 생산자는 여전히 잠들어 있기에 무한 대기가 발생하는 것이다.
단일 버퍼 생산자/소비자 해법
이 경우도 사실 간단하다. 두 개의 컨디션 변수를 사용하여 시스템의 상태가 변경되었을 때 깨워야 하는 쓰레드에게만 시그널을 전달하면 된다.
cond_t empty, fill;
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(&empty, &mutex); // p3
put(i); // p4
Pthread_cond_signal(&fill); // 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(&fill, &mutex); // c3
int tmp = get(); // c4
Pthread_cond_signal(&empty); // c5
Pthread_mutex_unlock(&mutex); // c6
printf("%d\n", tmp);
}
}
위의 코드는 생산자 쓰레드가 empty 조건 변수에서 대기하고 fill에 대해서 시그널을 발생시킨다.
정반대로 소비자 쓰레드는 fill에서 대기하고 empty에서 시그널을 발생시키는 것이다.
최종적인 생산자/소비자 해법
이제 제대로 작동하는 방법을 알았지만 보편적인 방법은 아니다.
마지막으로 병행성을 증가시켜 효율적으로 만들어야한다. 버퍼 공간을 추가하여 대기 상태에 들어가기 전에 여러 값들이 생산될 수 있도록 하는 것, 그리고 여러 값을 대기 상태 전에 소비하는 것이다.
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;
}
cond_t empty, fill;
mutex_t mutex;
void *producer(void *arg) {
int i;
for (i = 0; i < loops; i++) {
Pthread_mutex_lock(&mutex); // p1
while (count == MAX) // p2
Pthread_cond_wait(&empty, &mutex); // p3
put(i); // p4
Pthread_cond_signal(&fill); // 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(&fill, &mutex); // c3
int tmp = get(); // c4
Pthread_cond_signal(&empty); // c5
Pthread_mutex_unlock(&mutex); // c6
printf("%d\n", tmp);
}
}
📝컨디션 변수 사용 시 주의점
사실 진짜 문제는 멀티 쓰레드 기반 메모리 할당 라이브러리로 설명될 수 있다.
- 멀티 쓰레드 기반에서 메모리 할당 코드를 호출하면 공간이 생길 때까지 기다려야할 수 있다.
- 반대로 쓰레드가 메모리를 반납 시, 사용가능한 메모리 공간의 발생을 알리는 시그널을 생성한다.
이 때, 메모리 할당을 기다리던 쓰레드 중에서 어떤 쓰레드가 깨어나야하는가?
해결방법은 단순하다. 전부 다 깨우는 것, 하지만 안깨워도 되는 쓰레드를 모두 깨워야한다. 또한 오버헤드 또한 크다.
즉 브로드캐스트로 시그널을 바꾼 것이다.
📝요약
✅ 서론: 락과 조건 대기
- 스레드 동기화에서 조건이 만족될 때까지 기다리는 상황이 자주 발생한다.
- 예를 들어, 부모 스레드가 자식 스레드 작업 완료를 기다려야 할 수 있다.
- 단순 반복 검사(바쁜 대기, busy-wait)는 CPU 낭비가 크므로 적절하지 않다.
✅ 컨디션 변수(Condition Variable)의 개념과 사용
- 컨디션 변수는 어떤 조건이 만족될 때까지 스레드를 잠재우는 도구로, 보통 mutex와 함께 사용한다.
- 관련 함수:
- pthread_cond_wait(&cond, &mutex) : 락을 풀고 스레드를 대기 상태로 전환
- pthread_cond_signal(&cond) : 조건이 변했을 수 있음을 알림 (대기 중 스레드 하나 깨움)
⚠️ wait() 호출 시 mutex가 잠겨 있어야 하며, 리턴 전에 다시 획득해야 한다.
✅ 생산자/소비자 문제
- 공유 버퍼를 쓰는 생산자/소비자 스레드 간의 대표적인 동기화 문제.
- 문제점: 공유 자원(buffer, count)을 동시에 접근할 경우 경쟁 조건 발생.
- 단순 락만으로는 문제 해결이 안됨.
✅ 조건 변수 적용한 개선
🔹 단일 컨디션 변수 사용
if (count == 1) pthread_cond_wait(&cond, &mutex);
- 문제점: if 조건문은 깨진 상태를 재확인하지 않음 → 다른 스레드가 먼저 조건을 바꾸면 잘못 동작할 수 있음.
- 해결: if 대신 while 루프 사용하여 조건을 반복 확인해야 함.
🔹 두 개의 컨디션 변수 사용
pthread_cond_t empty, fill;
- empty: 버퍼가 비었을 때 기다림 (생산자용)
- fill: 버퍼가 찼을 때 기다림 (소비자용)
- 시그널을 목적에 맞게 정확한 컨디션 변수에 전달 → 올바른 스레드만 깨움.
✅ 병렬성 향상을 위한 원형 버퍼 확장
- MAX 크기의 원형 버퍼를 사용하여 여러 항목을 동시에 생산/소비 가능.
- 버퍼 인덱스를 fill, use, 그리고 count로 관리.
- 조건: count == MAX이면 생산자는 대기, count == 0이면 소비자는 대기.
✅ 컨디션 변수 사용 시 주의점
- 어떤 스레드를 깨울지는 명확하지 않다.
예) 조건을 만족하지 못한 스레드가 먼저 깨어나면 무한 대기 가능성 있음. - 해결책:
- 조건을 항상 while로 확인
- 경우에 따라 pthread_cond_broadcast() 사용하여 모든 대기 스레드 깨우기
'CS > 운영체제' 카테고리의 다른 글
[OSTEP 병행성(concurrency)]락 (0) | 2025.05.02 |
---|---|
[OSTEP 병행성(concurrency)]개요 (1) | 2025.04.28 |
[OSTEP 가상화(virtualization)]페이징 : 더 빠른 변환(TLB) (2) | 2025.04.25 |
[OSTEP 가상화(virtualization)]페이징: 개요 (0) | 2025.04.24 |
[OSTEP 가상화(virtualization)]빈 공간 메모리 관리 (0) | 2025.04.24 |