1. producer–consumer 아이디어와 역할
실제 프로그램을 보면 한 코드 조각은 “작업을 생성”하고 다른 조각은 그 작업을 “처리”하는 일이 아주 자주 발생합니다. 예를 들어, 사용자가 버튼을 누르면 이벤트를 생성하고 이벤트 핸들러가 이를 처리해야 합니다; 네트워크가 패킷을 가져오면 비즈니스 로직이 이를 처리해야 합니다; 로거가 메시지를 받아 파일에 기록합니다. 이런 상황에서는 producer가 처리 완료를 기다리지 않고 계속 작업을 생성하기를 바라고, consumer는 “내 작업은 어디 있지?”라고 10ms마다 바쁘게 루프 돌며 CPU를 낭비하지 않기를 바랍니다.
바로 이런 곳에 producer–consumer 모델이 등장합니다. Producer(생산자)는 큐에 “작업”을 넣고, consumer(소비자)는 그것을 꺼내 실행합니다. 그 사이에 있는 것이 작업 큐이며 — 공통 자원으로서 mutex로 보호되고 condition_variable로 «깨워»져서 consumer가 작업이 없을 때 잠들 수 있게 합니다.
세 가지 역할: producer, 큐, consumer
멀티스레딩을 마법이나 샤먼의 춤으로 만들지 않으려면 시스템을 세 부분으로 나누어 생각하는 것이 편합니다. Producer는 큐에 요소를 추가하는 역할입니다. Consumer는 요소를 꺼내 유용한 작업을 수행합니다. 큐는 이들 사이의 버퍼이자 동시에 진실의 근원입니다: 지금 작업이 있는가 없는가. 알림(notify_*)은 작업이 아니라 단지 “일어나서 상태를 다시 확인해”라는 정중한 찌름입니다.
역할을 정리한 작은 표:
| 역할 | 무엇을 하는가 | 무엇과 작업하는가 | 잘못하면 어떤 위험이 있는가 |
|---|---|---|---|
| Producer | 작업을 넣음 |
를 로 보호 |
알림을 잃거나 consumer를 깨우지 못함 |
| 큐 | 작업을 보관 | |
데이터 레이스, 구조 손상, 교착 |
| Consumer | 대기하고 로 작업을 꺼냄 |
로 “큐가 비어있지 않음” 조건 대기 |
busy-wait, 빈 큐 읽기, 데드락 |
개념적으로는 다음과 같습니다:
flowchart LR
P[Producer<br/>작업을 넣음] -->|push under mutex| Q[작업 큐<br/>공통 상태]
Q -->|pop under mutex| C[Consumer / Worker<br/>처리함]
P -->|notify_one| CV[condition_variable]
CV -->|깨움| C
핵심: consumer는 notify 자체가 아니라 큐의 상태를 기다린다 (예: !q.empty()). Notify는 단순히 “조건이 참이 되었을지도 모른다”는 신호입니다.
작업 큐를 보호되는 상태로 만들기
사람들은 종종 “q.empty()를 확인하고 비어있으면 기다리고, 아니면 꺼낸다”처럼 쓰고 싶어합니다. 단일 스레드에서는 괜찮지만 멀티스레드에서는 “봤더니 비어있었는데, 눈 깜빡이는 사이에 상황이 바뀐다”라는 스토리가 됩니다. 그래서 producer–consumer에는 철칙이 있습니다: 큐에 대한 모든 연산은 동일한 mutex 아래에서만 수행해야 합니다. 심지어 empty()도 공유 상태를 읽는 것이므로 락 없이 읽는 것은 안전하지 않습니다.
보통 우리는 다음 연산들을 보호합니다:
- Producer: lock → q.push(x) → unlock → notify_one
- Consumer: lock → wait(큐가 비어있지 않을 때까지) → 요소를 가져옴 → q.pop() → unlock → 요소 처리
“처리”라는 단어에 주목하세요: 처리는 락 밖에서 수행되어야 합니다. 락 안에서는 최소한만 수행합니다: 조건 확인, 큐에서 데이터를 로컬 변수로 이동, 큐 상태 업데이트.
왜 empty()도 락 없이 만지면 안 되는지 느끼게 하기 위한 작은 안티패턴(집에서 따라 하지 마세요 — 절대) 예가 있습니다:
#include <queue>
std::queue<int> q;
bool has_work_unsafe() {
return !q.empty(); // BAD: 멀티스레딩에서 mutex 없이 이렇게 하면 안 됨
}
설사 empty()를 “그냥 보는 것”으로 생각하더라도, 멀티스레딩에서는 여전히 공유 리소스를 일관성 없이 읽는 것이 됩니다. 전형적인 문제는 당신이 false를 보고(비어있지 않음) front()로 들어갔다가 큐가 이미 비어있게 되어 논리적 오류가 발생하는 것입니다.
2. 프로토콜: 락, 대기, 실행
임계 구역의 경계: “작업을 꺼냈으면 락을 풀 것”
초보자들은 흔히 두 가지 극단 중 하나를 택합니다. 첫째는 안전을 위해 mutex를 항상 잡고 있는 것입니다. 그러면 consumer가 작업을 오래 처리하는 동안 producer는 아무 것도 추가할 수 없게 되어 멀티스레딩의 의미가 사라집니다. 둘째는 거의 락을 잡지 않고 ‘운이 좋겠지’라고 기대하는 방식입니다. 보통은 운이 좋지 않습니다.
올바른 스타일은: mutex 아래에서는 오직 “큐 조작”만 하고, 작업 처리는 임계 구역을 벗어나서 수행하는 것입니다.
두 코드 조각을 비교해 봅시다. 먼저 나쁜 예(락을 너무 오래 잡음):
#include <mutex>
#include <queue>
#include <iostream>
std::mutex m;
std::queue<int> q;
void process_bad() {
std::lock_guard<std::mutex> lock(m);
int x = q.front();
q.pop();
// BAD: mutex 안에서 오랜 작업을 함
std::cout << "Processing " << x << "\n";
}
이제 좋은 예(락은 짧고 처리는 밖에서):
#include <mutex>
#include <queue>
#include <iostream>
std::mutex m;
std::queue<int> q;
void process_good() {
int x = 0;
{
std::lock_guard<std::mutex> lock(m);
x = q.front();
q.pop();
} // mutex가 여기서 풀림
std::cout << "Processing " << x << "\n"; // 이제 mutex 없이
}
두번째 버전이 더 좋은 이유는 producer가 이전 작업을 처리하는 동안에도 계속 새 작업을 넣을 수 있기 때문입니다. 비유하자면: “주문을 빠르게 받아 계산대에서 물러나 커피를 준비한다 — 큐를 차단하지 않는다”는 것입니다.
최소한의 작업 큐: TaskQueue 설계
이제 이후에 worker를 쉽게 만들 수 있는 작은 블록을 만들어봅시다. 목표는 클래스가 세 가지를 캡슐화하도록 하는 것입니다: std::queue, std::mutex, std::condition_variable. 그러면 프로토콜 규칙이 코드 곳곳에 흩어지지 않고 한 곳에 모입니다. 이는 단순히 실수로 락을 잊지 않게 하는 실용적인 방법입니다.
예제용으로 작업 모델은 id와 워커가 “실행”할 텍스트를 출력하는 것으로 합시다:
#include <string>
struct Task {
int id = 0;
std::string text;
};
이제 큐의 뼈대를 만듭니다:
#include <condition_variable>
#include <mutex>
#include <queue>
class TaskQueue {
public:
void push(Task t);
Task pop_blocking();
private:
std::mutex m_;
std::condition_variable cv_;
std::queue<Task> q_;
};
push을 구현합시다. 우리는 상태(즉, q_)를 mutex 아래에서 변경한 뒤 기다리는 consumer에게 알립니다. 알림은 보통 lock_guard 범위를 벗어난 후에 호출하는데, 이는 알림을 받아 깨어난 스레드가 불필요하게 락을 얻어 바로 블록되는 것을 피하기 위해서입니다. 단순한 시나리오에서는 락 안에서 알림을 해도 정합성에는 문제가 없고 단지 성능이 조금 나쁠 수 있습니다.
#include <utility> // std::move
void TaskQueue::push(Task t) {
{
std::lock_guard<std::mutex> lock(m_);
q_.push(std::move(t));
}
cv_.notify_one();
}
이제 pop_blocking입니다. 여기서는 std::unique_lock이 필요합니다. 왜냐하면 condition_variable::wait은 내부적으로 일시적으로 mutex를 풀었다가 깨어날 때 다시 잡을 수 있어야 하기 때문입니다. lock_guard는 그런 유연성이 없습니다 — 스코프 끝까지 잡기만 합니다.
Task TaskQueue::pop_blocking() {
std::unique_lock<std::mutex> lock(m_);
cv_.wait(lock, [&] {
return !q_.empty();
});
Task t = std::move(q_.front());
q_.pop();
return t;
}
주의할 점: 우리는 Task를 값으로 반환합니다. 이는 편리하고 안전합니다: 우리는 락 아래에서 큐에서 작업을 가져와 로컬로 옮기고, 이후 워커는 자신의 복사본/이동된 값을 처리합니다. 큐의 요소에 대한 참조를 반환하면 pop() 뒤에 요소가 사라지므로 위험합니다.
워커 스레드: consumer 루프
워커 스레드는 보통 무한 루프 형태로 존재합니다: “작업 대기 → 꺼내기 → 실행”. 이는 점원이 다음 손님을 기다리는 것과 비슷합니다: 점원은 10ms마다 가게를 뛰어다니며 “왔나?”를 묻지 않고 조용히 기다립니다. 코드에서는 condition_variable::wait가 이 “조용히 기다림” 역할을 합니다.
worker 함수를 만들어봅시다. 종료 프로토콜을 지금 설계하지 않기 위해 학습 목적의 제한을 둡니다: 워커는 정확히 N개의 작업을 처리하고 종료합니다. 운영 환경과는 다르지만, 데드락 없이 동작 모델을 검증하기에는 충분합니다.
#include <chrono>
#include <iostream>
#include <thread>
void worker_loop(TaskQueue& queue, int tasks_to_process) {
for (int i = 0; i < tasks_to_process; ++i) {
Task t = queue.pop_blocking(); // 작업이 생길 때까지 기다림
// 큐 밖에서 처리: 작업을 흉내냄
std::cout << "[worker] task #" << t.id << ": " << t.text << "\n";
// 출력 예: [worker] task #1: say hello
std::this_thread::sleep_for(std::chrono::milliseconds{50});
}
std::cout << "[worker] done\n"; // [worker] done
}
여기서 중요한 점은 pop_blocking()이 busy-wait 없이 스레드를 블록한다는 것입니다. 워커는 CPU를 소모하며 루프를 도는 것이 아니라 producer가 작업을 추가하고 notify_one()을 호출할 때까지 잠들어 있습니다.
Producer: 워커 실행하고 작업 큐에 넣기
이제 간단한 콘솔 애플리케이션을 만들어봅시다. 메인 스레드는 producer입니다: 작업을 생성해 큐에 넣습니다. 두번째 스레드는 consumer로, 작업을 꺼내 출력합니다. 이는 실제 상황과 매우 유사합니다: 메인 스레드는 이벤트를 생성하고 worker는 이를 처리합니다.
간단한 main() 예시는 다음과 같습니다:
#include <iostream>
#include <thread>
#include <vector>
int main() {
TaskQueue queue;
const std::vector<Task> tasks = {
{1, "say hello"},
{2, "compute something (pretend)"},
{3, "write log line"},
{4, "send email (pretend)"},
{5, "cleanup"},
};
std::thread worker(worker_loop, std::ref(queue), (int)tasks.size());
for (const auto& t : tasks) {
std::cout << "[main] push task #" << t.id << "\n";
// 출력 예: [main] push task #1
queue.push(t);
}
worker.join();
std::cout << "[main] program finished\n"; // [main] program finished
}
여기서 몇 가지 세부사항을 말해두고 싶습니다. std::ref(queue)는 큐를 참조로 전달하기 위해 필요합니다. 그렇지 않으면 std::thread는 queue를 복사하려 할 것이고, 우리의 TaskQueue는 내부에 mutex가 있어 복사 불가능합니다.
또한 순서에 주의하세요: 먼저 worker를 시작하고 그다음 작업을 넣는 방식으로 해도 되고 반대로 해도 “논리적으로” 깨지지는 않습니다. 중요한 것은 pop_blocking()이 상태를 올바르게 기다리기 때문에 누가 먼저든 프로토콜이 올바르게 동작해야 한다는 점입니다. 이는 좋은 프로토콜의 중요한 특징입니다.
mutex가 정확히 어디에 걸리는가: 프로토콜의 X-ray
처음에는 멀티스레딩이 블랙박스처럼 느껴집니다: “어떤 스레드가 기다리고 깨어나고 뭔가 하고…”. 마음이 편해지려면 consumer 루프 하나를 단계별로 나누고 어디서 락을 잡고 풀리는지 명확히 표시하는 것이 도움이 됩니다. 그러면 condition_variable이 마법이 아니라 평범한 프로토콜로 보입니다.
한 번의 push와 pop에 대한 이벤트 시퀀스는 다음과 같습니다:
sequenceDiagram
participant P as Producer (main)
participant M as Mutex
participant Q as Queue
participant CV as condition_variable
participant C as Consumer (worker)
C->>M: lock (unique_lock)
C->>CV: wait(lock, pred)<br/>mutex는 wait 내부에서 풀림
Note over C,CV: worker 스레드는 잠듦
P->>M: lock (lock_guard)
P->>Q: push(task)
P->>M: unlock
P->>CV: notify_one()
CV-->>C: wake up
C->>M: lock (wait에서 복귀하며 mutex를 다시 잡음)
C->>Q: pop(task)
C->>M: unlock
C->>C: task 처리 (mutex 없음)
요약하자면 거의 선(禅) 같습니다: 락 안에서는 상태를 읽고 변경하고, wait 는 “락을 일시적으로 풀고 잠들었다가 락과 함께 깨어나는 것”이다.
자주 발생하는 실수 중 하나는 작업 처리 중에 락을 유지하는 것입니다. 진단은 간단합니다: 워커가 mutex를 잡고 있는 상태에서 sleep_for를 호출하거나 많은 출력을 하거나 네트워크 호출을 하면, 당신의 큐는 병목이 됩니다.
3. 전형적인 실수
실수 1: “나는 그냥 볼 것이라서 empty() 를 mutex 없이 확인하겠다.”
이것이 희귀하고 잡기 어려운 버그의 시작입니다. 멀티스레딩에서 “그냥 본다”는 다른 스레드가 동시에 큐를 수정할 수 있으면 이미 데이터 레이스에 참여하는 것입니다. 올바른 규율은 모든 empty()/front()/pop()/push()가 동일한 mutex 아래에서 수행되는 것입니다.
실수 2: consumer가 front()/pop() 을 !empty()의 보장 없이 수행한다.
비록 당신이 “producer가 이미 넣었을 것”이라고 생각해도 실제 스케줄러는 그런 계약을 맺지 않았습니다. Consumer는 큐가 비어있지 않은 상태를 기다린 후에만 요소를 꺼내야 합니다. 올바른 모델은 cv.wait(lock, predicate)을 사용하거나 이에 상응하는 대기 루프를 사용하는 것입니다.
실수 3: 작업 처리 중에 mutex를 잡고 있음.
이는 병렬성을 죽입니다. 결과적으로 producer는 worker가 끝나기를 기다려야 합니다. 올바른 패턴은 락 아래에서 “작업을 큐에서 꺼내서 로컬로 이동”한 다음 락을 풀고 실제 처리는 락 밖에서 하는 것입니다. 처리가 오래 걸리면 더더욱 밖에서 해야 합니다.
실수 4: notify_one()을 어딘가 나중에 호출하거나 아예 잊어버림.
Producer가 상태를 변경하여 대기 조건이 참이 되었는데(큐가 비어있지 않음 등) notify_*을 호출하지 않으면 consumer는 너무 오래 잠들어 있을 수 있습니다. 좋은 구현에서는 push가 항상 알림으로 끝납니다. 이는 최적화가 아니라 프로토콜의 일부입니다.
실수 5: 큐 내부의 요소에 대한 참조/포인터를 밖으로 반환함.
큐는 컨테이너이고 그 안의 요소는 당신이 그것을 삭제할 때까지 살아 있습니다. 만약 당신이 front()를 호출하고 pop()을 한 뒤 외부에서 그 “front”에 대한 참조를 보관하면, 이미 존재하지 않는 객체를 가리키게 됩니다. 안전한 관행은 락 아래에서 값을 복사하거나 std::move로 로컬 변수로 옮긴 다음, 로컬 변수로 작업하는 것입니다.
GO TO FULL VERSION