25. 작업 큐 워커의 조건 대기 술어 재확인 부재 (spurious/다중 소비 wakeup)
난이도 중내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 작업 큐 워커의 조건 대기 술어 재확인 부재 (spurious/다중 소비 wakeup)
난이도: 중
요약
워커가 큐가 비었는지를 if로 한 번만 확인하고 Monitor.Wait(C) 한 뒤, 깨어나면 무조건 _q.Dequeue()(D) 한다. 하지만 "깨어남 == 처리할 작업이 있음"이 아니다. 가짜 깨어남(spurious wakeup) 이나, 여러 워커가 같은 Pulse에 경쟁해 한쪽이 이미 큐를 비운 경우, 깨어난 워커가 빈 큐에서 Dequeue 해 InvalidOperationException을 던진다. 조건 대기는 반드시 술어를 while로 재확인해야 한다.
문제점
1. (조건 대기) 술어를 while가 아니라 if로 확인 — (B)(C)(D)
- 증상: 워커가
_q.Dequeue()(D)에서InvalidOperationException("Queue empty")로 죽거나, 작업을 처리하지 못한다. - 재현조건:
- 다중 워커: 워커 둘이
Wait중인데Enqueue가 작업 1개 +Pulse(하나만 깨움) → 깨어난 워커가 처리. 그런데 또 다른 경로로 둘 다 깨어나거나,PulseAll류로 둘 다 깨면 한 워커가 먼저Dequeue해 비우고, 다음 워커가 빈 큐에서Dequeue. - 가짜 깨어남:
Monitor.Wait는Pulse없이도 깨어날 수 있다(스펙상 허용).if라 술어를 다시 안 보고 바로Dequeue.
- 다중 워커: 워커 둘이
- 근본원인: "깨어남"은 "조건 충족"의 보장이 아니라 "조건을 다시 확인하라"는 신호일 뿐이다.
if는 깨어난 직후 한 번도 재확인하지 않는다.
2. (확장성) 다중 소비자에서 깨어남-경쟁
- 워커가 여러 개면 Pulse 하나에 여러 워커가 순차로 락을 잡고 깨어나며, 작업 수보다 깨어난 워커가 많으면 잉여 워커는 빈 큐를 본다.
if는 이 경우를 처리 못 한다.
3. (종료 불가) 깔끔한 종료 경로 없음
- 무한
while(true)에 종료 신호가 없어 셧다운 시 워커를 멈출 방법이 없다(드레이닝/조인 불가).
수정안
핵심은 술어를 while로 재확인하는 것이다(+ 종료 신호).
private bool _closing = false;
public void WorkerLoop()
{
while (true)
{
Job job;
lock (_lock)
{
while (_q.Count == 0 && !_closing) // 깨어날 때마다 술어 재확인
Monitor.Wait(_lock);
if (_closing && _q.Count == 0) return; // 종료 경로
job = _q.Dequeue();
}
job.Run(); // 처리는 락 밖에서
}
}
public void Close()
{
lock (_lock) { _closing = true; Monitor.PulseAll(_lock); }
}
while이라 가짜 깨어남·다중 소비 경쟁에도 빈 큐를 다시 보고 안전하게 재대기한다.- 종료 시
PulseAll로 모든 워커를 깨워 드레이닝 후 종료. job.Run()은 락 밖에서 — 처리 동안 큐를 막지 않는다(처리량).
더 나은 설계
BlockingCollection<T>/Channel<T>: 표준 동시성 큐가 조건 대기·종료(complete)·다중 소비자를 안전하게 캡슐화한다. 직접Monitor구현보다 버그가 적다.- 종료 토큰:
CancellationToken으로 드레이닝·취소를 일급으로. - 트레이드오프: 직접
Monitor는 의존성이 없지만 술어 재확인·종료·예외 처리를 모두 손수 맞춰야 한다. 라이브러리 큐는 안전하지만 동작/할당 특성을 이해하고 써야 한다.
면접 포인트
- 조건 대기는 항상
while(술어 재확인) —Monitor.Wait/cv.wait는 가짜 깨어남이 허용되고, 다중 소비자에선 깨어남이 곧 조건 충족이 아니다. - "깨어남 = 조건을 다시 검사하라는 신호"라는 멘탈 모델.
if는 거의 항상 버그. - 실무에선
BlockingCollection/Channel로 바퀴 재발명을 피하고, 종료/드레이닝 경로를 처음부터 설계.
해설 · C++
해설 — 작업 큐 워커의 condition_variable 술어 없는 wait (spurious/lost wakeup) · C++
난이도: 중
요약
워커가 if (_q.empty()) _cv.wait(lk)(B)(C)로 술어 없이 한 번만 대기하고, 깨어나면 무조건 _q.front()/pop()(D)한다. C++ condition_variable은 가짜 깨어남(spurious wakeup) 이 표준적으로 허용되고, 알림에 메모리가 없어 notify_one이 대기 이전에 오면 유실(lost wakeup) 된다. 그래서 빈 큐에서 front() 호출(미정의 동작)이나, 알림 유실로 작업이 영영 처리되지 않는 일이 생긴다. 조건 대기는 술어 버전 wait(또는 while 재확인) 를 써야 한다.
문제점
1. (조건 대기) 술어 없는 wait → 빈 큐 접근 — (B)(C)(D)
- 증상:
_q.front()(D)가 빈 큐에 호출되어 미정의 동작(UB) — 크래시/쓰레기 처리. - 재현조건:
- 가짜 깨어남:
_cv.wait(lk)는notify없이도 반환할 수 있다(표준 허용).if라 술어를 재확인하지 않고 바로front. - 다중 워커: 워커 둘이 깨어나면 한쪽이 먼저
pop해 비우고, 다른 쪽이 빈 큐에서front.
- 가짜 깨어남:
- 근본원인: "깨어남"은 "조건 충족"의 보장이 아니다. 술어(
!_q.empty())를 깨어날 때마다 다시 확인해야 한다.
2. (C++/cv 특성) 알림은 메모리가 없다 → lost wakeup — (A)
- 증상: enqueue가 한 작업을 넣고
notify_one(A) 했는데 그 시점에 대기 중인 워커가 없으면(아직 wait 진입 전) 알림이 사라진다. 이후 워커가wait에 들어가면 새 알림이 올 때까지 깨지 못해 그 작업이 지연/유실된다. - 근본원인: condition_variable은 신호를 저장하지 않는다. 상태(큐 비었는지)가 진실원천이고 cv는 그 상태 변화를 알리는 보조일 뿐 — 그래서 술어 기반 대기가 필수다(술어가 큐에 든 작업을 보고 아예 대기하지 않게 된다).
3. (종료 불가)
while(true)에 종료 신호가 없어 셧다운 시 워커 조인/드레이닝이 불가.
수정안
핵심은 술어 버전 wait (내부적으로 while로 재확인)을 쓰는 것이다(+ 종료 플래그).
bool _closing = false;
void workerLoop() {
while (true) {
Job job;
{
std::unique_lock<std::mutex> lk(_m);
_cv.wait(lk, [&]{ return !_q.empty() || _closing; }); // 술어 재확인
if (_closing && _q.empty()) return; // 종료 경로
job = std::move(_q.front());
_q.pop();
}
job.run(); // 처리는 락 밖에서
}
}
void close() {
{ std::lock_guard<std::mutex> lk(_m); _closing = true; }
_cv.notify_all();
}
- 술어 버전
wait는while(!pred()) cv.wait(lk);와 동치 → 가짜 깨어남과 lost wakeup(이미 큐에 작업이 있으면 술어가 참이라 아예 대기 안 함) 둘 다 안전. - 종료 시
notify_all+ 플래그로 드레이닝 후 조인. job.run()은 락 밖에서 실행해 큐를 막지 않는다.
더 나은 설계
- 검증된 동시성 큐:
moodycamel::ConcurrentQueue나 표준 기반 블로킹 큐 래퍼로 술어/종료/다중 소비자를 캡슐화. 직접 cv 다루기보다 안전. - 상태가 진실, cv는 보조: 항상 "공유 술어 + 그것을 보호하는 mutex + 술어 기반 wait" 삼종 세트로 사고.
- 트레이드오프: 직접 구현은 의존성이 없으나 술어/종료/예외를 손수 맞춰야 하고, 잘못 쓰면 UB/데드락. 라이브러리는 안전하지만 특성 파악 필요.
면접 포인트
condition_variable은 가짜 깨어남 허용 + 알림에 메모리 없음 → 반드시 술어 버전wait(또는while재확인).if+ 술어 없는wait는 UB/유실의 전형.- cv는 보조 신호일 뿐, 공유 상태(술어)가 진실원천. 술어가 있으면 lost wakeup도 자연히 막힌다.
- 다중 소비자·종료(드레이닝)를 처음부터 설계 —
notify_all+종료 플래그, 또는 검증된 동시성 큐.