조건 변수
뮤텍스는 공유 자원에 대한 배타적 접근(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_done
을 true
로 설정한 뒤 cv.notify_all()
을 호출하면, 소비자 스레드는 wait()
에서 깨어나 producer_done
이 true
이고 큐가 비어 있음을 확인한 후 루프를 종료합니다.
가짜 깨어나기
가짜 깨어나기(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
)
이 두 가지 원시 요소를 함께 사용하면, 복잡한 멀티스레드 시나리오에서도 안전하고 효율적인 동기화를 구현할 수 있습니다.