조건 변수
공유 상태·뮤텍스·조건식·알림을 묶어 스레드를 대기시키고 깨우며 가짜 깨어나기와 타임아웃을 처리합니다.
뮤텍스와 락으로 여러 스레드가 같은 데이터를 차례로 쓰게 할 수 있지만, 어떤 스레드는 데이터가 준비될 때까지 기다려야 합니다. 데이터를 만드는 생산자(producer) 스레드와 그것을 처리하는 소비자(consumer) 스레드가 대표적입니다. 소비자가 반복문으로 계속 확인하며 기다리면 CPU를 헛되이 쓰므로, 조건이 만족될 때까지 잠들었다가 알림을 받으면 깨어나는 방법이 필요합니다.
<condition_variable>의 std::condition_variable이 그 도구입니다. 이 글은 조건 변수를 뮤텍스, 공유 상태, 조건식과 함께 쓰는 기본 방법, 생산자와 소비자가 큐를 함께 쓰는 예, 이유 없이 깨어나는 가짜 깨어남, 시간 제한이 있는 대기를 다룹니다.
조건 변수의 기본 사용법
조건 변수는 혼자 쓰지 않고, 공유 상태와 그 상태를 보호하는 뮤텍스, 그리고 기다릴 조건과 함께 씁니다. 기다리는 쪽은 std::unique_lock으로 뮤텍스를 잠근 뒤 wait(lock, 조건)을 부르고, 알리는 쪽은 상태를 바꾼 뒤 notify_one()이나 notify_all()로 기다리는 스레드를 깨웁니다.
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <thread>
int main() {
std::mutex m;
std::condition_variable cv;
bool ready = false;
int data = 0;
std::thread worker([&] {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [&] { return ready; });
std::cout << "received " << data << '\n';
});
{
std::lock_guard<std::mutex> lock(m);
data = 42;
ready = true;
}
cv.notify_one();
worker.join();
std::cout << "done\n";
}received 42
done아래 그림처럼 wait(lock, 조건)은 잠금을 쥔 채 조건을 검사하고, 거짓이면 잠금을 풀고 잠듭니다. 잠금을 풀고 잠드는 일은 한꺼번에 일어나므로, 그사이에 알림이 끼어들어 사라지지 않습니다. main이 ready를 바꾸고 알리면 worker가 깨어나 잠금을 다시 얻고 조건을 다시 검사하며, 이번에는 참이므로 wait가 돌아옵니다.
조건 변수 대기는 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를 확인한다. |
|
|
main이 ready를 바꿀 때도 같은 뮤텍스를 잠근다는 점이 중요합니다. 잠그지 않고 바꾸면 worker가 조건을 검사하는 것과 동시에 바뀌는 데이터 경쟁이 되기 때문입니다. notify_one()은 잠금을 푼 뒤에 불렀는데, 잠금을 쥔 채 불러도 올바르지만, 그러면 깨어난 스레드가 곧바로 잠금을 기다려야 해서 조금 비효율적입니다.
main이 worker보다 먼저 알려도 문제가 없습니다. worker가 나중에 wait에 들어가면 조건을 먼저 검사하는데 ready가 이미 참이므로 잠들지 않고 바로 진행합니다. 알림은 기억되지 않고 그 순간 잠든 스레드만 깨우므로, 기다릴지는 반드시 공유 상태로 판단해야 합니다.
생산자와 소비자
생산자가 큐에 값을 넣고 소비자가 꺼내 처리하는 구조는 조건 변수의 가장 흔한 쓰임입니다. 소비자는 큐가 비어 있으면 기다리고, 생산자는 값을 넣을 때마다 알립니다. 생산이 끝났다는 것도 공유 상태로 알려야 소비자가 영원히 기다리지 않습니다.
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>
int main() {
std::mutex m;
std::condition_variable cv;
std::queue<int> items;
bool finished = false;
std::thread consumer([&] {
int sum = 0;
while (true) {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [&] { return !items.empty() || finished; });
if (items.empty()) {
break;
}
int value = items.front();
items.pop();
lock.unlock();
std::cout << "got " << value << '\n';
sum += value;
}
std::cout << "sum " << sum << '\n';
});
for (int i = 1; i <= 5; ++i) {
{
std::lock_guard<std::mutex> lock(m);
items.push(i);
}
cv.notify_one();
}
{
std::lock_guard<std::mutex> lock(m);
finished = true;
}
cv.notify_one();
consumer.join();
}got 1
got 2
got 3
got 4
got 5
sum 15소비자는 큐에 값이 있거나 생산이 끝났을 때 깨어나며, 큐가 비어 있는데 깨어났다면 생산이 끝난 것이므로 반복을 멈춥니다. 큐는 먼저 넣은 값을 먼저 꺼내고 소비자가 하나뿐이므로 1부터 5까지 순서대로 처리하며, 생산자가 소비자보다 앞서 여러 값을 넣어도 결과는 같습니다. 값을 꺼낸 뒤에는 lock.unlock()으로 잠금을 먼저 풀고 출력해, 생산자가 그동안 다음 값을 넣을 수 있게 합니다.
가짜 깨어남과 조건 검사
조건 변수에서 기다리는 스레드는 알림이 없었는데도 깨어날 수 있으며, 이를 가짜 깨어남(spurious wakeup)이라고 합니다. 또 알림을 받아 깨어났더라도, 잠금을 다시 얻기 전에 다른 스레드가 먼저 상태를 바꿔 조건이 다시 거짓이 되었을 수 있습니다. 그래서 깨어난 뒤에는 언제나 조건을 다시 검사해야 하며, 운영체제 교재의 모니터와 고전적 동기화 문제에서는 다른 스레드가 먼저 상태를 바꾸는 경우를 예제로 재현해 이 이유를 확인합니다.
조건을 받는 wait(lock, 조건)은 안에서 while (!조건) { wait(lock); }처럼 동작해, 깨어날 때마다 조건을 다시 검사합니다. 조건 없이 wait(lock)만 쓴다면 이 반복문을 직접 써야 하며, 조건을 if로 한 번만 검사하면 가짜 깨어남에 그대로 진행해 버립니다. 조건을 받는 버전을 쓰면 이런 실수를 피할 수 있습니다.
더 깊이: 가짜 깨어남은 왜 허용될까
가짜 깨어남은 C++만의 규칙이 아니라, 조건 변수를 제공하는 운영체제의 기능에도 있는 성질입니다. POSIX의 pthread_cond_wait 명세는 이 함수에서 가짜 깨어남이 일어날 수 있다고 정하고, 일부 구현, 특히 다중 프로세서에서는 신호 하나에 여러 스레드가 깨어날 수 있다는 점을 설명하며 조건 대기를 조건을 검사하는 while 반복으로 감싸라고 권합니다. Windows의 SleepConditionVariableSRW 문서도 조건 변수에는 명시적인 깨우기와 상관없는 가짜 깨어남과, 깨어난 스레드보다 다른 스레드가 먼저 실행되는 가로챈 깨어남(stolen wakeup)이 있으므로 잠에서 돌아온 뒤 조건을 다시 검사하라고 적고 있습니다.
가로챈 깨어남은 본문에서 말한 두 번째 경우, 즉 알림을 받았지만 잠금을 다시 얻기 전에 다른 스레드가 상태를 바꾼 경우와 같습니다. C++의 wait도 알림 없이 깨어나는 것을 허용하므로, 어느 운영체제에서 실행되든 조건을 다시 검사하는 코드는 그대로 맞습니다. 조건을 받는 wait는 이 두 가지를 한꺼번에 다루는 가장 짧은 방법입니다.
시간 제한이 있는 대기
영원히 기다리면 안 되는 경우에는 wait_for(lock, 시간, 조건)이나 wait_until(lock, 시각, 조건)을 씁니다. 조건을 받는 버전은 조건이 참이 되어 돌아오면 true를, 시간이 지나도록 조건이 거짓이면 false를 돌려줍니다.
#include <chrono>
#include <condition_variable>
#include <iostream>
#include <mutex>
int main() {
std::mutex m;
std::condition_variable cv;
bool ready = false;
std::unique_lock<std::mutex> lock(m);
bool ok = cv.wait_for(lock, std::chrono::milliseconds(100), [&] { return ready; });
std::cout << (ok ? "ready" : "timeout") << '\n';
}timeout아무도 ready를 바꾸거나 알리지 않으므로, 100밀리초가 지난 뒤 조건이 여전히 거짓이어서 wait_for가 false를 돌려주고 timeout이 출력됩니다. 시간이 지나 돌아왔더라도 조건을 마지막으로 한 번 더 검사하므로, 시간 제한과 동시에 조건이 참이 된 경우에는 true를 돌려줍니다. 결과가 false라면 시간 초과를 오류로 처리할지, 다시 기다릴지를 프로그램이 정합니다.
std::condition_variable_any
std::condition_variable은 std::unique_lock<std::mutex>와만 함께 쓸 수 있습니다. 다른 종류의 뮤텍스나 락과 함께 쓰려면 std::condition_variable_any를 씁니다. 조금 더 무겁지만, std::shared_mutex나 직접 만든 락과도 쓸 수 있습니다.
C++20에서는 std::condition_variable_any의 wait에 std::stop_token을 넘길 수 있어, 스레드 생성과 관리에서 본 std::jthread의 멈춤 요청이 오면 기다림이 끝나도록 만들 수 있습니다. 그래서 std::jthread가 소멸하면서 멈춤을 요청하면, 조건 변수에서 기다리던 작업 스레드도 깨어나 정리할 수 있습니다.
정리
- 조건 변수는 조건이 만족될 때까지 스레드를 잠재웠다가, 알림을 받으면 깨워 다시 확인하게 합니다.
- 조건 변수는 공유 상태, 그 상태를 보호하는 뮤텍스, 기다릴 조건과 함께 쓰며, 상태를 바꿀 때도 같은 뮤텍스를 잠급니다.
wait(lock, 조건)은 조건이 거짓이면 잠금을 풀고 잠들며, 깨어나면 잠금을 다시 얻고 조건을 다시 검사합니다.- 알림은 기억되지 않으므로 기다릴지는 공유 상태로 판단하고, 생산이 끝났다는 것도 상태로 알립니다.
- 가짜 깨어남이 있으므로 깨어난 뒤에는 조건을 다시 검사하며, 조건을 받는
wait가 이를 대신합니다. wait_for는 시간 제한이 있는 대기이며, 조건을 받는 버전은 조건이 참인지를bool로 돌려줍니다.
연습 문제
1. 조건 없이 기다리기
condition_basic.cpp의 worker가 cv.wait(lock, [&] { return ready; }); 대신 cv.wait(lock);만 쓰고, main이 worker가 wait에 들어가기 전에 알림을 보내면 어떻게 되나요?
해설 보기
알림은 기억되지 않고 그 순간 잠든 스레드만 깨우므로, worker가 나중에 wait(lock)에 들어가면 이미 지나간 알림을 받지 못하고 영원히 기다릴 수 있습니다. 그러면 main의 join()도 끝나지 않습니다. 조건을 받는 wait는 먼저 ready를 검사해 이미 참이면 잠들지 않으므로 이 문제가 생기지 않습니다.
2. 소비자가 둘이면
producer_consumer.cpp에 같은 일을 하는 소비자 스레드를 하나 더 두고, 끝날 때 notify_all()로 알리도록 바꾸면 출력은 어떻게 되나요?
해설 보기
두 소비자가 값을 나누어 가지므로, 각 소비자가 어떤 값을 얼마나 꺼낼지와 got 줄의 순서는 실행할 때마다 달라질 수 있고 출력이 섞일 수도 있습니다. 다만 두 소비자가 출력하는 sum을 더하면 언제나 15입니다. 생산이 끝났을 때 notify_one()만 쓰면 한 소비자만 깨어나 다른 소비자는 계속 기다릴 수 있으므로, 모두 깨우려면 notify_all()을 씁니다.
3. 시간 제한과 알림
wait_for.cpp에서 다른 스레드가 50밀리초 뒤에 ready를 참으로 바꾸고 알린다면 무엇이 출력되나요? 그 스레드가 ready를 바꾸지 않고 알리기만 한다면 어떻게 되나요?
해설 보기
50밀리초 뒤에 깨어나 조건 ready가 참이므로 wait_for가 true를 돌려주고 ready가 출력됩니다. 알리기만 하고 ready를 바꾸지 않으면, 깨어나도 조건이 거짓이어서 다시 기다리다가 100밀리초가 지나 false를 돌려주므로 timeout이 출력됩니다. 깨어났는지가 아니라 조건이 참인지가 결과를 정합니다.
다음 글 비동기 프로그래밍에서는 스레드를 직접 다루지 않고 작업의 결과를 나중에 받는 std::async, std::future, std::promise를 다룹니다.