C++ condition_variable로 작업 큐 만들기: 허위 깨움 처리와 스레드 풀
공유 큐와 플래그는 mutex로 보호하고, condition_variable::wait에는 “다시 잠들어도 되는지”를 판단하는 predicate를 넘기고, 조건을 바꾼 쪽은 notify_one이나 notify_all로 기다리는 스레드를 깨웁니다. 이 글의 내용은 이 세 줄로 요약되며, 나머지는 각 규칙을 지키지 않았을 때 어떤 버그가 나는지에 대한 설명입니다. mutex가 처음이라면 mutex(#7-2)를 먼저 읽는 것이 좋습니다.
폴링으로 기다리는 워커의 문제
워커 스레드가 큐에서 작업을 꺼내 처리하는 구조를 처음 만들면 흔히 다음처럼 짭니다.
void worker() {
while (running) {
Task t;
if (queue.tryPop(t)) {
process(t);
} else {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
}
큐가 비어 있으면 100ms 자고 다시 확인하는 폴링(polling)입니다. 이 방식에는 서로 맞물린 두 문제가 있습니다. 작업이 하나도 없어도 워커마다 초당 10번씩 깨어나 락을 잡고 큐를 확인하므로, 유휴 상태에서도 CPU 시간과 전력을 씁니다. 그리고 작업이 큐에 들어온 직후 워커가 막 잠들었다면 최대 100ms 동안 아무도 그 작업을 꺼내지 않습니다. 주기를 줄이면 깨어나는 횟수가 그만큼 늘고, 늘리면 지연이 커지므로 어느 쪽으로도 둘을 함께 해결할 수 없습니다.
condition_variable(조건 변수)은 “조건이 참이 될 때까지 스레드를 재우고, 조건을 바꾼 쪽이 깨워 주는” 동기화 객체입니다. 큐가 비어 있으면 워커는 OS 수준에서 잠들어 CPU를 전혀 쓰지 않고, producer가 작업을 넣고 알리는 즉시 깨어납니다.
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 & 처리
mutex만으로는 왜 안 되는가
mutex는 “한 번에 한 스레드만 들어간다”를 보장할 뿐 “조건이 바뀔 때까지 기다린다”는 표현하지 못합니다. mutex만으로 기다리려면 결국 락을 잡고 확인하고, 락을 풀고 자고, 다시 잡는 폴링을 반복해야 합니다.
void waitWithMutexOnly() {
std::unique_lock<std::mutex> lock(mtx);
while (queue.empty()) {
lock.unlock();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
lock.lock();
}
// 큐에 데이터가 있음
}
잠든 동안에는 producer가 큐에 넣어도 알 방법이 없습니다. condition_variable의 wait는 “락을 풀고 잠드는 것”을 원자적으로 처리해, 락을 푼 직후와 잠들기 직전 사이에 온 알림을 놓치지 않습니다. 이 원자성이 직접 구현하기 어려운 부분입니다.
다른 대기 수단과의 비교
| 방식 | 적합한 경우 | 한계 |
|---|---|---|
| 폴링 + sleep | 구현이 단순해야 할 때 | 유휴 시 주기적 기상, 반응 지연 |
| busy-wait(spin) | 대기 시간이 매우 짧을 것이 확실할 때 | 대기 내내 코어 하나를 점유 |
condition_variable | 임의의 조건을 기다릴 때 | mutex와 함께 써야 함 |
std::future/promise | 결과값 하나를 한 번 전달할 때 | 반복되는 작업 큐에는 맞지 않음 |
C++20 std::latch/std::barrier | N개 스레드가 도착할 때까지 기다릴 때 | 카운트 기반 동기화 전용 |
C++20 std::counting_semaphore | 리소스 N개로 동시 접근을 제한할 때 | 임의의 조건은 표현 불가 |
C++20 std::atomic::wait/notify | atomic 값 하나가 바뀔 때까지 기다릴 때 | 단일 값 조건만 가능 |
wait, notify_one, notify_all
wait(lock)
std::condition_variable::wait(lock)은 다음 순서로 동작합니다.
- lock을 풀고 대기 상태로 들어갑니다. 이 두 동작은 원자적이라, 그 사이에 온 notify를 놓치지 않습니다.
notify_one()이나notify_all()이 호출되면(또는 허위 깨움이 일어나면) 깨어납니다.- 깨어나면 lock을 다시 잡은 뒤 반환합니다.
인자는 반드시 std::unique_lock<std::mutex>여야 합니다. 대기 중에 락을 풀었다가 다시 잡는 동작이 필요한데, std::lock_guard에는 unlock/lock 멤버가 없으므로 컴파일 에러가 납니다.
wait(lock, predicate)
predicate를 받는 오버로드는 다음과 같이 정의되어 있습니다.
while (!pred()) {
wait(lock);
}
먼저 predicate를 검사해 이미 참이면 기다리지 않고 바로 반환하고, 깨어날 때마다 다시 검사해 참일 때만 반환합니다. predicate는 항상 락을 잡은 상태에서 호출되므로 공유 상태를 안전하게 읽을 수 있습니다.
notify_one과 notify_all
notify_one()은 대기 중인 스레드 하나를, notify_all()은 전부를 깨웁니다. 작업 하나를 넣었을 때는 그 작업을 가져갈 워커 하나만 깨우면 되므로 notify_one()이 맞습니다. 종료 플래그처럼 모든 대기자가 조건을 다시 봐야 하는 변화에는 notify_all()을 씁니다. 기다리는 스레드가 없을 때 호출한 notify는 아무 효과도 없고, 나중에 wait하는 스레드를 위해 기억되지도 않습니다.
가장 작은 예
// g++ -std=c++17 -pthread -o cv_simple cv_simple.cpp && ./cv_simple
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <thread>
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();
}
int main() {
std::thread c(consumer);
std::thread p(producer);
p.join();
c.join();
}
두 스레드 중 어느 쪽이 먼저 실행되어도 consumer: ready가 한 번 출력됩니다. producer가 먼저 실행되어 consumer가 wait에 들어가기 전에 notify가 끝나 버려도, consumer는 predicate에서 ready가 이미 true인 것을 보고 기다리지 않고 지나갑니다. 상태를 공유 변수에 두고 predicate로 검사하는 것이 알림 유실을 막는 방법입니다.
허위 깨움과 predicate
허위 깨움이란
허위 깨움(spurious wakeup)은 notify를 받지 않았는데도 wait()에서 깨어나는 현상입니다. C++ 표준, POSIX의 pthread_cond_wait, Windows의 조건 변수 API 모두 이를 명시적으로 허용합니다. 조건 변수 구현이 시그널과 대기 큐를 완벽하게 맞추려면 비용이 크기 때문에 드물게 불필요한 기상을 허용하는 쪽을 택한 것입니다. 또 notify를 정상적으로 받았더라도, 깨어나 락을 다시 잡기 전에 다른 consumer가 먼저 작업을 가져가 버리면 깨어난 시점에는 큐가 비어 있습니다. 어느 경우든 “깨어났다”가 “조건이 만족됐다”를 뜻하지 않습니다.
// 위험: 깨어나면 조건 확인 없이 진행
cv.wait(lock);
auto item = std::move(queue.front()); // 큐가 비어 있으면 정의되지 않은 동작
queue.pop();
// 안전: 깨어날 때마다 조건을 다시 검사
cv.wait(lock, [&] { return !queue.empty(); });
auto item = std::move(queue.front());
queue.pop();
predicate 오버로드 대신 while (!condition) cv.wait(lock); 루프를 직접 써도 의미는 같습니다. 조건이 복잡해 람다로 쓰기 어색할 때 이 형태를 씁니다.
wait_for, wait_until
타임아웃이 필요하면 wait_for나 wait_until을 씁니다. predicate 오버로드는 타임아웃이 지난 시점의 predicate 값을 반환하므로, 반환값이 false면 타임아웃이고 조건은 여전히 거짓이라는 뜻입니다.
std::unique_lock<std::mutex> lock(mtx);
bool got = cv.wait_for(lock, std::chrono::seconds(5),
[&] { return !queue.empty(); });
if (!got) {
// 5초 동안 큐가 비어 있었음
}
predicate 없는 wait_for는 std::cv_status::timeout과 no_timeout을 반환하는데, no_timeout도 허위 깨움일 수 있으므로 결국 조건을 따로 검사해야 합니다. 그래서 predicate 버전을 쓰는 편이 간단합니다.
스레드 안전 작업 큐
한쪽 스레드들이 작업을 넣고 다른 쪽 스레드들이 꺼내 처리하는 producer-consumer 구조를 condition_variable로 구현합니다. 종료 처리를 위해 running_ 플래그를 함께 둡니다.
// g++ -std=c++17 -pthread -O2 -o producer_consumer producer_consumer.cpp && ./producer_consumer
#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();
}
// 작업을 꺼내면 true, 종료되어 더 꺼낼 작업이 없으면 false
bool popAndWait(Task& task) {
std::unique_lock<std::mutex> lock(mtx_);
cv_.wait(lock, [this] { return !queue_.empty() || !running_; });
if (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;
std::vector<std::thread> workers;
for (int i = 0; i < 4; ++i) {
workers.emplace_back([&queue] {
Task task;
while (queue.popAndWait(task)) task();
});
}
for (int i = 0; i < 20; ++i) {
queue.push([i] {
std::cout << "Task " << i << " on thread "
<< std::this_thread::get_id() << "\n";
});
}
queue.shutdown(); // 남은 작업을 모두 처리한 뒤 워커가 끝납니다.
for (auto& w : workers) w.join();
std::cout << "Done.\n";
}
push는 락 안에서 큐에 넣고, 락을 푼 뒤 notify_one()으로 워커 하나를 깨웁니다. popAndWait의 predicate는 “큐에 작업이 있거나 종료 중”일 때 참이 되며, 큐가 비어 있는데 종료 중이면 false를 반환해 워커 루프를 끝냅니다. 종료 중이라도 큐에 작업이 남아 있으면 계속 꺼내 주므로, shutdown()을 호출한 뒤에도 이미 넣은 작업은 모두 처리됩니다. 그래서 main에서 shutdown 전에 sleep으로 기다릴 필요가 없습니다. 여러 워커가 std::cout에 동시에 쓰므로 출력 줄이 섞일 수 있는데, 이는 예제를 단순하게 두기 위한 것입니다.
shutdown에서 notify_one()이 아니라 notify_all()을 쓴 이유는, 잠들어 있는 워커 전부가 running_이 바뀐 것을 확인하고 빠져나와야 하기 때문입니다. 하나만 깨우면 나머지 워커는 영원히 잠들어 join()이 끝나지 않습니다.
condition_variable로 만드는 스레드 풀
스레드 풀(thread pool)은 미리 만들어 둔 워커 스레드들이 작업 큐에서 작업을 꺼내 처리하는 구조입니다. 작업마다 std::thread를 만들고 없애는 비용을 피할 수 있고, 동시에 실행되는 스레드 수를 일정하게 제한할 수 있습니다. 앞의 작업 큐에 결과를 돌려받는 std::future를 붙이면 됩니다.
// g++ -std=c++17 -pthread -O2 -o thread_pool thread_pool.cpp && ./thread_pool
#include <condition_variable>
#include <functional>
#include <future>
#include <iostream>
#include <memory>
#include <mutex>
#include <queue>
#include <stdexcept>
#include <thread>
#include <type_traits>
#include <vector>
class ThreadPool {
public:
explicit ThreadPool(size_t num_threads) {
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 R = std::invoke_result_t<F, Args...>;
auto task = std::make_shared<std::packaged_task<R()>>(
std::bind(std::forward<F>(f), std::forward<Args>(args)...));
std::future<R> 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() {
for (;;) {
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_ = false;
};
int main() {
ThreadPool pool(4);
auto f1 = pool.submit([](int a, int b) { 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";
});
}
pool.shutdown();
std::cout << "Done.\n";
}
구현에서 볼 점은 다음과 같습니다.
worker()는 작업을 꺼내는 동안만 락을 잡고,task()실행은 락 밖에서 합니다. 락 안에서 실행하면 작업 하나가 끝날 때까지 다른 워커가 큐에 접근하지 못해 풀이 사실상 단일 스레드가 됩니다.std::packaged_task는 이동만 가능한데std::function은 복사 가능한 호출 객체를 요구하므로,shared_ptr로 감싼 뒤 그 포인터를 캡처했습니다. 작업에서 던진 예외는packaged_task가 잡아 future에 저장하므로get()에서 다시 던져집니다.shutdown()은 소멸자에서도 호출되므로 두 번 불릴 수 있습니다.joinable()검사 덕분에 두 번째 호출은 아무 일도 하지 않습니다.- 반환값을 받지 않는
pool.submit(...)호출은 future를 바로 버립니다.std::async가 돌려준 future와 달리packaged_task의 future는 소멸자에서 블로킹하지 않으므로 문제없습니다.
자주 하는 실수
predicate에 종료 조건을 넣지 않음
// 종료 플래그를 바꿔도 큐가 비어 있으면 다시 잠듦
cv.wait(lock, [this] { return !queue.empty(); });
shutdown()이 notify_all()로 모두를 깨워도 predicate가 여전히 거짓이라 워커가 다시 잠들고, join()이 영원히 끝나지 않습니다. predicate에 || !running을 넣고, 깨어난 뒤 종료 상태인지 확인해야 합니다.
조건을 바꾸지 않고 notify만 호출하거나, 락 밖에서 조건을 바꿈
// 1. notify가 먼저: consumer가 깨어나 빈 큐를 보고 다시 잠든 뒤 push → 알림 유실
cv.notify_one();
{ std::lock_guard<std::mutex> lock(mtx); queue.push(item); }
// 2. 락 없이 조건 변경: 플래그가 atomic이어도 알림이 유실될 수 있음
ready = true; // std::atomic<bool>
cv.notify_one();
첫 번째는 순서 문제입니다. 조건을 바꾸기 전에 깨운 consumer는 아무것도 없는 큐를 보고 다시 잠들고, 그 뒤에는 아무도 깨워 주지 않습니다. 두 번째는 덜 알려진 함정입니다. consumer가 락을 잡고 predicate에서 ready == false를 확인한 직후, 아직 잠들기 전 시점에 producer가 락 없이 ready = true와 notify_one()을 끝내 버리면 그 알림은 대기자가 없어 사라지고, consumer는 그대로 잠듭니다. 조건 변경을 같은 mutex 안에서 하면 consumer가 확인과 잠들기를 원자적으로 하는 동안 producer가 끼어들 수 없으므로 이 경쟁이 없어집니다. 공유 상태가 atomic이더라도 condition_variable과 함께 쓴다면 mutex를 잡고 바꿔야 합니다.
락 밖에서 공유 상태를 검사
if (queue.empty()) { // 락 없이 읽음: 데이터 레이스
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock);
}
검사와 대기 사이에 상태가 바뀔 수 있고, 락 없이 std::queue를 읽는 것 자체가 데이터 레이스입니다. 검사는 항상 predicate 안에서, 즉 락을 잡은 상태에서 해야 합니다.
서로 다른 조건을 condition_variable 하나로 기다림
크기 제한이 있는 큐에서 producer는 “가득 차지 않음”을, consumer는 “비어 있지 않음”을 기다립니다. 이 두 조건을 condition_variable 하나로 처리하면서 notify_one()을 쓰면 실제로 멈추는 버그가 생깁니다. consumer가 pop한 뒤 producer를 깨우려고 notify_one()을 호출했는데 마침 다른 consumer가 깨어나면, 그 consumer는 빈 큐를 보고 다시 잠들고 알림은 그대로 소비됩니다. 정작 깨어나야 할 producer는 계속 잠들어 있게 됩니다. 조건 변수 하나를 계속 쓰려면 notify_all()로 모두 깨워야 하고, 그러면 불필요한 기상이 늘어납니다. 기다리는 조건마다 condition_variable을 하나씩 두는 것이 정석입니다.
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));
lock.unlock();
not_empty_.notify_one();
}
T pop() {
std::unique_lock<std::mutex> lock(mtx_);
not_empty_.wait(lock, [this] { return !queue_.empty(); });
T item = std::move(queue_.front());
queue_.pop();
lock.unlock();
not_full_.notify_one();
return item;
}
private:
std::queue<T> queue_;
size_t max_size_;
std::mutex mtx_;
std::condition_variable not_empty_;
std::condition_variable not_full_;
};
producer가 consumer보다 빠를 때 큐가 무한정 커지는 것을 막아 주는 배압(backpressure) 장치로 쓰입니다. 실제로 쓰려면 앞의 작업 큐처럼 종료 플래그를 두 predicate에 모두 추가해야 합니다.
락을 잡은 채 notify
{
std::lock_guard<std::mutex> lock(mtx);
queue.push(item);
cv.notify_one(); // 락 안에서 notify
}
이 코드는 올바르게 동작합니다. 다만 깨어난 스레드가 곧바로 같은 락을 잡으려 하는데 producer가 아직 락을 쥐고 있어 한 번 더 기다리게 될 수 있습니다. 구현에 따라 이를 최적화하기도 하지만, 일반적으로는 락 범위를 닫은 뒤 notify하는 쪽을 씁니다. 예외는 notify 직후 조건 변수 객체가 파괴될 수 있는 경우로, 이때는 락 안에서 notify해야 대기 스레드가 이미 파괴된 객체에 접근하지 않습니다.
직접 만들 필요가 없는 경우
“N개 워커가 모두 준비될 때까지 기다린다” 같은 카운트 기반 대기는 condition_variable과 카운터로 만들 수 있지만, C++20에서는 표준 std::latch(한 번 쓰고 끝)와 std::barrier(단계마다 재사용, 완료 콜백 지원)가 이 역할을 합니다.
#include <latch>
std::latch ready(num_workers);
// 각 워커: 준비가 끝나면 ready.count_down();
ready.wait(); // 메인 스레드: 모든 워커가 준비될 때까지 대기
직접 만든 래치는 count_down을 초과 호출해 카운트가 음수가 되는 경우를 막지 않는 경우가 많은데, 표준 std::latch는 이를 사전 조건 위반으로 명시합니다. 동시에 접근할 수 있는 리소스 수를 제한하는 문제라면 std::counting_semaphore가 더 직접적이고, shared_mutex처럼 std::mutex가 아닌 락과 함께 기다려야 한다면 어떤 락 타입이든 받는 std::condition_variable_any를 씁니다.
다음 글: C++ 실전 가이드 #7-4: atomic과 메모리 모델 기초