icon
15장 : 멀티스레딩 기초

조건 변수


뮤텍스는 공유 자원에 대한 배타적 접근(exclusive access) 을 보장하지만, 때로는 스레드가 특정 조건을 만족할 때까지 기다려야 하는 상황이 발생합니다.

예를 들어, 생산자(Producer) 스레드가 데이터를 생성하고 소비자(Consumer) 스레드가 이 데이터를 소비하는 시나리오에서, 소비자는 데이터가 준비될 때까지 기다려야 합니다.

이러한 조건 대기(conditional waiting)스레드 간 통신을 위한 강력한 동기화 도구가 바로 조건 변수(Condition Variable) 입니다.

C++ 표준 라이브러리는 <condition_variable> 헤더에 std::condition_variable을 제공합니다.


조건 변수란 무엇인가?

조건 변수는 하나 이상의 스레드가 특정 조건이 참이 될 때까지 대기하도록 하고, 다른 스레드가 그 조건을 변경했을 때 대기 중인 스레드에게 알림(notification)을 보내 재개하도록 하는 동기화 원시 요소(synchronization primitive)입니다.

핵심 개념

  • 뮤텍스와 함께 사용: 조건 변수는 항상 뮤텍스와 함께 사용되어야 합니다. 뮤텍스는 조건 변수가 기다리는 공유 데이터에 대한 안전한 접근을 보장합니다.
  • 대기 (wait()): 스레드는 wait() 함수를 호출하여 특정 조건이 참이 될 때까지 뮤텍스를 해제하고 대기 상태로 들어갑니다.
  • 알림 (notify_one(), notify_all()): 다른 스레드가 조건을 변경한 후, notify_one() (하나의 대기 스레드 깨우기) 또는 notify_all() (모든 대기 스레드 깨우기)을 호출하여 대기 중인 스레드를 깨웁니다.

조건 변수의 기본 사용법

std::condition_variable을 사용하는 일반적인 패턴은 다음과 같습니다.

대기자 (Waiting Thread) 측

뮤텍스를 잠급니다. (일반적으로 std::unique_lock 사용)

조건이 참인지 확인합니다.

조건이 거짓이라면, condition_variable.wait(unique_lock, [프레디케이트])를 호출합니다.

  • wait()는 뮤텍스를 해제하고 스레드를 대기 상태로 만듭니다.
  • wait()가 반환될 때 (알림을 받거나 가짜 깨어나기), 뮤텍스를 다시 잠급니다.
  • 프레디케이트(predicate, 람다 함수 등)는 조건이 참인지 확인하며, wait()가 깨어날 때마다 자동으로 조건을 다시 확인합니다. 프레디케이트를 사용하는 것이 가짜 깨어나기(Spurious Wakeup) 를 안전하게 처리하는 권장 방법입니다.

조건이 참이면, 임계 영역 작업을 수행합니다.

알림자 (Notifying Thread) 측

뮤텍스를 잠급니다. (일반적으로 std::unique_lock 사용)

공유 데이터를 변경하여 조건을 참으로 만듭니다.

condition_variable.notify_one() 또는 condition_variable.notify_all()을 호출하여 대기 중인 스레드를 깨웁니다.

뮤텍스를 해제합니다.

조건 변수 예시: 생산자-소비자 문제
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue> // std::queue를 위해
#include <chrono> // std::chrono::milliseconds를 위해

std::queue<int> data_queue; // 공유 데이터 큐
std::mutex mtx;             // 큐 접근 보호를 위한 뮤텍스
std::condition_variable cv;  // 조건 변수

bool producer_done = false; // 생산자가 작업을 마쳤는지 나타내는 플래그

// 생산자 스레드
void producer() {
    for (int i = 0; i < 5; ++i) {
        std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 생산 시간 시뮬레이션
        std::unique_lock<std::mutex> lock(mtx); // 뮤텍스 잠금
        data_queue.push(i);
        std::cout << "Producer: Produced " << i << ". Queue size: " << data_queue.size() << std::endl;
        lock.unlock(); // 뮤텍스 해제 (조건 변수 notify 전에 해제하는 것이 성능에 유리)
        cv.notify_one(); // 대기 중인 소비자 스레드 하나를 깨움
    }
    std::unique_lock<std::mutex> lock(mtx);
    producer_done = true; // 생산 완료 플래그 설정
    lock.unlock();
    cv.notify_all(); // 모든 소비자 스레드를 깨워 종료를 알림
}

