본문 바로가기
운영체제/반효경 교수

운영체제: 프로세스 동기화 - 모니터 (Monitor)를 이용한 병행 제어

by 절차탁마 2025. 4. 13.

https://core.ewha.ac.kr/publicview/C0101020140411143154161543?vmode=f

 

반효경 [운영체제] 15. Process Synchronization 4

설명이 없습니다.

core.ewha.ac.kr

 

지난 글에서는 세마포어(Semaphore)를 이용하여 고전적인 동기화 문제들(유한 버퍼, 판독자-기록자, 식사하는 철학자)을 해결하는 방법을 살펴보았습니다. 세마포어는 강력한 도구이지만, P와 V 연산을 정확한 순서와 위치에 사용해야 하므로 프로그래머가 실수하기 쉽고, 복잡한 동기화 로직의 정확성을 검증하기 어렵다는 단점이 있습니다.

 

이번 글에서는 이러한 세마포어의 어려움을 해소하고, 더 높은 수준의 추상화를 통해 안전하고 쉬운 동기화를 지원하는 모니터(Monitor)에 대해 알아보겠습니다. 모니터는 병행 제어(Concurrency Control), 즉 프로세스 동기화를 위한 중요한 고급 기법입니다.

 


이전 내용 복습: 세마포어의 한계

시작하기 전에 세마포어의 핵심과 한계를 간단히 복습해 봅시다.

  • 세마포어: 정수 변수와 원자적 연산 P(wait, 획득/대기), V(signal, 반납/통지)로 구성된 동기화 추상 자료형.
  • 구현 방식: 바쁜 대기(Busy-wait, 스핀락) 방식은 CPU 낭비가 심해, 일반적으로 Block/Wakeup(잠들고 깨우기) 방식으로 구현됨 (세마포어 내부에 대기 큐 사용).
  • 종류: 카운팅 세마포어(자원 개수), 이진 세마포어/뮤텍스(상호 배제).
  • 문제점: P/V 연산 순서나 누락 등 프로그래머의 작은 실수가 교착 상태(Deadlock)나 상호 배제 실패 등 심각한 시스템 오류로 이어지기 쉬움. 정확성 증명이 어렵고 자발적인 협력이 필수적.

1. 모니터 (Monitor)란 무엇인가? - 고수준 동기화 구조

세마포어의 사용 어려움을 극복하기 위해 제안된 것이 모니터(Monitor)입니다. 모니터는 동시 실행 중인 프로세스들 사이에서 공유 데이터(추상 데이터 타입, ADT)를 안전하게 사용할 수 있도록 보장하는 고수준 동기화 구조(High-level Synchronization Construct)입니다. 프로그래밍 언어 차원에서 지원되는 경우가 많습니다.

  • 핵심 아이디어:
    1. 데이터 캡슐화: 관련된 공유 데이터와 이 데이터에 접근하고 조작하는 프로시저(메서드)들을 하나의 모듈(모니터) 안에 정의합니다.
    2. 자동 상호 배제 (Implicit Mutual Exclusion): 모니터의 가장 중요한 특징! 프로그래머가 명시적으로 Lock/Unlock(P/V 연산) 코드를 작성할 필요 없이, 모니터 자체가 내부 프로시저들에 대해 상호 배제를 자동으로 보장합니다. 즉, 한 번에 오직 하나의 프로세스(또는 스레드)만이 모니터 내의 코드를 실행(active)할 수 있습니다. 마치 모니터 전체에 보이지 않는 Lock이 걸려있는 것과 같습니다.
    3. 조건 변수 (Condition Variables): 모니터 내에서 특정 조건이 만족될 때까지 프로세스가 안전하게 대기(wait)하고, 다른 프로세스가 조건을 만족시켰을 때 깨워줄(signal) 수 있는 메커니즘을 제공합니다.
  • 구성 요소:
    • 공유 변수 선언: 모니터 내부에서만 관리될 공유 데이터.
    • 프로시저(메서드) 정의: 공유 데이터에 접근하는 함수들. 오직 이 프로시저들을 통해서만 내부 데이터 접근 가능.
    • (선택적) 초기화 코드: 모니터 생성 시 공유 변수 초기화.
    • 조건 변수 (Condition Variables): 프로세스 대기 및 통지를 위한 변수.
  • 장점:
    • 사용 용이성: 프로그래머는 상호 배제를 위한 복잡한 P/V 연산 로직을 직접 작성할 필요가 없습니다. 모니터가 알아서 처리해주므로 실수 가능성이 크게 줄어듭니다.
    • 가독성 및 유지보수성 향상: 공유 데이터와 관련 로직이 하나의 모듈 안에 캡슐화되어 코드 구조가 명확해집니다.

