모니터와 고전적 동기화 문제
세마포어는 강력하지만 위험합니다. 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)를 본격적으로 다루겠습니다. 교착 상태의 필요조건, 예방, 회피, 탐지와 복구 전략을 살펴봅니다.