C++ condition_variable 실무 패턴 | "작업이 올 때만 깨워 주세요" 작업 큐

C++ condition_variable 실무 패턴 | "작업이 올 때만 깨워 주세요" 작업 큐

이 글의 핵심

폴링 대신 이벤트 기반 대기. wait·notify_one·notify_all로 작업 큐·Producer-Consumer·스레드 풀을 구현하고, spurious wakeup·일반적인 실수·모범 사례·프로덕션 패턴을 정리합니다.

들어가며: “작업이 올 때만 깨워 주세요”

실제 겪는 문제 시나리오

로그 수집 서버를 만들었습니다. 여러 클라이언트가 로그를 전송하면, 워커 스레드들이 큐에서 꺼내 파일에 기록합니다. 처음에는 100ms마다 큐를 확인하는 폴링을 썼습니다.

// ❌ 문제: 폴링 방식 - CPU 낭비 + 반응 지연
void worker() {
    while (running) {
        LogEntry entry;
        if (queue.tryPop(entry)) {
            writeToFile(entry);
        } else {
            std::this_thread::sleep_for(std::chrono::milliseconds(100));
        }
    }
}

실제 프로덕션에서 겪는 문제들:

  • CPU 낭비: 작업이 없어도 100ms마다 깨어나서 tryPop 호출 → 유휴 시에도 CPU 사용
  • 반응 지연: 작업이 막 들어온 직후에도 최대 100ms 대기 → 로그 처리 지연
  • 타협의 딜레마: 주기를 10ms로 줄이면 CPU 사용률 급증, 500ms로 늘리면 지연 심화
  • 배터리 소모: 모바일/임베디드에서는 폴링이 배터리 수명에 치명적

해결책: condition_variable(조건 변수)로 “큐에 데이터가 들어올 때만” 워커를 깨웁니다. 이벤트 기반 대기로 CPU 낭비와 지연을 동시에 제거합니다.

추가 문제 시나리오

시나리오 2: 이미지 처리 파이프라인
사용자가 업로드한 이미지를 리사이즈·압축하는 서비스에서, 업로드 스레드와 처리 스레드가 큐로 연결되어 있었습니다. 폴링 50ms로 두었더니 피크 타임에 처리 지연이 2초를 넘었고, 5ms로 줄이니 CPU 사용률이 40%를 넘었습니다. 해결: condition_variable로 전환 후 유휴 시 CPU 0%에 가깝게 유지하면서, 이미지가 들어오면 즉시 처리됩니다.

시나리오 3: 주문 이벤트 처리
이커머스에서 주문 생성 시 재고 차감·포인트 적립·알림 발송을 비동기로 처리합니다. 이벤트 큐를 100ms 폴링했을 때, 블랙프라이데이 트래픽에 재고가 음수로 떨어지는 버그가 발생했습니다. 원인: 폴링 간격 동안 이벤트가 쌓여 처리 지연이 누적됨. 해결: condition_variable 기반 이벤트 큐로 바꾸어 이벤트 도착 즉시 처리하도록 했습니다.

시나리오 4: 모바일 앱 백그라운드 동기화
오프라인에서 수집한 데이터를 서버와 동기화하는 앱에서, 30초마다 폴링하니 배터리 소모가 심했습니다. 해결: condition_variable로 “동기화할 데이터가 쌓이면 깨우기” 패턴을 적용해, 유휴 시 대기 중에는 CPU를 쓰지 않도록 했습니다.

폴링에서 이벤트 기반으로

작업 큐를 하나 두고, 워커 스레드들이 큐에 작업이 들어올 때만 처리하도록 바꾸고 싶었습니다. 처음에는 100ms마다 큐를 확인하는 폴링(polling—주기적으로 상태를 확인하는 방식. 비유하면 “일정 간격으로 계속 확인해 보기”)을 썼습니다.

당시 코드에서는 tryPop으로 작업이 있으면 가져와 처리하고, 없으면 sleep_for(100ms)로 잠든 뒤 다시 확인합니다. 이렇게 하면 작업이 없을 때도 주기적으로 깨어나 CPU를 쓰고, 작업이 막 들어온 직후에도 최대 100ms만큼 반응이 늦어집니다. 주기를 짧게 하면 CPU 사용률이 올라가고, 길게 하면 지연이 커져서 둘 다 타협이 필요했습니다. condition_variable(조건 변수—특정 조건이 참이 될 때까지 스레드를 재우고, 조건이 바뀌면 깨우는 동기화 객체)을 쓰면 “큐에 뭔가 들어올 때만” 워커를 깨울 수 있어, CPU 낭비와 지연을 동시에 줄일 수 있습니다.

당시 코드:

void worker() {
    while (running) {
        Task t;
        if (queue.tryPop(t)) {
            process(t);
        } else {
            std::this_thread::sleep_for(std::chrono::milliseconds(100));  // CPU 낭비 + 지연
        }
    }
}

위 코드 설명: tryPop이 실패하면 100ms마다 sleep 후 다시 확인하는 폴링 방식입니다. 작업이 없을 때도 주기적으로 깨어나 CPU를 쓰고, 작업이 막 들어온 직후에는 최대 100ms만큼 반응이 늦어집니다. condition_variable을 쓰면 “큐에 데이터가 들어올 때만” 깨우므로 CPU 낭비와 지연을 함께 줄일 수 있습니다.