2. 조건 변수 (Condition Variables): 모니터 내에서의 대기 및 통지

모니터의 자동 상호 배제만으로는 부족할 때가 있습니다. 예를 들어, 생산자-소비자 문제에서 생산자가 모니터에 진입했지만 버퍼가 가득 차 있다면, 단순히 상호 배제만으로는 해결되지 않습니다. 생산자는 버퍼에 빈 공간이 생길 때까지 기다려야 합니다. 이때 사용되는 것이 조건 변수입니다.

  • 선언: condition x, y; 와 같이 선언합니다.
  • 주요 연산 (원자적으로 실행됨):
    • x.wait():
      1. 이 연산을 호출한 프로세스는 조건 x가 만족되지 않아 기다려야 함을 의미합니다.
      2. 프로세스는 모니터의 상호 배제 잠금(implicit lock)을 임시로 해제하고, 조건 변수 x에 대한 대기 큐(Waiting Queue)에 들어가 잠든 상태(Blocked/Waiting)가 됩니다. (다른 프로세스가 모니터에 진입할 수 있게 길을 터줌)
    • x.signal():
      1. 이 연산은 조건 x를 기다리며 잠들어 있는 프로세스 중 하나를 깨우는(Resume/Wakeup) 역할을 합니다.
      2. 만약 해당 조건 변수(x)의 대기 큐에 기다리는 프로세스가 있다면, 그중 하나를 깨웁니다. (어떤 프로세스를 깨울지는 스케줄링 정책에 따라 다름)
      3. 만약 기다리는 프로세스가 없다면, signal() 연산은 아무런 효과 없이 그냥 넘어갑니다. (세마포어의 V 연산과 다른 점! 신호가 저장되지 않음)
      4. signal()을 호출한 프로세스는 자신의 작업을 계속 수행합니다. 깨어난 프로세스는 즉시 실행되는 것이 아니라, signal()을 호출한 프로세스가 모니터를 나가거나 wait()를 호출하여 잠금을 해제할 때까지 기다렸다가 모니터 잠금을 다시 획득해야 실행을 재개할 수 있습니다. (Signal and Continue 방식, 다른 방식도 있음)

[Q&A]

  • Q: "모니터는 바쁜 대기(Busy Waiting)를 사용하나요? wait() 전에 ifwhile로 조건을 검사하는 것 같은데요?"
  • A: 중요한 포인트입니다! 모니터의 condition.wait() 자체는 프로세스를 잠들게(Block) 하므로 바쁜 대기가 아닙니다. CPU를 낭비하지 않습니다. 다만, wait() 전에 if (조건 불충족) 또는 while (조건 불충족) 구문으로 조건을 검사하는 코드가 흔히 사용됩니다. if보다는 while 루프를 사용하는 것이 더 안전하고 일반적입니다. 그 이유는 다음과 같습니다.
    1. Spurious Wakeup: 프로세스가 signal() 호출 없이도 (OS 내부적인 이유로) wait()에서 깨어날 수 있는 경우가 드물게 있습니다. while 루프는 깨어났을 때 조건을 다시 확인함으로써 이런 상황에 대처할 수 있습니다.
    2. Signal 의미의 모호성: signal()은 단순히 '조건이 만족되었을 수도 있다'는 힌트일 뿐, 깨어난 시점에도 여전히 조건이 만족된다는 보장은 없을 수 있습니다 (다른 스레드가 먼저 자원을 가져갔을 수도 있음). 따라서 깨어난 후 while 루프로 조건을 반드시 재확인해야 합니다.
      while 루프는 바쁜 대기가 아니라, 깨어난 후 조건을 확인하는 논리적인 검사입니다.
  • Q: "Condition Variable의 signal()과 세마포어의 V()는 어떻게 다른가요?"
  • A: 가장 큰 차이는 상태 저장 여부입니다. 세마포어 V(S)S 값을 항상 1 증가시키고, 그 결과값(S <= 0)을 보고 대기자를 깨울지 결정합니다. 즉, V 연산의 효과(카운트 증가)는 누가 기다리고 있든 없든 항상 반영됩니다. 반면, Condition Variable x.signal()기다리는 프로세스가 있을 때만 효과가 있고, 없을 때는 아무 일도 일어나지 않습니다. 즉, signal() 호출 자체가 저장되지 않습니다. 따라서 모니터에서는 wait() 전에 조건을 while로 검사하는 것이 중요합니다.

