안동민 개발노트 아이콘

안동민 개발노트

6장 : 프로세스 동기화

모니터와 고전적 동기화 문제

세마포어는 강력하지만 위험합니다. waitsignal의 순서를 프로그래머가 직접 관리해야 하며, 순서가 틀리면 데드락이 발생하고, signal을 빠뜨리면 영원히 대기하고, wait을 빠뜨리면 상호 배제가 깨집니다. 이 실수들은 컴파일 타임에 잡히지 않고 런타임에도 간헐적으로만 나타나서, 찾기가 극도로 어렵습니다.

모니터(Monitor)는 이런 저수준 실수를 언어 또는 라이브러리 차원에서 방지하는 고수준 동기화 도구입니다. 1974년 Hoare와 Hansen이 제안했으며, Java, Python, C# 등 주류 언어에 구현되어 있습니다.


모니터의 개념

모니터는 상호 배제가 내장된 추상 데이터 타입입니다. 핵심 특성:

  1. 모니터 안의 메서드(프로시저)는 한 번에 하나의 스레드만 실행할 수 있습니다.
  2. 개발자가 락을 직접 잡고 풀 필요가 없습니다 — 모니터가 자동으로 처리합니다.
  3. 조건 변수(Condition Variable)를 통해 특정 조건이 만족될 때까지 대기할 수 있습니다.

세마포어와의 핵심 차이: 세마포어는 프로그래머가 wait/signal을 올바른 위치에 올바른 순서로 배치해야 합니다. 모니터는 상호 배제를 구조적으로 보장하므로, 어디에 lock을 넣을까 고민이 없습니다.


Java의 synchronized — 모니터 구현

Java에서 모든 객체는 내장 모니터를 가지고 있습니다. synchronized 키워드로 접근합니다.

BoundedBuffer.java
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이 모니터 역할을 합니다.

monitor.py
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 item

with self.condition 블록에 진입하면 자동으로 내부 락이 잡히고, 블록을 벗어나면 자동으로 풀립니다. wait() 호출 시 락이 반납되고 스레드가 대기합니다. notify_all()이 호출되면 대기 중인 스레드들이 깨어나고, 모니터 락을 재획득한 후 while 조건을 다시 확인합니다.

세마포어 버전과 비교하면, 모니터 버전은 empty.acquire() 전에 mutex.acquire()를 하는 데드락 실수가 구조적으로 불가능합니다. 모니터가 알아서 락을 관리하기 때문입니다.


조건 변수와 허위 각성

아래 다이어그램은 이 절의 핵심 흐름을 역할과 상태 전환 중심으로 정리한 것입니다.

while을 써야 하는 이유

조건 변수에서 wait() 후 깨어났을 때, 조건이 여전히 만족되지 않을 수 있습니다. 이를 허위 각성(Spurious Wakeup)이라고 합니다. 두 가지 원인이 있습니다.

  1. OS/하드웨어 수준 허위 각성: OS 구현의 특성상, 명시적인 notify 없이도 스레드가 깨어날 수 있습니다. POSIX 표준에서 이를 허용합니다.

  2. 경쟁에 의한 허위 각성: notifyAll()로 여러 소비자가 동시에 깨어났지만, 첫 번째 소비자가 버퍼의 마지막 아이템을 가져가면 두 번째 소비자는 빈 버퍼를 만납니다.

그래서 조건 변수는 반드시 while 루프와 함께 사용합니다.

올바른 패턴
# 올바른 코드
while not condition_is_true():
    condition.wait()

# 위험한 코드 (절대 사용하지 말 것)
if not condition_is_true():
    condition.wait()
    # 깨어났을 때 조건이 여전히 거짓일 수 있음!

C의 pthread 조건 변수

condition_variable.c
#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 — 모니터 기반: 양쪽 젓가락이 모두 사용 가능할 때만 두 개를 동시에 집도록 합니다. 조건 변수로 "양쪽이 모두 비어있을 때까지" 대기합니다.

dining_philosophers_monitor.py
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)를 본격적으로 다루겠습니다. 교착 상태의 필요조건, 예방, 회피, 탐지와 복구 전략을 살펴봅니다.