동일한 프로세스 내에 있는 쓰레드는 메모리 자원을 공유한다.(스택 영역은 제외하고) 메모리 자원을 공유하면 보다 빠르고 간단하게 데이터를 관리할 수 있다. IPC라는 복잡한 장치가 없어도 데이터에 접근할 수 있기 떄문이다.
하지만 이럴 경우, 문제가 생긴다.
하나의 자원에 여러 쓰레드가 동시에 접근하는 경우이다.
하나의 예를 들어보자.
은행에서는 계좌를 관리한다. 어떤 계좌의 잔액에는 여러 쓰레드가 접근할 수 있다.
현재 잔액(balance)이 5만원이라고 할 때, 쓰레드 1(T1)에서는 1만원을 입금하고, 쓰레드 2(T2)에서는 1만원을 출금하려고 한다.
우리가 예상한 결과는 잔액(balance) 5만원이다.
하지만 T1, T2가 동시에 수행되면 어떤 결과로 나타날까?
CPU는 데이터를 수정하기 위해서 Register를 통해 읽어오고, 연산한 이후에 데이터를 다시 쓴다는 점을 상기하며 따라가보자.
Register1 = balance Register1 = Register1 + 10000Register2 = balanceRegister2 = Register2 - 10000balance = Register2 → 잔액은 40,000원balance = Register1 → 잔액은 60,000원이렇게 Thread1의 수행이 종료되기 전에, 새로운 Thread2의 요청에 따른 인터럽트가 발생하면 연산이 엉망이 된다.
위와 같이,
잔액이라는 공유 자원에 여러 쓰레드가 동시에 접근하여, 비동기적 실행 순서로 인해 결과가 달라지는 경우를 race condition 이라고 한다.
운영체제(자세히 말하면 커널)는 이러한 race condition 문제를 해결해야 한다.
위의 경쟁 조건을 해결하는 방안은 동기화이다. lock, mutex, semaphore 등으로 보호할 수 있다.
더 간단히 말하자면, T1이 작업 중일 때는 T2가 접근하지 못하도록 막는 것이다.
가장 간단하게는 이런 생각을 할 수 있다.
하나의 쓰레드가 동작 중일 때, 아무도 끼어들지 않으면 완전하게 이 문제가 해결될 수 있겠다!
동기화가 단순해지지만 이건 초강력하게 비추천한다. race condition을 막기 위해 전체 CPU 인터럽트를 비활성화하면 사용자는 마우스를 클릭하거나 키보드로 입력을 해도 바로바로 되지 않을 것이다. 긴 커널 작업 중 다른 스레드가 CPU를 점유하지 못해 반응성이 저하될 수 있다.
T1이 실행 되다가 예기치 않은 에러를 마주하며 종료 되었다고 가정하자.
현재 T1이 실행 중인 상태이기 때문에 인터럽트가 불가하다. T1이 종료 되었음에도 다른 작업을 수행할 수 없게 된 것이다.
혹은 T1이 우선순위가 낮은 작업이라고 하자. T1이 수행 되던 중에 선착순 이벤트 공지를 받게 되어, 빨리 선착순 클릭해야한다! 그런데 T1의 작업 시간이 하루 종일이다.. 이런 경우에는 무척이나 난감할 것이다.
다른 소프트웨어적인 방법을 떠올리자.
어떤 자원이 이미 사용 중이라면 그 사용이 종료될 때까지 다른 쓰레드에서 접근할 수 없도록 설정해주면 되겠다는 아이디어다.
그리고 이렇게 ‘여러 쓰레드가 공유하는 데이터에 접근하는 구역’을 임계구역 critical section이라고 한다.
임계구역은 동시에 두 개 이상의 쓰레드가 접근할 수 없다. 운영체제는 임계구역에 어떠한 쓰레드가 점유하고 있다면 다른 쓰레드는 대기하도록 만들어야 한다.
이 임계구역을 어떻게 안전하게 지켜줄 것인지를 고민하여 여러 동기화 Synchronization 방안이 제안되었다.
쓰레드 식별자로 turn을 지정한다. 각 쓰레드는 자기 자신 turn일 때 자원에 접근한다.
// Thread A
while (turn != A);
/* Critical Section */
turn = B;
/* Remainder Section */
// Thread B
while (turn != B);
/* Critical Section */
turn = A;
/* Remainder Section */
이 방법은 bounded waiting을 위반한다.
turn == A 일 때, A가 예기치 못하게 중단된다면, 다른 쓰레드는 평생 실행되지 않는다.
쓰레드 식별자를 원소하는 값을 T/F로 하여 접근을 제어한다. flag[A] == true 라면 자원에 접근한다.
// Thread A
flag[A] = 1;
while (flag[B]);
/* Critical Section */
flag[A] = 0;
/* Remainder Section */
// Thread B
flag[B] = 1;
while (flag[A]);
/* Critical Section */
flag[B] = 0;
/* Remainder Section */
이 방법은 progress를 위반한다.
A, B가 동시에 자원에 접근했을 때, flag[A]와 flag[B] 모두 true가 되어 어느 쓰레드도 critical section에 진입할 수 없다.
// Thread A
flag[A] = 1;
while (flag[B] && turn == B);
/* Critical Section */
flag[A] = 0;
turn = A;
/* Remainder Section */
// Thread B
flag[B] = 1;
while (flag[A] && turn == A);
/* Critical Section */
flag[B] = 0;
turn = B;
/* Remainder Section */