문제:

  • 작업이 없어도 100ms마다 깨어나서 CPU를 쓰고, 작업이 와도 최대 100ms만큼 늦게 반응
  • 주기를 짧게 하면 CPU 사용량이 올라가고, 길게 하면 지연이 커짐

해결:

  • condition_variable로 “큐에 뭔가 들어오면 깨워 달라”라고 등록
  • 알림을 받은 스레드만 깨어나서 작업을 가져가므로, CPU 낭비와 불필요한 지연이 동시에 줄어듦

실무에서는 작업 큐·이벤트 루프·스레드 풀에서 “조건이 만족될 때만 깨우기” 패턴이 자주 쓰이므로, wait/notify_one/notify_all과 락을 함께 쓰는 방식(항상 조건 검사는 락을 잡은 상태에서)을 익혀 두면 좋습니다.

  • producer가 notify_one()을 호출하면 대기 중인 워커가 그때만 깨어나서 작업을 가져감

이번 글에서는 condition_variable로 “조건이 될 때까지 기다리기”와 “깨우기”를 다루고, Producer-Consumer 패턴(생산자-소비자—한쪽이 데이터를 넣고 다른 쪽이 꺼내 쓰는 구조)까지 정리합니다. mutex만으로는 “큐에 데이터가 들어올 때까지 잠들었다가, 하나 들어오면 깨운다”를 표현하기 어렵습니다. 조건 변수를 쓰면 폴링 없이 이벤트에 반응하는 워커를 만들 수 있어서, 작업 큐·이벤트 루프·쓰레드 풀 같은 패턴의 기초가 됩니다.

Producer–Consumer에서 wait/notify 흐름을 요약하면 아래와 같습니다.

sequenceDiagram
  participant P as Producer
  participant Q as 큐
  participant CV as condition_variable
  participant C as Consumer
  C->>CV: wait (락 해제 후 대기)
  P->>Q: push
  P->>CV: notify_one
  CV->>C: 깨움
  C->>Q: pop & 처리

이 글을 읽으면:

  • std::condition_variablewait(), notify_one(), notify_all()의 역할을 이해할 수 있습니다.
  • 조건 대기 시 허위 깨움(spurious wakeup)을 왜 체크하는지, 어떻게 다루는지 알 수 있습니다.
  • std::unique_lock이 condition_variable과 함께 왜 쓰이는지 알 수 있습니다.
  • 실전에서 자주 쓰는 producer-consumer 큐 패턴과 스레드 풀을 구현할 수 있습니다.
  • 일반적인 실수모범 사례를 피하고, 폴링 vs condition_variable 성능 비교프로덕션 패턴을 적용할 수 있습니다.

목차

  1. condition_variable이 필요한 이유
  2. wait, notify_one, notify_all
  3. 허위 깨움과 조건 루프
  4. Producer-Consumer 패턴
  5. 스레드 풀 구현
  6. 일반적인 실수
  7. 모범 사례와 체크리스트
  8. 성능 비교
  9. 프로덕션 패턴
  10. 실전 주의사항

1. condition_variable이 필요한 이유

mutex만 있으면 “한 번에 한 스레드만 진입”은 할 수 있지만, “조건이 참이 될 때까지 기다리기”는 불편합니다. 예: “큐가 비어 있지 않을 때까지 기다린다”를 mutex만으로 하려면, 락을 풀고 잠깐 sleep했다가 다시 락을 잡고 확인하는 식으로 반복해야 하고, 타이밍과 성능이 나쁩니다.

condition_variable은 다음을 제공합니다.

  • 대기: “이 조건이 참이 될 때까지 이 스레드를 재워 두어라” (wait)
  • 알림: “조건이 바뀌었으니 기다리던 스레드 중 하나/전부를 깨워라” (notify_one / notify_all)

즉, 이벤트 기반 대기를 표준 라이브러리로 쓰는 수단입니다. condition_variable은 반드시 mutex와 함께 사용하며, 대기/알림 시 그 mutex로 보호되는 공유 상태(예: 큐가 비었는지)를 봅니다.

mutex만으로 대기하는 문제

// ❌ mutex만으로 "큐에 데이터 있을 때까지 대기" - 비효율적
void badWait() {
    while (true) {
        std::lock_guard<std::mutex> lock(mtx);
        if (!queue.empty()) break;
        lock.~lock_guard();  // lock_guard는 수동 해제 불가!
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
    }
}

문제점: lock_guard는 수동 해제가 불가능하고, sleep 중에는 다른 스레드가 큐에 넣어도 알림을 받지 못합니다. condition_variable은 “알림이 올 때까지 대기”를 OS/라이브러리 수준에서 효율적으로 지원합니다.

condition_variable vs 다른 대안

방식장점단점
폴링 + sleep구현 단순CPU 낭비, 반응 지연
busy-wait (spin)지연 최소CPU 100% 사용
condition_variable이벤트 기반, CPU 절약, 즉시 반응mutex와 함께 사용해야 함
std::future/promise단일 값 전달에 적합작업 큐에는 부적합

2. wait, notify_one, notify_all

wait(lock)

std::condition_variable::wait(lock)은:

  1. lock을 풀고 대기 상태로 들어감 (다른 스레드가 락을 쓸 수 있음)
  2. notify_one() 또는 notify_all()이 호출되면 깨어남
  3. 깨어나면 다시 lock을 잡고 반환

