역할과 목표
| 역할 | 담당 |
|---|
| 프로듀서(Producer) | 일감 생성 후 큐에 넣음 |
| 컨슈머(Consumer) | 큐에서 일감을 꺼내 처리 |
왜 이 패턴을 쓰는가
- 생성 속도와 처리 속도를 분리해 시스템 안정성을 높입니다.
- 네트워크 수신 스레드와 게임 로직/DB 처리 스레드를 느슨하게 연결할 수 있습니다.
- 큐가 "완충 장치(buffer)" 역할을 해 순간 트래픽 스파이크를 흡수합니다.
바쁜 대기(Busy Polling)의 한계
나쁜 예시
while (running) {
std::lock_guard<std::mutex> lock(m);
if (!q.empty()) {
}
}
문제점
- 큐가 비어 있어도 계속 루프를 돌며 CPU를 소모합니다.
- 경합이 늘고 전력 소모가 커지며, 실제 작업 스레드의 실행 기회도 줄어듭니다.
- 따라서 "조건이 만족될 때 잠들었다가 깨우는" 메커니즘이 필요합니다.
대기/알림 도구 선택
| 도구 | 특징 | 사용 맥락 |
|---|
| Windows Event | 커널 오브젝트, 프로세스 간 동기화 가능 | Win32 기반 시스템 수준 동기화 |
std::condition_variable | C++ 표준, mutex와 조합, 경량 | 일반적인 C++ 서버/엔진 코드 |
실무 권장
- C++ 코드베이스에서는 기본적으로
std::condition_variable을 우선 사용합니다.
- OS 경계를 넘는 동기화가 필요할 때만 이벤트 같은 커널 객체를 고려합니다.
std::condition_variable 핵심 규칙
기본 패턴
std::mutex m;
std::condition_variable cv;
std::queue<int> q;
{
std::lock_guard<std::mutex> lock(m);
q.push(data);
}
cv.notify_one();
{
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return !q.empty(); });
int v = q.front();
q.pop();
}
왜 predicate가 필수인가
- Spurious wakeup(가짜 깨어남)이 발생할 수 있습니다.
- notify가 와도 다른 스레드가 먼저 아이템을 가져갈 수 있습니다.
- 그래서
wait(lock, predicate) 또는 while (!condition) wait(lock) 형태를 사용해야 안전합니다.
wait 내부 동작
- 락을 잡고 조건 검사
- 조건 불만족이면 락을 풀고 대기
- 깨어난 뒤 다시 락 획득
- 조건 재검사 후 진행
종료(Shutdown) 가능한 안전 큐
운영에서 꼭 필요한 이유
- 서버 종료 시 컨슈머 스레드가
wait에서 영원히 잠들면 정상 종료가 불가능합니다.
- 따라서 큐 비어 있음 + 종료 플래그를 함께 조건으로 다뤄야 합니다.
예시 코드
class WorkQueue {
public:
void Push(int v) {
{
std::lock_guard<std::mutex> lock(m_);
q_.push(v);
}
cv_.notify_one();
}
bool Pop(int& out) {
std::unique_lock<std::mutex> lock(m_);
cv_.wait(lock, [&] { return stop_ || !q_.empty(); });
if (stop_ && q_.empty()) return false;
out = q_.front();
q_.pop();
return true;
}
void Stop() {
{
std::lock_guard<std::mutex> lock(m_);
stop_ = true;
}
cv_.notify_all();
}
private:
std::mutex m_;
std::condition_variable cv_;
std::queue<int> q_;
bool stop_ = false;
};
배울 포인트
- 상태(
q_, stop_)는 같은 mutex로 보호해야 합니다.
Stop()에서 notify_all()을 쓰지 않으면 일부 스레드가 깨어나지 못할 수 있습니다.
notify_one vs notify_all
| 방식 | 장점 | 주의점 |
|---|
notify_one() | 불필요한 깨움이 적어 효율적 | 다수 대기자 처리량이 낮을 수 있음 |
notify_all() | 모든 대기자를 깨워 빠른 반응 | thundering herd(한꺼번에 깨어 경합) 가능 |
실무 기준
- 일반적인 큐 push 시에는
notify_one()이 기본입니다.
- 종료 신호, 전역 상태 변경처럼 모두가 조건을 재확인해야 할 때
notify_all()을 사용합니다.
Bounded Queue와 Backpressure
왜 필요할까
- 무한 큐는 급격한 트래픽에서 메모리를 폭증시킬 수 있습니다.
- 상한(capacity)을 두고, 가득 차면 프로듀서를 잠시 기다리게 해야 합니다.
핵심 아이디어
not_empty와 not_full 두 조건 변수를 사용
- 컨슈머가 pop하면
not_full을 notify
- 프로듀서가 push하면
not_empty를 notify
서버 관점
- backpressure는 장애 전파를 줄이는 핵심 전략입니다.
- 큐 길이, 대기 시간, drop 수를 반드시 지표로 수집해야 합니다.
강의 시 유의사항
강조 포인트
- Producer-Consumer의 본질은 "락"이 아니라 "조건 기반 대기/알림"입니다.
- predicate 없는
wait는 실무 버그의 주요 원인입니다.
- 종료 플래그 없는 큐는 운영 종료 시 반드시 문제를 일으킵니다.
자주 하는 오해
| 오해 | 바로잡기 |
|---|
wait()는 notify 오면 바로 안전하게 진행된다 | 깨어난 뒤 조건 재검사가 필수 |
notify_one()이 항상 정답이다 | 종료/브로드캐스트 상황은 notify_all() 필요 |
| 큐는 크게만 잡으면 안전하다 | 메모리 폭증 방지를 위해 bounded/backpressure 필요 |
체크 질문 (스스로 답해보기)
- 왜
cv.wait(lock, predicate) 형태를 습관화해야 하는가?
- 종료 시
notify_all()이 필요한 구체적 상황은?
- unbounded queue가 라이브 서버에서 어떤 장애 패턴을 만들 수 있는가?