
지난시간에 공유 메모리를 프로세스들이 동시에 건들면서 생기는 임계구역에 관한 문제들에 대해서 짧게 배웠었다. 이번시간에는 이런 임계 문제들을 어떻게 해결하는지에 대해서 다뤄보도록 하겠다.
임계구역 문제는 OS에서 여러 프로세스가 공유 메모리에 동시에 접근할 때 발생하는 데이터 불일치 문제를 해결하기 위한 "동기화" 이슈이다.
임계구역 문제를 해결하기 위해서는 밑에 조건을 "반드시" 만족해야 한다.
단순 Lock 변수를 사용해 임계구역 진입 전 확인하는 방법
shared int locked = 0;
do {
while (locked == 1);
locked = 1;
critical section
locked = 0;
remainder section
} while (true);
turn 변수를 두고, 자신의 차례일때만 임계구역에 진입하는 방법
shared int turn = 0;
do {
while (turn != me);
critical section
turn = ! me;
remainder section
} while (true);
각 프로세스가 자신의 진입 의도를 flag 변수로 표시한다.
shared int flag[2];
do {
flag[me] = true;
while (flag[ !me] == true);
critical section
flag[me] = false;
remainder section
} while (true);
Perterson 알고리즘은 위의 3개의 조건을 만족하는 고전적 소프트웨어 해법이다.
- 각 프로세스에 진입 의도를 flag 변수를 통해 구현하고 상대방에게 turn 을 넘긴다.
- 임계구역 진입 전, 상대방이 진입 의도가 있고 turn 이 상대면 대기.
- 임계구역에서 나오면 자신의 flag를 false로 한다.
//공유 변수
shared int turn, flag[2];
do {
flag[me] = true;
turn = ! me;
while (flag[! me] && turn == ! me);
critical section
flag[me] = false;
remainder section
} while (true);
<공유 변수의 문제>
register = <memory>; // 메모리에서 값을 읽어 레지스터에 저장
register = <new value>; // 레지스터 값을 갱신
<memory> = register; // 레지스터 값을 다시 메모리에 저장
대부분의 프로그래밍 언어에서 이렇게 메모리를 읽고 쓰는 작업을 수행하게 되는데, 읽기(get)와 쓰기(set)사이에 인터럽트나 context switch가 발생하면 다른 프로세스가 동일한 메모리 값을 변경할 수 있다. -> Race Condition발생
- 단일 프로세스 환경에서는 임계구역에 진입할때 인터럽트를 비활성화 하면 현재 실행중인 코드가 중간에 끊기지 않고 끝까지 실행될 수 있다.
- 하지만 멀티 프로세스 환경에서는 비효율적이고, 전체 시스템 저하를 야기할 수 있다.
현대 CPU는 TestAndSet, CompareAndSwap, Swap등 특수한 원자적 명령어를 제공한다.
shared int locked = false;
do {
while (locked == true); // 다른 프로세스가 임계구역에 있으면 대기
locked = true; // 임계구역 진입 시도
// critical section
locked = false; // 임계구역 종료 후 lock 해제
// remainder section
} while (true);
기존 Lock구현 코드를 보면 여러 프로세스가 동시에 lock변수를 확인하고 임계구역에 들어가면 문제가 생긴다.
이는, while (locked==true)와 locked = true; 사이에 인터럽트나 context switch가 발생하게 되면 두 프로세스가 들어갈 수 있게 되는 것이다.
"즉, TEST(확인)와 SET(설정) 사이의 틈(gap) 때문에 문제가 발생."
따라서 원자적 명령어가 필요한 것이다. 참고로 원자성이란 결과가 온전히 실행되거나 아예 실행되지 않아야 하는것을 의미한다.
boolean TestAndSet (boolean *target)
{
boolean rv = *target; //(1)현재 값을 읽는다.
*target = true; //(2)값을 true로 한다.
return rv: //(3)읽은 값을 반환한다.
}
여기서 핵심은 (1)번과 (2)번이 반드시 원자적으로 실행된다는 점이다. 따라서 이 함수가 실행되는 동안 어떤 다른 프로세스도 target값을 변경할 수 없다.
shared boolean lock = false;
do {
while (TestAndSet(&lock)); // lock이 TRUE면 대기(busy waiting)
// critical section
lock = false; // 임계구역 종료 후 lock 해제
// remainder section
} while (true);
따라서 기존 Lock에서 TestAndSet을 사용하기 위해서는 이렇게 사용하면 된다.
do {
waiting[i] = TRUE;
key = TRUE;
while (waiting[i] && key)
key = TestAndSet(&lock);
waiting[i] = FALSE;
// critical section
j = (i + 1) % n;
while ((j != i) && !waiting[j])
j = (j + 1) % n;
if (j == i)
lock = FALSE;
else
waiting[j] = FALSE;
// remainder section
} while (TRUE);
⚠️ 하지만 TestAndSet등 하드웨어를 명령어를 이용한 방법은 busy waiting(무한 루프) 의 문제점이 존재한다.
세마포어는 정수값을 가지는 변수로, 두 가지 원자적 연산만 허용된다.
- wait(S): S가 0보다 크면 1감소, 0이면 대기.
- signal(S): S를 1증가, 대기중인 프로세스가 있으면 깨운다.
wait (S) {
while S <= 0
; // no-op (busy waiting)
S--;
}
signal (S) {
S++;
}
wait
S가 0보다 크면 S를 1 감소시킨다.
S가 0 이하이면, S가 0보다 커질 때까지(즉, 다른 프로세스가 signal을 호출할 때까지) 반복해서 대기한다(busy waiting).
이 연산은 임계구역 진입 전에 호출되어, 자원이 없으면 진입을 막고, 자원이 있으면 진입함.
signal
세마포의 종류로는 크게 2가지가 있다.
1. 카운팅 세마포어: 값이 0 이상인 정수 (여러 자원 관리)
2. 이진 세마포어(뮤텍스):값이 0 또는 1(임계구역 보호)
semaphore count_mutex = 1;
do {
wait(count_mutex);
// critical section
signal(count_mutex);
// remainder section
} while (TRUE);
// 세마포어 구조체
typedef struct {
int value;
struct process *list; // 대기 큐
} semaphore;
// wait 연산
wait(semaphore *S) {
S->value--;
if (S->value < 0) {
// S->list에 현재 프로세스 추가
block();
}
}
// signal 연산
signal(semaphore *S) {
S->value++;
if (S->value <= 0) {
// S->list에서 프로세스 하나 꺼냄
wakeup();
}
}
block() => 해당 작업을 요청한 프로세스를 적절한 대기 큐에 넣는다.
wakeup() => 대기 큐에 있는 프로세스중 하나를 꺼내서 ready 큐에 넣는다.
signal에서 value보다 작으면 꺼내는 이유: 대기 큐에 있다는 뜻이기 때문에.
세마포어의 waiting list
☝️ wait과 signal 연산 자체가 공유 데이터를 수정하기 때문에 이 연산들 자체가 임계구역이 된다!
✌️ 단일 프로세서의 경우에는 이 구간에서 인터럽트를 비활성화 하면 되지만 멀티 환경에서는 어렵고 비효율적이기 때문에 이 구간에서 spinlock을 사용해 임계구역을 보호한다.
두개 이상의 프로세스가 서로 상대방이 signal을 호출해주길 기다리며 영원히 block상태에 빠지는 현상.
- 생산자는 버퍼가 가득차면 대기해야 하고, 소비자는 버퍼가 비면 대기해야 한다.
- 생산자와 소비자가 동시에 버퍼에 접근하면 안된다.(임계구역 문제)
이 문제를 세마포어를 이용해 해결할 수 있다. 이제 단계적으로 어떻게 해결하는지 알아보자.
생산자
do{
//produce an item
wait(empty);
//버퍼에 아이템 추가
}while(true);
소비자
do{
// 버퍼에서 아이템 삭제
signal(empty);
// 아이템 소비
}while(true);
생산자
do{
//produce an item
wait(empty);
//버퍼에 아이템 추가
signal(full);
}while(true);
소비자
do{
wait(full);
// 버퍼에서 아이템 삭제
signal(empty);
// 아이템 소비
}while(true);
생산자
do{
//item 생성
wait(empty);
wait(mutex);
//버퍼에 아이템 추가
signal(mutex);
signal(full);
}while(true);
소비자
do{
wait(full);
wait(mutex);
// 버퍼에서 아이템 삭제
signal(mutex);
signal(empty);
// 아이템 소비
}while(true);
mutex 초기값 = 1;
full 초기값 = 0;
empty 초기값 = N(버퍼 크기)
mutex를 추가하여 생산자와 소비자가 공유버퍼를 동시에 접근하는것을 방지하여 데이터 불일치와 race condition을 완전히 해결하였다.
=> wrt는 오직 reader들끼리 read_count를 안전하게 조작하기 위해 경쟁한다.
=> rw_mutex는 reader와 writer 모두 공유 자원 접근에 대해 경쟁한다.
이를 해결하기 위해 mutex(이진 세마포어)를 사용해 상호배제 한다.
do {
wait(wrt)
// 쓰기 작업 수행
signal(wrt);
}while(true);
do {
wait(mutex);
read_count++;
if (read_count == 1)
wait(wrt); // 첫번째 리더면 wrt 대기
signal(mutex);
//읽기 작업 수행
wait(mutex);
read_count--;
if (read_count == 0)
signal(wrt);
signal(mutex);
} while(true);

