32. 단일 조건변수 + Pulse(notify_one) 의 깨어남 유실(생산자/소비자 혼재) (C#)
난이도 상내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 단일 조건변수 + Pulse(notify_one) 의 깨어남 유실(생산자/소비자 혼재) (C#)
난이도: 상
요약
생산자("가득 참" 대기)와 소비자("비어 있음" 대기)가 하나의 모니터(조건) 에서 대기하는데, 깨울 때 Monitor.Pulse(단 하나만 깨움)를 쓴다. 술어를 while 로 재검사(가짜 깨어남 대비)해도, 깨울 대상 종류를 구분하지 못해 소비자를 깨워야 할 신호가 다른 소비자(또는 생산자)를 깨우고 그가 술어 불만족으로 도로 자면 신호가 유실된다 → 큐에 자리/항목이 있는데도 교착.
문제점
- [대상 구분 없는 단일 신호] (B)
Monitor.Pulse(_gate);- 증상:
Pulse는 대기 큐에서 임의의 한 스레드를 깨운다. 생산자가 항목을 넣고 "소비자를 깨우려" Pulse 해도, 대기 중인 다른 생산자나 엉뚱한 소비자가 깨어나 술어(여전히 불만족)를 보고 다시 잔다. 그 Pulse 는 소비해 버렸으므로 정작 깨어나야 할 스레드에 전달되지 않는다(lost wakeup). - 근본원인: 서로 다른 두 조건("not full", "not empty")을 한 조건변수로 통합하고
Pulse로 하나만 깨움. 신호가 "종류"를 담지 못한다.
- 증상:
- [while 재검사만으론 부족] (A)
while (...) Monitor.Wait(_gate);while술어 재검사는 가짜 깨어남(spurious wakeup) 은 막지만(→ 이건 올바름), 깨어남이 아예 유실되는 이 문제는 못 막는다.if를while로 바꾸는 것(concurrency_memory/problem25)과는 다른 결함임에 주의.
재현(교착) — 용량 1, 생산자 P1·P2, 소비자 C1·C2
- 큐 빔(count=0). C1·C2 가
Dequeue에서 대기(not empty). - P1
Enqueue: count 0→1,Pulse→ C1 을 깨움(아직 실행 전). - 그 사이 P2
Enqueue: count==1(가득) → 대기(not full). - C1 실행: 술어 재검사 count==0? 아님(1). 꺼냄 count 1→0,
Pulse(생산자 P2 를 깨우려는 신호) → 그런데 C2 를 깨움. - C2 실행: 술어 재검사 count==0? 예 → 다시 대기. P2 를 위한 신호가 C2 에게 소비되어 유실.
- 최종: count=0, P2 는 not-full 을 기다리며 잠(그러나 큐에 자리 있음), C2 는 not-empty 를 기다리며 잠(그러나 채워 줄 P2 가 잠듦). 아무도 못 깨어남 = 교착.
수정안
최소 수정: PulseAll
public void Enqueue(T item)
{
lock (_gate)
{
while (_q.Count == _cap) Monitor.Wait(_gate);
_q.Enqueue(item);
Monitor.PulseAll(_gate); // 모든 대기자를 깨워 올바른 종류가 반드시 술어를 재평가
}
}
// Dequeue 도 동일하게 PulseAll
- 정확하지만 대기자가 많으면 천둥 무리(thundering herd) — 다 깨어나 대부분 도로 잠(경합·컨텍스트 스위치 낭비).
더 나은 수정: 조건을 종류별로 분리 (SemaphoreSlim 카운팅)
public sealed class BoundedQueue<T>
{
private readonly object _gate = new();
private readonly Queue<T> _q = new();
private readonly SemaphoreSlim _empty; // 남은 빈 자리 수
private readonly SemaphoreSlim _full; // 채워진 항목 수
public BoundedQueue(int cap) { _empty = new(cap, cap); _full = new(0, cap); }
public void Enqueue(T item)
{
_empty.Wait(); // 빈 자리 1 확보(없으면 생산자만 대기)
lock (_gate) { _q.Enqueue(item); }
_full.Release(); // 채워진 항목 1 → 소비자만 깨움
}
public T Dequeue()
{
_full.Wait(); // 항목 1 확보(없으면 소비자만 대기)
T item; lock (_gate) { item = _q.Dequeue(); }
_empty.Release(); // 빈 자리 1 → 생산자만 깨움
return item;
}
}
- 두 세마포어가 "not full"/"not empty" 를 분리해, 깨우는 대상이 항상 올바른 종류. 유실·천둥 무리 없음.
더 나은 설계
- 두 개의 조건변수 규칙: 서로 다른 술어에는 서로 다른 조건변수를 두고, 그 술어를 만족시킨 쪽이 해당 조건만 신호한다("not full 을 만든 소비자는 notFull 만 신호"). C# 모니터는 객체당 조건 1개라, 종류별 분리엔 세마포어/
Channel<T>/두 모니터 계층이 적합. System.Threading.Channels: .NET 표준의 유계 채널이 이 문제(백프레셔·정확한 깨움)를 이미 올바르게 구현 — 실무에선 직접 구현보다 이걸 쓴다.- 원칙: 한 조건변수에 이질적 대기자가 섞이면 반드시
PulseAll(정확성) 또는 조건 분리.Pulse는 "모든 대기자가 같은 술어" 일 때만 안전.
면접 포인트
- 가짜 깨어남(while 재검사로 해결)과 깨어남 유실(신호 대상 오류로 발생) 의 차이 — 둘은 다른 버그.
- 단일 조건 + notify_one 이 이질적 대기자에서 왜 교착하는지(위 6단계 트레이스).
PulseAll의 정확성 vs 천둥 무리, 그리고 조건 분리(세마포어/두 CV)가 왜 근본 해법인지.
해설 · C++
해설 — 단일 조건변수 + notify_one 의 깨어남 유실(생산자/소비자 혼재) (C++)
난이도: 상
요약
생산자("가득 참" 대기)와 소비자("비어 있음" 대기)가 하나의 std::condition_variable 에서 대기하는데 notify_one 으로 깨운다. predicate 형 wait(술어 재검사)로 가짜 깨어남은 막지만, notify_one 이 깨울 종류를 구분하지 못해 소비자를 깨워야 할 신호가 다른 소비자를 깨우고 그가 술어 불만족으로 도로 자면 신호가 유실된다 → 큐에 자리/항목이 있어도 교착.
문제점
- [대상 구분 없는 단일 신호] (B)
cv_.notify_one();- 증상:
notify_one은 대기 스레드 중 하나(임의) 만 깨운다. push 가 항목을 넣고 소비자를 깨우려 해도, 대기 중인 다른 생산자나 엉뚱한 소비자가 깨어나 술어 불만족으로 다시 잔다. 그 알림은 소비되어 정작 필요한 스레드에 전달되지 않는다(lost wakeup). - 근본원인: 이질적 두 조건("!full", "!empty")을 한 CV 로 통합하고
notify_one. 알림이 "종류"를 담지 못함.
- 증상:
- [predicate wait 만으론 부족] (A)
cv_.wait(lk, pred)- predicate 형
wait는 내부적으로while(!pred) cv.wait(lk);라 가짜 깨어남/유실 재검사엔 옳지만, 알림 자체가 엉뚱한 대상에 소비되어 사라지는 이 문제는 못 막는다. (if→while문제인concurrency_memory/problem25와는 다른 결함.)
- predicate 형
재현(교착) — 용량 1, 생산자 P1·P2, 소비자 C1·C2
- 큐 빔(size=0). C1·C2 가
pop에서 대기(!empty). - P1
push: size 0→1,notify_one→ C1 깨움(실행 전). - 그 사이 P2
push: size==1(가득) → 대기(!full). - C1 실행: 술어 재검사 empty? 아님. 꺼냄 size 1→0,
notify_one(P2 를 깨우려는 신호) → C2 깨움. - C2 실행: 술어 재검사 empty? 예 → 다시 대기. P2 를 위한 알림이 C2 에 소비되어 유실.
- 최종: size=0, P2 는 !full 을 기다리며 잠(자리 있음), C2 는 !empty 를 기다리며 잠(채울 P2 가 잠듦). 교착.
수정안
최소 수정: notify_all
void push(T item) {
std::unique_lock<std::mutex> lk(m_);
cv_.wait(lk, [this]{ return q_.size() < cap_; });
q_.push(std::move(item));
cv_.notify_all(); // 전원 깨워 올바른 종류가 반드시 술어 재평가
}
// pop 도 notify_all
- 정확하지만 대기자가 많으면 천둥 무리 — 대부분 깨어나 도로 잠(경합·스위치 낭비).
더 나은 수정: 조건변수를 종류별로 분리
template <class T>
class BoundedQueue {
public:
explicit BoundedQueue(size_t cap) : cap_(cap) {}
void push(T item) {
std::unique_lock<std::mutex> lk(m_);
notFull_.wait(lk, [this]{ return q_.size() < cap_; }); // 생산자만 대기
q_.push(std::move(item));
notEmpty_.notify_one(); // 소비자만 깨움 (올바른 종류)
}
T pop() {
std::unique_lock<std::mutex> lk(m_);
notEmpty_.wait(lk, [this]{ return !q_.empty(); }); // 소비자만 대기
T item = std::move(q_.front()); q_.pop();
notFull_.notify_one(); // 생산자만 깨움
return item;
}
private:
size_t cap_; std::queue<T> q_; std::mutex m_;
std::condition_variable notFull_, notEmpty_;
};
- 두 CV 가 "not full"/"not empty" 를 분리해 항상 올바른 종류를 깨움 →
notify_one으로도 유실·천둥 무리 없음(효율·정확 동시 달성).
더 나은 설계
- 원칙: 한 조건변수에 이질적 대기자가 섞이면
notify_one은 위험 → 조건변수 분리(정석) 또는notify_all(정확하지만 비효율). - 락 잡은 채 notify 여부: 표준상 락 안/밖 모두 정당하나, 대기자가 즉시 락 경합에 빠지는 것을 줄이려면 상태 변경 후 락을 풀고 notify 하기도 한다(구현 트레이드오프).
- 성숙한 대안: 프로덕션에선 검증된 유계 큐(
moodycamel::BlockingConcurrentQueue, TBBconcurrent_bounded_queue등)나 세마포어 쌍(counting_semaphore, C++20)으로 구현. 직접 CV 구현은 이 함정을 반복하기 쉽다.
면접 포인트
- 가짜 깨어남(predicate wait 로 해결)과 깨어남 유실(신호 대상 오류) 의 차이 — 다른 버그.
- 단일 CV + notify_one 이 이질적 대기자에서 교착하는 메커니즘(위 6단계).
notify_all(정확·비효율) vs 조건변수 분리(정확·효율)의 트레이드오프.