생산자-소비자 문제 — 세마포어로 해결하기
생산자-소비자 문제는 운영체제 동기화의 가장 실용적인 예제입니다. Kafka, RabbitMQ 같은 메시지 큐의 기본 원리이기도 합니다.
문제 정의
생산자(Producer) 가 데이터를 만들어 공유 버퍼 에 넣고, 소비자(Consumer) 가 버퍼에서 데이터를 꺼내 처리합니다.
Producer → [버퍼 (크기 N)] → Consumer
제약 조건:
1. 버퍼가 가득 차면 Producer는 대기
2. 버퍼가 비어있으면 Consumer는 대기
3. 동시에 버퍼에 접근하면 안 됨 (상호 배제)
세마포어를 이용한 해법
#define BUFFER_SIZE 10
int buffer[BUFFER_SIZE];
int in = 0, out = 0;
semaphore mutex = 1; // 버퍼 접근 상호 배제
semaphore empty = BUFFER_SIZE; // 빈 슬롯 수
semaphore full = 0; // 채워진 슬롯 수
// 생산자
void producer() {
while (true) {
int item = produce_item(); // 아이템 생산
wait(empty); // 빈 슬롯이 있을 때까지 대기
wait(mutex); // 버퍼 잠금
buffer[in] = item; // 버퍼에 넣기
in = (in + 1) % BUFFER_SIZE;
signal(mutex); // 버퍼 잠금 해제
signal(full); // 채워진 슬롯 수 증가
}
}
// 소비자
void consumer() {
while (true) {
wait(full); // 채워진 슬롯이 있을 때까지 대기
wait(mutex); // 버퍼 잠금
int item = buffer[out]; // 버퍼에서 꺼내기
out = (out + 1) % BUFFER_SIZE;
signal(mutex); // 버퍼 잠금 해제
signal(empty); // 빈 슬롯 수 증가
consume_item(item); // 아이템 소비
}
}
세마포어의 역할
| 세마포어 | 초기값 | 역할 |
|---|---|---|
| mutex | 1 | 버퍼 동시 접근 방지 (이진 세마포어) |
| empty | N | 빈 슬롯 수 추적 — 0이면 Producer 대기 |
| full | 0 | 채워진 슬롯 수 추적 — 0이면 Consumer 대기 |
wait/signal 순서가 중요한 이유
// 잘못된 순서 — 데드락 발생!
void producer_wrong() {
wait(mutex); // 먼저 잠금
wait(empty); // 빈 슬롯 대기 → 버퍼가 꽉 차면 여기서 블록
// Consumer가 mutex를 기다림 → 데드락!
}
반드시 세마포어(empty/full)를 먼저 wait하고, mutex를 나중에 wait 해야 합니다.
Java로 구현
BlockingQueue 사용 (가장 간단)
BlockingQueue<Integer> buffer = new ArrayBlockingQueue<>(10);
// 생산자
new Thread(() -> {
while (true) {
int item = produceItem();
buffer.put(item); // 가득 차면 자동 대기
}
}).start();
// 소비자
new Thread(() -> {
while (true) {
int item = buffer.take(); // 비어있으면 자동 대기
consumeItem(item);
}
}).start();
모니터(synchronized + wait/notify) 사용
class BoundedBuffer<T> {
private final Queue<T> queue = new LinkedList<>();
private final int capacity;
public BoundedBuffer(int capacity) {
this.capacity = capacity;
}
public synchronized void put(T item) throws InterruptedException {
while (queue.size() == capacity) {
wait(); // 버퍼가 가득 차면 대기
}
queue.add(item);
notifyAll(); // 소비자 깨움
}
public synchronized T take() throws InterruptedException {
while (queue.isEmpty()) {
wait(); // 버퍼가 비어있으면 대기
}
T item = queue.poll();
notifyAll(); // 생산자 깨움
return item;
}
}
생산자-소비자 패턴
| 시스템 | 생산자 | 버퍼 | 소비자 |
|---|---|---|---|
| Kafka | 이벤트 발행 서비스 | 토픽(파티션) | 이벤트 처리 서비스 |
| 스레드 풀 | 작업 제출자 | 작업 큐 | 워커 스레드 |
| 로깅 | 애플리케이션 | 로그 버퍼 | 로그 기록 스레드 |
| 파이프라인 | 이전 단계 | 파이프 버퍼 | 다음 단계 |
핵심 포인트
- 세마포어 3개의 역할: mutex(상호 배제), empty(빈 공간 추적), full(데이터 추적)
- wait 순서 실수 → 데드락: mutex를 먼저 잡으면 안 됨
- while vs if:
wait()에서 깨어날 때 조건을 다시 확인해야 함 (Spurious Wakeup) - 활용: Java의
BlockingQueue가 이 문제의 완전한 추상화
정리
생산자-소비자 문제는 동기화의 기본이면서, 실제로 메시지 큐와 스레드 풀의 근간이 되는 패턴입니다. 세마포어 3개의 역할과 wait 순서를 정확히 이해하고 있으면 실제로 충분히 설명할 수 있습니다.