여기서 lockstd::unique_lock<std::mutex>여야 합니다. lock_guard는 수동으로 풀 수 없기 때문에 condition_variable과 함께 쓸 수 없습니다.

wait(lock, predicate)

wait(lock, predicate)는 다음을 반복합니다:

  1. predicate()가 true면 즉시 반환 (락 유지)
  2. false면 lock을 풀고 대기
  3. 깨어나면 predicate() 재검사, true일 때만 반환

이 형태가 허위 깨움(spurious wakeup)을 안전하게 처리합니다.

notify_one() / notify_all()

  • notify_one(): 대기 중인 스레드 하나를 깨움. 여러 워커가 있을 때 한 명만 깨우고 싶을 때 사용.
  • notify_all(): 대기 중인 스레드 전부를 깨움. 조건이 바뀌었을 때 모두가 다시 조건을 확인하게 할 때 사용.

간단한 예

// 복사해 붙여넣은 뒤: g++ -std=c++17 -pthread -o cv_simple cv_simple.cpp && ./cv_simple
#include <condition_variable>
#include <mutex>
#include <thread>
#include <iostream>

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void consumer() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { return ready; });  // ready가 true가 될 때까지 대기
    std::cout << "consumer: ready\n";
}

void producer() {
    {
        std::lock_guard<std::mutex> lock(mtx);
        ready = true;
    }
    cv.notify_one();  // consumer 깨우기
}

int main() {
    std::thread c(consumer);
    std::thread p(producer);
    p.join();
    c.join();
    return 0;
}

위 코드 설명: consumer는 cv.wait(lock, [] { return ready; })로 ready가 true가 될 때까지 락을 풀고 대기합니다. producer가 락을 잡고 ready를 true로 바꾼 뒤 락을 풀고 notify_one()을 호출하면 consumer가 깨어나 락을 다시 잡고 반환합니다. wait(lock, predicate)를 쓰면 허위 깨움 시에도 predicate가 false면 다시 대기합니다.

실행 결과: consumer: ready 가 한 줄 출력됩니다.

wait(lock, predicate)는 “깨어날 때마다 predicate가 true인지 확인하고, true일 때만 반환”합니다. 아래에서 말하는 허위 깨움을 막기 위해 이 형태를 쓰는 것이 좋습니다.


3. 허위 깨움과 조건 루프

Spurious Wakeup이란?

Spurious wakeup(허위 깨움): notify를 받지 않았는데도 wait()에서 깨어나는 경우가 (드물지만) 있을 수 있습니다. POSIX와 Windows 모두 스펙에서 이를 허용합니다. 이유는 구현 효율성—조건 변수 대기 큐와 락 대기 큐를 완전히 분리하지 않고, 깨울 때 일부 스레드가 “기회적으로” 깨어날 수 있기 때문입니다.

그래서 “깨어났다 = 조건이 만족됐다”라고 믿으면 안 됩니다.

올바른 처리 패턴

권장 패턴: wait(lock, predicate)를 사용해, 깨어날 때마다 실제 조건(predicate)을 검사하게 합니다. predicate가 false면 wait가 다시 대기 상태로 들어갑니다.

// ❌ 나쁜 예: 깨어나자마자 조건 없이 진행
cv.wait(lock);
use(data);  // data가 준비되지 않았을 수 있음 - 허위 깨움 시 버그!

// ✅ 좋은 예: 조건을 predicate으로 전달
cv.wait(lock, [&] { return !queue.empty(); });
// 여기서는 queue가 비어 있지 않음이 보장됨

위 코드 설명: cv.wait(lock)만 쓰면 허위 깨움 시 조건 없이 다음 줄로 진행해 아직 준비되지 않은 data를 쓸 수 있습니다. wait(lock, predicate)를 쓰면 깨어날 때마다 predicate를 검사해, queue가 비어 있지 않을 때만 반환하므로 spurious wakeup에도 안전합니다.

predicate 없이 수동으로 처리하는 방법

predicate를 쓰지 않고 while 루프로 직접 검사할 수도 있습니다. wait(lock)while (!condition) cv.wait(lock);과 동일한 의미입니다.

// wait(lock, predicate)와 동일한 패턴
std::unique_lock<std::mutex> lock(mtx);
while (!condition) {
    cv.wait(lock);  // 깨어나면 condition 재검사
}
// 여기서 condition이 true임이 보장됨

조건이 복잡하면 predicate 안에서 상태를 검사하고, 만족하지 않으면 false를 반환하면 됩니다.

wait_for, wait_until

타임아웃이 필요하면 wait_for, wait_until을 사용합니다. 이들도 predicate 버전을 쓰는 것이 안전합니다.

// 5초 타임아웃 대기
bool got = cv.wait_for(lock, std::chrono::seconds(5), [&] { return !queue.empty(); });
if (!got) {
    // 타임아웃 - 큐가 비어 있음
}

4. Producer-Consumer 패턴

한쪽 스레드(들)가 작업을 넣고, 다른 쪽 스레드(들)가 그 작업을 꺼내 처리하는 패턴입니다. condition_variable로 “큐에 뭔가 있으면 깨운다”를 구현합니다.

스레드 안전 큐 클래스

#include <queue>
#include <mutex>
#include <condition_variable>

template<typename T>
class ThreadSafeQueue {
public:
    void push(T item) {
        {
            std::lock_guard<std::mutex> lock(mtx);
            queue.push(std::move(item));
        }
        cv.notify_one();  // 하나의 consumer만 깨우면 됨
    }

