본문 바로가기

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

잠금과 조건 대기를 하나로 묶은 모니터를 Java의 synchronized와 Python의 Condition으로 구현하고, 조건 변수에서 깨어난 뒤 조건을 반드시 다시 확인해야 하는 이유를 실행해 확인합니다. 식사하는 철학자 문제의 교착 상태와 해결 전략, 실무에서 쓰는 상위 수준의 동기화 방법을 정리합니다.

앞 글의 세마포어는 쓰임새가 넓지만 다루기가 까다롭습니다. wait와 signal을 어디에, 어떤 순서로 둘지 프로그래머가 모두 직접 정해야 하기 때문입니다. 순서를 바꾸면 교착 상태에 빠지고, signal을 빠뜨리면 다른 스레드가 영원히 기다리며, wait를 빠뜨리면 상호 배제가 깨집니다. 이런 실수는 컴파일할 때 드러나지 않고 실행 중에도 가끔만 나타나서 찾기가 아주 어렵습니다.

모니터(monitor)는 이런 실수를 구조로 줄이려는 동기화 도구입니다. 1970년대 토니 호어(C. A. R. Hoare)와 페르 브린치 한센(Per Brinch Hansen)의 연구에서 나왔으며, 공유 데이터와 그것을 다루는 연산, 그리고 잠금과 조건 대기를 하나로 묶습니다. 이 글에서는 Java와 Python으로 모니터를 만들어 보고, 조건 변수를 쓸 때 꼭 지켜야 할 규칙과 동기화의 고전 문제인 식사하는 철학자 문제를 살펴봅니다.

모니터는 무엇을 묶는가

모니터는 상호 배제가 들어 있는 추상 자료형입니다. 공유 데이터는 모니터 안에 숨겨져 있어 모니터의 연산을 통해서만 다룰 수 있고, 모니터의 연산은 한 번에 한 스레드만 실행할 수 있습니다. 연산에 들어가고 나올 때 잠금을 잡고 푸는 일은 언어나 라이브러리가 처리하므로, 프로그래머가 잠금을 빠뜨리거나 풀기를 잊는 실수가 생기지 않습니다.

모니터의 또 다른 부품은 조건 변수(condition variable)입니다. 연산 안에서 "버퍼에 빈 자리가 생길 때까지"처럼 어떤 조건이 참이 될 때까지 기다려야 할 때 씁니다. 조건 변수에서 기다리는 스레드는 모니터의 잠금을 놓고 잠들어, 다른 스레드가 모니터에 들어와 조건을 바꿀 수 있게 합니다. 조건을 바꾼 스레드가 알림을 보내면, 기다리던 스레드는 깨어나 잠금을 다시 쥔 뒤 실행을 이어 갑니다.

그렇다고 모니터가 모든 실수를 막아 주지는 않습니다. 어떤 잠금으로 어떤 데이터를 보호할지, 조건을 검사하고 바꾸는 모든 코드가 같은 잠금 아래에 있는지는 여전히 프로그래머가 설계해야 합니다. 모니터가 해 주는 것은 잠금을 잡고 푸는 시점을 연산의 시작과 끝에 묶어 두는 것입니다.

Java의 synchronized로 만든 모니터

Java에서는 모든 객체가 모니터를 하나씩 가지고 있으며, synchronized 메서드가 그 모니터에 들어가는 연산입니다. 다음은 용량이 정해진 버퍼를 모니터로 만든 것입니다.

BoundedBuffer.java
import java.util.ArrayList;
import java.util.List;

public class BoundedBuffer<T> {
    private final Object[] buffer;
    private int count = 0, in = 0, out = 0;

    public BoundedBuffer(int capacity) {
        buffer = new Object[capacity];
    }

    public synchronized void put(T item) throws InterruptedException {
        while (count == buffer.length) {
            wait();                       // 가득 찼으면 모니터 잠금을 놓고 기다린다
        }
        buffer[in] = item;
        in = (in + 1) % buffer.length;
        count++;
        notifyAll();                      // 기다리는 소비자에게 알린다
    }

    @SuppressWarnings("unchecked")
    public synchronized T take() throws InterruptedException {
        while (count == 0) {
            wait();                       // 비었으면 기다린다
        }
        T item = (T) buffer[out];
        buffer[out] = null;
        out = (out + 1) % buffer.length;
        count--;
        notifyAll();                      // 기다리는 생산자에게 알린다
        return item;
    }

