condition_variable과 동기화 패턴
조건이 충족될 때까지 스레드를 어떻게 재웠다 깨우는가? 바쁜 대기를 없애는 condition_variable, lost/spurious wakeup을 막는 이유, 그리고 생산자·소비자 작업 큐.
이전 편에서는 “공유 자원을 어떻게 안전하게 잠그는가”를 다뤘다. 이번 편의 주제는 한 걸음 더 나아간 질문이다 — 스레드를 어떻게 효율적으로 기다렸다 깨우는가. 핵심 도구는 condition_variable이고, mutex와 거의 항상 짝으로 쓰인다.
왜 필요한가 — 바쁜 대기의 낭비
“다른 스레드가 어떤 조건을 만족시킬 때까지 기다린다”를 mutex만으로 하려고 하면 이렇게 된다.
while (!ready) {
// 계속 확인... CPU를 태우며 도는 바쁜 대기(busy waiting)
}
조건이 만족될 때까지 CPU를 한없이 낭비한다. 우리가 원하는 건 “조건이 안 됐으면 스레드를 재워서 CPU를 양보하고, 조건이 되면 누군가 깨워주는” 방식이다. 그걸 해주는 게 condition_variable(줄여서 cv)이다.
기본 구조 — 생산자·소비자
가장 전형적인 형태는 한쪽이 조건을 만들고(생산자) 다른 쪽이 그 조건을 기다리는(소비자) 패턴이다.
#include <mutex>
#include <condition_variable>
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
// 기다리는 쪽 (소비자)
void consumer() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []{ return ready; }); // ready가 true가 될 때까지 잠들어 대기
// 깨어나면 여기서부터 실행 (lock을 다시 쥔 상태)
}
// 깨우는 쪽 (생산자)
void producer() {
{
std::lock_guard<std::mutex> lock(mtx);
ready = true; // 조건 변경
}
cv.notify_one(); // 기다리는 스레드 하나를 깨움
}
이전 편에서 배운 것들이 여기서 전부 연결된다.
왜 unique_lock이어야 하는가
cv.wait()은 내부에서 lock을 풀고 잠들었다가, 깨어날 때 다시 lock을 잡는다. 이 unlock/lock 반복이 가능하려면 상태를 들고 유연하게 여닫을 수 있는 unique_lock이 필요하다. lock_guard는 이걸 못 하므로 쓸 수 없다. 이전 편에서 “조건 변수를 쓸 때만 unique_lock“이라고 미뤄둔 이유가 이것이다.
왜 mutex와 짝인가 — lost wakeup
cv가 mutex를 필수로 요구하는 건 우연이 아니다. “조건을 검사하는 행위”와 “조건이 바뀌고 notify하는 행위” 사이의 race를 막아야 하기 때문이다.
lock 없이 ready를 확인하고 wait에 들어가는 그 찰나에, 생산자가 ready = true; notify()를 끝내버린다고 하자. 그러면 소비자는 방금 지나간 notify를 영영 놓치고, 아무도 다시 깨워주지 않아 영원히 잠든다. 이것이 lost wakeup이다. mutex가 “조건 검사 → 대기 진입” 구간을 원자적으로 묶어 이 틈을 없앤다.
왜 predicate를 함께 넘기는가 — spurious wakeup
cv.wait(lock, predicate) 형태를 항상 쓰자. 이유는 두 가지다.
첫째, spurious wakeup. 아무도 깨우지 않았는데 OS가 임의로 스레드를 깨우는 현상이 실제로 일어난다. 둘째, notify가 wait보다 먼저 오는 타이밍 문제도 있다.
predicate를 주면 wait은 깨어날 때마다 조건을 다시 검사해서, 조건이 진짜로 참일 때만 진행하고 아니면 도로 잠든다. predicate 없는 wait(lock)은 이 함정에 그대로 노출된다. 위 두 줄짜리 predicate 버전은 사실상 다음과 같은 안전한 재검사 루프의 축약이다.
while (!ready) {
cv.wait(lock);
}
notify_one과 notify_all
notify_one()은 대기자 중 하나만 깨운다(누가 깨어날지는 정해져 있지 않다). notify_all()은 대기자 전원을 깨운다.
여기서 흔한 오해 하나를 짚자. notify_all이 전원을 깨우긴 하지만, 깨어난 스레드가 전부 곧바로 일하는 건 아니다. 두 단계로 걸러진다.
첫째, mutex 경합. 깨어난 스레드들은 wait에서 빠져나오며 다시 mutex를 잡아야 한다. 한 번에 하나만 잡으므로 나머지는 다시 블록되어 한 줄로 선다. 다 같이 깼지만 결국 차례차례 통과하는 이 현상을 thundering herd라 한다.
둘째, predicate 재검사. lock을 잡은 스레드는 predicate를 다시 확인한다. 조건이 이미 거짓이면(예: 앞선 스레드가 유일한 일감을 가져가 버림) 진행하지 않고 도로 잠든다.
그래서 “notify_all을 불렀는데 결국 하나만 일하더라”는 관찰은 정확하다. 깨우는 범위(전원 vs 하나)와 실제로 일하는 스레드 수(조건을 만족한 만큼)는 구분해야 한다.
선택 기준은 이렇다. 깨울 대상이 명확히 하나면(일감 하나당 처리자 하나) notify_one이 효율적이다. 여러 대기자의 조건이 동시에 바뀔 수 있거나 누가 진행 가능한지 불확실하면 notify_all이 안전하다. 잘 모르겠으면 notify_all로 정확성을 확보하고, 병목이 보이면 notify_one으로 최적화하는 순서를 권한다.
한 가지 주의. notify는 저장되지 않는다. notify 시점에 아직 wait에 진입하지 않은 스레드는 그 신호를 받지 못한다. 그 스레드는 나중에 wait에 들어가 다음 notify를 기다리거나, predicate가 이미 참이면 wait에서 즉시 통과한다.
실전 예 — 작업 큐
앞의 조각들을 모으면 스레드 안전한 작업 큐가 된다.
#include <queue>
#include <mutex>
#include <condition_variable>
std::queue<int> tasks;
std::mutex mtx;
std::condition_variable cv;
void push(int task) {
{
std::lock_guard<std::mutex> lock(mtx);
tasks.push(task);
}
cv.notify_one(); // 대기 중인 워커 하나를 깨움
}
int pop() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []{ return !tasks.empty(); }); // 큐가 빌 동안 대기
int task = tasks.front();
tasks.pop();
return task;
}
일감 하나당 워커 하나가 처리하면 되므로 notify_one이 알맞다. 여러 워커가 대기 중이어도, 깨어난 하나가 일감을 가져가고 나머지는 predicate가 다시 거짓이 되어 도로 잠든다.
더 간단한 대안들
cv는 강력하지만 손이 많이 가고 함정도 많다. 상황에 따라 더 간결한 도구가 있다.
일회성 결과 전달이라면 std::future/std::promise나 std::async가 훨씬 간단하다. 이들은 “결과가 준비될 때까지 대기”라는 cv 패턴을 특수화한 고수준 래퍼다.
#include <future>
std::future<int> fut = std::async(std::launch::async, []{
return heavyComputation();
});
int result = fut.get(); // 준비될 때까지 대기했다가 결과 수령
cv를 직접 다루는 것은 작업 큐처럼 반복적으로 신호를 주고받는 구조에서 진가를 발휘한다.
C++20 — 더 가벼워진 동기화 도구들
C++20은 cv+mutex 조합 없이 같은 일을 더 간결하게 표현하는 도구들을 새로 들여왔다.
std::counting_semaphore / std::binary_semaphore — 카운트 기반 신호. “동시에 N개까지 허용” 같은 제한에 알맞다.
std::latch — 일회용 “N개가 다 모일 때까지 대기”. 한 번 열리면 재사용하지 않는다.
std::barrier — 반복적인 단계 동기화. 여러 스레드가 각 단계 끝에서 서로를 기다렸다가 함께 다음 단계로 넘어간다.
그리고 mutex 없이 atomic 변수 하나로 조건 대기를 구현하는 가장 가벼운 방법도 생겼다.
#include <atomic>
std::atomic<bool> ready{false};
// 대기 측
ready.wait(false); // ready가 false인 동안 대기
// 알림 측
ready.store(true);
ready.notify_one(); // 대기 스레드를 깨움
std::atomic의 wait/notify_one/notify_all은 mutex도 cv도 없이 “기다렸다 깨우기”를 해낸다. 단순한 플래그 신호에는 이쪽이 가장 가볍다.
정리
- cv의 목적 — 바쁜 대기를 없애고, 조건이 충족될 때까지 스레드 재웠다가 notify로 깨운다.
- unique_lock 필수 —
wait이 내부에서 lock을 풀고 다시 잡기 때문.lock_guard는 불가. - mutex와 짝인 이유 — 조건 검사와 대기 진입을 원자적으로 묶어 lost wakeup을 막는다.
- predicate 필수 —
wait(lock, pred)형태로 spurious wakeup과 타이밍 문제를 흡수한다. - notify_one vs notify_all — 전자는 하나, 후자는 전원을 깨우되 mutex 경합과 predicate 재검사로 걸러진다. 깨우는 범위와 실제 일하는 수는 별개.
- 대안 — 일회성이면
future/promise/async. C++20의semaphore/latch/barrier,atomic::wait는 더 간결한 표현을 제공한다.
두 편에 걸쳐 mutex로 잠그는 법과 cv로 기다렸다 깨우는 법을 정리했다. 이 둘이 C++ 동시성의 두 축이다 — 하나는 접근을 직렬화하고, 다른 하나는 스레드 간 신호를 주고받는다.