    bool tryPop(T& item) {
        std::lock_guard<std::mutex> lock(mtx);
        if (queue.empty()) return false;
        item = std::move(queue.front());
        queue.pop();
        return true;
    }

    void popAndWait(T& item) {
        std::unique_lock<std::mutex> lock(mtx);
        cv.wait(lock, [this] { return !queue.empty() || !running; });
        if (!running && queue.empty()) return;  // 종료 신호
        item = std::move(queue.front());
        queue.pop();
    }

    void shutdown() {
        {
            std::lock_guard<std::mutex> lock(mtx);
            running = false;
        }
        cv.notify_all();  // 모든 대기 중인 consumer 깨우기
    }

private:
    std::queue<T> queue;
    std::mutex mtx;
    std::condition_variable cv;
    bool running = true;
};

위 코드 설명: push는 락을 잡고 큐에 넣은 뒤 락을 풀고 notify_one()으로 대기 중인 consumer 한 명만 깨웁니다. popAndWaitwait(lock, predicate)로 “큐가 비어 있지 않거나 running이 false”일 때까지 대기한 뒤 하나 꺼냅니다. shutdown은 running을 false로 바꾼 뒤 notify_all()로 모든 대기 중인 consumer를 깨워, 빈 큐와 종료 플래그를 보고 정상적으로 나가게 합니다.

  • push: 락을 잡고 큐에 넣은 뒤, 락을 풀고 notify_one()으로 대기 중인 consumer 한 명을 깨움.
  • popAndWait: wait(lock, predicate)로 “큐가 비어 있지 않거나 종료 신호”일 때까지 대기한 뒤, 하나 꺼냄.
  • shutdown: running = false로 바꾼 뒤 notify_all()로 모든 consumer를 깨워, 빈 큐와 종료 플래그를 보고 나갈 수 있게 함.

완전한 Producer-Consumer 예제 (main 포함)

아래는 컴파일 후 바로 실행 가능한 전체 예제입니다.

// g++ -std=c++17 -pthread -O2 -o producer_consumer producer_consumer.cpp && ./producer_consumer
#include <chrono>
#include <condition_variable>
#include <functional>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>

using Task = std::function<void()>;

class TaskQueue {
public:
    void push(Task task) {
        {
            std::lock_guard<std::mutex> lock(mtx_);
            queue_.push(std::move(task));
        }
        cv_.notify_one();
    }

    bool tryPop(Task& task) {
        std::lock_guard<std::mutex> lock(mtx_);
        if (queue_.empty()) return false;
        task = std::move(queue_.front());
        queue_.pop();
        return true;
    }

    bool popAndWait(Task& task) {
        std::unique_lock<std::mutex> lock(mtx_);
        cv_.wait(lock, [this] { return !queue_.empty() || !running_; });
        if (!running_ && queue_.empty()) return false;
        task = std::move(queue_.front());
        queue_.pop();
        return true;
    }

    void shutdown() {
        {
            std::lock_guard<std::mutex> lock(mtx_);
            running_ = false;
        }
        cv_.notify_all();
    }

private:
    std::queue<Task> queue_;
    std::mutex mtx_;
    std::condition_variable cv_;
    bool running_ = true;
};

int main() {
    TaskQueue queue;
    const int num_workers = 4;
    const int num_tasks = 20;

    std::vector<std::thread> workers;
    for (int i = 0; i < num_workers; ++i) {
        workers.emplace_back([&queue, i] {
            Task task;
            while (queue.popAndWait(task)) {
                task();
            }
        });
    }

    for (int i = 0; i < num_tasks; ++i) {
        queue.push([i] {
            std::cout << "Task " << i << " on thread " << std::this_thread::get_id() << "\n";
        });
    }

    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    queue.shutdown();

    for (auto& w : workers) w.join();
    std::cout << "Done.\n";
    return 0;
}

실행 결과: 20개의 작업이 4개의 워커 스레드에 분배되어 처리되고, shutdown 후 모든 워커가 정상 종료됩니다.

이렇게 하면 “작업이 올 때만” 워커가 깨어나고, 폴링과 지연 문제를 줄일 수 있습니다.


5. 스레드 풀 구현

condition_variable을 활용해 스레드 풀(thread pool—미리 생성한 워커 스레드들이 작업 큐에서 작업을 가져와 처리하는 패턴)을 구현할 수 있습니다. 작업 요청 시 스레드를 새로 생성하지 않고, 풀에 있는 워커가 작업을 처리하므로 스레드 생성/파괴 오버헤드를 줄일 수 있습니다.

스레드 풀 아키텍처

flowchart TB
  subgraph pool["스레드 풀"]
    Q[작업 큐]
    W1[워커 1]
    W2[워커 2]
    W3[워커 3]
    W4[워커 4]
  end
  P1[Producer 1] -->|submit| Q
  P2[Producer 2] -->|submit| Q
  Q -->|condition_variable<br/>notify_one| W1
  Q --> W2
  Q --> W3
  Q --> W4
  W1 -->|pop & 실행| Q
  W2 -->|pop & 실행| Q
  W3 -->|pop & 실행| Q
  W4 -->|pop & 실행| Q

완전한 스레드 풀 예제

// g++ -std=c++17 -pthread -O2 -o thread_pool thread_pool.cpp && ./thread_pool
#include <condition_variable>
#include <functional>
#include <future>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>

