
데이터를 만드는 쪽과 쓰는 쪽이 버퍼를 사이에 두고 협력합니다. 버퍼 칸 수가 정해져 있어서 bounded buffer problem이라고 부릅니다.
여기서 지켜야 할 것이 세 가지입니다.
세 번째는 상호 배제이고, 앞의 둘은 남은 자원의 개수를 세는 문제입니다. 그래서 세마포어 세 개를 씁니다. mutex는 초기값 1로 상호 배제용, empty는 초기값 N으로 빈 칸 수, full은 초기값 0으로 찬 칸 수입니다.
producer: consumer:
do { do {
produce item P(full); // 찬 칸 있나
P(empty); // 빈 칸 있나 P(mutex);
P(mutex); ... 버퍼에서 꺼냄 ...
... 버퍼에 넣음 ... V(mutex);
V(mutex); V(empty); // 빈 칸 하나 늘었다
V(full); // 찬 칸 하나 늘었다 consume item
} while (1); } while (1);
P(empty)와 P(mutex)의 순서가 중요합니다. 뒤집으면 버퍼가 꽉 찬 상태에서 생산자가 mutex를 잡은 채로 empty를 기다리고, 소비자는 mutex를 얻지 못해 아무것도 꺼내지 못합니다. 서로 기다리며 멈춥니다.
버퍼 3칸으로 값 변화를 따라갑니다.
| 동작 | empty | full | 결과 |
|---|---|---|---|
| 초기 상태 | 3 | 0 | - |
| 생산 1회 | 2 | 1 | 성공 |
| 생산 2회 | 1 | 2 | 성공 |
| 생산 3회 | 0 | 3 | 버퍼 꽉 참 |
| 생산 4회 시도 | 0 | 3 | P(empty)에서 대기 |
| 소비 1회 | 1 | 2 | 대기하던 생산자가 깨어남 |
공유 데이터베이스에 읽는 프로세스와 쓰는 프로세스가 접근합니다. 성질이 다릅니다.
전체를 하나의 mutex로 묶으면 정확하긴 한데, 읽기끼리도 줄을 서게 되어 손해입니다. 그래서 읽는 프로세스가 몇 명인지 세는 readcount 변수를 둡니다. 세마포어는 db(공유 데이터 접근권)와 mutex(readcount 보호용) 둘입니다.
writer: reader:
P(db); P(mutex);
... 쓰기 ... readcount++;
V(db); if (readcount == 1) P(db); // 첫 독자가 잠금
V(mutex);
... 읽기 ...
P(mutex);
readcount--;
if (readcount == 0) V(db); // 마지막 독자가 품
V(mutex);
핵심은 첫 번째 독자만 잠그고 마지막 독자만 푼다는 것입니다. 중간 독자들은 그냥 들어갑니다. readcount 자체도 공유 데이터이므로 mutex로 보호해야 합니다.
이 방식에는 알려진 문제가 있습니다. 독자가 끊임없이 들어오면 readcount가 0이 되지 않아 쓰는 프로세스가 영원히 기다립니다.
두 코드를 보면 알 수 있듯이, 세마포어는 동작은 하지만 쓰기 까다롭습니다.
| 실수 | 결과 |
|---|---|
| P와 V의 순서를 바꿈 | 교착 상태 |
| V를 빠뜨림 | 다음 프로세스가 영원히 대기 |
| P 대신 V를 먼저 호출 | 상호 배제가 깨져 여럿이 동시 진입 |
| P를 두 번 호출 | 자기 자신이 영원히 대기 |
게다가 이런 오류는 특정 실행 순서에서만 드러나므로 재현과 디버깅이 어렵습니다. 한 줄 실수가 시스템 전체에 영향을 줍니다. 동기화 코드가 여기저기 흩어져 있다는 점도 문제입니다. P와 V가 서로 다른 함수, 다른 파일에 있으면 짝이 맞는지 눈으로 확인할 수 없습니다.
모니터(monitor)는 동시에 수행 중인 프로세스들 사이에서 추상 자료형(abstract data type)의 안전한 공유를 보장하기 위한 고수준 동기화 구조입니다.
발상은 단순합니다. 공유 데이터와 그것을 다루는 연산을 하나로 묶어 두고, 모니터 안의 연산은 한 번에 하나의 프로세스만 수행할 수 있게 언어 차원에서 보장합니다. 상호 배제가 이미 걸려 있으므로 프로그래머가 직접 락을 걸 필요가 없습니다. 3절의 실수들이 애초에 불가능해집니다.
남는 문제는 "자원이 없어서 기다려야 하는" 경우입니다. 이때 쓰는 것이 조건 변수(condition variable)이고 연산은 둘입니다.
x.wait(): 호출한 프로세스를 x의 큐에 매달고 재웁니다x.signal(): x의 큐에서 기다리던 프로세스 하나를 깨웁니다조건 변수는 값을 가지지 않습니다. 프로세스를 큐에 매달아 재우거나 큐에서 깨우는 역할만 합니다. 세마포어와 결정적으로 다른 점입니다.
| 구분 | 세마포어 | 모니터 |
|---|---|---|
| 상호 배제 | 프로그래머가 P/V로 직접 | 언어가 보장 |
| 값 | 정수값을 가짐 | 조건 변수는 값 없음 |
| signal을 아무도 안 기다릴 때 | 값이 올라가 다음 P가 통과 | 아무 일도 일어나지 않고 사라짐 |
| 실수 가능성 | 높음 | 낮음 |
식사하는 철학자 문제를 모니터로 풀면 이렇게 됩니다. 젓가락을 하나씩 집는 대신, 양쪽이 모두 비었을 때만 한꺼번에 집습니다.
monitor dining_philosopher {
enum { thinking, hungry, eating } state[5];
condition self[5];
void pickup(int i) {
state[i] = hungry;
test(i); // 먹을 수 있나 확인
if (state[i] != eating)
self[i].wait(); // 안 되면 잠든다
}
void putdown(int i) {
state[i] = thinking;
test((i + 4) % 5); // 왼쪽 이웃을 깨워 본다
test((i + 1) % 5); // 오른쪽 이웃을 깨워 본다
}
void test(int i) {
if (state[(i + 4) % 5] != eating &&
state[i] == hungry &&
state[(i + 1) % 5] != eating) {
state[i] = eating;
self[i].signal();
}
}
}
test()는 양옆 이웃이 식사 중이 아닐 때만 상태를 eating으로 바꿉니다. 따라서 "왼쪽만 집은 채로 멈추는" 상태가 아예 생기지 않습니다. 식사를 마친 철학자는 양옆 이웃에게 기회를 넘겨 줍니다. 락을 거는 코드가 한 줄도 없다는 점을 눈여겨볼 만합니다.