조건 변수
공유 상태·뮤텍스·조건식·알림을 묶어 스레드를 대기시키고 깨우며 가짜 깨어나기와 타임아웃을 처리합니다.
뮤텍스는 공유 자원에 대한 배타적 접근(exclusive access)을 보장하지만, 때로는 스레드가 특정 조건을 만족할 때까지 기다려야 하는 상황이 발생합니다.
예를 들어, 생산자(Producer) 스레드가 데이터를 생성하고 소비자(Consumer) 스레드가 이 데이터를 소비하는 시나리오에서, 소비자는 데이터가 준비될 때까지 기다려야 합니다.
이러한 조건 대기(conditional waiting) 및 스레드 간 통신을 담당하는 동기화 도구가 바로 조건 변수(Condition Variable)입니다.
C++ 표준 라이브러리는 <condition_variable> 헤더에 std::condition_variable을 제공합니다.
조건 변수 자체보다, 공유 상태·락·프레디케이트·알림을 하나의 계약으로 묶어 봐야 한다는 점을 먼저 확인합니다.
조건 변수란 무엇인가?
조건 변수는 하나 이상의 스레드가 특정 조건이 참이 될 때까지 대기하도록 하고, 다른 스레드가 그 조건을 변경했을 때 대기 중인 스레드에게 알림(notification)을 보내 재개하도록 하는 동기화 원시 요소(synchronization primitive)입니다.
핵심 개념- 뮤텍스와 함께 사용: 조건 변수는 항상 뮤텍스와 함께 사용되어야 합니다. 뮤텍스는 조건 변수가 기다리는 공유 데이터에 대한 안전한 접근을 보장합니다.
- 대기 (
wait()): 스레드는wait()함수를 호출하여 특정 조건이 참이 될 때까지 뮤텍스를 해제하고 대기 상태로 들어갑니다. - 알림 (
notify_one(),notify_all()): 다른 스레드가 조건을 변경한 후,notify_one()(하나의 대기 스레드 깨우기) 또는notify_all()(모든 대기 스레드 깨우기)을 호출하여 대기 중인 스레드를 깨웁니다.
조건 변수의 기본 사용법
먼저 wait(lock, predicate)가 내부에서 어떤 순서로 락을 놓고 다시 확인하는지 흐름을 잡고 코드를 보면 이해가 쉽습니다.
조건 변수 대기는 predicate 재검사 계약이다
소비자 거짓 검사→원자적 해제/대기→생산자 갱신/알림→재획득/재검사의 여섯 교환이 notify=잠금 전달 오해를 줄인다.
C++ · CONDITION VARIABLE
깨어난 뒤에도 잠금을 재획득하고 조건을 검사한다
소비자가 잠금을 풀고 기다리는 동안 생산자가 상태를 갱신하고 알린 뒤, 소비자가 재획득해 다시 검사한다.
- 소비자 검사
뮤텍스를 잠그고 predicate가 거짓임을 확인한다.
- 원자적 대기 진입
wait가 잠금을 풀고 소비자를 대기 상태로 넣는다.
- 생산자 상태 변경
같은 뮤텍스 아래에서 큐 또는 완료 상태를 바꾸고 잠금을 푼다.
- 알림
생산자가 notify_one으로 대기자에게 재검사 기회를 준다.
- 재획득과 재검사
소비자는 뮤텍스를 다시 얻고 재검사한다. 거짓이면 다시 기다리고 참일 때만 wait가 반환한다.
본문 predicate · 데이터 또는 종료
큐와 producer_done을 같은 mutex 아래서 검사한다.
!data_queue.empty()
|| producer_done큐가 있으면 소비한다. 큐가 비었고 producer_done이면 소비자 루프를 종료한다.
알림 ≠ 조건 충족
notify_one은 재검사 기회를 주며 가짜 깨어나기도 가능하다.
while (!pred())
wait(lock);predicate가 처음부터 참이면 대기하지 않는다. notify 자체가 잠금을 넘겨주지는 않는다.
wait는 잠금 해제와 대기 진입을 원자적으로 수행한다. 깨어나면 같은 mutex를 재획득한다. 본문은 상태를 변경하고 unlock한 뒤 알린다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 소비자가 같은 mutex 아래서 조건 거짓을 확인하고 wait에서 원자적으로 해제·대기한다. 생산자는 상태를 바꾸고 unlock한 뒤 notify_one한다. wake는 진행 후보이며 소비자는 mutex를 다시 획득하여 조건을 재검사한다. 거짓이면 다시 기다리고 참이면 predicate wait가 반환한다. 모바일 여섯 교환도 같은 참여자·방향·연산을 보존한다. |
| 노드·연결선 라벨 | 1 · 가능한 실행 순서 하나 |
| 노드·연결선 라벨 | 2 · 가능한 실행 순서 하나 |
| 노드·연결선 라벨 | 3 · 가능한 실행 순서 하나 |
| 노드·연결선 라벨 | 4 · 가능한 실행 순서 하나 |
| 노드·연결선 라벨 | 5 · 가능한 실행 순서 하나 |
| 노드·연결선 라벨 | 6 · 가능한 실행 순서 하나 |
| 본문·주석 | 큐에 데이터가 있으면 소비한다. 큐가 비었고 producer_done이면 소비자 루프를 종료한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 소비자의 거짓 검사 잠금 아래 predicate가 거짓이면 기다린다. |
|
|
| 생산자 갱신·알림 생산자가 보호 상태를 바꾸고 notify한다. |
|
|
| 재획득·재검사 소비자가 잠금을 다시 얻어 predicate를 확인한다. |
|
|
조건 변수 대기는 predicate 재검사 계약이다
소비자 거짓 검사→원자적 해제/대기→생산자 갱신/알림→재획득/재검사의 여섯 교환이 notify=잠금 전달 오해를 줄인다.
C++ · CONDITION VARIABLE
깨어난 뒤에도 잠금을 재획득하고 조건을 검사한다
소비자가 잠금을 풀고 기다리는 동안 생산자가 상태를 갱신하고 알린 뒤, 소비자가 재획득해 다시 검사한다.
- 소비자 검사
뮤텍스를 잠그고 predicate가 거짓임을 확인한다.
- 원자적 대기 진입
wait가 잠금을 풀고 소비자를 대기 상태로 넣는다.
- 생산자 상태 변경
같은 뮤텍스 아래에서 큐 또는 완료 상태를 바꾸고 잠금을 푼다.
- 알림
생산자가 notify_one으로 대기자에게 재검사 기회를 준다.
- 재획득과 재검사
소비자는 뮤텍스를 다시 얻고 재검사한다. 거짓이면 다시 기다리고 참일 때만 wait가 반환한다.
본문 predicate · 데이터 또는 종료
큐와 producer_done을 같은 mutex 아래서 검사한다.
!data_queue.empty()
|| producer_done큐가 있으면 소비한다. 큐가 비었고 producer_done이면 소비자 루프를 종료한다.
알림 ≠ 조건 충족
notify_one은 재검사 기회를 주며 가짜 깨어나기도 가능하다.
while (!pred())
wait(lock);predicate가 처음부터 참이면 대기하지 않는다. notify 자체가 잠금을 넘겨주지는 않는다.
wait는 잠금 해제와 대기 진입을 원자적으로 수행한다. 깨어나면 같은 mutex를 재획득한다. 본문은 상태를 변경하고 unlock한 뒤 알린다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 소비자가 같은 mutex 아래서 조건 거짓을 확인하고 wait에서 원자적으로 해제·대기한다. 생산자는 상태를 바꾸고 unlock한 뒤 notify_one한다. wake는 진행 후보이며 소비자는 mutex를 다시 획득하여 조건을 재검사한다. 거짓이면 다시 기다리고 참이면 predicate wait가 반환한다. 모바일 여섯 교환도 같은 참여자·방향·연산을 보존한다. |
| 노드·연결선 라벨 | 1 · 가능한 실행 순서 하나 |
| 노드·연결선 라벨 | 2 · 가능한 실행 순서 하나 |
| 노드·연결선 라벨 | 3 · 가능한 실행 순서 하나 |
| 노드·연결선 라벨 | 4 · 가능한 실행 순서 하나 |
| 노드·연결선 라벨 | 5 · 가능한 실행 순서 하나 |
| 노드·연결선 라벨 | 6 · 가능한 실행 순서 하나 |
| 본문·주석 | 큐에 데이터가 있으면 소비한다. 큐가 비었고 producer_done이면 소비자 루프를 종료한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 소비자의 거짓 검사 잠금 아래 predicate가 거짓이면 기다린다. |
|
|
| 생산자 갱신·알림 생산자가 보호 상태를 바꾸고 notify한다. |
|
|
| 재획득·재검사 소비자가 잠금을 다시 얻어 predicate를 확인한다. |
|
|
std::condition_variable을 사용하는 일반적인 패턴은 다음과 같습니다.
뮤텍스를 잠급니다. (일반적으로 std::unique_lock 사용)
조건이 참인지 확인합니다.
조건이 거짓이라면, condition_variable.wait(unique_lock, [프레디케이트])를 호출합니다.
wait()는 뮤텍스를 해제하고 스레드를 대기 상태로 만듭니다.wait()가 반환될 때 (알림을 받거나 가짜 깨어나기), 뮤텍스를 다시 잠급니다.- 프레디케이트(predicate, 람다 함수 등)는 조건이 참인지 확인하며,
wait()가 깨어날 때마다 자동으로 조건을 다시 확인합니다. 프레디케이트를 사용하는 것이 가짜 깨어나기(Spurious Wakeup)를 안전하게 처리하는 권장 방법입니다.
조건이 참이면, 임계 영역 작업을 수행합니다.
뮤텍스를 잠급니다. (일반적으로 std::unique_lock 사용)
공유 데이터를 변경하여 조건을 참으로 만듭니다.
condition_variable.notify_one() 또는 condition_variable.notify_all()을 호출하여 대기 중인 스레드를 깨웁니다.
뮤텍스를 해제합니다.
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue> // std::queue를 위해
#include <chrono> // std::chrono::milliseconds를 위해
std::queue<int> data_queue; // 공유 데이터 큐
std::mutex mtx; // 큐 접근 보호를 위한 뮤텍스
std::condition_variable cv; // 조건 변수
bool producer_done = false; // 생산자가 작업을 마쳤는지 나타내는 플래그
// 생산자 스레드
void producer() {
for (int i = 0; i < 5; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 생산 시간 시뮬레이션
std::unique_lock<std::mutex> lock(mtx); // 뮤텍스 잠금
data_queue.push(i);
std::cout << "Producer: Produced " << i << ". Queue size: " << data_queue.size() << std::endl;
lock.unlock(); // 뮤텍스 해제 (조건 변수 notify 전에 해제하는 것이 성능에 유리)
cv.notify_one(); // 대기 중인 소비자 스레드 하나를 깨움
}
std::unique_lock<std::mutex> lock(mtx);
producer_done = true; // 생산 완료 플래그 설정
lock.unlock();
cv.notify_all(); // 모든 소비자 스레드를 깨워 종료를 알림
}
// 소비자 스레드
void consumer() {
while (true) {
std::unique_lock<std::mutex> lock(mtx); // 뮤텍스 잠금
// 큐가 비어 있고 생산자가 아직 끝나지 않았다면 대기
cv.wait(lock, []{ return !data_queue.empty() || producer_done; });
// wait 함수는 조건이 거짓이면 뮤텍스를 자동으로 해제하고 대기,
// 알림을 받거나 가짜 깨어나기 시 뮤텍스를 다시 잠근 후 프레디케이트 재확인
if (data_queue.empty() && producer_done) {
std::cout << "Consumer: Producer done and queue is empty. Exiting.\n";
break; // 큐가 비어 있고 생산자도 끝났으면 종료
}
int data = data_queue.front();
data_queue.pop();
std::cout << "Consumer: Consumed " << data << ". Queue size: " << data_queue.size() << std::endl;
lock.unlock(); // 뮤텍스 해제
std::this_thread::sleep_for(std::chrono::milliseconds(200)); // 소비 시간 시뮬레이션
}
}
int main() {
std::cout << "Main: Starting producer and consumer threads.\n";
std::thread prod_t(producer);
std::thread cons_t(consumer);
prod_t.join();
cons_t.join();
std::cout << "Main: All threads finished.\n";
return 0;
}가능한 흐름 하나를 따라가 보겠습니다. 소비자가 큐가 비어 있고 producer_done도 false인 상태에서 cv.wait()에 도달하면 대기합니다.
이때 뮤텍스를 해제하고 대기합니다. 생산자가 먼저 실행해 predicate가 이미 true라면 대기하지 않고 다음 단계로 갑니다.
생산자 스레드가 데이터를 생성하고 큐에 넣은 후 cv.notify_one()을 호출합니다.
소비자 스레드가 깨어나고 뮤텍스를 다시 획득한 후, 큐에 데이터가 있음을 확인하고 소비합니다.
생산자가 모든 데이터를 생산한 뒤 producer_done을 true로 설정하고 cv.notify_all()을 호출해도 큐에 데이터가 남아 있을 수 있습니다. 소비자는 남은 항목을 계속 처리하고, 큐가 비어 있으면서 producer_done이 true일 때만 루프를 종료합니다.
큐와 완료 플래그를 함께 따라가기
위 프로그램은 생산 값 0, 1, 2, 3, 4를 소비자가 같은 FIFO 큐에서 한 번씩 꺼냅니다. 생산과 소비 메시지의 줄 순서는 고정하지 않지만, 단일 소비자가 꺼낸 데이터의 순서는 큐에 넣은 순서를 따릅니다. producer_done은 더 들어올 값이 없다는 뜻이며, 큐에 남은 값을 버리라는 뜻은 아닙니다.
notify_one은 알림을 나중에 꺼내 쓸 수 있는 데이터로 저장하지 않습니다. 생산자가 먼저 큐에 넣고 알린 다음 소비자가 도착해도, 소비자가 같은 뮤텍스 아래서 큐를 검사하면 조건이 이미 참이므로 기다리지 않습니다. 기억해야 할 것은 알림 횟수보다 큐와 완료 플래그의 현재 상태입니다.
독립적으로 다음 소비자 동작 판단하기
소비자가 잠금을 획득한 시점의 상태만 주어집니다. A는 빈 큐·완료 false, B는 [4]·완료 false, C는 [4]·완료 true, D는 빈 큐·완료 true입니다. 각각 대기·한 항목 소비·루프 종료 중 무엇을 해야 하나요? B 상태에서는 생산자의 알림이 소비자 도착 전에 이미 끝났다고 가정합니다. 또 두 소비자가 깨어났지만 먼저 잠근 소비자가 마지막 항목을 꺼낸 뒤, 완료 false 상태에서 두 번째 소비자가 잠금을 얻었다면 무엇을 해야 하나요?
풀이 확인
A는 predicate가 거짓이므로 잠금을 놓고 대기합니다. B와 C는 항목 4를 소비합니다. C도 큐가 남아 있어 즉시 종료하면 안 됩니다. D는 predicate가 참이고 종료 조건도 참이므로 루프를 끝냅니다. B는 알림을 다시 받을 필요 없이 처음 predicate 검사에서 진행합니다. 두 번째 소비자는 알림을 받았어도 재검사 결과가 거짓이므로 다시 기다립니다. 어느 경우에도 큐와 완료 플래그는 같은 뮤텍스로 보호합니다.
가짜 깨어나기
가짜 깨어나기의 핵심은 “깨어났다”가 아니라 “조건이 참인지 다시 확인했다”입니다.
앞의 wait 흐름에서 재획득과 재검사 단계를 확인합니다.
가짜 깨어나기(Spurious Wakeup)는 조건 변수가 notify() 호출 없이도 대기 중인 스레드를 깨울 수 있는 현상입니다.
이는 운영체제 스케줄러의 특성이나 멀티프로세서 시스템의 특정 구현 때문에 발생할 수 있습니다.
스레드가 깨어나더라도 wait()를 호출하기 전의 조건이 실제로 참이 아닐 수 있다는 의미입니다.
해결책:
std::condition_variable::wait() 함수는 항상 프레디케이트(Predicate)와 함께 사용하는 것이 강력히 권장됩니다.
프레디케이트는 람다 함수나 함수 객체로, 조건이 참인지 확인하는 역할을 합니다.
cv.wait(lock, []{ return !data_queue.empty(); });이 코드는 다음과 같이 동작합니다.
wait()가 호출되면, 먼저 프레디케이트를 실행하여 조건이 참인지 확인합니다.
조건이 참이면 즉시 반환하고, 거짓이면 뮤텍스를 해제하고 대기합니다.
notify()를 받거나 가짜 깨어나기로 깨어나면, wait()는 뮤텍스를 다시 잠그고 프레디케이트를 다시 확인합니다.
여전히 조건이 거짓이면, 다시 뮤텍스를 해제하고 대기합니다.
조건이 참이 될 때까지 이 과정을 반복합니다.
조건 검사는 이미 재획득한 뮤텍스를 소유한 상태에서 이루어집니다. 조건이 거짓이면 다시 놓고 기다리며, 참일 때만 대기 함수 다음의 큐 처리 코드로 진행합니다. 가짜 깨어나기 때문에 빈 큐를 처리하게 되는 흐름을 막는 것입니다.
타임아웃을 이용한 대기
조건 변수는 특정 시간 동안만 대기하거나, 특정 시점까지 대기할 수 있는 오버로드된 wait 함수들을 제공합니다.
이는 비동기 작업의 타임아웃 처리나 주기적인 폴링(polling)이 필요한 경우에 유용합니다.
wait_for(lock, duration, predicate): 지정된duration동안만 대기합니다.duration이 지나면 조건이 참이 아니더라도 반환합니다. 반환 값은true(조건이 충족됨) 또는false(타임아웃 발생)입니다.wait_until(lock, time_point, predicate): 지정된time_point까지 대기합니다.
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <chrono> // std::chrono를 위해
std::mutex mtx_timeout;
std::condition_variable cv_timeout;
bool data_ready = false;
void consumer_timeout() {
std::unique_lock<std::mutex> lock(mtx_timeout);
std::cout << "Consumer with timeout: Waiting for data...\n";
// 5초 동안만 대기
if (cv_timeout.wait_for(lock, std::chrono::seconds(5), []{ return data_ready; })) {
std::cout << "Consumer with timeout: Data is ready! (Predicate is true)\n";
} else {
std::cout << "Consumer with timeout: Timeout occurred! Data not ready.\n";
}
}
void producer_timeout() {
std::this_thread::sleep_for(std::chrono::seconds(2)); // 2초 후 데이터 준비
std::unique_lock<std::mutex> lock(mtx_timeout);
data_ready = true;
std::cout << "Producer with timeout: Data is set to ready.\n";
lock.unlock(); // notify_one/all 호출 전에 락 해제 권장
cv_timeout.notify_one();
}
int main() {
std::cout << "Main: Starting threads for timeout example.\n";
std::thread t1(consumer_timeout);
std::thread t2(producer_timeout);
t1.join();
t2.join();
std::cout << "Main: Timeout example threads finished.\n";
// ----------------------------------------------------
std::cout << "\n--- Another timeout example (long wait) ---\n";
data_ready = false; // Reset for next example
std::thread t3(consumer_timeout);
std::this_thread::sleep_for(std::chrono::seconds(6)); // 생산자가 6초 후에 알림
std::unique_lock<std::mutex> lock(mtx_timeout);
data_ready = true;
lock.unlock();
cv_timeout.notify_one(); // 이 때는 소비자가 이미 타임아웃되어 대기하지 않을 수 있음
t3.join();
std::cout << "Main: Another timeout example threads finished.\n";
return 0;
}첫 시나리오는 준비 완료, 두 번째는 타임아웃을 설명하기 위한 시간 배치입니다. 스케줄링과 뮤텍스 재획득 지연 때문에 sleep 길이만으로 출력 분기를 보장하지는 않습니다. 성공은 반환 시 조건식이 참이라는 뜻이며 반드시 알림으로 깨어났다는 뜻은 아닙니다.
타임아웃의 반환값을 상태로 설명하기
시간 제한이 지난 뒤에도, 최종 predicate가 참이면 predicate 오버로드는 true를 반환할 수 있습니다. 그러므로 false는 단순히 시계만 보고 붙이는 표지가 아니라 마지막 조건 검사도 거짓이라는 결과입니다. 반환 직후에는 unique_lock이 같은 뮤텍스를 소유하므로 필요한 상태를 그 잠금 아래에서 읽습니다.
독립 문제로, data_ready = true인 상태에서 이미 잠근 락으로 wait_for(lock, std::chrono::milliseconds(0), predicate)를 호출하면 기다려야 하나요? 같은 조건에서 data_ready = false이고 다른 스레드가 변경하지 않는 경우에는 어떤 반환값을 기대하나요?
풀이 확인
처음부터 조건이 참이면 대기하지 않고 true를 반환합니다. 두 번째 조건에서는 최종 검사도 거짓이므로 false입니다. 두 경우 모두 반환 시 락을 소유한다는 계약은 같습니다. 이 판단에는 특정 알림이나 스레드 실행 속도를 가정할 필요가 없습니다.
std::condition_variable_any
std::condition_variable은 오직 std::unique_lock<std::mutex>만 사용할 수 있습니다.
그러나 때로는 다른 종류의 뮤텍스(예: std::shared_mutex)와 함께 조건 변수를 사용해야 할 필요가 있습니다.
이때 std::condition_variable_any를 사용할 수 있습니다.
std::condition_variable_any는 모든 종류의 락(lockable) 객체를 wait 함수의 인자로 받을 수 있지만, std::condition_variable보다 약간의 오버헤드가 더 있습니다.
일반적으로 std::condition_variable이 더 효율적이므로, 특별한 이유가 없다면 std::condition_variable을 사용하는 것이 좋습니다.
사용하는 잠금 타입이 std::unique_lock<std::mutex>인지, 다른 Lockable인지에 따라 선택합니다.
스레드 간 통신 및 동기화 정리
- 뮤텍스: 공유 자원에 대한 배타적 접근을 보장하여 경쟁 조건을 방지합니다. (
std::mutex,std::lock_guard,std::unique_lock,std::shared_mutex,std::shared_lock) - 조건 변수: 특정 조건이 만족될 때까지 스레드를 대기시키고, 조건이 변경되면 대기 중인 스레드를 깨워 스레드 간 통신을 가능하게 합니다. (
std::condition_variable,std::condition_variable_any)
이 두 가지 원시 요소를 함께 사용하면, 복잡한 멀티스레드 시나리오에서도 안전하고 효율적인 동기화를 구현할 수 있습니다.
조건 변수는 wait 조건, notify_one/all, 가짜 깨어나기까지 고려해 대기 루프를 만들어야 합니다.
공유 상태, predicate, notify 시점을 하나의 계약으로 관리합니다.