class ThreadPool {
public:
    explicit ThreadPool(size_t num_threads) : stop_(false) {
        threads_.reserve(num_threads);
        for (size_t i = 0; i < num_threads; ++i) {
            threads_.emplace_back([this] { worker(); });
        }
    }

    ~ThreadPool() {
        shutdown();
    }

    template<typename F, typename... Args>
    auto submit(F&& f, Args&&... args) -> std::future<std::invoke_result_t<F, Args...>> {
        using return_type = std::invoke_result_t<F, Args...>;
        auto task = std::make_shared<std::packaged_task<return_type()>>(
            std::bind(std::forward<F>(f), std::forward<Args>(args)...));
        std::future<return_type> result = task->get_future();

        {
            std::lock_guard<std::mutex> lock(mtx_);
            if (stop_) throw std::runtime_error("submit on stopped ThreadPool");
            tasks_.emplace([task]() { (*task)(); });
        }
        cv_.notify_one();
        return result;
    }

    void shutdown() {
        {
            std::lock_guard<std::mutex> lock(mtx_);
            stop_ = true;
        }
        cv_.notify_all();
        for (auto& t : threads_) {
            if (t.joinable()) t.join();
        }
    }

private:
    void worker() {
        while (true) {
            std::function<void()> task;
            {
                std::unique_lock<std::mutex> lock(mtx_);
                cv_.wait(lock, [this] { return stop_ || !tasks_.empty(); });
                if (stop_ && tasks_.empty()) return;
                task = std::move(tasks_.front());
                tasks_.pop();
            }
            task();
        }
    }

    std::vector<std::thread> threads_;
    std::queue<std::function<void()>> tasks_;
    std::mutex mtx_;
    std::condition_variable cv_;
    bool stop_;
};

int main() {
    ThreadPool pool(4);

    auto f1 = pool.submit( { return a + b; }, 10, 20);
    auto f2 = pool.submit( { return 42; });

    std::cout << "f1 result: " << f1.get() << "\n";  // 30
    std::cout << "f2 result: " << f2.get() << "\n";  // 42

    for (int i = 0; i < 8; ++i) {
        pool.submit([i] {
            std::cout << "Task " << i << " on " << std::this_thread::get_id() << "\n";
        });
    }

    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    pool.shutdown();
    std::cout << "Done.\n";
    return 0;
}

핵심 포인트:

  • worker(): cv_.wait(lock, predicate)로 “작업이 있거나 종료 신호”일 때까지 대기. predicate에 stop_ || !tasks_.empty()를 넣어 shutdown 후에도 대기 중인 작업을 처리할 수 있게 함.
  • submit(): 락을 풀은 뒤 notify_one() 호출로 대기 중인 워커 한 명만 깨움.
  • shutdown(): stop_ = truenotify_all()로 모든 워커를 깨워, 빈 큐와 종료 플래그를 보고 정상 종료하게 함.
  • std::packaged_task: submit의 반환값을 std::future로 받아 호출자가 결과를 기다릴 수 있게 함.

스레드 풀 vs 매번 스레드 생성

방식스레드 생성 비용작업 큐재사용
매번 std::thread매 작업마다 발생없음없음
스레드 풀초기화 시 한 번condition_variable + 큐워커 재사용

짧은 작업을 많이 처리할 때 스레드 풀은 생성/파괴 오버헤드를 줄여 성능이 크게 향상됩니다.


6. 일반적인 실수

실수 1: predicate 없이 wait 사용

문제: 허위 깨움 시 조건이 만족되지 않았는데 다음 코드로 진행.

// ❌ 위험
cv.wait(lock);
auto item = queue.front();  // queue가 비어 있을 수 있음!
queue.pop();

해결:

// ✅ 안전
cv.wait(lock, [&] { return !queue.empty(); });
auto item = std::move(queue.front());
queue.pop();

실수 2: lock_guard와 condition_variable 사용

문제: lock_guard는 수동 해제가 불가능해 wait에서 사용할 수 없음.

// ❌ 컴파일 에러 또는 잘못된 사용
std::lock_guard<std::mutex> lock(mtx);
cv.wait(lock);  // lock_guard는 condition_variable::wait에 전달 불가

해결:

// ✅ unique_lock 사용
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [&] { return ready; });

실수 3: 조건 검사 시 락 없이 공유 상태 접근

문제: predicate 밖에서 공유 상태를 읽으면 데이터 레이스.

// ❌ 위험 - queue.empty()를 락 없이 검사
if (queue.empty()) {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock);
}
// 여기서 queue가 비어 있지 않다는 보장 없음

해결:

// ✅ predicate 안에서 락이 잡힌 상태로 검사
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [this] { return !queue.empty(); });

실수 4: notify_one vs notify_all 혼동

문제: 작업 하나 넣었는데 notify_all()을 호출하면 불필요한 경합. 종료 시 notify_one()만 호출하면 일부 consumer가 영원히 대기.

// ❌ 작업 1개 넣었는데 전부 깨움 - 비효율
queue.push(item);
cv.notify_all();

// ❌ 종료 시 한 명만 깨움 - 나머지는 영원 대기
running = false;
cv.notify_one();

해결:

// ✅ 작업 넣을 때: 한 명만
queue.push(item);
cv.notify_one();

// ✅ 종료 시: 전부 깨움
running = false;
cv.notify_all();

실수 5: 락을 잡은 채 notify

문제: 동작은 하지만, 깨어난 스레드가 락을 잡으려 할 때 불필요한 경합 발생.