3. 고전적 동기화 문제 해결 (모니터 버전)

세마포어로 복잡하게 해결했던 문제들을 모니터로 얼마나 간결하게 해결할 수 있는지 비교해 봅시다.

유한 버퍼 문제 (Bounded-Buffer Problem) - 모니터 버전

monitor BoundedBuffer {
    // 1. 공유 데이터 (모니터 내부에 캡슐화)
    item buffer[N];
    int count = 0; // 버퍼에 있는 아이템 개수
    int in = 0, out = 0; // 인덱스

    // 2. 조건 변수
    condition not_full, not_empty;

    // 3. 프로시저 (자동 상호 배제 보장)
    procedure produce(item x) {
        // 버퍼가 가득 찼는지 확인
        while (count == N) {
            not_full.wait(); // 가득 찼으면, 빈 공간이 생길 때까지 대기 (lock 해제하고 잠듦)
        }

        // 버퍼에 아이템 삽입
        buffer[in] = x;
        in = (in + 1) % N;
        count++;

        // 버퍼가 비어있지 않게 되었으므로, 기다리는 소비자에게 신호
        not_empty.signal();
    }

    procedure consume(ref item x) {
        // 버퍼가 비었는지 확인
        while (count == 0) {
            not_empty.wait(); // 비었으면, 아이템이 채워질 때까지 대기 (lock 해제하고 잠듦)
        }

        // 버퍼에서 아이템 꺼내기
        x = buffer[out];
        out = (out + 1) % N;
        count--;

        // 버퍼에 빈 공간이 생겼으므로, 기다리는 생산자에게 신호
        not_full.signal();
    }
} // End of Monitor
  • 세마포어 버전과의 비교:
    • 버퍼(buffer, count, in, out) 접근을 위한 별도의 mutex 세마포어(P/V 연산)가 필요 없습니다. 모니터가 프로시저(produce, consume) 실행 시 자동으로 상호 배제를 보장합니다.
    • 버퍼 상태(가득 참/비어 있음)에 따른 대기는 조건 변수 not_fullnot_emptywait()signal()을 통해 명확하고 안전하게 처리됩니다. 프로그래머는 세마포어 카운터 값을 직접 관리할 필요가 없습니다.
    • 전체적인 코드가 더 직관적이고 이해하기 쉬우며, 실수할 여지가 적습니다.

식사하는 철학자 문제 (Dining Philosophers Problem) - 모니터 버전

monitor DiningPhilosophers {
    // 철학자 상태
    enum state_type { THINKING, HUNGRY, EATING };
    state_type state[5];

    // 조건 변수: 각 철학자가 식사 가능할 때까지 기다림
    condition self[5];

    // 인접 철학자 인덱스 계산 함수 (내부 헬퍼)
    function LEFT(i): integer { return (i + 4) % 5; }
    function RIGHT(i): integer { return (i + 1) % 5; }

    // 철학자 i가 식사 가능한지 확인하고, 가능하면 깨우는 내부 프로시저
    procedure test(int i) {
        if (state[LEFT(i)] != EATING) && (state[i] == HUNGRY) && (state[RIGHT(i)] != EATING) {
            state[i] = EATING;
            self[i].signal(); // 기다리던 철학자 i를 깨움
        }
    }

    // 철학자 i가 포크를 집는 프로시저 (외부 호출 가능)
    procedure pickup(int i) {
        state[i] = HUNGRY; // 배고픔 상태 표시
        test(i);           // 바로 먹을 수 있는지 확인
        if (state[i] != EATING) { // 바로 못 먹으면
            self[i].wait();      // 자신의 조건 변수에서 대기 (lock 해제하고 잠듦)
        }
    }

    // 철학자 i가 포크를 내려놓는 프로시저 (외부 호출 가능)
    procedure putdown(int i) {
        state[i] = THINKING; // 생각 상태로 변경
        // 양 옆 철학자가 식사 가능한지 확인하고 깨워줌
        test(LEFT(i));
        test(RIGHT(i));
    }

    // 초기화 코드
    initialization_code() {
        for i = 0 to 4 {
            state[i] = THINKING;
            // condition 변수는 별도 초기화 불필요 (대기 큐만 존재)
        }
    }
} // End of Monitor