N명의 철학자가 원형 테이블에 앉아 있음.
각 철학자 사이에는 젓가락(자원)이 하나씩 놓여 있음(총 N개).
철학자는 생각(thinking)하다가 배가 고프면 식사(eating)를 하려 하고, 식사하려면 양쪽의 젓가락 두 개를 모두 들어야 함.
식사가 끝나면 젓가락을 내려놓고 다시 생각에 잠김.
공유 데이터: 젓가락으르 세마포어로 모델링 -> semephore chopstick[5]; (각 chopstick은 1로 초기화 한다.)
do {
wait(chopstick[i]);
wait(chopstick[(i+1)%5])
//eat
signal(chopstick[i]);
signal(chopstick[(i+1)%5]);
//think
} while(true);
wait(chopstick[i]): 왼쪽 젓가락을 집음
wait(chopstick[(i+1)%5]): 오른쪽 젓가락을 집음
signal(...): 식사 후 젓가락을 내려놓음
⚠️모든 철학자(프로세서)가 동시에 한 젓가락씩만 잡으면 교착상태에 빠지게 된다.
여러 프로세스가 서로가 가진 자원을 기다리며 영원히 Block되는 문제.
예시: 시스템 디스크 드라이브 2개, p1 과 p2가 각각 하나씩 보유중인데 서로가 상대방의 디스크를 요구하며 대기한다 -> 교착상태 발생.
Mutual Exclusion(상호 배제): 한 번에 하나만 자원 사용 가능
Hold and Wait(보유 및 대기): 자원을 보유한 채 추가 자원 요구
No Preemption(비선점): 자원을 강제로 빼앗을 수 없음
Circular Wait(순환 대기): 원형으로 자원을 기다림
시스템 내 프로세스와 자원 간의 할당 및 요청 관계를 시각적으로 표현한 그래프.
정점(Vertex, V): 두가지 타입이 존재.
간선(Edge E):
R1, R2처럼 자원 타입이 여러 인스턴스를 가지게 되면 여러 프로세서에 할당될 수 있다.
P1-> R1-> P3-> R2로 사이클이 있지만 자원 인스턴스가 2개씩 존재하기 때문에 교착상태에 빠지지않는다.
즉 사이클이 존재하는데, 자원 인스턴스가 한개면 데드락이 발생하지만, 2개 이상이라면 데드락의 "가능성"이 있는것이다.
P2나 P4의 작업이 끝나서 자원을 반납하게 되면, P1과 P3가 동작할 수 있기 때문에 데드락이 발생하지 않는다.
교착상태를 처리하는 방법으로는 크게 3가지가 존재한다.
- 데드락 예방
- 데드락 회피
- 데드락 감지
교착상태에 빠지게 되는 4가지 필요조건중 하나 이상을 시스템적으로 "무효화"하는 전략
자원을 여러 프로세스가 동시에 사용할 수 있게 하기.
프로세스가 실행하기 전에 필요한 모든 자원을 한번에 요청하게 하거나, 새로운 자원을 요청하기 전에 이미 점유한 자원은 반납하도록 요구한다.
프로세스가 자원을 점유한 상태에서 추가 자원을 요청하다가 대기하게 되면, 기존에 점유한 자원을 강제로 회수해 다른 프로세스에 할당해준다.
자원에 고유한 번호를 부여하고, 프로세스가 항상 오름차순(또는 내림차순)으로만 요청하게 하여 순환대기 조건을 방지한다.
✅ Deadlock Prevention은 데드락의 네 가지 필요조건 중 하나 이상을 시스템적으로 무효화하여, 데드락이 발생할 수 없는 환경을 만드는 방법이다.
✅ 각 조건을 무효화하는 방법은 시스템의 특성과 자원 종류에 따라 적용 가능 여부와 효율성이 다르다.
✅ 현실적으로 모든 조건을 무효화하는 것은 어려우며, 실제 시스템에서는 여러 방법을 조합하거나, 일부만 적용하는 경우가 많다.
교착상태 빠지지 않도록 자원 할당 시점마다 동적으로 안정성을 검사하는 방법이다. 데드락 예방처럼 미리 무효화 하는 것이 아니라, 자원 할당 요청이 들어올 때마다 그 요청을 허용할지 말지를 판단해 교착상태를 피하는 것이다.
안전 상태 (safe state)
불완전 상태 (unsafe state)
단일 인스턴스 자원일 경우 사용하는 Avoid 알고리즘이다.
이렇게 실선을 통해 claim edge를 표현한다. 만약 R2자원이 P2에 할당이 되어있다면, 데드락 발생 가능성이 있어서 unsafe하다.
여러 인스턴스가 있는 자원 환경에서 deadlock을 회피하기 위한 대표적 알고리즘이다.
😃 데이터 구조
| 이름 | 형태 | 의미 |
|---|---|---|
| Available | 1 x m 벡터 | 각 자원 타입별 현재 남은 인스턴스 수 |
| Max | n x m 행렬 | 각 프로세스가 최대 몇개의 자원을 요구할 수 있는지 |
| Allocation | n x m 행렬 | 현재 각 프로세스에 할당된 자원 수 |
| Need | n x m 행렬 | 각 프로세스가 앞으로 추가로 필요한 자원 수 (max - allocation) |
시스템이 현재 안전 상태인지(모든 프로세스가 반드시 완료될 수 있는 순서가 존재하는지) 검사하는 절차
- 자원 할당의 "안정성"만을 검사한다.
- 뱅커 알고리즘의 핵심 구성 요소임
입려: 현재 Available, Allocation, Need등
출력: 안전 상태 여부(true,false), 안전 순서
Step 1: 두 벡터를 준비한다.
Step 2: 완료 가능한 프로세스 찾기.
Step 3: 자원 반환 및 완료 표시.
Step 4: 안정성 판별
프로세스 Pi가 자원을 요청할 때, 그 요청을 허용하면 시스템이 안전 상태를 유지할 수 있는지 검사하는 알고리즘.

