← 문제로

32. 단일 조건변수 + Pulse(notify_one) 의 깨어남 유실(생산자/소비자 혼재) (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) 은 막지만(→ 이건 올바름), 깨어남이 아예 유실되는 이 문제는 못 막는다. ifwhile 로 바꾸는 것(concurrency_memory/problem25)과는 다른 결함임에 주의.

재현(교착) — 용량 1, 생산자 P1·P2, 소비자 C1·C2

  1. 큐 빔(count=0). C1·C2 가 Dequeue 에서 대기(not empty).
  2. P1 Enqueue: count 0→1, Pulse → C1 을 깨움(아직 실행 전).
  3. 그 사이 P2 Enqueue: count==1(가득) → 대기(not full).
  4. C1 실행: 술어 재검사 count==0? 아님(1). 꺼냄 count 1→0, Pulse(생산자 P2 를 깨우려는 신호) → 그런데 C2 를 깨움.
  5. C2 실행: 술어 재검사 count==0? 예 → 다시 대기. P2 를 위한 신호가 C2 에게 소비되어 유실.
  6. 최종: 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)가 왜 근본 해법인지.