// --- 철학자 프로세스 로직 ---
procedure Philosopher(int i) {
    while (true) {
        think();
        DiningPhilosophers.pickup(i); // 모니터의 pickup 프로시저 호출
        eat();
        DiningPhilosophers.putdown(i); // 모니터의 putdown 프로시저 호출
    }
}
  • 세마포어 버전과의 비교:
    • 철학자 상태(state 배열) 접근을 위한 별도의 mutex 세마포어가 필요 없습니다. pickup, putdown, test 프로시저는 모니터에 의해 자동으로 상호 배제가 보장됩니다.
    • 각 철학자가 식사 가능할 때까지 기다리는 로직이 self[i].wait()self[i].signal()**을 통해 명확하게 구현됩니다. 세마포어 버전처럼 복잡한 P(self[i])V(self[i])의 의미를 해석할 필요가 줄어듭니다.
    • 전체적인 구조가 더 객체지향적이고 이해하기 쉬워집니다. (공유 데이터와 관련 연산이 모니터 안에 캡슐화됨)

[Q&A]

  • Q: "모니터의 signal()은 기다리는 프로세스 중 '하나'만 깨운다고 했는데, 만약 여러 명이 기다리고 있다면 어떤 기준으로 깨우나요? 공정한가요?"
  • A: 어떤 프로세스를 깨울지는 모니터의 **구현 방식(스케줄링 정책)**에 따라 다릅니다. 일반적으로는 FIFO(선입선출) 방식으로 가장 오래 기다린 프로세스를 깨우는 것이 공정하지만, 우선순위 기반 등 다른 방식도 가능합니다. 중요한 것은 프로그래머가 이 순서를 직접 제어하기는 어렵고, 모니터 구현에 맡겨야 한다는 점입니다. (Java의 notify()는 JVM 구현에 따라 달라질 수 있으며, 특정 스레드를 지정할 수 없습니다. notifyAll()은 모든 대기 스레드를 깨웁니다.)
  • Q: "모니터가 세마포어보다 항상 더 좋은 선택인가요?"
  • A: 사용 편의성과 안전성 측면에서는 모니터가 일반적으로 더 우수합니다. 프로그래머의 실수를 줄여주기 때문입니다. 하지만 세마포어는 카운팅 기능을 통해 더 유연하고 다양한 동기화 시나리오를 구현할 수 있다는 장점이 있습니다. 예를 들어, 특정 개수만큼의 프로세스만 진입을 허용하는 등의 제어는 카운팅 세마포어가 더 직관적일 수 있습니다. 또한, 모니터는 언어 수준의 지원이 필요하지만 세마포어는 더 기본적인 시스템 콜 형태로 제공되는 경우가 많습니다. 따라서 상황과 요구사항, 사용 언어 등에 따라 적절한 도구를 선택해야 합니다.

[마무리하며]

모니터는 세마포어의 단점을 보완하여 더 안전하고 쉽게 동기화 문제를 해결할 수 있도록 설계된 고수준의 동기화 구조입니다. 공유 데이터와 관련 연산을 캡슐화하고 자동으로 상호 배제를 보장하며, 조건 변수를 통해 명확한 대기/통지 메커니즘을 제공합니다.

 

비록 세마포어만큼의 유연성은 부족할 수 있지만, 프로그래머의 실수를 줄이고 코드의 가독성과 유지보수성을 높여준다는 점에서 현대적인 병행 프로그래밍 환경에서 매우 중요한 개념입니다. Java, C#, Python 등 많은 객체지향 언어들이 모니터와 유사한 동기화 기능을 내장하고 있습니다. (Java의 synchronized, wait, notify). 이러한 고수준 동기화 도구 덕분에 우리는 복잡한 동시성 문제에 더 효과적으로 대처할 수 있게 되었습니다.