모니터와 고전적 동기화 문제
세마포어는 강력하지만 위험합니다. wait과 signal의 순서를 프로그래머가 직접 관리해야 하며, 순서가 틀리면 데드락이 발생하고, signal을 빠뜨리면 영원히 대기하고, wait을 빠뜨리면 상호 배제가 깨집니다. 이 실수들은 컴파일 타임에 잡히지 않고 런타임에도 간헐적으로만 나타나서, 찾기가 극도로 어렵습니다.
모니터(Monitor)는 이런 저수준 실수를 언어 또는 라이브러리 차원에서 방지하는 고수준 동기화 도구입니다. 1974년 Hoare와 Hansen이 제안했으며, Java, Python, C# 등 주류 언어에 구현되어 있습니다.
모니터의 개념
모니터는 상호 배제가 내장된 추상 데이터 타입입니다. 핵심 특성:
- 모니터 안의 메서드(프로시저)는 한 번에 하나의 스레드만 실행할 수 있습니다.
- 개발자가 락을 직접 잡고 풀 필요가 없습니다 — 모니터가 자동으로 처리합니다.
- 조건 변수(Condition Variable)를 통해 특정 조건이 만족될 때까지 대기할 수 있습니다.
세마포어와의 핵심 차이: 세마포어는 프로그래머가 wait/signal을 올바른 위치에 올바른 순서로 배치해야 합니다. 모니터는 상호 배제를 구조적으로 보장하므로, 어디에 lock을 넣을까 고민이 없습니다.
Java의 synchronized — 모니터 구현
Java에서 모든 객체는 내장 모니터를 가지고 있습니다. synchronized 키워드로 접근합니다.
public class BoundedBuffer<T> {
private final Object[] buffer;
private int count = 0, in = 0, out = 0;
public BoundedBuffer(int size) {
buffer = new Object[size];
}
public synchronized void produce(T item) throws InterruptedException {
while (count == buffer.length) {
wait(); // 버퍼 가득 참 → 조건 변수에서 대기 (락 자동 반납)
}
buffer[in] = item;
in = (in + 1) % buffer.length;
count++;
notifyAll(); // 대기 중인 소비자 깨우기
}
@SuppressWarnings("unchecked")
public synchronized T consume() throws InterruptedException {
while (count == 0) {
wait(); // 버퍼 비어있음 → 조건 변수에서 대기
}
T item = (T) buffer[out];
out = (out + 1) % buffer.length;
count--;
notifyAll(); // 대기 중인 생산자 깨우기
return item;
}
}synchronized 메서드에 진입하면 자동으로 해당 객체의 모니터 락이 잡힙니다. 다른 스레드가 같은 객체의 synchronized 메서드를 호출하면 블로킹됩니다. 메서드가 반환되면(예외로든 정상으로든) 락이 자동으로 해제됩니다.
wait(): 현재 스레드를 조건 큐(Wait Set)에 넣고, 모니터 락을 원자적으로 반납합니다. 다른 스레드가 notify()나 notifyAll()을 호출할 때까지 대기합니다.
notifyAll(): 조건 큐의 모든 스레드를 깨웁니다. 깨어난 스레드들은 모니터 락을 다시 경쟁합니다.
notify(): 조건 큐에서 하나의 스레드만 깨웁니다. 어떤 스레드가 깨어나는지는 비결정적입니다. notifyAll()이 더 안전합니다.
Python의 Condition — 모니터 구현
Python에서는 threading.Condition이 모니터 역할을 합니다.
import threading
class BoundedBuffer:
def __init__(self, size):
self.buffer = []
self.size = size
self.condition = threading.Condition()
def produce(self, item):
with self.condition: # 자동으로 락 획득
while len(self.buffer) >= self.size:
self.condition.wait() # 락 반납 + 대기
self.buffer.append(item)
self.condition.notify_all() # 대기자 깨우기
def consume(self):
with self.condition:
while len(self.buffer) == 0:
self.condition.wait()
item = self.buffer.pop(0)
self.condition.notify_all()
return itemwith self.condition 블록에 진입하면 자동으로 내부 락이 잡히고, 블록을 벗어나면 자동으로 풀립니다. wait() 호출 시 락이 반납되고 스레드가 대기합니다. notify_all()이 호출되면 대기 중인 스레드들이 깨어나고, 모니터 락을 재획득한 후 while 조건을 다시 확인합니다.
세마포어 버전과 비교하면, 모니터 버전은 empty.acquire() 전에 mutex.acquire()를 하는 데드락 실수가 구조적으로 불가능합니다. 모니터가 알아서 락을 관리하기 때문입니다.
조건 변수와 허위 각성
아래 다이어그램은 이 절의 핵심 흐름을 역할과 상태 전환 중심으로 정리한 것입니다.
while을 써야 하는 이유
조건 변수에서 wait() 후 깨어났을 때, 조건이 여전히 만족되지 않을 수 있습니다. 이를 허위 각성(Spurious Wakeup)이라고 합니다. 두 가지 원인이 있습니다.
-
OS/하드웨어 수준 허위 각성: OS 구현의 특성상, 명시적인 notify 없이도 스레드가 깨어날 수 있습니다. POSIX 표준에서 이를 허용합니다.
-
경쟁에 의한 허위 각성:
notifyAll()로 여러 소비자가 동시에 깨어났지만, 첫 번째 소비자가 버퍼의 마지막 아이템을 가져가면 두 번째 소비자는 빈 버퍼를 만납니다.
그래서 조건 변수는 반드시 while 루프와 함께 사용합니다.
# 올바른 코드
while not condition_is_true():
condition.wait()
# 위험한 코드 (절대 사용하지 말 것)
if not condition_is_true():
condition.wait()
# 깨어났을 때 조건이 여전히 거짓일 수 있음!C의 pthread 조건 변수
#include <stdio.h>
#include <pthread.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t data_ready = PTHREAD_COND_INITIALIZER;
int data_available = 0;
void *producer(void *arg) {
pthread_mutex_lock(&mutex);
data_available = 1;
printf("데이터 준비 완료\n");
pthread_cond_signal(&data_ready); /* 대기 중인 소비자 하나 깨우기 */
pthread_mutex_unlock(&mutex);
return NULL;
}
void *consumer(void *arg) {
pthread_mutex_lock(&mutex);
while (!data_available) { /* while, not if */
pthread_cond_wait(&data_ready, &mutex);
/* wait 중에는 mutex가 반납됨 */
/* 깨어나면 mutex를 다시 잡은 상태로 리턴 */
}
printf("데이터 소비\n");
data_available = 0;
pthread_mutex_unlock(&mutex);
return NULL;
}pthread_cond_wait(&cond, &mutex)는 세 가지를 원자적으로 수행합니다. mutex 반납 → 조건 큐에 추가 → 슬립. 깨어날 때 mutex를 재획득한 후 리턴합니다. 이 원자성이 보장되지 않으면, mutex를 반납한 후 조건 큐에 추가되기 전에 producer가 signal을 보내면 잃어버린 깨우기(Lost Wakeup)가 발생합니다.
식사하는 철학자 문제
식사하는 철학자(Dining Philosophers)는 Dijkstra가 1965년에 제안한 동기화의 가장 유명한 문제입니다. 5명의 철학자가 원탁에 앉아 있고, 각 철학자 사이에 젓가락(포크) 하나씩 총 5개가 놓여 있습니다. 식사하려면 양쪽의 젓가락 두 개가 모두 필요합니다.
문제 상황: 5명 모두 동시에 왼쪽 젓가락을 집으면, 오른쪽 젓가락은 이미 옆 사람이 잡고 있습니다. 5명 모두 오른쪽 젓가락을 기다리며 영원히 멈춥니다. 교착 상태(Deadlock)입니다.
해결 전략들
전략 1 — 비대칭 순서: 짝수 번 철학자는 왼쪽부터, 홀수 번 철학자는 오른쪽부터 집습니다. 7장에서 다룰 "순환 대기 조건"을 깨뜨립니다.
전략 2 — 최대 인원 제한: 동시에 식사를 시도하는 철학자를 최대 4명으로 제한합니다. 세마포어(초기값 4)를 사용합니다. 5개 자리에 4명만 앉으면, 최소 한 명은 양쪽 젓가락을 가질 수 있습니다.
전략 3 — 모니터 기반: 양쪽 젓가락이 모두 사용 가능할 때만 두 개를 동시에 집도록 합니다. 조건 변수로 "양쪽이 모두 비어있을 때까지" 대기합니다.
import threading
import time
import random
class DiningTable:
def __init__(self, n):
self.n = n
self.state = ["thinking"] * n # thinking, hungry, eating
self.condition = [threading.Condition() for _ in range(n)]
self.lock = threading.Lock()
def pickup(self, i):
with self.lock:
self.state[i] = "hungry"
self._test(i)
with self.condition[i]:
while self.state[i] != "eating":
self.condition[i].wait()
def putdown(self, i):
with self.lock:
self.state[i] = "thinking"
# 양옆 철학자가 먹을 수 있는지 확인
self._test((i - 1) % self.n)
self._test((i + 1) % self.n)
def _test(self, i):
left = (i - 1) % self.n
right = (i + 1) % self.n
if (self.state[i] == "hungry" and
self.state[left] != "eating" and
self.state[right] != "eating"):
self.state[i] = "eating"
with self.condition[i]:
self.condition[i].notify()
def philosopher(table, i):
for _ in range(3):
print(f"철학자 {i} 생각 중...")
time.sleep(random.uniform(0.1, 0.3))
table.pickup(i)
print(f"철학자 {i} 식사 중!")
time.sleep(random.uniform(0.1, 0.2))
table.putdown(i)
table = DiningTable(5)
threads = [threading.Thread(target=philosopher, args=(table, i)) for i in range(5)]
for t in threads: t.start()
for t in threads: t.join()왜 이 문제가 중요한가
식사하는 철학자는 교과서 문제처럼 보이지만, 실무에서 정확히 같은 구조가 반복됩니다.
- 데이터베이스 락: 트랜잭션 A가 테이블 1의 락을 잡고 테이블 2를 기다리고, 트랜잭션 B가 테이블 2의 락을 잡고 테이블 1을 기다리면 데드락
- 네트워크 라우팅: 라우터 A가 링크 1을 점유하고 링크 2를 기다리고, 라우터 B가 링크 2를 점유하고 링크 1을 기다리면 데드락
- 분산 시스템: 서비스 A가 서비스 B에 API 호출을 하며 대기하고, 서비스 B가 서비스 A에 콜백을 하며 대기하면 분산 데드락
실무에서의 동기화 패턴
현대 프로그래밍에서는 저수준 동기화 프리미티브보다 더 안전한 상위 추상화를 선호합니다.
Lock-Free 자료구조: CAS 기반으로 락 없이 동시 접근을 지원합니다. Java의 ConcurrentLinkedQueue, ConcurrentHashMap이 내부적으로 CAS를 사용합니다. 락에 의한 블로킹이 없으므로, 고성능 시스템에서 사용됩니다.
Actor 모델: 공유 상태 자체를 없앱니다. 각 Actor가 자신만의 상태를 가지고, 다른 Actor와는 메시지 전달로만 통신합니다. Erlang이 선구자이며, Akka(JVM), Orleans(.NET)이 대표적입니다.
채널 기반 통신: Go의 유명한 격언 "Do not communicate by sharing memory; instead, share memory by communicating." 고루틴 간에 채널을 통해 데이터를 전달합니다. 채널이 동기화를 내장하고 있어 별도의 락이 필요 없습니다.
비동기 프로그래밍: Python의 asyncio, JavaScript의 async/await처럼 단일 스레드에서 동시성을 구현합니다. 스레드가 하나이므로 경쟁 조건이 발생하지 않습니다(단, I/O 대기 중에 다른 코루틴이 실행되므로 완전히 안전하지는 않습니다).
| 패턴 | 공유 상태 | 동기화 방식 | 복잡도 | 대표 기술 |
|---|---|---|---|---|
| 뮤텍스/세마포어 | 있음 | 명시적 락 | 높음 | pthread, threading |
| 모니터 | 있음 | 구조적 락 | 중간 | Java synchronized, Python Condition |
| Lock-Free | 있음 | CAS 루프 | 매우 높음 | java.util.concurrent.atomic |
| Actor | 없음 | 메시지 전달 | 낮음 | Erlang, Akka |
| 채널 | 없음 | 채널 통신 | 낮음 | Go channel |
다음 장에서는 동기화 실패의 극단적 결과인 교착 상태(Deadlock)를 본격적으로 다루겠습니다. 교착 상태의 필요조건, 예방, 회피, 탐지와 복구 전략을 살펴봅니다.