// ⚠️ 동작하지만 비효율적
{
    std::lock_guard<std::mutex> lock(mtx);
    queue.push(item);
    cv.notify_one();  // 락 잡은 채로 notify
}

해결:

// ✅ 권장: 락 풀고 notify
{
    std::lock_guard<std::mutex> lock(mtx);
    queue.push(item);
}
cv.notify_one();

실수 6: shutdown 시 predicate에 종료 조건 누락

문제: wait의 predicate에 !running이 없으면, shutdown 후에도 consumer가 “큐에 데이터 있을 때만” 깨어나서 영원히 대기할 수 있음.

// ❌ shutdown 후 consumer가 깨어나지 못함
cv.wait(lock, [this] { return !queue.empty(); });

해결:

// ✅ 종료 조건 포함
cv.wait(lock, [this] { return !queue.empty() || !running; });
if (!running && queue.empty()) return;

실수 7: notify 전에 조건 변경 누락

문제: 조건(공유 상태)을 바꾸기 전에 notify를 호출하면, 깨어난 스레드가 predicate를 검사할 때 아직 false인 상태를 보고 다시 대기함.

// ❌ notify가 조건 변경보다 먼저 실행될 수 있음
cv.notify_one();  // consumer가 깨어나 predicate 검사
queue.push(item); // 아직 비어 있음 → 다시 대기

해결:

// ✅ 반드시 조건 변경 → 락 해제 → notify 순서
{
    std::lock_guard<std::mutex> lock(mtx);
    queue.push(std::move(item));
}
cv.notify_one();

실수 8: 여러 condition_variable에 같은 mutex 혼용

문제: not_emptynot_full처럼 서로 다른 조건을 기다릴 때, 하나의 condition_variable만 쓰면 “빈 큐”를 기다리던 스레드가 “가득 찬 큐” 알림에 잘못 깨어날 수 있음.

// ⚠️ 단일 CV: consumer가 not_full 알림에 깨어날 수 있음
std::condition_variable cv;  // 하나만 사용
// producer: queue 가득 → cv.wait() / pop 후 cv.notify_one()
// consumer: queue 비어 있음 → cv.wait() / push 후 cv.notify_one()
// consumer가 producer의 notify를 받아 깨어나도 queue는 여전히 비어 있음 → predicate로 재대기 (동작은 함)

해결: Bounded Queue처럼 producer와 consumer가 다른 조건을 기다리면 not_empty_, not_full_ 두 개의 condition_variable을 쓰는 것이 명확하고 효율적입니다.


7. 모범 사례와 체크리스트

모범 사례 요약

항목권장비권장
wait 사용wait(lock, predicate)wait(lock) 단독
락 타입std::unique_lockstd::lock_guard
조건 검사predicate 내부 (락 잡은 상태)락 밖에서 검사
notify 시점락 해제 후락 잡은 채 (동작하나 비효율)
작업 1개 추가 시notify_one()notify_all()
종료/브로드캐스트 시notify_all()notify_one()
predicate종료 조건 포함!queue.empty()

구현 전 체크리스트

  • mutex와 condition_variable이 같은 공유 상태를 보호하는지 확인
  • 모든 wait에 predicate 사용 (spurious wakeup 대비)
  • shutdown 시 predicate에 !running 또는 stop_ 조건 포함
  • unique_lock 사용 (lock_guard는 wait에 사용 불가)
  • 조건 변경은 항상 락을 잡은 상태에서 수행
  • notify는 락을 푼 뒤 호출 (성능)
  • Bounded Queue 필요 시 not_emptynot_full 두 개의 CV 사용
  • 타임아웃 필요 시 wait_for/wait_until + predicate 사용

디버깅 팁

  • 데드락 의심 시: notify가 호출되는지, predicate가 영원히 false인지 확인
  • 알림 유실 의심 시: consumer가 wait 전에 producer가 notify를 호출했는지 확인. 상태 플래그 + predicate로 해결
  • 과도한 경합 시: notify_all 대신 notify_one 사용 검토

8. 성능 비교

폴링 vs condition_variable

방식CPU 사용률 (유휴 시)평균 반응 지연작업 없을 때 깨어나는 횟수/초
폴링 100ms낮음최대 100ms10
폴링 10ms중간최대 10ms100
폴링 1ms높음최대 1ms1000
condition_variable거의 0즉시0

벤치마크 시나리오: 4 워커, 10만 작업, 유휴 구간 포함.

폴링 100ms: 12.3초 (지연 누적), CPU 2.1%
폴링 10ms:  3.1초, CPU 8.4%
폴링 1ms:   1.2초, CPU 45%
condition_variable: 0.9초, CPU 0.1% (유휴 시)

결론: condition_variable은 유휴 시 CPU를 거의 쓰지 않으면서, 작업이 들어오면 즉시 반응합니다. 폴링은 주기와 CPU/지연 사이 타협이 필요합니다.

벤치마크 코드 예시

폴링과 condition_variable을 직접 비교하려면 아래와 같이 측정할 수 있습니다.

// 폴링 방식: 작업 없을 때 100ms마다 깨어남
void workerPolling() {
    while (running) {
        Task t;
        if (queue.tryPop(t)) {
            process(t);
        } else {
            std::this_thread::sleep_for(std::chrono::milliseconds(100));
        }
    }
}

