프로듀서-컨슈머 패턴

Jaemyeong Lee·2024년 12월 27일

게임 서버1

목록 보기
117/220

역할과 목표

역할담당
프로듀서(Producer)일감 생성 후 큐에 넣음
컨슈머(Consumer)큐에서 일감을 꺼내 처리

왜 이 패턴을 쓰는가

  • 생성 속도와 처리 속도를 분리해 시스템 안정성을 높입니다.
  • 네트워크 수신 스레드와 게임 로직/DB 처리 스레드를 느슨하게 연결할 수 있습니다.
  • 큐가 "완충 장치(buffer)" 역할을 해 순간 트래픽 스파이크를 흡수합니다.

바쁜 대기(Busy Polling)의 한계

나쁜 예시

while (running) {
    std::lock_guard<std::mutex> lock(m);
    if (!q.empty()) {
        // pop + process
    }
}

문제점

  • 큐가 비어 있어도 계속 루프를 돌며 CPU를 소모합니다.
  • 경합이 늘고 전력 소모가 커지며, 실제 작업 스레드의 실행 기회도 줄어듭니다.
  • 따라서 "조건이 만족될 때 잠들었다가 깨우는" 메커니즘이 필요합니다.

대기/알림 도구 선택

도구특징사용 맥락
Windows Event커널 오브젝트, 프로세스 간 동기화 가능Win32 기반 시스템 수준 동기화
std::condition_variableC++ 표준, mutex와 조합, 경량일반적인 C++ 서버/엔진 코드

실무 권장

  • C++ 코드베이스에서는 기본적으로 std::condition_variable을 우선 사용합니다.
  • OS 경계를 넘는 동기화가 필요할 때만 이벤트 같은 커널 객체를 고려합니다.

std::condition_variable 핵심 규칙

기본 패턴

std::mutex m;
std::condition_variable cv;
std::queue<int> q;

// producer
{
    std::lock_guard<std::mutex> lock(m);
    q.push(data);
}
cv.notify_one();

// consumer
{
    std::unique_lock<std::mutex> lock(m);
    cv.wait(lock, [] { return !q.empty(); });  // predicate 필수
    int v = q.front();
    q.pop();
}

왜 predicate가 필수인가

  • Spurious wakeup(가짜 깨어남)이 발생할 수 있습니다.
  • notify가 와도 다른 스레드가 먼저 아이템을 가져갈 수 있습니다.
  • 그래서 wait(lock, predicate) 또는 while (!condition) wait(lock) 형태를 사용해야 안전합니다.

wait 내부 동작

  1. 락을 잡고 조건 검사
  2. 조건 불만족이면 락을 풀고 대기
  3. 깨어난 뒤 다시 락 획득
  4. 조건 재검사 후 진행

종료(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_emptynot_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가 라이브 서버에서 어떤 장애 패턴을 만들 수 있는가?

profile
李家네_공부방

0개의 댓글