모니터와 고전적 동기화 문제
모니터와 조건 변수가 상호 배제를 구조화하는 방식을 배우고 Java와 Python으로 고전적 동기화 문제를 구현합니다.
세마포어는 강력하지만 위험합니다.
wait과 signal의 순서를 프로그래머가 직접 관리해야 하며, 순서가 틀리면 데드락이 발생하고, signal을 빠뜨리면 영원히 대기하고, wait을 빠뜨리면 상호 배제가 깨집니다.
이 실수들은 컴파일 타임에 잡히지 않고 런타임에도 간헐적으로만 나타나서, 찾기가 극도로 어렵습니다.
모니터(Monitor)는 이런 저수준 실수를 언어 또는 라이브러리 차원에서 방지하는 고수준 동기화 도구입니다.
1974년 Hoare와 Hansen이 제안했으며, Java, Python, C# 등 주류 언어에 구현되어 있습니다.
세마포어는 강력하지만 위험합니다. wait과 signal의 순서를 프로그래머가 직접 관리해야 하며, 순서가 틀리면 데드락이 발생하고, signal을 빠뜨리면 영원히 대기하고, wait을 빠뜨리면 상호 배제가 깨집니다.
- 1모니터의 개념
모니터는 상호 배제가 내장된 추상 데이터 타입입니다. 모니터(Monitor)
- 2Java의 synchronized — 모니터 구현
Java에서 모든 객체는 내장 모니터를 가지고 있습니다. 언어 또는 라이브러리 차원
- 3Python의 Condition — 모니터 구현
Python에서는 threading.Condition이 모니터 역할을 합니다. 상호 배제가 내장된 추상 데이터 타입
- 4모니터와 고전적 동기화 문제 기준
진입 경계 모니터 메서드는 한 번에 한 스레드만 공유 상태를 바꿉니다. predicate buffer가 비었는지 가득 찼는지는 공유 상태로 직접 표현합니다. wait · notify 조건이 거짓이면 락을 놓고 자며, 상태 변경 뒤 대기자를 깨웁니다. 재검사 깨어난 스레드는 락을 다시 얻고 while 조건을 다시 확인합니다.
- 5진입 경계 모니터 메서드
한 번에 한 스레드만 공유 상태를 바꿉니다.
- 6predicate buffer
비었는지 가득 찼는지는 공유 상태로 직접 표현합니다.
- 7wait · notify 조건
거짓이면 락을 놓고 자며, 상태 변경 뒤 대기자를 깨웁니다.
- 8재검사 깨어난 스레드
락을 다시 얻고 while 조건을 다시 확인합니다.
모니터의 개념
모니터는 상호 배제가 내장된 추상 데이터 타입입니다.
핵심 특성:
- 모니터 안의 메서드(프로시저)는 한 번에 하나의 스레드만 실행할 수 있습니다.
- 개발자가 락을 직접 잡고 풀 필요가 없습니다 — 모니터가 자동으로 처리합니다.
- 조건 변수(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()를 하는 데드락 실수가 구조적으로 불가능합니다.
모니터가 알아서 락을 관리하기 때문입니다.
조건 변수와 허위 각성
모니터와 고전적 동기화 문제에서는 공유 자원, 락 경계, 대기 큐 이동을 확인합니다.
wait는 mutex를 내려놓고 잠들며, 깨어난 뒤에는 조건이 여전히 참인지 while로 다시 확인해야 한다.
- mutex 획득
lock mutex 획득 조건과 상태 보호
- while predicate
check while predicate 조건이 거짓이면 대기
- unlock + sleep
wait unlock + sleep 깨어나면 재획득
- state changed
signal state changed 대기자에게 재검사 기회
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)가 발생합니다.
조건 변수는 wait() 한 줄만 따로 보면 위험하게 단순해 보입니다.
실제로는 조건 검사, 락 반납, 조건 큐 대기, 알림 수신, 락 재획득, while 재검사가 하나의 규칙으로 묶여야 합니다.
아래 다이어그램은 if가 아니라 while을 써야 하는 이유를 락 상태와 조건 큐 이동까지 함께 놓고 보여줍니다.
조건 변수의 안전성은 락 반납과 대기 등록의 원자성, 그리고 깨어난 뒤 while 재검사까지 포함한 규칙에서 나온다.
- 조건 검사
점검 락을 잡은 상태에서 버퍼가 비었는지, 가득 찼는지 확인한다.
- 락 반납과 수면
wait 조건이 거짓이면 wait이 락 반납과 큐 등록을 끊기지 않게 수행한다.
- 알림 수신
notify 생산자나 소비자가 상태를 바꾼 뒤 대기자를 깨운다.
- 락 재획득
lock 깨어난 스레드는 곧바로 실행하지 않고 모니터 락을 다시 경쟁한다.
- 조건 재검사
while 다른 스레드가 먼저 자원을 가져갔거나 허위 각성이면 다시 기다린다.
- lost wakeup 방지
락 반납과 큐 등록 사이에 signal이 끼어들 수 없게 한다.
- spurious wakeup 방어
깨웠다는 사실이 조건 참을 보장하지 않으므로 while로 감싼다.
- notifyAll 이후 경쟁
여러 스레드가 깨어나도 락은 하나씩만 얻으므로 조건을 다시 확인한다.
식사하는 철학자 문제
식사하는 철학자(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에 콜백을 하며 대기하면 분산 데드락
철학자 문제의 핵심은 젓가락 자체가 아니라 여러 주체가 같은 자원 집합을 다른 타이밍에 붙잡는 구조입니다.
해결 전략들은 결국 순환 대기를 깨거나, 동시에 진입하는 수를 제한하거나, 두 자원을 원자적으로 확인하게 만드는 방식으로 수렴합니다.
모두가 하나를 쥔 채 다음 하나를 기다리는 고리가 생기면 아무도 식사를 시작할 수 없다.
- 위험 패턴
P0 → P1 → P2 → P3 → P4 ↩ 각자가 왼쪽을 보유한 채 이웃의 오른쪽을 기다리면 대기 고리가 닫힌다.
- order비대칭 순서
order 일부 철학자의 집는 순서를 뒤집어 모두 같은 방향으로 기다리는 고리를 끊는다.
- limit동시 시도 4명 제한
limit 한 자리를 비워 두면 최소 한 명은 양쪽 젓가락을 얻고 식사를 끝낼 수 있다.
- monitor조건 검사 후 동시 획득
monitor 모니터가 양쪽이 모두 비어 있을 때만 상태를 eating으로 바꿔 부분 점유를 막는다.
실무에서의 동기화 패턴
현대 프로그래밍에서는 저수준 동기화 프리미티브보다 더 안전한 상위 추상화를 선호합니다.
동기화 도구를 고를 때는 공유 상태가 있는지, 블로킹을 허용할 수 있는지, 실패 시 복구 정책이 있는지를 먼저 나누면 선택이 쉬워집니다.
락을 쓸지, 메시지를 보낼지, CAS를 쓸지는 공유 상태의 형태와 실패 비용을 기준으로 나누면 명확해집니다.
- 첫 질문
판단 공유 상태가 있는가? 없앨 수 있다면 Actor나 채널이 단순합니다. 블로킹을 허용하는가? 짧은 임계 구역이면 모니터, 긴 대기는 큐가 낫습니다.
- 공유 상태가 있는가?
없앨 수 있다면 Actor나 채널이 단순합니다.
- 블로킹을 허용하는가?
짧은 임계 구역이면 모니터, 긴 대기는 큐가 낫습니다.
- 패턴 지도
매핑 Monitor 공유 객체 내부에서 조건 대기와 상호 배제를 함께 관리합니다. Lock-Free 짧고 빈번한 갱신을 CAS로 처리하지만 구현 난도가 높습니다. Actor 상태를 소유자 안에 가두고 메시지로 요청을 전달합니다. Channel 데이터 이동 자체가 동기화 경계가 되어 공유 메모리를 줄입니다.
- 운영 체크
risk 데드락 둘 이상의 락이 필요하면 획득 순서를 문서화합니다. 기아 재시도 기반 구조는 대기 횟수와 백오프 정책이 필요합니다. 관측성 스레드 덤프, 큐 길이, 락 대기 시간을 지표로 남깁니다.
- 데드락
둘 이상의 락이 필요하면 획득 순서를 문서화합니다.
- 기아
재시도 기반 구조는 대기 횟수와 백오프 정책이 필요합니다.
- 관측성
스레드 덤프, 큐 길이, 락 대기 시간을 지표로 남깁니다.
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)를 본격적으로 다루겠습니다.
교착 상태의 필요조건, 예방, 회피, 탐지와 복구 전략을 살펴봅니다.
조건 변수는 상태를 저장하지 않는다. mutex가 보호하는 predicate가 진행 가능 여부의 유일한 근거다.
- 대기자 lock(m) while (!ready)
cond_wait(cv, m) wait는 mutex를 원자적으로 놓고 잠든다. 반환하기 전에 같은 mutex를 다시 획득한다.
- 통지자 lock(m) ready = true
signal(cv) unlock(m) 상태를 먼저 바꾼다. signal은 실행권이나 mutex를 넘기지 않으며 대기자는 이후 경쟁한다.
- 전체 자원 순서
모두 낮은 번호부터 획득해 순환을 끊는다.
- 동시 진입 제한
waiter가 최대 N−1명만 시도를 허용한다.
- 모니터 판정
양쪽 자원이 모두 비었을 때만 한 번에 허가한다.
공유 상태·그 상태를 보호하는 mutex·상태 변화를 기다리는 condition을 같은 경계로 묶어야 lost wakeup과 잘못된 진행을 막을 수 있다.
- Mutex · 누가 만지나
predicate를 읽고 공유 상태를 바꾸는 모든 경로를 같은 lock 규칙으로 직렬화한다.
- Predicate · 진행 가능한가
count > 0 처럼 공유 변수로 표현한 조건만이 실행 가능 여부를 결정한다.
- Condition · 다시 보라
상태가 바뀌었을 가능성을 waiter에게 알린다. 깨어남 자체는 predicate 참을 보장하지 않는다.
- 1. Lock
Lock 연결된 mutex를 획득한다.
- 2. While check
While check predicate가 false인 동안만 기다린다.
- 3. Wait
Wait mutex release와 sleep을 원자적으로 연결한다.
- 4. Change + notify
Change + notify 다른 thread가 lock 안에서 상태를 바꾸고 signal/broadcast한다.
- 5. Reacquire
Reacquire wait가 돌아오기 전에 같은 mutex를 다시 소유한다.
- 6. Recheck
Recheck spurious wakeup·경쟁을 고려해 while 처음으로 돌아간다.