이 경우에는 다음과 같이 mutual exclusion을 위반한다.
알다시피, 하나의 프로그램은 하나의 자원만을 사용하지 않는다. 어떤 자원을 사용할 때는 선후관계가 있어, 첫 자원의 연산을 수행한 이후 두번째 자원의 연산을 수행해야 한다.
이런 상황이 양 쪽으로 동시에 발생하면 어떻게 되는걸까?

말 그대로 Dead Lock 죽음의 자물쇠.. 죽음의 굴레에 갇히게 된다.
이 교착 상태를 해소하기 위해, 그리고 임계 구역을 지키기 위한 방법을 하나씩 살펴보자.
공유 자원에 접근하는 주체에 Lock을 부여하고, 작업이 완료되면 Lock을 반환하는 방법이다.
// 초기 상태: 자원 1개 (즉, 락이 비어 있음)
int available = 1;
if (available > 0) {
available = 0; // Lock 획득
/* ---- 임계 구역 진입 ---- */
} else {
/* ---- busy wait이거나 block 상태로 진입 ---- */
}
available = 1; // 자원 반납
/* ---- 대기 중인 스레드 하나를 깨움 (blocking 기반일 경우) ---- */
자원의 개수만큼만 스레드가 자원을 점유할 수 있도록 보장하는 방법이다.
struct semaphore {
unsigned value; // 자원 개수
struct list waiters; // 대기 중인 스레드 목록
};
value = 3;)| 종류 | 자원 개수 | 예시 |
|---|---|---|
| Binary Semaphore | value = 0 또는 1 (락처럼 사용됨) | Mutex 대용 |
| Counting Semaphore | value ≥ 0 (자원이 여러 개일 때) | 프린터 3대, 접속 제한 등 |
0 → 처음부터 자원을 획득하지 못하고 우선, 대기하도록 한다.
1 → 최초에 바로 자원을 획득할 수 있도록 한다.
생산자-소비자 문제, reader-writer 문제 등에 쓰이는 고수준 동기화 구조이다.
Mutex 구조에 Condition Variable(조건 변수)을 추가로 고려한 방법으로, Lock으로 임계 구역을 보호하고, 조건 변수로 상태 변화에 따른 대기/알림을 처리한다.
빵집을 떠올려보자. 진열대에 빵이 가득 차 있으면
1. 제빵사는 할 일이 없어 대기한다.
2. 손님은 살 수 있다.
반면, 진열대에 빵이 비어있다.
1. 손님은 빵이 나오기를 기다린다.
2. 제빵사는 빵을 만들어야 한다.
이를 보면 제빵사 혹은 손님이 Lock을 획득했을 때, 조건을 판단하고 다음 Lock을 획득할 주체를 선택할 수 있다.
손님이 빵을 샀다면, 진열대가 빈다.
→ 제빵사는 이제 빈 진열대에 빵을 두기 위해 일을 한다.(제빵사에게 Lock을 준다.)
제빵사가 빵을 만들었다, 진열대가 차있다.
→ 손님은 이제 빵을 살 수 있다.(손님에게 Lock을 준다.)
위에서 비유한 진열대, 제빵사, 손님은 다음과 같이 매핑된다.