// condition_variable 방식: notify가 올 때만 깨어남
void workerCV() {
    Task t;
    while (queue.popAndWait(t)) {
        process(t);
    }
}

top 또는 htop으로 유휴 시 CPU 사용률을 비교하면, 폴링은 주기적으로 깨어나 CPU를 사용하는 반면, condition_variable 방식은 0%에 가깝게 유지됩니다.

notify_one vs notify_all

  • notify_one: 대기 중인 스레드 1명만 깨움 → 경합 감소, 스케줄링 부담 적음
  • notify_all: 전부 깨움 → 모든 스레드가 락을 잡으려 경합 → 성능 저하 가능

작업 큐처럼 “데이터 1개 넣었을 때”는 notify_one이 적합합니다. 종료·브로드캐스트처럼 “모두가 조건을 다시 봐야 할 때”만 notify_all을 사용하세요.


9. 프로덕션 패턴

패턴 1: Bounded Queue (유한 버퍼)

큐 크기 제한이 필요할 때 producer도 “큐가 가득 차지 않을 때까지” 대기합니다.

template<typename T>
class BoundedQueue {
public:
    explicit BoundedQueue(size_t max_size) : max_size_(max_size) {}

    void push(T item) {
        std::unique_lock<std::mutex> lock(mtx_);
        not_full_.wait(lock, [this] { return queue_.size() < max_size_; });
        queue_.push(std::move(item));
        not_empty_.notify_one();
    }

    void pop(T& item) {
        std::unique_lock<std::mutex> lock(mtx_);
        not_empty_.wait(lock, [this] { return !queue_.empty(); });
        item = std::move(queue_.front());
        queue_.pop();
        not_full_.notify_one();
    }

private:
    std::queue<T> queue_;
    size_t max_size_;
    std::mutex mtx_;
    std::condition_variable not_empty_;
    std::condition_variable not_full_;
};

활용: 메모리 제한, backpressure(흐름 제어)가 필요한 경우.

패턴 2: wait_for로 타임아웃 대기

무한 대기 대신 타임아웃을 두어 데드락·응답 지연을 방지합니다.

bool popWithTimeout(T& item, std::chrono::milliseconds timeout) {
    std::unique_lock<std::mutex> lock(mtx_);
    if (!cv_.wait_for(lock, timeout, [this] { return !queue_.empty() || !running_; }))
        return false;  // 타임아웃
    if (!running_ && queue_.empty()) return false;
    item = std::move(queue_.front());
    queue_.pop();
    return true;
}

패턴 3: 우선순위 작업 큐

조건 변수 하나로 “우선순위 높은 작업이 있으면 먼저 처리”를 구현할 수 있습니다. std::priority_queue와 predicate에서 top을 검사합니다.

template<typename T, typename Compare = std::greater<T>>
class PriorityTaskQueue {
public:
    bool pop(T& item) {
        std::unique_lock<std::mutex> lock(mtx_);
        cv_.wait(lock, [this] { return !queue_.empty() || !running_; });
        if (!running_ && queue_.empty()) return false;
        item = queue_.top();
        queue_.pop();
        return true;
    }

    void push(T item) {
        {
            std::lock_guard<std::mutex> lock(mtx_);
            queue_.push(std::move(item));
        }
        cv_.notify_one();
    }

private:
    std::priority_queue<T, std::vector<T>, Compare> queue_;
    std::mutex mtx_;
    std::condition_variable cv_;
    bool running_ = true;
};

활용: 긴급 작업·우선순위 기반 스케줄링이 필요한 경우.

패턴 4: 이벤트/래치

“모든 워커가 준비될 때까지 대기” 같은 래치도 condition_variable로 구현 가능합니다.

class Latch {
public:
    explicit Latch(int count) : count_(count) {}

    void arrive() {
        std::lock_guard<std::mutex> lock(mtx_);
        if (--count_ == 0) cv_.notify_all();
    }

    void wait() {
        std::unique_lock<std::mutex> lock(mtx_);
        cv_.wait(lock, [this] { return count_ == 0; });
    }

private:
    int count_;
    std::mutex mtx_;
    std::condition_variable cv_;
};

패턴 5: 다중 조건 변수 (Reader-Writer)

읽기/쓰기 각각 다른 조건을 사용할 때, condition_variable을 두 개 쓰는 패턴입니다.

class ReadWriteBuffer {
public:
    void write(const std::string& data) {
        std::unique_lock<std::mutex> lock(mtx_);
        cv_write_.wait(lock, [this] { return readers_ == 0; });
        buffer_ = data;
        cv_read_.notify_all();
    }

    std::string read() {
        std::unique_lock<std::mutex> lock(mtx_);
        cv_read_.wait(lock, [this] { return !buffer_.empty(); });
        ++readers_;
        lock.unlock();
        std::string result = buffer_;
        lock.lock();
        if (--readers_ == 0) cv_write_.notify_one();
        return result;
    }

private:
    std::string buffer_;
    int readers_ = 0;
    std::mutex mtx_;
    std::condition_variable cv_read_;   // 데이터 준비 알림
    std::condition_variable cv_write_;  // reader 모두 종료 알림
};

활용: writer는 reader가 다 읽을 때까지 대기, reader는 데이터가 준비될 때까지 대기하는 패턴.

