03장은 능동적으로 상태를 확인(spinning)하는 대신, 다른 스레드가 신호를 보낼 때까지 안전하게 대기하는 패턴들을 다룬다. 이전 장의 락은 “공유 상태를 보호하는 것"에 집중했다면, 이 장의 condition variable은 **“조건이 만족될 때까지 대기”**의 효율성을 높인다.
이 장을 읽기 전에
완전한 초보자? 이 장은 02장: 락 관용구에서 다룬 std::mutex, std::lock_guard, std::unique_lock을 전제로 합니다. 특히 std::unique_lock이 std::lock_guard와 달리 “락을 중간에 풀고 다시 잡을 수 있다"는 점을 모른다면 02장을 먼저 보고 오세요. std::condition_variable을 한 번도 본 적이 없어도 괜찮습니다 — 이 장이 처음부터 설명합니다.
이 장의 깊이: 이 장은 중급~전문가 수준입니다. std::condition_variable로 Monitor Object, Guarded Suspension, Balking 패턴을 구현하고, spurious wakeup과 lost wakeup이라는 두 가지 흔한 버그를 코드로 재현한 뒤 고칩니다. 전문가 구간에서는 OS별 spurious wakeup의 실제 발생 메커니즘, notify를 락 안/밖에서 호출할 때의 트레이드오프, 그리고 다중 condition_variable을 이용한 Bounded Queue의 실전 구현까지 다룹니다. 다루지 않는 것: std::future/std::promise 기반의 비동기 결과 전달(07장), std::semaphore(C++20)와 std::barrier/std::latch 같은 더 새로운 동기화 프리미티브의 일반론(이 장에서는 비교 차원에서만 짧게 언급), 그리고 Producer-Consumer의 본격적인 큐 설계와 backpressure(04장)는 다음 장으로 넘긴다.
당신의 수준에 맞는 경로
| 수준 | 읽을 부분 | 핵심 목표 |
|---|---|---|
| 초보자 | “문제” ~ “Monitor Object 패턴” | condition_variable 기본 사용법, predicate의 필요성 |
| 중급자 | “Guarded Suspension 패턴” ~ “Balking 패턴” | BlockingQueue 구현, Balking의 적용 시점 판단 |
| 전문가 | “Spurious Wakeup의 OS별 메커니즘” ~ “실전: 여러 조건 변수” | lost wakeup 진단, 다중 CV 설계, notify 위치 선택 |
문제: Busy-Wait의 비용
01장에서 본 패턴을 다시 보자:
| |
while (!ready.load(...))는 busy-wait: CPU를 낭비하며 계속 폴링한다. 멀티코어 CPU라도 이 스레드는 100% CPU를 소비하고, 다른 스레드의 작업을 방해한다. 만약 대기 시간이 길다면 (예: 네트워크 응답 대기) 이는 매우 비효율적이다.
Monitor Object 패턴
Monitor Object는 condition variable을 사용해 효율적으로 대기하는 패턴이다. 핵심은:
- 뮤텍스: 상태를 보호
- Condition Variable: 조건 신호 송수신
- Predicate: “언제 계속할 건가"의 논리
| |
주요 요소:
unique_lock vs lock_guard:
cv.wait()는 내부적으로 lock을 해제했다가 다시 획득해야 하므로,unique_lock이 필요하다 (lock_guard는 불가).Predicate (조건 함수):
cv.wait(lock, [this] { return ready; })람다는 깨어난 후 조건을 확인한다. 만약 조건이 거짓이면 다시 대기한다. 이것이 spurious wakeup 처리다.notify_one vs notify_all:
notify_one()은 대기 중인 스레드 하나만 깨우고,notify_all()은 모두를 깨운다. 한 스레드만 진행되면 충분하면 notify_one, 여러 스레드가 깨어나야 하면 notify_all을 쓴다.
Spurious Wakeup이란?
Spurious wakeup: OS가 조건 신호 없이도 스레드를 깨울 수 있다는 뜻이다. 따라서 cv.wait()에서 깨어났다고 해서 조건이 반드시 만족된 것은 아니다. 그래서 항상 predicate를 확인해야 한다:
| |
cv.wait(lock, pred)는 사실 다음 코드와 동일하다 — 즉 predicate 버전은 “더 똑똑한 wait"가 아니라 아래 루프의 축약형이다.
| |
Spurious Wakeup의 OS별 메커니즘 (전문가)
“OS가 신호 없이 깨운다"는 말은 추상적으로 들리지만, 실제로는 구현상의 이유가 있다.
- Linux (futex 기반): glibc의
pthread_cond_wait는 내부적으로futex시스템 콜로 구현된다. 시그널(POSIX signal)이 대기 중인 스레드에 전달되면futex_wait가EINTR로 깨어날 수 있고, glibc는 이를 spurious wakeup으로 다시 사용자에게 전달한다. 또한 멀티코어 환경에서notify와wait사이의 타이밍 윈도우 때문에, 구현이 “한 번의 notify로 여러 스레드를 깨우는” 경우를 허용한다(과도하게 깨우는 것이 신호를 놓치는 것보다 안전하기 때문). - Windows (조건 변수 + Condition Variable API): Win32
SleepConditionVariableCS/SRW기반 구현은 내부적으로 커널 디스패처 객체의 알람(alertable wait)을 사용하는데, I/O completion이나 APC(Asynchronous Procedure Call)가 대기 중인 스레드를 깨울 수 있어 spurious wakeup의 원인이 된다. - 표준의 입장: C++ 표준은
wait가 spurious하게 반환될 수 있음을 명시적으로 허용한다(이는 버그가 아니라 명세다). 그 이유는 구현이 “신호를 절대 놓치지 않는다"를 보장하기 위해 “가끔 더 깨운다"를 허용하는 쪽이 훨씬 구현하기 쉽고 빠르기 때문이다. predicate 검사는 이 trade-off를 사용자 코드에서 흡수하는 비용이다.
Lost Wakeup: 더 위험한 버그
Spurious wakeup은 predicate 검사로 항상 해결되지만, lost wakeup은 더 치명적이다 — predicate를 빠뜨려도 평소엔 잘 동작하다가 타이밍에 따라 영원히 멈춘다.
| |
스레드 스케줄링상 set()이 waitForReady()보다 먼저 실행되면, notify_one()은 “아직 아무도 듣고 있지 않은 상태"에서 호출되어 그냥 사라진다. 이후 waitForReady()가 cv.wait(lock)에 들어가면 더 이상 깨워줄 신호가 없으므로 영구 대기한다.
| |
핵심은 “상태 변경 + notify"와 “predicate 확인 + wait"가 같은 mutex로 보호되어야 한다는 것이다. 그래야 set()이 먼저 실행되어도 ready = true가 이미 반영되어 있어 wait의 predicate가 즉시 true를 반환한다.
안전성 검증: ThreadSanitizer로 확인
BrokenSignal은 ready에 대한 비보호 읽기/쓰기가 있어 TSAN이 데이터 레이스로 잡아낸다. 또한 lost wakeup 자체는 데이터 레이스가 아니라 “타이밍에 따라 영원히 멈추는” 문제이므로, TSAN보다는 타임아웃을 건 재현 테스트가 효과적이다.
| |
FixedSignal은 두 검사 모두 깨끗하게 통과한다.
Guarded Suspension 패턴
Guarded Suspension은 Monitor Object와 유사하지만, 조건이 만족되지 않으면 “대기"가 아니라 “보류(suspend)“한다. 다음은 큐의 예제다:
| |
Producer-Consumer 패턴에서 자주 사용된다.
| |
Balking 패턴
Balking은 조건이 만족되지 않으면 “대기하지 않고 즉시 포기"하는 패턴이다. Guarded Suspension의 반대다.
| |
Balking은 멱등성(idempotent) 작업에 유용하다. 예: 초기화 함수가 두 번 호출되는 것을 방지.
| |
실전: 여러 조건 변수
notify를 락 안에서 호출할까, 밖에서 호출할까
set()/push() 예제들은 모두 notify_one()을 락을 잡은 상태에서 호출한다. 하지만 가장 앞의 DataHolder::set() 예제는 notify_one()을 락 해제 후 호출했다. 둘 다 정답이 될 수 있지만 트레이드오프가 다르다.
| |
(A)는 notify된 스레드가 즉시 깨어나도 곧바로 mu를 다시 기다려야 하는 “hurry up and wait” 현상이 있을 수 있다(대부분의 구현은 이를 최적화하지만 표준이 보장하진 않는다). (B)는 이 낭비를 줄이지만, notify와 unlock 사이에 컨텍스트 스위치가 끼어들 여지가 생긴다는 점에서 “더 위험해 보일 수 있다 — 그러나 predicate 기반 wait를 쓴다면 (B)도 안전하다. 정답이 없으므로, 측정 후 결정하되 predicate를 항상 쓴다는 원칙은 둘 다에서 동일하게 적용된다.
패턴 1: Single Condition, Multiple Waiters
여러 스레드가 같은 조건을 기다린다면 notify_all()을 쓴다.
| |
패턴 2: 시간제한 대기
| |
패턴 3: 여러 조건 변수 (고급)
경우에 따라 여러 CV를 쓰는 게 더 효율적이다. 단일 notEmpty CV만 쓰면 push()는 깨울 대상이 없어 항상 notify_all()로 모든 대기자를 깨워야 하지만, “가득 찼다"와 “비었다"라는 서로 다른 조건을 분리하면 각각 notify_one()으로 정확한 대상만 깨울 수 있다.
| |
capacity == 1인 BoundedQueue<int>는 사실상 “교대로만 진행 가능한” 동기화 채널이 되어, Producer와 Consumer가 빠르게 핑퐁하는 구조를 강제한다. capacity를 키우면 Producer가 일시적으로 앞서갈 수 있는 버퍼 여유가 생기는데, 이 트레이드오프(메모리 vs 처리량)는 04장 Producer-Consumer에서 본격적으로 다룬다.
안전성 검증: ThreadSanitizer로 확인
BoundedQueue는 push/pop이 항상 mu로 보호되므로 데이터 레이스는 없지만, 두 CV를 혼동하면(notFull.wait에 !q.empty()를 넣는 등) lost wakeup이 발생한다. 이런 버그는 TSAN의 데이터 레이스 검출 범위 밖이므로, 단위 테스트로 “용량 1짜리 큐에 두 스레드가 핑퐁하며 1000번 주고받기"를 타임아웃과 함께 실행해 확인하는 것이 실용적이다.
| |
학습 성과 평가 기준
- Monitor Object 패턴에서 mutex, condition_variable, predicate의 역할을 설명할 수 있는가?
- Spurious wakeup이란 무엇이며, predicate를 통해 어떻게 처리하는가? OS별로 왜 발생하는지 설명할 수 있는가?
- Lost wakeup을 코드로 재현하고, mutex+predicate 조합으로 고칠 수 있는가?
- Guarded Suspension과 Balking의 차이를 예제로 들어 설명할 수 있는가?
- BlockingQueue를 구현하고, Producer-Consumer 시나리오에서 작동시킬 수 있는가?
- Bounded Queue (용량 제한)에서 두 개의 condition_variable을 써서 구현하고, notify 위치(락 안/밖)의 트레이드오프를 설명할 수 있는가?
다음 장에서는
04장 **「데이터 흐름(Data Flow)」**에서는 Producer-Consumer의 심화 패턴, bounded buffer와 backpressure, 그리고 비동기 파이프라인을 다룬다.
참고 및 출처
- POSA2 (Schmidt et al.), Chapter 2 & 4 — Monitor Object와 Guarded Suspension
- Anthony Williams, 『C++ Concurrency in Action』(2nd ed., 2019), Chapter 4 — condition_variable, lost wakeup, Bounded Queue 구현
- C++ Standards Committee,
<condition_variable>documentation — wait/wait_for의 spurious wakeup 명세 - Linux man-pages,
pthread_cond_wait(3)— futex 기반 구현과 spurious wakeup의 원인 - Microsoft Docs, “Condition Variables” (Win32 API) — alertable wait와 spurious wakeup
![Featured image of post [Concurrency Patterns] 03. 대기와 조정](/post/multithreading-patterns/cpp-condition-variable-monitor-object-guarded-suspension/wordcloud_hu_ebae57079549ae1e.webp)
![[Concurrency Patterns] 01. 동시성 기초와 C++ 메모리 모델](/post/multithreading-patterns/getting-started-cpp-concurrency-fundamentals-memory-model/wordcloud_hu_61c0c60d19b6fa49.webp)
![[Concurrency Patterns] 02. 락 관용구](/post/multithreading-patterns/cpp-locking-idioms-scoped-locking-thread-safe-interface/wordcloud_hu_fa5a9d576f9499eb.webp)
![[Concurrency Patterns] 03. 대기와 조정](/post/multithreading-patterns/cpp-condition-variable-monitor-object-guarded-suspension/wordcloud_hu_3fa6659dfc8981c1.webp)
![[Concurrency Patterns] 04. 데이터 흐름: Producer-Consumer](/post/multithreading-patterns/cpp-producer-consumer-bounded-buffer-backpressure/wordcloud_hu_5af444c86ef999cd.webp)
![[Concurrency Patterns] 05. 읽기 최적화와 지연 초기화](/post/multithreading-patterns/cpp-read-write-lock-dclp-call-once-lazy-init/wordcloud_hu_58028050dfa28943.webp)
![[Concurrency Patterns] 00. 멀티스레딩 디자인 패턴 시리즈 소개와 커리큘럼](/post/multithreading-patterns/getting-started-multithreading-design-patterns/wordcloud_hu_2452ba41c5c18ed2.webp)