    public static void main(String[] args) throws InterruptedException {
        BoundedBuffer<Integer> buffer = new BoundedBuffer<>(2);
        List<Integer> taken = new ArrayList<>();
        Thread producer = new Thread(() -> {
            try {
                for (int i = 1; i <= 5; i++) {
                    buffer.put(i);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        Thread consumer = new Thread(() -> {
            try {
                for (int i = 0; i < 5; i++) {
                    taken.add(buffer.take());
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        producer.start();
        consumer.start();
        producer.join();
        consumer.join();
        System.out.println("꺼낸 순서: " + taken);
    }
}
꺼낸 순서: [1, 2, 3, 4, 5]

Java 25에서 java BoundedBuffer.java로 실행한 결과입니다. 용량이 2이므로 생산자는 두 개를 넣은 뒤 put의 while에서 기다리고, 소비자가 하나를 꺼낼 때마다 깨어나 하나씩 더 넣습니다. 소비자는 넣은 순서대로 꺼냅니다. taken 리스트는 소비자 스레드가 채우고 main이 읽지만, main은 소비자를 join한 뒤에 읽으므로 소비자가 넣은 값이 모두 보입니다.

synchronized 메서드에 들어가면 그 객체의 모니터 잠금을 얻고, 메서드가 정상적으로든 예외로든 끝나면 잠금이 풀립니다. 다른 스레드가 같은 객체의 synchronized 메서드를 부르면 잠금이 풀릴 때까지 기다립니다. wait()는 현재 스레드를 그 객체의 대기 집합(wait set)에 넣으면서 모니터 잠금을 놓아 주고, 알림이나 인터럽트로 깨어나면 같은 모니터 잠금을 다시 얻은 뒤에 돌아옵니다. notifyAll()은 대기 집합의 스레드를 모두 깨우고, notify()는 그 가운데 하나만 깨우는데 누구를 깨울지는 정해져 있지 않습니다.

이 버퍼에서는 생산자와 소비자가 같은 대기 집합에서 기다립니다. 그래서 notify()로 하나만 깨우면, 소비자를 깨워야 할 때 다른 생산자가 깨어나 다시 잠드는 일이 생길 수 있습니다. 모두를 깨우는 notifyAll()을 쓰고, 깨어난 스레드가 각자 자기 조건을 다시 확인하게 하는 것이 안전합니다. 알림은 깨워 줄 뿐 잠금이나 다음 실행 차례를 바로 넘겨주는 것이 아니므로, 깨어난 스레드들은 모니터 잠금을 다시 얻으려고 경쟁합니다.

원형 버퍼의 위치 계산도 따라가 볼 만합니다. 용량 2인 버퍼에 a와 b를 넣으면 in은 0, 1을 거쳐 다시 0이 되고 count는 2가 됩니다. 이때 c를 넣으려는 호출은 while에서 기다리며 아직 배열에 쓰지 않습니다. 소비자가 a를 꺼내 out이 1, count가 1이 된 뒤에야 생산자가 깨어나 배열의 0번 자리에 c를 넣습니다. 배열은 물리적으로 [c, b]이지만, 다음에 꺼낼 위치 out이 1이므로 꺼내는 순서는 b, c입니다.

Python의 Condition으로 만든 모니터

Python에서는 threading.Condition이 잠금 하나와 대기 큐를 묶은 모니터 역할을 합니다. 같은 버퍼를 Python으로 만들면 다음과 같습니다.

import threading

class BoundedBuffer:
    def __init__(self, capacity):
        self.items = []
        self.capacity = capacity
        self.condition = threading.Condition()   # 잠금 하나와 대기 큐를 묶은 것

    def put(self, item):
        with self.condition:                     # 모니터에 들어간다
            while len(self.items) >= self.capacity:
                self.condition.wait()            # 잠금을 놓고 기다렸다가 다시 쥔다
            self.items.append(item)
            self.condition.notify_all()

    def take(self):
        with self.condition:
            while not self.items:
                self.condition.wait()
            item = self.items.pop(0)
            self.condition.notify_all()
            return item

buffer = BoundedBuffer(2)
taken = []
producer = threading.Thread(target=lambda: [buffer.put(i) for i in range(1, 6)])
consumer = threading.Thread(target=lambda: taken.extend(buffer.take() for _ in range(5)))
producer.start(); consumer.start()
producer.join(); consumer.join()
print("꺼낸 순서:", taken)
꺼낸 순서: [1, 2, 3, 4, 5]

with self.condition: 블록에 들어가면 내부 잠금을 얻고, 블록을 나가면 풉니다. wait()는 잠금을 놓고 기다렸다가, 깨어나면 잠금을 다시 얻은 뒤 돌아옵니다. 앞 글에서 생산자가 뮤텍스를 쥔 채 빈 자리를 기다려 교착 상태에 빠졌던 것과 달리, 조건 변수의 wait()는 바로 그 잠금을 놓고 기다리므로 같은 문제가 생기지 않습니다. 다만 다른 잠금을 쥔 채 wait()를 부르면 그 잠금은 놓지 않으므로, 여전히 교착 상태에 빠질 수 있습니다.

깨어난 뒤에는 조건을 다시 확인한다

두 예제 모두 조건을 if가 아니라 while로 확인했습니다. 조건 변수에서 깨어났을 때, 기다리던 조건이 여전히 참이 아닐 수 있기 때문입니다. 이유는 두 가지입니다.

첫째, 알림을 받은 스레드가 여럿이면 먼저 잠금을 얻은 스레드가 조건을 다시 거짓으로 바꿀 수 있습니다. 소비자 둘이 빈 버퍼를 기다리는데 생산자가 항목 하나를 넣고 모두를 깨우면, 먼저 잠금을 얻은 소비자가 항목을 가져가고, 나중에 잠금을 얻은 소비자는 다시 빈 버퍼를 만납니다. 둘째, 조건 변수는 알림이 없는데도 깨어날 수 있습니다. 이를 가짜 깨어남(spurious wakeup)이라고 하며, POSIX와 Java는 구현의 효율을 위해 이를 허락합니다.

다음 코드는 첫째 경우를 재현합니다. 소비자 둘이 기다리는 동안 항목 하나를 넣고 둘 다 깨운 뒤, 조건을 if로 확인할 때와 while로 확인할 때를 비교합니다. 어느 소비자가 먼저 잠금을 얻을지는 정해져 있지 않으므로 결과를 정렬해 출력합니다.

import threading
import time

def run(use_while):
    items, condition, results = [], threading.Condition(), []

    def consumer():
        with condition:
            if use_while:
                while not items:
                    if not condition.wait(timeout=0.3):
                        results.append("다시 기다리다 시간 초과")
                        return
            elif not items:
                condition.wait(timeout=0.3)
            try:
                items.pop()
                results.append("소비 성공")
            except IndexError:
                results.append("빈 버퍼에서 꺼내려다 IndexError")

    consumers = [threading.Thread(target=consumer) for _ in range(2)]
    for c in consumers:
        c.start()
    time.sleep(0.05)                       # 두 소비자가 모두 기다리게 한다
    with condition:
        items.append("항목")               # 항목은 하나뿐인데
        condition.notify_all()             # 둘 다 깨운다
    for c in consumers:
        c.join()
    return sorted(results)

print("if로 확인:", run(use_while=False))
print("while로 확인:", run(use_while=True))
if로 확인: ['빈 버퍼에서 꺼내려다 IndexError', '소비 성공']
while로 확인: ['다시 기다리다 시간 초과', '소비 성공']

if로 확인하면, 나중에 잠금을 얻은 소비자는 깨어난 것만 믿고 바로 꺼내려다 빈 리스트에서 IndexError를 만납니다. while로 확인하면, 깨어난 뒤 조건을 다시 보고 버퍼가 비어 있으니 다시 기다립니다. 이 예제에서는 더 들어오는 항목이 없으므로 시간 제한에 걸려 끝나지만, 실제 프로그램이라면 다음 항목이 들어올 때까지 기다렸을 것입니다. 알림을 받았다는 사실이 아니라, 잠금을 쥔 상태에서 다시 읽은 조건이 진행할지를 정한다는 것이 핵심입니다. Python의 Condition 문서도 깨어난 뒤 조건을 다시 확인하라고 권합니다.

pthread의 조건 변수

C에서는 pthread의 조건 변수를 뮤텍스와 함께 씁니다. 아래 코드에서 두 스레드가 함께 보는 조건은 data_available 하나이고, 생산자와 소비자 모두 같은 mutex를 쥔 채로만 이 값을 읽고 씁니다. 제목의 "일부"처럼 스레드를 만들고 기다리는 main은 생략했습니다.

condition_variable.c (일부)
#include <pthread.h>
#include <stdio.h>

static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t data_ready = PTHREAD_COND_INITIALIZER;
static int data_available = 0;

static void *producer(void *arg)
{
    (void) arg;
    pthread_mutex_lock(&mutex);
    data_available = 1;                       /* 조건을 바꾸고 */
    pthread_cond_signal(&data_ready);         /* 기다리는 쪽 하나를 깨운다. */
    pthread_mutex_unlock(&mutex);
    return NULL;
}

static void *consumer(void *arg)
{
    (void) arg;
    pthread_mutex_lock(&mutex);
    while (!data_available)                   /* if가 아니라 while */
        pthread_cond_wait(&data_ready, &mutex);
    printf("데이터 소비\n");
    data_available = 0;
    pthread_mutex_unlock(&mutex);
    return NULL;
}

pthread_cond_wait(&cond, &mutex)는 뮤텍스를 푸는 것과 조건 변수에서 기다리기 시작하는 것을 한 번에 처리하고, 깨어나면 뮤텍스를 다시 얻은 뒤 돌아옵니다. 이 두 일이 한 번에 처리되지 않는다면, 소비자가 뮤텍스를 푼 직후 아직 대기 목록에 들어가기 전에 생산자가 신호를 보낼 수 있고, 그 신호는 아무도 받지 못한 채 사라집니다. 소비자는 그 뒤 대기 목록에 들어가 오지 않을 신호를 영원히 기다리게 되는데, 이를 깨어남 손실(lost wakeup)이라고 합니다. 생산자가 조건을 바꾸는 것도 같은 뮤텍스 아래에서 해야 하는 이유가 여기에 있습니다.

아래 단계 개념도는 소비자가 조건 변수에서 정상적으로 깨어나 조건을 다시 확인하기까지의 흐름을 보여 줍니다.

조건 대기에서 정상 반환하면 같은 락 아래에서 재검사

소비자가 같은 mutex를 보유한 채 조건을 읽는다. 조건이 참일 때만 락 안에서 진행한다. 각 단계의 값과 전환 이유를 비교합니다.

조건 변수 · 알림과 조건의 차이

wait가 정상 반환해도 같은 락 아래에서 조건을 다시 검사합니다

wait는 연결된 락을 놓고 기다리며, 정상 반환하기 전에 그 락을 다시 획득합니다. 알림은 조건이 참이거나 내가 바로 진행할 수 있다는 보장이 아닙니다.

락과 조건 재검사같은 락 획득 후 조건이 참이면 락 안에서 진행한다. 거짓이면 락을 놓고 기다리고, 정상 반환 전 재획득한 뒤 조건을 다시 검사한다.같은 락 획득진행 가능?락 안에서 진행락 반납·대기정상 반환 전 재획득참거짓반환 후 재검사
락과 조건 재검사같은 락으로 조건을 검사한다. 거짓이면 대기하고 락 재획득 후 다시 검사한다. 참일 때만 락 안에서 진행한다.같은 락 획득진행 가능?락 반납·대기정상 반환 전같은 락 재획득락 안에서 진행거짓참왼쪽 복귀: 조건 재검사알림이 진행 허가를 주지는 않음

while로 재검사하는 이유는 spurious wakeup뿐 아니라 다른 스레드가 먼저 조건을 소비할 수 있기 때문입니다. 락 반납과 대기 등록은 wait API가 연결해 처리합니다. 이 흐름은 정상 반환 경로이며 오류·취소 처리는 API별로 확인합니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 조건 거짓부터 소비까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목조건 대기에서 정상 반환하면 같은 락 아래에서 재검사
예의 값과 변화
관찰·상황값·보충 정보변화·이유
조건 거짓

소비자가 같은 mutex를 보유한 채 조건을 읽는다.

  • 조건·락 1: data_available 0
  • 조건·락 2: mutex 보유
  • wait가 반납·대기등록
생산자 변경

생산자가 mutex 안에서 준비한다.

  • 조건·락 1: data_available 1
  • 조건·락 2: signal
  • 생산자 mutex 해제
정상 반환과 재검사

소비자가 mutex를 재획득한 뒤 돌아온다.

  • 조건·락 1: data_available 다시 검사
  • 조건·락 2: while
  • 이 경로의 조건 참 확인
소비

조건이 참일 때만 락 안에서 진행한다.

  • 조건·락 1: data_available 0
  • 소비 후 mutex 반납

조건 대기에서 정상 반환하면 같은 락 아래에서 재검사

소비자가 같은 mutex를 보유한 채 조건을 읽는다. 조건이 참일 때만 락 안에서 진행한다. 각 단계의 값과 전환 이유를 비교합니다.

조건 변수 · 알림과 조건의 차이

wait가 정상 반환해도 같은 락 아래에서 조건을 다시 검사합니다

wait는 연결된 락을 놓고 기다리며, 정상 반환하기 전에 그 락을 다시 획득합니다. 알림은 조건이 참이거나 내가 바로 진행할 수 있다는 보장이 아닙니다.

락과 조건 재검사같은 락 획득 후 조건이 참이면 락 안에서 진행한다. 거짓이면 락을 놓고 기다리고, 정상 반환 전 재획득한 뒤 조건을 다시 검사한다.같은 락 획득진행 가능?락 안에서 진행락 반납·대기정상 반환 전 재획득참거짓반환 후 재검사
락과 조건 재검사같은 락으로 조건을 검사한다. 거짓이면 대기하고 락 재획득 후 다시 검사한다. 참일 때만 락 안에서 진행한다.같은 락 획득진행 가능?락 반납·대기정상 반환 전같은 락 재획득락 안에서 진행거짓참왼쪽 복귀: 조건 재검사알림이 진행 허가를 주지는 않음

while로 재검사하는 이유는 spurious wakeup뿐 아니라 다른 스레드가 먼저 조건을 소비할 수 있기 때문입니다. 락 반납과 대기 등록은 wait API가 연결해 처리합니다. 이 흐름은 정상 반환 경로이며 오류·취소 처리는 API별로 확인합니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 조건 거짓부터 소비까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목조건 대기에서 정상 반환하면 같은 락 아래에서 재검사
예의 값과 변화
관찰·상황값·보충 정보변화·이유
조건 거짓

소비자가 같은 mutex를 보유한 채 조건을 읽는다.

  • 조건·락 1: data_available 0
  • 조건·락 2: mutex 보유
  • wait가 반납·대기등록
생산자 변경

생산자가 mutex 안에서 준비한다.

  • 조건·락 1: data_available 1
  • 조건·락 2: signal
  • 생산자 mutex 해제
정상 반환과 재검사

소비자가 mutex를 재획득한 뒤 돌아온다.

  • 조건·락 1: data_available 다시 검사
  • 조건·락 2: while
  • 이 경로의 조건 참 확인
소비

조건이 참일 때만 락 안에서 진행한다.

  • 조건·락 1: data_available 0
  • 소비 후 mutex 반납

소비자는 뮤텍스를 쥔 채 data_available이 0인 것을 확인하고, pthread_cond_wait로 뮤텍스를 놓으며 기다립니다. 생산자가 뮤텍스 안에서 값을 1로 바꾸고 신호를 보낸 뒤 뮤텍스를 풀면, 소비자는 뮤텍스를 다시 얻고 돌아와 조건을 다시 확인합니다. 조건이 참임을 확인한 뒤에야 뮤텍스 안에서 데이터를 소비하고 뮤텍스를 놓습니다.

더 깊이: 호어의 모니터와 Mesa의 모니터

조건을 while로 다시 확인하는 습관은 모니터의 의미가 바뀌면서 생겼습니다. 호어가 처음 정의한 모니터에서는 조건 변수에 신호를 보내면 기다리던 스레드가 곧바로 실행되고, 신호를 보낸 스레드는 그 스레드가 모니터를 떠난 뒤에야 다시 실행됩니다. 깨어난 순간 조건이 참이라는 것이 정해져 있으므로 if로 한 번만 확인해도 충분했습니다. 반면 램프슨(Butler Lampson)과 레델(David Redell)이 1980년에 Mesa 언어의 경험을 정리한 논문에서는 알림(notify)을 기다리는 스레드에게 주는 힌트로 봅니다. 알림을 받은 스레드는 나중의 적당한 시점에 다시 실행되므로, 깨어날 때마다 상황을 다시 판단해야 합니다.

논문이 드는 이유는 실용적입니다. 호어의 방식은 기다리던 스레드가 이어서 실행될 때마다 프로세스 전환이 여러 번 더 필요하지만, Mesa의 방식에는 그런 전환이 없습니다. 또 시간 제한이 지나 깨어나거나 중단되어 깨어나는 경우도 같은 규칙으로 다룰 수 있고, 알림이 힌트일 뿐이므로 하나 대신 모두를 깨우는 broadcast를 써도 언제나 올바릅니다. Java의 notify()와 Python의 Condition.notify()도 이 방식이어서, 깨어난 스레드는 알림을 보낸 스레드가 잠금을 놓은 뒤에야 잠금을 다시 얻고 진행합니다.

POSIX는 가짜 깨어남을 허락하는 이유도 pthread_cond_signal의 설명에 밝혀 두었습니다. 다중 프로세서에서는 신호 한 번에 기다리던 스레드가 둘 이상 깨어나는 일을 구현이 피하지 못할 수 있는데, 이 문제를 고칠 수는 있지만 드물게 일어나는 경우를 위해 효율을 잃는 것은 받아들일 수 없다고 보았습니다. 어차피 조건은 다시 확인해야 하기 때문입니다. 그 결과 응용 프로그램은 조건을 확인하는 루프를 쓰게 되고, 다른 곳에서 보낸 불필요한 신호에도 흔들리지 않게 됩니다.

식사하는 철학자 문제

식사하는 철학자(dining philosophers) 문제는 데이크스트라가 1965년 무렵 제안한 동기화의 고전 문제입니다. 철학자 다섯이 둥근 식탁에 앉아 있고, 이웃한 두 철학자 사이에 젓가락이 하나씩, 모두 다섯 개 놓여 있습니다. 철학자는 생각하다가 배가 고프면 양옆의 젓가락 두 개를 모두 집어야 먹을 수 있고, 다 먹으면 젓가락을 내려놓고 다시 생각합니다.

가장 단순한 방법은 왼쪽 젓가락을 먼저 집고, 이어서 오른쪽 젓가락을 집는 것입니다. 그런데 다섯 철학자가 동시에 왼쪽 젓가락을 집으면 어떻게 될까요? 다음 코드는 그 상황을 재현합니다. 모두 왼쪽 젓가락을 집을 때까지 기다렸다가 오른쪽 젓가락을 집으려 하며, 영원히 멈추지 않도록 오른쪽 젓가락은 0.2초까지만 기다립니다.

import threading

N = 5
forks = [threading.Lock() for _ in range(N)]
everyone_holds_left = threading.Barrier(N)
everyone_tried = threading.Barrier(N)

def naive_philosopher(i, results):
    left, right = forks[i], forks[(i + 1) % N]
    with left:                                   # 왼쪽 젓가락을 집고
        everyone_holds_left.wait()               # 모두 왼쪽을 집을 때까지 맞춘다
        got = right.acquire(timeout=0.2)         # 오른쪽 젓가락을 기다린다
        results.append(got)
        everyone_tried.wait()                    # 모두 시도를 마칠 때까지 왼쪽을 놓지 않는다
        if got:
            right.release()

results = []
threads = [threading.Thread(target=naive_philosopher, args=(i, results)) for i in range(N)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print("모두 왼쪽부터 집을 때 오른쪽을 얻은 철학자:", results.count(True), "명")
모두 왼쪽부터 집을 때 오른쪽을 얻은 철학자: 0 명

철학자마다 오른쪽 젓가락은 오른쪽 이웃의 왼쪽 젓가락이므로, 모두가 왼쪽을 쥔 순간에는 아무도 오른쪽을 얻을 수 없습니다. 각자 하나씩 쥔 채 이웃이 내려놓기를 기다리는 원이 생긴 것이며, 시간 제한이 없었다면 다섯 명 모두 영원히 멈췄을 것입니다. 이것이 교착 상태(deadlock)입니다.

교착 상태를 피하는 세 가지 전략

첫째 전략은 젓가락을 집는 순서를 비대칭으로 만드는 것입니다. 짝수 번 철학자는 왼쪽부터, 홀수 번 철학자는 오른쪽부터 집게 하거나, 모든 젓가락에 번호를 매기고 번호가 작은 것부터 집게 합니다. 그러면 모두가 같은 방향으로 하나씩 쥐고 원을 이루는 일이 생기지 않습니다. 이는 7장에서 다룰 순환 대기 조건을 깨는 방법입니다.

둘째 전략은 동시에 젓가락을 집으려는 철학자의 수를 넷으로 제한하는 것입니다. 초깃값이 4인 세마포어를 두고, 젓가락을 집기 전에 허가를 얻고 두 젓가락을 모두 내려놓은 뒤 허가를 돌려줍니다. 다섯 명이 모두 왼쪽 젓가락만 쥐는 상황이 원천적으로 생기지 않으므로, 넷 가운데 적어도 한 명은 두 젓가락을 모두 집을 수 있습니다.

셋째 전략은 모니터로 두 젓가락을 한꺼번에 확인하는 것입니다. 철학자는 양옆의 이웃이 모두 먹고 있지 않을 때만 먹는 상태로 바뀌고, 그렇지 않으면 조건 변수에서 기다립니다. 젓가락 하나만 쥐고 기다리는 상태가 없으므로 교착 상태가 생기지 않습니다. 다음 코드는 이 방식을 구현하고, 철학자마다 100번씩 먹게 한 뒤 이웃이 동시에 먹은 적이 있는지 확인합니다.

import threading

N = 5

class DiningTable:
    def __init__(self, n):
        self.n = n
        self.state = ["thinking"] * n
        self.lock = threading.Lock()
        self.can_eat = [threading.Condition(self.lock) for _ in range(n)]
        self.meals = [0] * n
        self.neighbors_together = 0

    def _test(self, i):                          # 호출하는 쪽이 self.lock을 쥐고 있다
        left, right = (i - 1) % self.n, (i + 1) % self.n
        if self.state[i] == "hungry" and "eating" not in (self.state[left], self.state[right]):
            self.state[i] = "eating"
            self.can_eat[i].notify()

    def pickup(self, i):
        with self.lock:
            self.state[i] = "hungry"
            self._test(i)
            while self.state[i] != "eating":
                self.can_eat[i].wait()
            left, right = (i - 1) % self.n, (i + 1) % self.n
            if "eating" in (self.state[left], self.state[right]):
                self.neighbors_together += 1
            self.meals[i] += 1

    def putdown(self, i):
        with self.lock:
            self.state[i] = "thinking"
            self._test((i - 1) % self.n)         # 양옆이 이제 먹을 수 있는지 확인한다
            self._test((i + 1) % self.n)

table = DiningTable(N)

def philosopher(i):
    for _ in range(100):
        table.pickup(i)
        table.putdown(i)

threads = [threading.Thread(target=philosopher, args=(i,)) for i in range(N)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print("모니터 방식에서 각자 먹은 횟수:", table.meals)
print("이웃이 함께 먹은 횟수:", table.neighbors_together)
모니터 방식에서 각자 먹은 횟수: [100, 100, 100, 100, 100]
이웃이 함께 먹은 횟수: 0

모든 철학자가 100번씩 먹고 끝났으므로 교착 상태가 없었고, 이웃이 동시에 먹은 적도 없습니다. 철학자의 상태는 모두 같은 잠금 self.lock 아래에서만 바뀌고, 조건 변수 다섯 개도 모두 이 잠금을 씁니다. 그래서 _test가 양옆 이웃의 상태를 확인하고 자신을 먹는 상태로 바꾸는 과정이 한 덩어리로 처리되어, 확인하는 사이에 이웃이 먹기 시작하는 일이 생기지 않습니다. 다 먹은 철학자는 putdown에서 양옆 이웃이 이제 먹을 수 있는지 확인해 주어, 기다리던 이웃을 깨웁니다. 다만 이 방식이 개별 철학자의 기아까지 막아 주지는 않습니다. 양옆 이웃이 번갈아 계속 먹으면 가운데 철학자가 오래 기다릴 수 있습니다.

젓가락 문제가 실무와 닮은 점

식사하는 철학자는 장난감 같은 문제지만, 같은 구조가 실무에서 그대로 나타납니다. 트랜잭션 A가 테이블 1을 잠그고 테이블 2를 기다리는데 트랜잭션 B가 테이블 2를 잠그고 테이블 1을 기다리면 데이터베이스 교착 상태가 됩니다. 서비스 A가 서비스 B를 호출하고 응답을 기다리는 동안, B가 처리 중에 A를 다시 호출하고 기다리는데 A의 작업 스레드가 모두 바쁘다면 분산 시스템의 교착 상태가 됩니다. 핵심은 젓가락이 아니라, 여러 주체가 같은 자원 묶음을 서로 다른 순서로 하나씩 붙잡는 구조입니다. 해결 전략도 결국 순서를 정해 원이 생기지 않게 하거나, 동시에 들어오는 수를 제한하거나, 필요한 자원을 한꺼번에 확인하는 방법으로 모입니다.

실무에서 쓰는 상위 수준의 방법

오늘날의 프로그래밍에서는 잠금과 조건 변수를 직접 다루기보다, 그 위에 만든 더 안전한 도구를 먼저 찾습니다. 공유 상태가 꼭 필요한지, 기다리는 동안 스레드를 막아도 되는지, 실패했을 때 어떻게 되돌릴지를 먼저 따져 보면 알맞은 도구를 고르기 쉽습니다.

동시성 자료구조는 잠금이나 CAS로 안전하게 만든 자료구조를 라이브러리로 제공합니다. Java의 ConcurrentLinkedQueue는 잠금 없는 알고리즘으로 만든 큐이고, ConcurrentHashMap은 일부 갱신에 잠금을 쓰므로 잠금 없는 자료구조라고 하지는 않습니다. Python의 queue.Queue는 이 글의 버퍼를 이미 구현해 둔 것이므로, 생산자와 소비자가 필요하면 이것부터 씁니다.

액터 모델(actor model)은 상태를 각 액터 안에 가두고, 액터끼리는 메시지로만 통신하게 해 공유 자체를 없앱니다. Erlang이 대표적이고, JVM의 Akka와 .NET의 Orleans가 이 방식을 씁니다. Go는 "메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라"는 말로 채널을 쓰는 방식을 권합니다. 다만 채널로 포인터를 넘긴 뒤 보낸 쪽과 받은 쪽이 같은 객체를 계속 고친다면 그 객체에는 여전히 동기화가 필요합니다.

비동기 프로그래밍은 Python의 asyncio나 JavaScript의 async와 await처럼 스레드 하나에서 여러 작업을 번갈아 실행합니다. 스레드가 하나이므로 데이터 경쟁은 없지만, await에서 다른 작업으로 넘어간 사이에 공유 상태가 바뀔 수 있으므로 확인 후 행동 같은 논리적 경쟁은 여전히 생길 수 있습니다. 이럴 때는 asyncio.Lock처럼 그 실행 모델에 맞는 동기화 도구를 씁니다.

정리

  • 모니터는 공유 데이터와 연산, 잠금과 조건 대기를 하나로 묶어 연산을 한 번에 한 스레드만 실행하게 합니다. 잠금을 잡고 푸는 시점이 연산의 시작과 끝에 묶여 실수가 줄어듭니다.
  • Java는 synchronized와 wait, notifyAll로, Python은 threading.Condition으로 모니터를 만듭니다. 조건 변수의 wait는 연결된 잠금을 놓고 기다렸다가, 깨어나면 다시 쥐고 돌아옵니다.
  • 깨어난 뒤에는 조건을 while로 다시 확인해야 합니다. 다른 스레드가 먼저 조건을 바꿀 수 있고, 알림 없이 깨어나는 가짜 깨어남도 있기 때문입니다.
  • 조건을 바꾸는 쪽도 같은 잠금 아래에서 바꾸고 알려야 깨어남 손실을 막을 수 있습니다.
  • 식사하는 철학자 문제의 교착 상태는 순서를 비대칭으로 만들거나, 동시에 시도하는 수를 제한하거나, 두 자원을 한꺼번에 확인하는 방법으로 피합니다.
  • 실무에서는 동시성 자료구조, 액터, 채널, 비동기 프로그래밍 같은 상위 수준의 방법을 먼저 검토합니다.

연습 문제

1. 용량 3인 원형 버퍼

앞의 Java 버퍼를 용량 3으로 만들고, 10, 20, 30을 차례로 넣은 뒤 하나를 꺼내고, 다시 40을 넣었습니다. in과 out은 0에서 시작하고 모든 호출은 정상적으로 끝납니다. 마지막 시점의 in, out, count와 배열의 모습, 그리고 앞으로 꺼낼 순서를 답하세요.

해설 보기

세 번 넣으면 in은 0, 1, 2를 거쳐 다시 0이 되고, out은 0, count는 3입니다. 10을 꺼내면 out은 1, count는 2가 됩니다. 40은 in이 가리키는 0번 자리에 들어가므로 in은 1, count는 3이 됩니다. 배열은 물리적으로 [40, 20, 30]이지만, 다음에 꺼낼 위치가 out = 1이므로 앞으로 꺼낼 순서는 20, 30, 40입니다. 원형 버퍼는 배열의 물리적 순서가 아니라 out에서 시작하는 순서로 읽어야 합니다.

2. 알림 셋과 항목 둘

빈 버퍼를 기다리는 소비자가 셋 있습니다. 생산자가 같은 잠금 아래에서 항목 두 개를 넣고 notify_all()을 불렀습니다. 각 소비자는 잠금을 쥔 채 항목을 하나씩만 꺼내며, 더 들어오는 항목이나 가짜 깨어남은 없습니다. 몇 명이 항목을 꺼내고, 나머지는 어떻게 되나요? 조건을 if로 확인했다면 무엇이 달라질까요?

해설 보기

셋 모두 깨어나 잠금을 얻으려고 경쟁하고, 먼저 잠금을 얻은 두 명이 하나씩 꺼냅니다. 세 번째 소비자가 잠금을 얻었을 때는 버퍼가 비어 있으므로, while로 확인했다면 조건을 다시 보고 다시 기다립니다. 어느 두 명이 먼저 꺼낼지는 정해져 있지 않습니다. if로 확인했다면 세 번째 소비자는 깨어났다는 사실만 믿고 빈 버퍼에서 꺼내려다, 앞의 예제처럼 IndexError를 만나거나 언어에 따라 엉뚱한 값을 읽게 됩니다. 알림의 수와 꺼낼 수 있는 항목의 수는 같지 않다는 것이 핵심입니다.

3. 철학자의 상태 판단

모니터 방식의 식사하는 철학자에서 철학자 0만 먹고 있고, 철학자 2는 배가 고파 기다리며, 나머지는 생각하고 있습니다. 철학자는 다섯 명입니다. _test(2)를 실행한 뒤, 이어서 철학자 1이 배가 고파져 _test(1)을 실행하면 두 판단의 결과는 각각 어떻게 되나요?

해설 보기

철학자 2의 이웃은 1과 3이고, 둘 다 먹고 있지 않으므로 _test(2)는 철학자 2를 먹는 상태로 바꾸고 깨웁니다. 철학자 1의 이웃은 0과 2인데, 0은 처음부터 먹고 있었고 2도 방금 먹기 시작했으므로, _test(1)은 철학자 1을 배고픈 상태로 둡니다. 철학자 1은 조건 변수에서 기다리다가, 0이나 2가 다 먹고 putdown에서 _test(1)을 다시 실행해 줄 때 먹을 수 있는지 다시 판단받습니다. 이 추적은 이웃이 동시에 먹지 않는다는 것을 보여 줄 뿐, 철학자 1이 언젠가 반드시 먹는다는 것까지 보장하지는 않습니다. 0과 2가 번갈아 계속 먹는다면 1은 오래 기다릴 수 있습니다.

다음 장의 교착 상태의 개념에서는 이 글에서 본 교착 상태가 생기는 네 가지 조건과, 자원 할당 그래프로 교착 상태를 찾는 방법을 살펴봅니다.