// 소비자 스레드
void consumer() {
    while (true) {
        std::unique_lock<std::mutex> lock(mtx); // 뮤텍스 잠금
        
        // 큐가 비어 있고 생산자가 아직 끝나지 않았다면 대기
        cv.wait(lock, []{ return !data_queue.empty() || producer_done; });
        // wait 함수는 조건이 거짓이면 뮤텍스를 자동으로 해제하고 대기,
        // 알림을 받거나 가짜 깨어나기 시 뮤텍스를 다시 잠근 후 프레디케이트 재확인

        if (data_queue.empty() && producer_done) {
            std::cout << "Consumer: Producer done and queue is empty. Exiting.\n";
            break; // 큐가 비어 있고 생산자도 끝났으면 종료
        }

        int data = data_queue.front();
        data_queue.pop();
        std::cout << "Consumer: Consumed " << data << ". Queue size: " << data_queue.size() << std::endl;
        lock.unlock(); // 뮤텍스 해제
        std::this_thread::sleep_for(std::chrono::milliseconds(200)); // 소비 시간 시뮬레이션
    }
}

int main() {
    std::cout << "Main: Starting producer and consumer threads.\n";

    std::thread prod_t(producer);
    std::thread cons_t(consumer);

    prod_t.join();
    cons_t.join();

    std::cout << "Main: All threads finished.\n";
    return 0;
}

실행 흐름

소비자 스레드가 시작하고 큐가 비어 있으므로 cv.wait()에서 대기합니다. 이때 뮤텍스는 해제됩니다.

생산자 스레드가 데이터를 생성하고 큐에 넣은 후 cv.notify_one()을 호출합니다.

소비자 스레드가 깨어나고 뮤텍스를 다시 획득한 후, 큐에 데이터가 있음을 확인하고 소비합니다.

이 과정이 반복되다가, 생산자가 모든 데이터를 생산하고 producer_donetrue로 설정한 뒤 cv.notify_all()을 호출하면, 소비자 스레드는 wait()에서 깨어나 producer_donetrue이고 큐가 비어 있음을 확인한 후 루프를 종료합니다.


가짜 깨어나기

가짜 깨어나기(Spurious Wakeup) 는 조건 변수가 notify() 호출 없이도 대기 중인 스레드를 깨울 수 있는 현상입니다.

이는 운영체제 스케줄러의 특성이나 멀티프로세서 시스템의 특정 구현 때문에 발생할 수 있습니다.

스레드가 깨어나더라도 wait()를 호출하기 전의 조건이 실제로 참이 아닐 수 있다는 의미입니다.

해결책: std::condition_variable::wait() 함수는 항상 프레디케이트(Predicate) 와 함께 사용하는 것이 강력히 권장됩니다. 프레디케이트는 람다 함수나 함수 객체로, 조건이 참인지 확인하는 역할을 합니다.

가짜 깨어나기 방지 예시
cv.wait(lock, []{ return !data_queue.empty(); });

이 코드는 다음과 같이 동작합니다.

wait()가 호출되면, 먼저 프레디케이트를 실행하여 조건이 참인지 확인합니다.

조건이 참이면 즉시 반환하고, 거짓이면 뮤텍스를 해제하고 대기합니다.

notify()를 받거나 가짜 깨어나기로 깨어나면, wait()는 뮤텍스를 다시 잠그고 프레디케이트를 다시 확인합니다.

여전히 조건이 거짓이면, 다시 뮤텍스를 해제하고 대기합니다.

조건이 참이 될 때까지 이 과정을 반복합니다.

이로써 가짜 깨어나기가 발생하더라도 스레드는 실제로 조건이 참이 될 때까지 임계 영역에 진입하지 않으므로 안전합니다.


타임아웃을 이용한 대기

조건 변수는 특정 시간 동안만 대기하거나, 특정 시점까지 대기할 수 있는 오버로드된 wait 함수들을 제공합니다.