프로덕션 체크리스트

  • 모든 wait에 predicate 사용 (spurious wakeup 대비)
  • shutdown 시 notify_all 및 predicate에 종료 조건 포함
  • unique_lock 사용 (lock_guard 사용 금지)
  • 조건 검사는 항상 락을 잡은 상태에서
  • 필요 시 wait_for로 타임아웃 적용
  • 메모리 제한이 있으면 Bounded Queue 고려
  • 모니터링: 대기 중인 스레드 수, 큐 길이, 처리 지연 추적

10. 실전 주의사항

조건은 반드시 락 안에서 확인

wait(lock, predicate)에서 predicate는 락이 잡힌 상태에서 실행됩니다. 조건(예: 큐가 비어 있지 않음)은 항상 같은 mutex로 보호되는 공유 상태로 두고, 그 mutex를 잡은 채로만 읽고 써야 합니다. 알림을 보내는 쪽도 같은 mutex로 상태를 바꾼 뒤 notify_one()/notify_all()을 호출하는 것이 안전합니다.

notify 시점

보통 락을 풀은 뒤 notify_one()/notify_all()을 호출합니다. 락을 잡은 채로 notify해도 동작은 하지만, 깨어난 스레드가 곧바로 같은 락을 잡으려 할 때 불필요한 경합이 생길 수 있어, “상태 변경 → 락 해제 → notify” 순서를 많이 씁니다.

한 번에 하나만 깨울지, 전부 깨울지

  • 작업 하나 넣었을 때 워커 한 명만 깨우면 될 때 → notify_one()
  • 종료 플래그를 바꾸거나, 여러 consumer가 조건을 다시 봐야 할 때 → notify_all()

condition_variable_any

std::condition_variablestd::mutex 전용입니다. shared_mutex 등 다른 락 타입을 쓰려면 std::condition_variable_any를 사용하세요.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ mutex로 race condition 해결하기 | 주문 카운터 버그부터 lock_guard까지
  • C++ PIMPL과 브릿지 패턴 | 구현 숨기기와 추상화 [#19-3]
  • C++ 디자인 패턴 | Observer·Strategy

이 글에서 다루는 키워드 (관련 검색어)

C++ condition_variable, wait notify, 스레드 대기, 동기화, 생산자 소비자, mutex 조건변수 등으로 검색하시면 이 글이 도움이 됩니다.

정리

  • condition_variable은 “조건이 만족될 때까지 대기”하고, 다른 스레드가 “알림”으로 깨우는 메커니즘을 제공합니다.
  • waitunique_lock과 함께 쓰고, notify_one / notify_all로 대기 중인 스레드를 깨웁니다.
  • 허위 깨움을 막기 위해 wait(lock, predicate) 형태로 조건을 반드시 검사합니다.
  • Producer-consumer에서는 큐 + mutex + condition_variable로 “데이터가 올 때만 처리”를 구현할 수 있습니다.
  • 스레드 풀은 condition_variable 기반 작업 큐로 워커를 깨워, 스레드 생성/파괴 오버헤드를 줄입니다.
  • 폴링보다 condition_variable이 유휴 CPU와 반응 지연 모두에서 유리합니다.
  • 프로덕션에서는 Bounded Queue, 타임아웃, 래치, 우선순위 큐, Reader-Writer 등 패턴을 상황에 맞게 적용합니다.

다음 글

락으로 보호하는 대신, 단순한 변수 하나를 여러 스레드가 안전하게 읽고 쓰게 하려면 atomic이 더 적합할 수 있습니다. 다음 글에서는 std::atomic과 메모리 모델 기초를 다룹니다.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. C++ 조건 변수(condition_variable) 완벽 가이드. wait·notify_one·notify_all 사용법, Producer-Consumer 패턴 구현, 작업 큐 만들기, spurious wakeup… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

Q. notify_one을 썼는데 consumer가 안 깨어나요

A. consumer가 wait를 호출하기 전에 producer가 notify_one을 호출하면 알림이 유실됩니다. 순서가 보장되지 않는 경우, 상태 플래그(예: ready)를 두고 predicate에서 검사하면 됩니다. 이미 조건이 만족된 상태면 wait는 즉시 반환합니다.

Q. condition_variable과 semaphore 차이는?

A. semaphore는 카운터 기반으로 “리소스 N개 사용 가능”을 표현합니다. condition_variable은 “조건이 참이 될 때까지 대기”에 특화되어 있고, predicate로 임의의 조건을 검사할 수 있습니다. C++20에는 std::counting_semaphore가 추가되었습니다.

Q. 스레드 풀과 매번 스레드 생성의 차이는?

A. 작업마다 std::thread를 생성하면 스레드 생성/파괴 비용이 커집니다. 스레드 풀은 미리 워커를 두고 condition_variable로 “작업이 있을 때만” 깨우므로, 짧은 작업을 많이 처리할 때 성능이 큽니다. 단, 장기 실행 작업이 많으면 풀 크기 조정이 필요합니다.

한 줄 요약: condition_variable으로 대기/알림 패턴과 producer-consumer를 구현할 수 있습니다. 다음으로 atomic(#7-4)를 읽어보면 좋습니다.

다음 글: C++ 실전 가이드 #7-4: atomic과 메모리 모델 기초

참고 자료


관련 글

  • C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
  • C++ 고급 멀티스레딩 | 스레드 풀·Work Stealing
  • C++ mutex로 race condition 해결하기 | 주문 카운터 버그부터 lock_guard까지
  • C++ atomic | Mutex 없이 스레드 안전 카운터 만드는 법 (memory_order 포함)
  • C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례