> [!abstract] Introduction > [[Semaphore#세마포어의 정의|세마포어]]는 강력한 동기화 도구이지만, [[Semaphore#P 연산과 V 연산|P·V 연산]]의 순서를 한 번이라도 잘못 배치하면 교착 상태나 경쟁 조건이 발생할 수 있습니다. 이러한 저수준 동기화의 위험성을 구조적으로 제거하기 위해 등장한 것이 바로 모니터*Monitor*입니다. 이 글에서는 모니터가 왜 필요한지, 어떤 구성 요소로 이루어져 있는지, 조건 변수의 두 가지 시맨틱스는 무엇이 다른지, 그리고 실제 시스템에서는 어떻게 구현되는지를 살펴봅니다. ## 세마포어의 한계 [[Semaphore#세마포어의 정의|세마포어]]는 정수 변수와 두 개의 원자적 연산([[Semaphore#P 연산 (wait, down)|`P`]]/[[Semaphore#V 연산 (signal, up)|`V`]], 또는 `wait`/`signal`)만으로 상호 배제와 실행 순서 제어를 모두 구현할 수 있는 범용 동기화 프리미티브다. 그러나 세마포어의 범용성은 곧 위험성이기도 하다. 세마포어를 사용하는 프로그래머는 `P`와 `V`의 호출 순서, 호출 위치, 호출 횟수를 모두 올바르게 관리해야 한다. 하나의 임계 구역*critical section*[^critical-section]을 보호하는 코드만 해도 아래와 같은 실수가 발생할 수 있다(Silberschatz, Galvin and Gagne, 2018). ```c /* 올바른 사용 */ P(&mutex); // 임계 구역 V(&mutex); /* 실수 1: V와 P의 순서를 뒤바꿈 — 상호 배제 위반 */ V(&mutex); // 임계 구역 P(&mutex); /* 실수 2: P를 두 번 호출 — 교착 상태 */ P(&mutex); // 임계 구역 P(&mutex); /* 실수 3: V 호출을 빠뜨림 — 영구 블로킹 */ P(&mutex); // 임계 구역 // V(&mutex); ← 누락 ``` 이러한 실수는 단일 세마포어에서도 발생하지만, 복수의 세마포어가 얽히는 실제 시스템에서는 문제가 기하급수적으로 복잡해진다. 더 근본적인 문제는, 세마포어 기반 코드에서는 **동기화 로직이 비즈니스 로직 전체에 흩어져** 있어 정확성을 검증하기 어렵다는 점이다. 이 문제를 해결하기 위해 Per Brinch Hansen(1973)과 C. A. R. Hoare(1974)가 독립적으로 제안한 것이 바로 모니터다. ## 모니터의 정의 모니터*Monitor*는 동기화가 필요한 데이터와 그 데이터에 접근하는 절차*procedure*를 하나의 추상 자료형*Abstract Data Type*으로 묶은 고수준 동기화 구조체다(Hoare, 1974). 모니터는 세 가지 구성 요소로 이루어진다. **모니터 락*monitor lock***: 모니터 내부에 진입하기 위해 반드시 획득해야 하는 [[Lock|락]]이다. 한 시점에 오직 하나의 스레드만 모니터 내부에 존재할 수 있으므로, 보호 대상 데이터에 대한 상호 배제*mutual exclusion*가 구조적으로 보장된다. **보호 데이터*protected data***: 모니터가 보호하는 공유 상태다. 이 데이터에 접근하는 유일한 방법은 모니터가 제공하는 절차를 통하는 것이다. **조건 변수*condition variable***: 모니터 내부에서 특정 조건이 충족될 때까지 스레드가 대기하고, 조건이 충족되면 대기 중인 스레드를 깨우는 메커니즘이다. ```mermaid flowchart TB subgraph Monitor["모니터"] direction TB LOCK["모니터 락"] subgraph DATA["보호 데이터"] D1["공유 상태"] end subgraph PROCS["절차"] P1["procedure A()"] P2["procedure B()"] end subgraph CONDS["조건 변수"] C1["condition x"] C2["condition y"] end end T1["스레드 1"] -->|"락 획득"| LOCK T2["스레드 2"] -->|"대기"| LOCK LOCK --> PROCS PROCS --> DATA PROCS <--> CONDS ``` 모니터의 핵심 아이디어는 **동기화 로직을 한 곳에 캡슐화**하는 것이다. 프로그래머는 모니터의 절차를 호출하기만 하면 되고, 락의 획득과 해제는 모니터 진입·퇴출 시 자동으로 이루어진다. 세마포어에서 프로그래머가 직접 관리하던 `P`/`V`의 순서와 위치를 모니터가 구조적으로 강제하는 셈이다. ## 조건 변수 모니터 내부에서 스레드가 어떤 조건이 참이 될 때까지 기다려야 하는 상황은 빈번하다. 예를 들어, 버퍼가 비어 있는데 소비자가 데이터를 꺼내려 한다면, 생산자가 데이터를 넣을 때까지 기다려야 한다. 이를 위해 모니터는 **조건 변수*condition variable***를 제공한다. 조건 변수 자체는 어떤 값을 저장하지 않는다. 조건 변수는 순수하게 대기열의 역할만 수행하며, 세 가지 연산을 지원한다. ### `wait` `cond_wait(&condition, &lock)` 연산은 다음의 세 단계를 **원자적으로** 수행한다(Silberschatz, Galvin and Gagne, 2018). 1. 호출 스레드를 조건 변수의 대기열에 삽입한다. 2. 모니터 락을 해제한다. 3. 호출 스레드를 잠재운다*sleep*. 스레드가 깨어나면 모니터 락을 다시 획득한 뒤에야 `wait`에서 복귀한다. 2번(락 해제)과 3번(잠듦)이 원자적으로 이루어지는 것이 핵심이다. 만약 락을 해제한 뒤 잠들기 전에 다른 스레드가 `signal`을 보내면, 이 `signal`이 소실되는 **깨움 손실*lost wakeup*** 문제가 발생할 수 있기 때문이다.[^lost-wakeup] ### `signal` `cond_signal(&condition)` 연산은 조건 변수의 대기열에서 하나의 스레드를 깨운다. 대기열이 비어 있으면 아무 일도 일어나지 않는다. 이 점이 세마포어의 `V` 연산과 다르다 — 세마포어는 대기자가 없어도 내부 카운터를 증가시키므로 다음 `P` 호출이 즉시 통과하지만, 조건 변수의 `signal`은 대기자가 없으면 효과가 사라진다. ### `broadcast` `cond_broadcast(&condition)` 연산은 조건 변수의 대기열에 있는 **모든** 스레드를 깨운다. 깨어난 스레드들은 모니터 락을 놓고 경쟁하며, 한 번에 하나씩 모니터에 재진입한다. 어떤 스레드가 조건을 충족하는지 시그널러가 알 수 없는 상황에서 유용하다. ## Mesa 시맨틱스 vs. Hoare 시맨틱스 `signal` 연산이 호출되는 순간, 모니터 안에는 두 스레드가 존재할 수 있다. 시그널을 보낸 스레드(시그널러)와, 깨어난 스레드(웨이터)다. 모니터의 상호 배제 불변식[^monitor-invariant]을 유지하려면 둘 중 하나만 즉시 실행되어야 한다. 이 선택에 따라 두 가지 시맨틱스가 나뉜다. ### Hoare 시맨틱스 Hoare(1974)가 제안한 원래의 시맨틱스에서는 `signal`이 호출되면 **시그널러가 즉시 멈추고, 웨이터가 모니터 락을 받아 곧바로 실행을 재개**한다. 웨이터는 `wait`에서 복귀하는 시점에 조건이 반드시 참임을 보장받는다. ``` /* Hoare 시맨틱스: 조건 확인에 if 사용 가능 */ procedure consume(): if buffer_empty: wait(not_empty) /* 이 시점에서 buffer_empty == false가 보장됨 */ item = remove_from_buffer() ``` 이 시맨틱스의 장점은 웨이터 입장에서 조건이 확실히 보장되므로 추론이 명확하다는 것이다. 그러나 시그널러가 웨이터에게 실행을 양보했다가 돌아오는 추가 컨텍스트 스위치가 필요하고, 구현 복잡도가 높다. ### Mesa 시맨틱스 Lampson과 Redell(1980)이 Mesa 언어에서 도입한 시맨틱스에서는 `signal`이 호출되어도 **시그널러가 계속 실행**한다. 웨이터는 레디 큐에 삽입될 뿐, 즉시 실행되지는 않는다. 따라서 웨이터가 실제로 모니터 락을 재획득하여 `wait`에서 복귀하는 시점에는 다른 스레드가 먼저 실행되어 조건이 다시 거짓이 되었을 수 있다.[^mesa-spurious-wakeup] ``` /* Mesa 시맨틱스: 반드시 while 루프로 조건을 재확인 */ procedure consume(): while buffer_empty: wait(not_empty) /* while 덕분에 이 시점에서 조건이 참임이 보장됨 */ item = remove_from_buffer() ``` Mesa 시맨틱스에서는 `wait`에서 복귀한 뒤 조건을 **다시 확인**해야 하므로, `if` 대신 `while` 루프를 사용하는 것이 필수적이다. 이것이 바로 모니터 프로그래밍에서 가장 자주 언급되는 규칙이다. ### 비교 | 항목 | Hoare 시맨틱스 | Mesa 시맨틱스 | |------|----------------|---------------| | `signal` 후 실행 | 웨이터가 즉시 실행 | 시그널러가 계속 실행 | | 조건 보장 | `wait` 복귀 시 조건이 반드시 참 | `wait` 복귀 시 조건이 거짓일 수 있음 | | 조건 확인 | `if`로 충분 | `while` 필수 | | 컨텍스트 스위치 | 추가 스위치 필요 | 추가 스위치 없음 | | 구현 복잡도 | 높음 | 낮음 | | 실제 채택 | 거의 없음 | POSIX, Java, Pintos 등 대부분 | 오늘날 실제로 사용되는 거의 모든 시스템은 Mesa 시맨틱스를 채택하고 있다. Hoare 시맨틱스는 이론적으로 깔끔하지만, 추가 컨텍스트 스위치의 비용과 구현 복잡도 때문에 실용적이지 않기 때문이다. ## Producer-Consumer 예제 모니터를 이해하는 가장 고전적인 예제는 생산자-소비자*Producer-Consumer* 문제다. 유한한 크기의 버퍼에 생산자가 데이터를 넣고, 소비자가 데이터를 꺼내가는 상황을 모니터로 구현해 보자. 이 문제에서는 두 가지 조건을 기다려야 한다. 생산자는 버퍼가 가득 차 있으면 빈 공간이 생길 때까지 기다려야 하고, 소비자는 버퍼가 비어 있으면 데이터가 들어올 때까지 기다려야 한다. 이를 위해 두 개의 조건 변수 `not_full`과 `not_empty`를 사용한다. ```c /* 모니터: 유한 버퍼 */ monitor BoundedBuffer { item_t buffer[N]; int count = 0; int in = 0, out = 0; condition not_full; /* 버퍼에 빈 공간이 있음 */ condition not_empty; /* 버퍼에 데이터가 있음 */ procedure produce(item_t item) { while (count == N) wait(not_full); buffer[in] = item; in = (in + 1) % N; count++; signal(not_empty); } procedure consume() -> item_t { while (count == 0) wait(not_empty); item_t item = buffer[out]; out = (out + 1) % N; count--; signal(not_full); return item; } } ``` 이 코드에서 주목해야 할 점은 `P`/`V` 호출이 전혀 없다는 것이다. 모니터에 진입하는 순간 락이 자동으로 획득되고, 퇴출 시 자동으로 해제된다. `wait`은 락 해제와 잠들기를 원자적으로 수행하고, 깨어나면 락을 재획득한다. 프로그래머는 **조건이 무엇인지**에만 집중하면 되고, **어떻게 동기화할 것인지**는 모니터가 처리한다. ## 실제 구현 ### POSIX 스레드 POSIX 스레드(pthreads)는 Mesa 시맨틱스의 모니터를 `pthread_mutex_t`(모니터 락)와 `pthread_cond_t`(조건 변수)의 조합으로 구현한다. 앞의 유한 버퍼 예제를 pthreads로 옮기면 다음과 같다. ```c #include <pthread.h> #define N 10 typedef struct { int buffer[N]; int count, in, out; pthread_mutex_t lock; pthread_cond_t not_full; pthread_cond_t not_empty; } bounded_buffer_t; void buffer_init(bounded_buffer_t *b) { b->count = b->in = b->out = 0; pthread_mutex_init(&b->lock, NULL); pthread_cond_init(&b->not_full, NULL); pthread_cond_init(&b->not_empty, NULL); } void produce(bounded_buffer_t *b, int item) { pthread_mutex_lock(&b->lock); /* 모니터 진입 */ while (b->count == N) pthread_cond_wait(&b->not_full, &b->lock); /* wait */ b->buffer[b->in] = item; b->in = (b->in + 1) % N; b->count++; pthread_cond_signal(&b->not_empty); /* signal */ pthread_mutex_unlock(&b->lock); /* 모니터 퇴출 */ } int consume(bounded_buffer_t *b) { pthread_mutex_lock(&b->lock); /* 모니터 진입 */ while (b->count == 0) pthread_cond_wait(&b->not_empty, &b->lock); /* wait */ int item = b->buffer[b->out]; b->out = (b->out + 1) % N; b->count--; pthread_cond_signal(&b->not_full); /* signal */ pthread_mutex_unlock(&b->lock); /* 모니터 퇴출 */ return item; } ``` `pthread_cond_wait`의 두 번째 인자가 모니터 락(`pthread_mutex_t`)이라는 점에 주목하자. 이 함수는 내부적으로 락 해제 → 대기열 삽입 → 잠들기를 원자적으로 수행하며, 깨어나면 락을 재획득한 뒤에야 반환한다.[^pthread-cond-wait-atomicity] 리눅스에서 이 원자성은 [[Inter-Process Communication#Futex: 사용자 공간 동기화의 기반|Futex]][^futex-def]를 기반으로 구현된다. ### Java Java는 언어 수준에서 모니터를 내장 지원한다. 모든 Java 객체는 내부에 모니터 락(고유 락*intrinsic lock* 또는 모니터 락이라 불린다)을 하나씩 가지고 있으며, `synchronized` 키워드가 이 락의 획득·해제를 자동으로 처리한다(Goetz et al., 2006). ```java public class BoundedBuffer { private final int[] buffer; private int count = 0, in = 0, out = 0; public BoundedBuffer(int capacity) { buffer = new int[capacity]; } public synchronized void produce(int item) throws InterruptedException { while (count == buffer.length) wait(); /* Object.wait() */ buffer[in] = item; in = (in + 1) % buffer.length; count++; notifyAll(); /* Object.notifyAll() */ } public synchronized int consume() throws InterruptedException { while (count == 0) wait(); /* Object.wait() */ int item = buffer[out]; out = (out + 1) % buffer.length; count--; notifyAll(); /* Object.notifyAll() */ return item; } } ``` Java의 `synchronized` 메서드는 호출 시 해당 객체의 고유 락을 자동으로 획득하고, 메서드가 반환(정상이든 예외든)하면 자동으로 해제한다. `wait()`과 `notify()`/`notifyAll()`은 `java.lang.Object`에 정의된 메서드로, 각각 조건 변수의 `wait`과 `signal`/`broadcast`에 대응한다. 다만 Java의 기본 모니터에는 한 가지 한계가 있다. **하나의 객체에 조건 변수가 하나뿐**이라는 점이다. 앞의 예제에서 `notifyAll()` 대신 `notify()`를 사용하면, 생산자가 생산자를 깨우거나 소비자가 소비자를 깨우는 상황이 발생할 수 있다.[^java-single-condition] 이 한계는 Java 5에서 도입된 `java.util.concurrent.locks` 패키지의 `Condition` 인터페이스로 해결되었다. ```java /* java.util.concurrent.locks를 사용한 다중 조건 변수 */ ReentrantLock lock = new ReentrantLock(); Condition notFull = lock.newCondition(); Condition notEmpty = lock.newCondition(); ``` ## 모니터의 위치 모니터는 동기화 프리미티브의 계층에서 [[Semaphore#세마포어의 정의|세마포어]]와 [[Lock|락]] 위에 위치하는 고수준 추상화다. 세마포어가 [[Semaphore#P 연산 (wait, down)|`P`]]/[[Semaphore#V 연산 (signal, up)|`V`]]라는 두 개의 원자적 연산으로 임의의 동기화 패턴을 구현할 수 있는 만능 도구라면, 모니터는 **상호 배제를 구조적으로 강제하고 조건 대기를 선언적으로 표현**함으로써 프로그래머의 실수를 줄이는 방향으로 진화한 것이다. ```mermaid flowchart BT HW["하드웨어 원자적 명령어<br/>(CAS, Test-and-Set)"] LOCK["[[Lock|락]]<br/>(spinlock, mutex)"] SEM["[[Semaphore|세마포어]]"] MON["모니터<br/>(lock + condition variable)"] LANG["언어 수준 지원<br/>(Java synchronized, Python with)"] HW --> LOCK LOCK --> SEM LOCK --> MON SEM -.->|"동등한 표현력"| MON MON --> LANG ``` 세마포어와 모니터는 표현력 면에서 동등하다. 세마포어로 모니터를 구현할 수 있고, 모니터로 세마포어를 구현할 수도 있다(Silberschatz, Galvin and Gagne, 2018). 그러나 모니터가 제공하는 구조적 강제는 대규모 소프트웨어에서 정확성 검증을 훨씬 수월하게 만든다. 이것이 현대 프로그래밍 언어 대부분이 세마포어 대신 모니터 스타일의 동기화(Java `synchronized`, Python `with threading.Condition()`, Rust `Condvar`)를 기본으로 채택하는 이유다. --- ## 출처 - Hoare, C.A.R. (1974) 'Monitors: An Operating System Structuring Concept', *Communications of the ACM*, 17(10), pp. 549–557. - Hansen, P.B. (1973) *Operating System Principles*. Englewood Cliffs: Prentice-Hall. - Lampson, B.W. and Redell, D.D. (1980) 'Experience with Processes and Monitors in Mesa', *Communications of the ACM*, 23(2), pp. 105–117. - Silberschatz, A., Galvin, P.B. and Gagne, G. (2018) *Operating System Concepts*. 10th edn. Hoboken: Wiley. - Goetz, B. et al. (2006) *Java Concurrency in Practice*. Upper Saddle River: Addison-Wesley. - Pfaff, B., Romano, A. and Back, G. (2009) 'The Pintos Instructional Operating System Kernel', *Proceedings of the 40th ACM Technical Symposium on Computer Science Education*, pp. 453–457. [^critical-section]: 임계 구역*critical section*이란 공유 자원에 접근하는 코드 영역으로, 한 시점에 하나의 스레드만 실행해야 하는 구간이다. — Silberschatz, Galvin and Gagne (2018), §6.1. [^lost-wakeup]: 깨움 손실*lost wakeup* 문제: 스레드 A가 조건을 확인한 뒤 잠들기 전에, 스레드 B가 조건을 참으로 만들고 `signal`을 보내면, 이 `signal`은 아직 대기열에 없는 A에게 전달되지 않는다. 이후 A가 잠들면 영원히 깨어나지 못한다. `wait`에서 락 해제와 잠들기를 원자적으로 수행함으로써 이 문제를 방지한다. [^monitor-invariant]: 모니터의 상호 배제 불변식*mutual exclusion invariant*: 어떤 시점에든 모니터 내부에서 활성 상태인 스레드는 최대 하나뿐이다. — Hoare (1974). [^mesa-spurious-wakeup]: Mesa 시맨틱스에서는 *허위 깨움*spurious wakeup*도 발생할 수 있다. `signal`이 호출되지 않았는데도 `wait`에서 복귀하는 현상으로, 일부 구현에서 성능 최적화를 위해 허용한다. `while` 루프는 이 허위 깨움까지도 안전하게 처리한다. — POSIX `pthread_cond_wait(3)` 명세. [^pthread-cond-wait-atomicity]: `pthread_cond_wait`은 내부적으로 Futex의 `FUTEX_WAIT` 연산을 사용한다. 커널에 진입하기 전에 뮤텍스를 해제하되, 해제와 대기열 삽입 사이에 `signal`이 소실되지 않도록 Futex 값 비교로 원자성을 보장한다. — `nptl/pthread_cond_wait.c`. [^futex-def]: Futex*Fast Userspace Mutex*: 경합이 없는 경로에서 커널 진입 없이 원자적 CAS 연산만으로 동작하는 동기화 프리미티브. `pthread_mutex_lock()`, `pthread_cond_wait()`, `sem_wait()` 등의 기반이 된다. [^java-single-condition]: Java의 기본 모니터(`synchronized` + `wait`/`notify`)에서는 모든 `wait` 호출이 같은 대기열에 들어가므로, `notify()`가 의도한 유형의 스레드를 깨운다는 보장이 없다. 이 때문에 기본 모니터에서는 안전을 위해 `notifyAll()`을 사용하는 것이 관례다. — Goetz et al. (2006), §14.2.