이는 비동기 작업의 타임아웃 처리나 주기적인 폴링(polling)이 필요한 경우에 유용합니다.

  • wait_for(lock, duration, predicate): 지정된 duration 동안만 대기합니다. duration이 지나면 조건이 참이 아니더라도 반환합니다. 반환 값은 true (조건이 충족됨) 또는 false (타임아웃 발생)입니다.
  • wait_until(lock, time_point, predicate): 지정된 time_point까지 대기합니다.
타임아웃을 가진 소비자 예시
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <chrono> // std::chrono를 위해

std::mutex mtx_timeout;
std::condition_variable cv_timeout;
bool data_ready = false;

void consumer_timeout() {
    std::unique_lock<std::mutex> lock(mtx_timeout);
    std::cout << "Consumer with timeout: Waiting for data...\n";

    // 5초 동안만 대기
    if (cv_timeout.wait_for(lock, std::chrono::seconds(5), []{ return data_ready; })) {
        std::cout << "Consumer with timeout: Data is ready! (Woke up by notification)\n";
    } else {
        std::cout << "Consumer with timeout: Timeout occurred! Data not ready.\n";
    }
}

void producer_timeout() {
    std::this_thread::sleep_for(std::chrono::seconds(2)); // 2초 후 데이터 준비
    std::unique_lock<std::mutex> lock(mtx_timeout);
    data_ready = true;
    std::cout << "Producer with timeout: Data is set to ready.\n";
    lock.unlock(); // notify_one/all 호출 전에 락 해제 권장
    cv_timeout.notify_one();
}

int main() {
    std::cout << "Main: Starting threads for timeout example.\n";

    std::thread t1(consumer_timeout);
    std::thread t2(producer_timeout);

    t1.join();
    t2.join();

    std::cout << "Main: Timeout example threads finished.\n";

    // ----------------------------------------------------
    std::cout << "\n--- Another timeout example (long wait) ---\n";
    data_ready = false; // Reset for next example
    std::thread t3(consumer_timeout);
    std::this_thread::sleep_for(std::chrono::seconds(6)); // 생산자가 6초 후에 알림
    std::unique_lock<std::mutex> lock(mtx_timeout);
    data_ready = true;
    lock.unlock();
    cv_timeout.notify_one(); // 이 때는 소비자가 이미 타임아웃되어 대기하지 않을 수 있음
    t3.join();
    
    std::cout << "Main: Another timeout example threads finished.\n";

    return 0;
}

첫 번째 시나리오에서 consumer_timeout은 2초 후 producer_timeout의 알림을 받아 깨어나고, 두 번째 시나리오에서는 5초 타임아웃이 먼저 발생하여 "Timeout occurred!" 메시지를 출력합니다.


std::condition_variable_any

std::condition_variable은 오직 std::unique_lock<std::mutex>만 사용할 수 있습니다.

그러나 때로는 다른 종류의 뮤텍스(예: std::shared_mutex)와 함께 조건 변수를 사용해야 할 필요가 있습니다.

이때 std::condition_variable_any를 사용할 수 있습니다.

std::condition_variable_any는 모든 종류의 락(lockable) 객체를 wait 함수의 인자로 받을 수 있지만, std::condition_variable보다 약간의 오버헤드가 더 있습니다.

일반적으로 std::condition_variable이 더 효율적이므로, 특별한 이유가 없다면 std::condition_variable을 사용하는 것이 좋습니다.


스레드 간 통신 및 동기화 정리

  • 뮤텍스: 공유 자원에 대한 배타적 접근을 보장하여 경쟁 조건을 방지합니다. (std::mutex, std::lock_guard, std::unique_lock, std::shared_mutex, std::shared_lock)
  • 조건 변수: 특정 조건이 만족될 때까지 스레드를 대기시키고, 조건이 변경되면 대기 중인 스레드를 깨워 스레드 간 통신을 가능하게 합니다. (std::condition_variable, std::condition_variable_any)

이 두 가지 원시 요소를 함께 사용하면, 복잡한 멀티스레드 시나리오에서도 안전하고 효율적인 동기화를 구현할 수 있습니다.