지난 글에서는 임계 구역 문제 해결을 위한 기본적인 조건들과 고전적인 알고리즘(피터슨 등), 하드웨어 기반 해결책, 그리고 바쁜 대기(Busy Waiting) 문제를 해결하는 강력한 도구인 세마포어(Semaphore)의 개념을 소개했습니다.
이번 글에서는 세마포어를 활용하여 고전적인 동기화 문제들을 어떻게 해결하는지 구체적인 사례를 통해 살펴보겠습니다. 이 문제들은 동시성 제어의 어려움을 잘 보여주며, 다양한 동기화 메커니즘 설계의 기반이 됩니다.
아래 강의를 참고했습니다:
반효경 [운영체제] 14. Process Synchronization 3
설명이 없습니다.
core.ewha.ac.kr
세마포어(Semaphore) 복습
시작하기 전에 세마포어의 핵심을 다시 짚어보겠습니다.
- 개념: 정수 값을 가지는 동기화 추상 자료형. 오직 두 개의 원자적 연산 P(wait)와 V(signal)를 통해서만 접근 가능.
- P(S) (자원 획득 또는 대기):
- S 값 1 감소 (S--).
- 만약 S < 0 이면, 해당 프로세스는 대기 큐에서 잠든다 (Block).
- V(S) (자원 반납 또는 통지):
- S 값 1 증가 (S++).
- 만약 S <= 0 이면 (누군가 기다리고 있다면), 대기 큐에서 프로세스 하나를 깨운다 (Wakeup).
- 종류:
- 카운팅 세마포어: 임의의 정수 값. 사용 가능한 자원 개수 표현.
- 이진 세마포어 (뮤텍스): 0 또는 1 값. 상호 배제(Mutual Exclusion)용.
- 주의: 잘못 사용 시 교착 상태(Deadlock)나 기아 상태(Starvation) 발생 가능.
고전적인 동기화 문제들 (Classical Problems of Synchronization)
세마포어 등의 동기화 도구를 평가하고 이해하는 데 자주 사용되는 고전적인 문제들이 있습니다.
- 유한 버퍼 문제 (Bounded-Buffer Problem) 또는 생산자-소비자 문제 (Producer-Consumer Problem)
- 판독자-기록자 문제 (Readers-Writers Problem)
- 식사하는 철학자 문제 (Dining-Philosophers Problem)
이제 각 문제를 세마포어를 이용해 어떻게 해결하는지 살펴보겠습니다.
1. 유한 버퍼 문제 (Bounded-Buffer Problem)
- 문제 상황:
- 크기가 정해진 유한한 버퍼(Shared Buffer)가 있습니다.
- 생산자(Producer) 프로세스(들)는 데이터를 생성하여 버퍼에 넣습니다.
- 소비자(Consumer) 프로세스(들)는 버퍼에서 데이터를 꺼내어 사용합니다.
- 동기화 요구사항:
- 생산자는 버퍼가 가득 차 있으면(Full) 더 이상 데이터를 넣을 수 없고 기다려야 합니다.
- 소비자는 버퍼가 비어 있으면(Empty) 데이터를 꺼낼 수 없고 기다려야 합니다.
- 여러 생산자/소비자가 동시에 버퍼 자체(예: 인덱스 변수)에 접근하여 조작하면 안 됩니다 (상호 배제).
- 해결 전략 (세마포어 사용):
- mutex (이진 세마포어): 버퍼 및 관련 변수(in/out 인덱스) 접근에 대한 상호 배제를 보장합니다. 초기값은 1.
- empty (카운팅 세마포어): 사용 가능한 빈 버퍼 슬롯의 개수를 나타냅니다. 초기값은 버퍼 크기 N.
- full (카운팅 세마포어): 데이터가 채워진 사용 중인 버퍼 슬롯의 개수를 나타냅니다. 초기값은 0.
- 수도 코드 (Pseudocode):
// --- 전역 변수 및 초기화 ---
#define BUFFER_SIZE N
item buffer[BUFFER_SIZE]; // 공유 버퍼
int in = 0; // 다음 생산 데이터가 들어갈 인덱스
int out = 0; // 다음 소비 데이터가 나올 인덱스
// 동기화 변수 (세마포어)
semaphore mutex = 1; // 버퍼 접근 보호용 (초기값: 1)
semaphore empty = N; // 빈 슬롯 개수 (초기값: N)
semaphore full = 0; // 채워진 슬롯 개수 (초기값: 0)
// --- 생산자 프로세스 ---
void Producer() {
item next_produced;
while (true) {
// 1. 데이터 생산
produce_item(&next_produced);
// 2. 빈 슬롯이 생길 때까지 대기 (또는 통과)
P(empty); // empty 카운터 감소, 0 이하면 대기
// 3. 임계 구역 진입 (버퍼 접근)
P(mutex); // mutex 획득 (상호 배제 시작)
// 4. 버퍼에 데이터 삽입 및 인덱스 업데이트
buffer[in] = next_produced;
in = (in + 1) % BUFFER_SIZE;
V(mutex); // mutex 해제 (상호 배제 종료)
// 5. 채워진 슬롯 개수 증가 및 소비자 깨우기 (필요시)
V(full); // full 카운터 증가, 대기 중인 소비자 있으면 깨움
}
}
// --- 소비자 프로세스 ---
void Consumer() {
item next_consumed;
while (true) {
// 1. 채워진 슬롯이 생길 때까지 대기 (또는 통과)
P(full); // full 카운터 감소, 0 이하면 대기
// 2. 임계 구역 진입 (버퍼 접근)
P(mutex); // mutex 획득 (상호 배제 시작)
// 3. 버퍼에서 데이터 꺼내기 및 인덱스 업데이트
next_consumed = buffer[out];
out = (out + 1) % BUFFER_SIZE;
V(mutex); // mutex 해제 (상호 배제 종료)
// 4. 빈 슬롯 개수 증가 및 생산자 깨우기 (필요시)
V(empty); // empty 카운터 증가, 대기 중인 생산자 있으면 깨움
// 5. 데이터 소비
consume_item(next_consumed);
}
}
- 설명:
- 생산자는 P(empty)를 통해 빈 슬롯이 있는지 확인하고 기다립니다. 소비자는 P(full)을 통해 가져갈 데이터가 있는지 확인하고 기다립니다. 이는 카운팅 세마포어의 value와 block/wakeup 메커니즘 덕분에 효율적으로 동작합니다.
- 실제 버퍼(buffer[], in, out)에 접근하는 코드는 P(mutex)와 V(mutex) 사이에 두어 반드시 하나의 프로세스만 접근하도록 보호합니다 (상호 배제).
- 생산자는 데이터 삽입 후 V(full)을 호출하여 채워진 슬롯이 생겼음을 알리고, 소비자는 데이터 제거 후 V(empty)를 호출하여 빈 슬롯이 생겼음을 알립니다. 이 V 연산은 해당 자원을 기다리며 잠들어 있던 다른 프로세스를 깨우는 역할도 합니다.
[Q&A]
- Q: "왜 세마포어가 3개나 필요한가요? mutex 하나만으로는 안 되나요?"
- A: mutex는 버퍼 자체에 대한 동시 접근(상호 배제)만을 제어합니다. 하지만 생산자는 버퍼가 가득 찼을 때, 소비자는 버퍼가 비었을 때 기다려야 하는 조건이 추가로 있습니다. empty와 full 카운팅 세마포어는 바로 이 버퍼의 상태(가득 참/비어 있음)에 따른 대기 조건을 관리하기 위해 필요합니다. P(empty)는 생산자가 진행해도 되는지(빈 공간 있는지)를, P(full)은 소비자가 진행해도 되는지(데이터 있는지)를 확인하고 필요시 잠들게 합니다.
- Q: "P(mutex)와 P(empty)/P(full)의 순서가 바뀌면 어떻게 되나요?"
- A: 순서가 매우 중요하며, 잘못 바꾸면 교착 상태(Deadlock)가 발생할 수 있습니다! 예를 들어 생산자에서 P(mutex)를 먼저 호출하고 P(empty)를 호출한다고 가정해 봅시다. 만약 버퍼가 가득 차서 P(empty)에서 잠들게 되면, 생산자는 mutex를 계속 점유한 상태로 잠들게 됩니다. 소비자는 데이터를 꺼내기 위해 P(full)은 통과할 수 있지만, 버퍼에 접근하려면 P(mutex)를 호출해야 하는데 생산자가 mutex를 놓지 않고 잠들어 있으므로 영원히 기다리게 됩니다. 생산자도 소비자가 데이터를 꺼내 V(empty)를 호출해주길 기다리지만 소비자는 mutex 때문에 접근 불가 상태이므로 교착 상태가 발생합니다. 따라서 반드시 P(empty)/P(full)를 먼저 호출하여 진행 가능 여부를 확인한 후, 실제 버퍼 접근 직전에 P(mutex)를 호출해야 합니다.
2. 판독자-기록자 문제 (Readers-Writers Problem)
RDBMS에서 공유락, 배타락을 복습할 좋은 기회이다.
- 문제 상황:
- 공유 데이터(예: 데이터베이스, 파일)가 있습니다.
- 판독자(Reader) 프로세스는 데이터를 읽기만 합니다.
- 기록자(Writer) 프로세스는 데이터를 변경(쓰기)합니다.
- 동기화 요구사항:
- 여러 명의 판독자는 동시에 데이터를 읽을 수 있습니다. (읽기는 서로에게 영향을 주지 않음)
- 기록자는 오직 한 명만 데이터에 접근해야 하며, 기록자가 접근 중일 때는 다른 어떤 판독자나 기록자도 접근할 수 없습니다. (쓰기는 배타적이어야 함)
- (데이터베이스의 Shared Lock / Exclusive Lock 개념과 유사합니다.)
- 해결 전략 (첫 번째 변형: Readers' Priority):
- 판독자들은 서로 방해하지 않으므로 동시에 접근을 허용합니다.
- 기록자는 다른 기록자뿐만 아니라 모든 판독자와도 상호 배타적으로 접근해야 합니다.
- 현재 접근 중인 판독자의 수(readcount)를 추적하는 공유 변수가 필요합니다.
- mutex (이진 세마포어): readcount 변수를 안전하게 수정하기 위한 상호 배제용. 초기값 1.
- db (이진 세마포어): 데이터베이스 자체에 대한 접근 제어용. 기록자가 사용하거나, (첫 번째) 판독자가 사용할 때 lock을 겁니다. 초기값 1.
- 수도 코드 (Readers' Priority):
// --- 공유 데이터 및 초기화 ---
// DB: 공유 데이터베이스 자체
int readcount = 0; // 현재 DB를 읽고 있는 판독자 수
// 동기화 변수 (세마포어)
semaphore mutex = 1; // readcount 보호용 (초기값: 1)
semaphore db = 1; // DB 접근 제어용 (초기값: 1)
// --- 판독자 프로세스 ---
void Reader() {
while (true) {
// --- 진입 영역 ---
P(mutex); // readcount 수정을 위해 mutex 획득
readcount++;
if (readcount == 1) { // 내가 첫 번째 판독자라면
P(db); // 기록자가 DB에 접근 못하도록 db 세마포어 획득 (lock)
}
V(mutex); // readcount 수정 완료, mutex 해제
// --- 임계 구역 (DB 읽기) ---
// 여러 판독자가 이 부분을 동시에 수행 가능
read_database();
// --- 퇴장 영역 ---
P(mutex); // readcount 수정을 위해 mutex 획득
readcount--;
if (readcount == 0) { // 내가 마지막 판독자라면
V(db); // 기록자가 DB에 접근 가능하도록 db 세마포어 해제 (unlock)
}
V(mutex); // readcount 수정 완료, mutex 해제
// --- 나머지 작업 ---
use_data_read();
}
}
// --- 기록자 프로세스 ---
void Writer() {
while (true) {
// --- 임계 구역 진입 (DB 쓰기) ---
P(db); // DB에 대한 배타적 접근 권한 획득 시도 (다른 판독자/기록자 없어야 통과)
// --- 임계 구역 (DB 쓰기) ---
write_database();
// --- 임계 구역 퇴장 ---
V(db); // DB 접근 권한 해제
// --- 나머지 작업 ---
}
}
- 설명 (Readers' Priority):
- 판독자는 mutex를 이용해 readcount를 안전하게 증가시킵니다. 첫 번째 판독자(readcount == 1)는 P(db)를 호출하여 DB에 대한 잠금을 시도합니다 (기록자 방지). 이후 판독자들은 P(db)를 호출하지 않고 바로 DB를 읽습니다.
- 판독자는 읽기가 끝나면 mutex로 보호하며 readcount를 감소시킵니다. 마지막 판독자(readcount == 0)는 V(db)를 호출하여 DB 잠금을 해제합니다.
- 기록자는 DB에 쓰기 전에 P(db)를 호출하여 배타적인 접근 권한을 얻으려 합니다. 만약 한 명이라도 판독자가 DB를 사용 중(db가 0인 상태)이라면 기록자는 대기합니다.
- 문제점 (Readers' Priority): 이 방식은 판독자에게 우선권을 줍니다. 만약 판독자가 계속해서 들어온다면(readcount가 0이 되지 않음), 기록자는 P(db)에서 영원히 기다릴 수 있습니다. 즉, 기록자 기아(Writer Starvation) 문제가 발생할 수 있습니다.
- 다른 변형: 기록자에게 우선권을 주거나(Writer's Priority), 판독자와 기록자 모두 공평하게 기회를 주는(Fair) 해결책도 존재하지만 더 복잡합니다.
[Q&A]
- Q: "왜 readcount를 보호하기 위해 mutex 세마포어가 따로 필요한가요? db 세마포어만 쓰면 안 되나요?"
- A: db 세마포어는 데이터베이스 자체에 대한 접근(특히 쓰기 접근)을 제어하는 역할입니다. 여러 판독자가 readcount라는 공유 변수를 동시에 증가시키거나 감소시키려고 할 때 경쟁 상태가 발생할 수 있습니다. 예를 들어, 두 판독자가 동시에 readcount++를 실행하면 값이 제대로 증가하지 않을 수 있습니다. 따라서 readcount 변수 자체를 안전하게 수정하기 위한 별도의 상호 배제 장치(mutex)가 필요합니다.
- Q: "기록자 기아(Writer Starvation) 문제를 해결하려면 어떻게 해야 하나요?"
- A: 간단한 방법 중 하나는, 기록자가 도착하여 P(db)에서 기다리기 시작하면 새로운 판독자들이 P(db)를 통과하지 못하도록 막는 것입니다. 즉, 기록자가 대기 중일 때는 판독자들도 기다리게 하여 기록자에게 우선권을 주는 방식입니다. 이를 구현하려면 추가적인 세마포어나 변수가 필요하며 로직이 더 복잡해집니다.
3. 식사하는 철학자 문제 (Dining-Philosophers Problem)
- 문제 상황:
- 원탁에 N명의 철학자가 앉아 있습니다.
- 각 철학자 사이에는 포크(또는 젓가락)가 하나씩 놓여 있습니다 (총 N개).
- 철학자는 두 가지 상태를 반복합니다: 생각하기(Thinking) 또는 식사하기(Eating).
- 식사를 하려면, 자신의 왼쪽과 오른쪽 포크를 모두 집어야 합니다.
- 식사가 끝나면 두 포크를 모두 내려놓습니다.
- 동기화 요구사항:
- 한 번에 한 철학자만 하나의 포크를 사용할 수 있습니다 (포크는 공유 자원).
- 교착 상태(Deadlock)가 발생하지 않아야 합니다. (예: 모든 철학자가 동시에 왼쪽 포크만 집고 오른쪽 포크를 기다리는 상황)
- 기아 상태(Starvation)가 발생하지 않아야 합니다. (특정 철학자만 계속 굶는 상황)
- 단순한 (하지만 잘못된) 시도: 각 포크를 이진 세마포어로 표현하고, 철학자는 왼쪽 포크 -> 오른쪽 포크 순서로 집으려고 시도합니다.
// --- 초기화 ---
#define N 5 // 철학자 수
semaphore fork[N]; // 각 포크를 나타내는 세마포어
for (int i = 0; i < N; i++) {
fork[i] = 1; // 모든 포크는 사용 가능
}
// --- 철학자 i의 로직 ---
void Philosopher(int i) {
while (true) {
think();
// 왼쪽 포크 집기 시도
P(fork[i]);
// 오른쪽 포크 집기 시도
P(fork[(i + 1) % N]);
// --- 식사 (임계 구역) ---
eat();
// 포크 내려놓기 (순서는 크게 상관 없음)
V(fork[i]);
V(fork[(i + 1) % N]);
}
}
- 문제점: 이 방식은 교착 상태(Deadlock)에 빠질 위험이 매우 높습니다. 만약 모든 철학자가 동시에 왼쪽 포크(fork[i])를 집는 데 성공하면, 모든 포크가 하나씩 사용 중인 상태가 됩니다. 이제 모든 철학자는 자신의 오른쪽 포크(fork[(i + 1) % N])를 집으려고 P() 연산에서 대기하게 되는데, 그 포크는 이미 다른 철학자(오른쪽에 앉은)가 점유하고 있으므로 영원히 대기하게 됩니다.
- 교착 상태 해결 방안들:
- 최대 철학자 수 제한: 테이블에 동시에 앉을 수 있는 철학자 수를 N-1명으로 제한합니다. 최소 한 명은 포크 두 개를 모두 집을 수 있게 됩니다.
- 두 포크 동시 획득 보장: 포크 두 개를 모두 사용할 수 있을 때만 집도록 허용합니다. 즉, P(fork[i])와 P(fork[(i + 1) % N])을 하나의 원자적인 연산처럼 만들어야 합니다. (세마포어만으로는 직접 구현하기 어렵고, 보통 모니터나 추가적인 락 사용)
- 비대칭 획득 순서: 모든 철학자가 같은 순서(왼쪽->오른쪽)로 포크를 집는 대신, 홀수번 철학자는 왼쪽->오른쪽, 짝수번 철학자는 오른쪽->왼쪽 순서로 집도록 규칙을 정합니다. 이렇게 하면 순환 대기(Circular Wait) 조건이 깨져 교착 상태를 예방할 수 있습니다.
- 교착 상태 없는 해결책 (상태 변수와 세마포어 사용): 철학자의 상태(THINKING, HUNGRY, EATING)를 추적하고, 포크를 집을 수 있는지(test 함수) 확인 후 식사를 시작하는 방식입니다.
// --- 초기화 ---
#define N 5
enum { THINKING, HUNGRY, EATING } state[N]; // 철학자 상태
semaphore mutex = 1; // state 배열 접근 보호용
semaphore self[N]; // 각 철학자가 식사 가능할 때까지 기다리는 세마포어
for (int i = 0; i < N; i++) {
state[i] = THINKING;
self[i] = 0; // 초기값 0: 식사 불가 상태로 시작
}
// 인접 철학자 인덱스 계산 함수
int LEFT(int i) { return (i + N - 1) % N; }
int RIGHT(int i) { return (i + 1) % N; }
// 철학자 i가 식사 가능한지 확인하고, 가능하면 깨우는 함수
void test(int i) {
// 내가 배고프고(HUNGRY), 왼쪽과 오른쪽 철학자가 모두 식사 중이 아닐 때
if (state[i] == HUNGRY && state[LEFT(i)] != EATING && state[RIGHT(i)] != EATING) {
state[i] = EATING; // 식사 상태로 변경
V(self[i]); // 철학자 i를 깨움 (P(self[i])에서 대기 중이었다면)
}
}
// 철학자 i가 포크 집기를 시도하는 함수
void pickup(int i) {
P(mutex); // 임계 구역 진입 (state 배열 접근)
state[i] = HUNGRY; // "나 배고파!" 상태 표시
test(i); // 지금 바로 식사 가능한지 확인
V(mutex); // 임계 구역 퇴장
P(self[i]); // 만약 test(i)에서 V(self[i])가 호출되지 않았다면(식사 불가 상태),
// 여기서 잠들어서 기다린다.
}
// 철학자 i가 포크를 내려놓는 함수
void putdown(int i) {
P(mutex); // 임계 구역 진입 (state 배열 접근)
state[i] = THINKING; // "다 먹었으니 생각하자" 상태 변경
// 내가 포크를 내려놓았으니, 양 옆 철학자가 식사 가능한지 확인
test(LEFT(i));
test(RIGHT(i));
V(mutex); // 임계 구역 퇴장
}
// --- 철학자 i의 로직 ---
void Philosopher(int i) {
while (true) {
think();
pickup(i); // 포크 집기 시도 (필요하면 대기)
eat();
putdown(i); // 포크 내려놓기 (이웃 깨울 수 있음)
}
}
- 설명: 이 해결책은 철학자가 배고프다고(HUNGRY) 상태를 표시한 뒤, 양쪽 포크가 모두 사용 가능할 때만(test 함수) 식사(EATING) 상태로 넘어가도록 합니다. 만약 바로 식사할 수 없다면, 자신의 self[i] 세마포어에서 P() 연산을 통해 잠들게 됩니다. 다른 철학자가 식사를 마치고 putdown에서 test를 호출하여 이 철학자가 식사 가능 상태가 되면 V(self[i])를 통해 깨어나 식사를 시작합니다. mutex는 state 배열이라는 공유 자원을 안전하게 접근하기 위해 사용됩니다. 이 방식은 교착 상태를 방지합니다.
[Q&A]
- Q: "식사하는 철학자 문제의 교착 상태 없는 해결책은 왜 이렇게 복잡한가요?"
- A: 교착 상태를 피하려면 "두 포크를 모두 잡을 수 있을 때만 잡는다"는 조건을 만족해야 합니다. 이를 세마포어만으로 직접 구현하려면, 각 철학자의 상태를 추적하고, 포크를 내려놓을 때 이웃 철학자가 식사할 수 있는지 확인하여 깨워주는 등의 부가적인 로직이 필요하기 때문에 코드가 복잡해집니다. 이것이 바로 세마포어의 단점(코딩 복잡성, 오류 가능성)을 보여주는 예시이며, 모니터와 같은 더 고수준의 동기화 도구가 필요한 이유이기도 합니다.
- Q: "교착 상태 없는 해결책에서도 기아 상태(Starvation)가 발생할 수 있나요?"
- A: 네, 이론적으로 가능합니다. 예를 들어, 특정 철학자의 양 옆 철학자들이 계속 번갈아가며 식사를 한다면, 가운데 있는 철학자는 계속 HUNGRY 상태이지만 test 함수에서 항상 양 옆 중 한 명이 EATING 상태라 V(self[i])가 호출되지 못하고 계속 굶을 수 있습니다. 실제 구현에서는 이를 방지하기 위한 추가적인 공정성 메커니즘이 필요할 수 있습니다.
6. 모니터 (Monitor) 소개: 세마포어의 어려움을 넘어
세마포어는 강력하지만 다음과 같은 문제점 때문에 사용하기 까다롭습니다.
- 코딩의 어려움: P와 V 연산을 정확한 순서와 위치에 사용하는 것이 중요하며, 실수하기 쉽습니다.
- 정확성 증명 어려움: 복잡한 동기화 로직의 정확성을 검증하기 어렵습니다.
- 자발적 협력 필요: 모든 프로세스가 규칙을 정확히 따라야만 동기화가 보장됩니다.
- 하나의 실수가 시스템 전체에 영향: P 또는 V 연산을 빠뜨리거나 순서를 잘못 사용하면 교착 상태나 상호 배제 실패 등 심각한 문제가 발생합니다. (예: V(mutex) -> 임계구역 -> P(mutex) ⇒ 상호 배제 깨짐 / P(mutex) -> 임계구역 -> P(mutex) ⇒ 교착 상태)
이러한 어려움을 해결하기 위해 더 높은 수준의 추상화를 제공하는 동기화 도구가 모니터(Monitor)입니다.
- 모니터란? 동시 실행 중인 프로세스(스레드) 사이에서 공유 데이터(추상 데이터 타입, ADT)를 안전하게 사용할 수 있도록 보장하는 고수준 동기화 구조(High-level Synchronization Construct)입니다. 프로그래밍 언어 수준에서 지원되는 경우가 많습니다. (Java의 synchronized 키워드 및 wait(), notify(), notifyAll() 메서드가 모니터 개념에 기반합니다.)
- 핵심 특징:
- 데이터 캡슐화: 공유 데이터와 해당 데이터에 접근하는 프로시저(메서드)들을 하나의 모듈 안에 캡슐화합니다.
- 자동 상호 배제: 모니터 내부에 정의된 프로시저들은 한 번에 오직 하나의 프로세스만 실행할 수 있도록 자동으로 보장됩니다. 프로그래머가 직접 P(mutex), V(mutex) 같은 코드를 작성할 필요가 없습니다.
- 조건 변수 (Condition Variables): 프로세스가 모니터 내에서 특정 조건이 만족될 때까지 안전하게 기다릴 수 있는 메커니즘을 제공합니다.
- condition x; : 조건 변수 선언.
- x.wait(); : 이 연산을 호출한 프로세스는 모니터의 잠금을 임시로 풀고 해당 조건 변수의 대기 큐에서 잠듭니다.
- x.signal(); : 해당 조건 변수의 대기 큐에서 잠들어 있는 프로세스 중 하나를 깨웁니다. (깨어난 프로세스는 다시 모니터 잠금을 얻으려 시도합니다.) 깨울 프로세스가 없으면 아무 일도 일어나지 않습니다.
- 생산자-소비자 문제 (모니터 버전): 세마포어 버전보다 훨씬 간결하고 안전하게 구현 가능합니다.
monitor BoundedBuffer {
private final int BUFFER_SIZE = 10;
private item buffer[] = new item[BUFFER_SIZE];
private int in = 0, out = 0, count = 0;
// Condition Variables (Java에서는 Object의 wait/notify/notifyAll 사용)
// 여기서는 개념 설명을 위해 condition 키워드 사용
condition notEmpty;
condition notFull;
// 모니터 내 프로시저(메서드)는 자동으로 상호 배제 보장
public void insert(item next_produced) {
// 버퍼가 가득 찼으면 기다림
while (count == BUFFER_SIZE) {
notFull.wait(); // 모니터 lock 풀고 notFull 큐에서 대기
}
// 버퍼에 삽입
buffer[in] = next_produced;
in = (in + 1) % BUFFER_SIZE;
count++;
// 버퍼가 비어있지 않게 되었으므로, 기다리는 소비자 깨움
notEmpty.signal();
}
public item remove() {
item next_consumed;
// 버퍼가 비었으면 기다림
while (count == 0) {
notEmpty.wait(); // 모니터 lock 풀고 notEmpty 큐에서 대기
}
// 버퍼에서 제거
next_consumed = buffer[out];
out = (out + 1) % BUFFER_SIZE;
count--;
// 버퍼에 빈 공간이 생겼으므로, 기다리는 생산자 깨움
notFull.signal();
return next_consumed;
}
}
- 모니터의 장점: 프로그래머는 공유 데이터 접근 시 상호 배제를 위한 lock/unlock 코드를 직접 작성할 필요가 없어 실수 가능성이 줄어듭니다. 조건 변수를 통해 특정 조건 만족 시까지 안전하게 대기하는 로직을 더 명확하게 표현할 수 있습니다.
[Q&A]
- Q: "모니터는 세마포어보다 항상 더 좋은 건가요?"
- A: 모니터는 사용하기 쉽고 안전하다는 장점이 있지만, 세마포어보다 유연성이 떨어질 수 있습니다. 세마포어는 카운팅 기능을 이용해 더 복잡한 동기화 시나리오를 구현할 수 있는 반면, 모니터는 주로 상호 배제와 조건 대기에 특화되어 있습니다. 또한, 모니터는 프로그래밍 언어나 시스템 수준에서의 지원이 필요합니다. 성능 측면에서는 구현 방식에 따라 다를 수 있습니다.
- Q: "Condition Variable의 wait()와 signal()은 세마포어의 P()와 V()와 어떻게 다른가요?"
- A: 중요한 차이가 있습니다. 세마포어 V(S)는 S 값을 증가시키고, 만약 누군가 기다리고 있다면 깨웁니다. S 값 자체가 상태(자원 수 또는 대기자 수)를 기억합니다. 반면, Condition Variable x.signal()은 단순히 신호를 보내는 역할만 합니다. 만약 x.wait()으로 기다리는 프로세스가 없다면 signal()은 아무 효과 없이 사라집니다 (신호가 저장되지 않음). 따라서 모니터에서는 보통 while 루프 안에서 조건을 다시 확인하며 wait()를 호출하는 패턴이 사용됩니다 (Spurious Wakeup 대비 및 조건 재확인). 또한, wait()는 호출 시 모니터의 잠금을 자동으로 해제하고 잠들며, 깨어날 때 다시 잠금을 획득하려고 시도한다는 점도 다릅니다.
[마무리하며]
고전적인 동기화 문제들(유한 버퍼, 판독자-기록자, 식사하는 철학자)은 세마포어와 같은 동기화 도구의 필요성과 그 사용법, 그리고 잠재적인 문제점(교착 상태, 기아 상태)을 이해하는 데 중요한 사례들입니다. 세마포어는 강력하지만 사용하기 까다로운 면이 있어, 이를 개선한 모니터와 같은 고수준 동기화 구조가 등장했습니다. 이러한 동기화 메커니즘들을 이해하는 것은 안전하고 효율적인 동시성 프로그래밍을 위한 필수적인 기초입니다.
'운영체제 > 반효경 교수' 카테고리의 다른 글
운영체제: 교착 상태 (Deadlock) - 원인과 해결 전략 (0) | 2025.04.13 |
---|---|
운영체제: 프로세스 동기화 - 모니터 (Monitor)를 이용한 병행 제어 (0) | 2025.04.13 |
운영체제: 프로세스 동기화 - 세마포어 (Semaphore) (0) | 2025.04.12 |
운영체제: 임계 구역(Critical Section) 문제와 해결 방법 소개 (0) | 2025.04.11 |
CPU 스케줄링 심화와 프로세스 동기화 도입 (0) | 2025.04.11 |