여러 스레드가 작업 큐를 공유합니다. 빈 큐에서 조건 변수로 기다리던 스레드가 알림을 받고 깨어났는데, 왜 큐가 다시 비어 있을 수 있고 어떻게 처리해야 하나요?
잠깐, 답변을 보기 전에
먼저 내 말로 설명해 볼까요?
답변과 점검 포인트 펼치기답변 접기
구두 답변
정의를 외우기보다, 원리와 조건을 연결해 설명해 보세요.
알림을 받은 스레드가 큐를 다시 확인하기 전에, 다른 스레드가 먼저 작업을 가져갈 수 있기 때문입니다. 따라서 깨어났다는 사실만 믿고 작업을 꺼내지 말고, 큐가 비어 있는지 다시 검사해야 합니다.
여기서 생산자는 큐에 작업을 넣는 스레드이고, 소비자는 작업을 꺼내 처리하는 스레드입니다. 소비자는 할 일이 없을 때 큐를 계속 확인하며 CPU를 쓰는 대신 조건 변수(condition variable)를 이용해 잠들어 기다릴 수 있습니다. 생산자는 작업을 넣은 뒤 알림을 보내 소비자를 깨웁니다. 다만 이 알림이 특정 작업을 그 소비자에게 예약해 주는 것은 아닙니다.
C++에서 소비자가 아래처럼 작성됐다고 하겠습니다. queue는 여러 소비자가 공유하는 정수 작업 큐, mutex는 그 큐의 접근을 보호하는 뮤텍스, cv는 대기와 알림에 쓰는 조건 변수입니다. 모든 스레드가 큐를 같은 뮤텍스로 보호하더라도 빈 큐를 if로 한 번만 확인하는 아래 코드는 안전하지 않습니다.
std::unique_lock<std::mutex> lock(mutex);
if (queue.empty()) {
cv.wait(lock);
}
int job = queue.front(); // 깨어났어도 비어 있을 수 있습니다.
queue.pop();
wait는 뮤텍스를 놓고 대기에 들어가며, 반환하기 전에는 뮤텍스를 다시 획득합니다. 하지만 깨어난 스레드가 그 뮤텍스를 바로 얻는다는 보장은 없습니다. 다음 실행 순서가 가능합니다.
| 순서 | 실행 흐름 | 큐 상태 |
|---|---|---|
| 1 | 소비자 A가 빈 큐를 보고 wait |
비어 있음 |
| 2 | 생산자가 작업 하나를 넣고 A에게 알림 | 작업 1개 |
| 3 | A가 락을 다시 얻기 전에 소비자 B가 들어와 작업을 꺼냄 | 비어 있음 |
| 4 | A가 락을 얻고 wait에서 반환 |
여전히 비어 있음 |
A는 알림을 받았지만 작업을 소유한 것은 아닙니다. if는 이미 검사한 조건을 다시 보지 않으므로 빈 큐에 front()를 호출하게 됩니다. 또 조건 변수는 알림 없이 깨어나는 허위 깨움(spurious wakeup)도 허용합니다. 둘 다 처리하려면 깨어날 때마다 조건을 다시 확인해야 합니다.
std::unique_lock<std::mutex> lock(mutex);
while (queue.empty()) {
cv.wait(lock);
}
int job = queue.front();
queue.pop();
이제 A가 락을 얻었는데 작업이 없다면 다시 기다립니다. 조건 검사부터 작업을 꺼내는 부분까지 락을 유지하므로, 검사 직후 다른 소비자가 작업을 가져가는 것도 막습니다.
C++에서는 조건을 받는 wait를 사용하면 반복 검사를 더 간결하게 표현할 수 있습니다. 아래는 종료가 시작되면 남은 작업을 처리하고, 큐까지 비었을 때 소비자 함수를 끝내는 정책의 예입니다.
std::unique_lock<std::mutex> lock(mutex);
cv.wait(lock, [&] { return stopping || !queue.empty(); });
if (queue.empty()) {
return; // 여기서는 stopping이 true인 상태입니다.
}
int job = queue.front();
queue.pop();
lock.unlock();
process(job); // 긴 처리는 큐의 락 밖에서 실행합니다.
생산자는 같은 뮤텍스로 큐에 작업을 넣은 뒤 알리고, 종료를 담당하는 쪽은 같은 뮤텍스로 stopping을 바꾼 뒤 기다리는 소비자들을 깨워야 합니다. 알림을 기억하는 것이 아니라, 락으로 보호한 상태를 다시 검사하는 구조라고 이해하면 됩니다.
조건 변수는 신호를 저장하는 큐가 아닙니다
기다려도 되는지를 결정하는 queue.empty()나 stopping 같은 검사를 조건식(predicate)이라고 합니다. 올바른 기준은 알림을 몇 번 받았는지가 아니라 지금 조건식이 참인지입니다. 조건 변수는 과거 알림을 토큰처럼 쌓아 두는 세마포어와 다릅니다. 생산자가 소비자보다 먼저 작업을 넣고 알림을 보내도, 소비자가 같은 락 아래에서 큐가 비지 않았음을 보면 기다리지 않고 진행할 수 있습니다. 공유 상태가 사실을 기억하고 알림은 재검사할 계기를 준다는 점이 중요합니다.
조건 검사 뒤 락을 직접 풀고 나중에 대기를 등록하면, 그 사이 생산자가 값을 바꾸고 알림까지 끝낼 수 있습니다. 이후 소비자는 더 올 알림이 없는데 잠듭니다. 이런 문제를 알림 유실(lost wakeup)이라고 부릅니다. 조건 변수의 wait가 잠금 해제와 대기 등록을 연결하는 계약을 제공하므로 이를 사용해야 합니다. 단순히 while로 바꾸는 것만으로 조건 검사와 대기 등록 사이의 잘못된 사용까지 해결되는 것은 아닙니다. 생산자와 소비자 모두 조건에 관련된 상태를 같은 동기화 규칙으로 다뤄야 합니다.
깨어난 뒤에는 경쟁이 다시 시작됩니다
알림을 보낸 스레드가 계속 실행하고, 깨어난 스레드가 나중에 락을 다시 얻는 의미를 Mesa식 모니터 의미라고 설명합니다. 깨어날 때 조건이 여전히 참이라는 보장이 없으므로 다시 검사합니다. 이와 별개로 실제 알림 없이 깨어나는 허위 깨움도 허용될 수 있습니다. 두 이유를 구분하면 ‘내 환경에서는 허위 깨움이 안 보이니 if가 안전하다’는 잘못된 결론을 피할 수 있습니다.
notify_one은 보통 하나의 대기자를 깨우고 notify_all은 여러 대기자에게 재검사 기회를 줍니다. 모두 깨웠는데 대부분 다시 기다리면 락 경쟁과 스케줄링 비용이 커지며, 이런 과도한 동시 깨움을 썬더링 허드(thundering herd)라고 부릅니다. 하지만 서로 다른 조건을 같은 조건 변수로 기다리는 구조에서 잘못된 대기자 하나만 깨우면 진행 가능한 대기자가 계속 잠들 수도 있습니다. 조건 변수를 나누거나 필요한 상황에 전체 알림을 택하는 등, 알림 비용만이 아니라 진행성을 함께 보겠습니다.
종료 때는 새 생산을 먼저 차단하고 stopping을 같은 락으로 바꾼 뒤 필요한 대기자를 모두 깨워야 합니다. 남은 작업을 소진하는 정책이라면 소비 루프는 큐가 비고 stopping일 때 끝납니다. 즉시 중단 정책이라면 남은 작업을 반환·취소하거나 다른 저장소로 넘기는 책임이 필요합니다. 위의 한 작업 처리 조각은 반복 소비 루프 안에서 사용한다는 전제이며, 실제 종료는 소비자들이 빠져나왔음을 join 등으로 확인한 뒤 조건 변수와 큐를 파괴해야 합니다.
시간 제한 대기에서는 허위 깨움마다 원래 timeout을 다시 주지 않고 동일한 최종 데드라인을 기준으로 남은 시간을 계산하겠습니다. 깨어난 직후 종료와 작업 추가가 겹치는 경우, 작업이 하나인데 소비자가 여러 개인 경우, 생산자가 먼저 알린 경우를 각각 재현하겠습니다. 상태 검사·원자적인 대기 진입·재검사·종료 수명이 하나의 계약으로 이어져야 안전합니다.
득점 포인트
이해가 드러나는 지점을 확인해 보세요.
- 알림과 자원 소유를 구분한다. 알림을 받은 A보다 B가 먼저 락을 얻어 작업을 소비할 수 있다는 순서를 설명한다.
- wait의 잠금 계약을 설명한다. 대기하면서 락을 놓고, 반환 전에 다시 획득하며, 조건 확인과 큐 접근은 같은 락 아래에서 수행한다.
- 종료 정책까지 구체화한다. 작업 존재와 종료 상태를 대기 조건에 넣고, 종료 시 남은 작업을 처리할지 버릴지 명시한다.
감점 포인트
놓치기 쉬운 조건과 오해를 짚어 보세요.
- “허위 깨움만 없으면 if도 안전하다”고 말한다. 다른 소비자가 먼저 상태를 바꾸는 경쟁만으로도 문제가 생긴다.
- “notify_one이 큐의 작업 하나를 그 스레드에 넘겨준다”고 말한다. 알림은 락 획득 순서나 작업 소유권을 보장하지 않는다.
- wait 전에 락을 직접 풀고 나중에 대기한다. 조건 확인과 대기 등록 사이에 알림이 지나가는 틈을 만들 수 있다. 잠금 해제와 대기를 연결하는 조건 변수의 API를 사용해야 한다.
더 파고들 거리
하나 더 질문하며 이해의 범위를 넓혀 보세요.
- 조건 변수의 notify를 락 밖으로 옮깁니다. 깨움 경합과 조건 변수 객체 수명은 어떻게 보호하나요?
- 작업 큐 종료에서 전부 처리하는 정책과 남은 작업을 버리는 정책은 대기 조건과 결과 처리가 어떻게 다른가요?
- 시간 제한 대기에서 반복해서 깨어날 때마다 제한 시간을 다시 시작하지 않으려면 어떤 데드라인을 사용해야 할까요?
득점·감점 포인트는 학습용 자기 점검 기준이며, 특정 회사의 실제 채점표가 아닙니다.
원리부터 공부하고 싶다면
답변을 보충하는 개념·예제·슈도코드를 읽어 보세요.