만약 이렇게 프로세스와 자원들이 있다고 가정해보자.

Max - Allocation이 Need가 되기 때문에 행렬로 각 프로세스들의 Need를 구할 수 있다.
그 다음에 Availavle이 (3,3,2)이기 때문에
Step 1: 실행 가능한 프로세스 찾기 (Need <= Available)
P1부터 실행하게 되면 -> Available = (3,3,2) + (2,0,0)(사용후 P1의 allocation 반환) = (5,3,2)
P3가 실행 가능하기 때문에 실행하면 -> Available = (5,3,2) + (2,1,1) = (7,4,3)
P4가 실행 가능하기 때문에 진행 후 P2진행
따라서 safe 시퀀스는 <p1, p3, p4, p2, p0>가 된다.
만약 safe시퀀스가 없다면 자원이 더 생길때까지 대기하게 된다.(실행 X)

원래 Available이 (3,3,2) 였는데 새로 요청된 (1,0,2)를 사용하기 때문에 (2,3,0)이 새로운 Available이 된다.
Allocation[P1]도 (2,0,0) + (1,0,2) = (3,0,2)가 된다.
Need[P1]도 (1,2,2) - (1,0,2) = (0,2,0)이 된다.
따라서 시퀀스를 다시 계산하게 되면 <P1,P3,P4,P0,P2>가 나온다.
데드락이 발생하는 것을 허용하지만, 시스템이 주기적으로 데드락 상태를 탐지하고 탐지된 후 적절한 복구를 수행하는 것.
노드: 프로세스
간선: Pi->Pj(Pi가 Pj가 점유한 자원을 기다린다)

P1은 P2가 할당하고 있는 자원을 요청하고 있기 때문에 wait-for 그래프에서는 P1->P2로 표현하는 것이다.
✅ 알고리즘 복잡도: O(m × n²)
(m: 자원 타입 수, n: 프로세스 수)

현재 그림을 보면 Available이 (0,0,0)인것을 볼 수 있다.
또한 Finish[i] != 0 이므로 모두 false가 된다.
P0과 P2의 request 값이 (0,0,0) <= work(0,0,0)이므로 만족하기 때문에 P0을 true로 하고 P0이 점유하고 있던 (0,1,0)을 work에 추가한다. 이렇게 하다보면 모두 ture가 되는 시퀀스가 나오게 된다.

데드락 탐지 알고리즘이 언제, 얼마나 자주 실행할지는 다음과 같은 요소에 의해 달라진다.
데드락 발생 가능성
복구 시 롤백해야 하는 프로세스 수
임의로 탐지 알고리즘을 사용할 경우 문제
데드락이 탐지된 후 시스템을 정상 상태로 되돌리기 위한 대표적인 방법은 프로세스 종료와 자원 선점이다.
모든 데드락 프로세스 종료
하나씩 순차적으로 종료
종료할 프로세스 선정 기준