티스토리 뷰
이전에 뮤텍스 락과 세마포에 대해서 다룬 적이 있습니다.
현재 글은 이전 글을 다듬어서 새로 작성된 게시물입니다.
https://server-technology.tistory.com/338
동기화가 필요한 이유
여러 스레드가 동시에 임계 구역(Critical Section)에 접근하면 데이터 일관성 문제가 발생할 수 있다. 임계 구역이란 공유 자원에 여러 스레드가 동시에 접근하였을 때 문제가 발생할 수 있는 코드 영역이다. 여러 스레드가 동시에 임계 구역에 접근하여 문제가 발생하는 것을 레이스 컨디션(Race Condition)이라고 한다.
레이스 컨디션 문제를 해결하기 위해서는 동기화 처리가 필요하다. 동기화란 실행 순서를 제어하고 상호 배제를 준수하는 것이다.
- 실행 순서 제어 : 여러 스레드가 존재할 때 실행 순서를 제어한다.
- 상호 배제 : 공유 자원에 하나의 스레드만 접근할 수 있도록 한다.
동기화 기법
1. 뮤텍스락 (mutex lock)
뮤텍스 락은 한 개의 공유 자원에 동시에 접근하지 못하도록 상호 배제를 보장한다. 임계 구역에 접근하기 위해서는 lock을 획득해야 하고 임계 구역에서 작업을 마쳤다면 lock을 반납해야 한다. 반납한 lock은 임계 구역에 접근하기 위해 대기 중인 스레드가 획득하고 접근한다.
한 개의 공유 자원이 있고 두 개의 스레드 P1과 P2가 존재한다.
- P1이 먼저 lock을 획득하여 임계 구역에 접근한다. P1는 공유 자원을 사용하여 작업을 진행한다.
- P2가 임계 구역에 접근하기 위해 lock 획득을 시도하였으나 P1이 먼저 획득하였으므로 대기한다.
- P1이 작업을 마쳤기 때문에 lock을 반납하고 임계 구역을 빠져나온다.
- 대기 중이던 P2는 lock을 획득하고 임계 구역에서 작업을 수행한다.
public class MutexLock {
static int sharedData; // 공유 자원
static Lock lock = new ReentrantLock(); // Lock
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new Increment());
Thread t2 = new Thread(new Decrease());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("최종 결과 sharedDate = " + sharedData); // 최종 결과 sharedDate = 0
}
static class Increment implements Runnable {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
lock.lock(); // 임계 구역에 접근하기 위한 lock 획득
try {
sharedData++; // 공유 자원 접근
} finally {
lock.unlock(); // 반복문 종료 시에 lock을 반납한다.
}
}
}
}
static class Decrease implements Runnable {
@Override
public void run() {
for (int i = 0; i < 10_000; i++) {
lock.lock(); // lock 획득
try {
sharedData--; // 공유 자원 접근
} finally {
lock.unlock(); // lock 반납
}
}
}
}
}
2. 카운팅 세마포 (counting semaphore)
뮤렉스 락은 하나의 공유 자원에 동시에 접근하는 것을 방지하는 방법이라면, 세마포 방식은 여러 개의 공유 자원이 존재할 때 사용된다.
- 변수 S : 현재 사용 가능한 공유 자원 개수 변수
- wait() : 임계 구역 진입 전 호출하는 함수
- signal() : 임계 구역에서 작업을 마친 후 호출하는 함수
wait() 함수는 임계 구역에 접근하기 전에 호출한다. 가장 먼저 S—를 통해 사용 가능한 자원의 개수를 1개 감소한다. 이때, S ≥ 0이라면 사용 가능한 공유 자원이 있었다는 의미이므로 임계 구역에 접근할 수 있고, S < 0이라면 사용 가능한 공유 자원이 없으므로 임계 구역에 접근하지 못하고 대기 상태가 된다.
wait(){
S--; // 사용 가능한 공유 자원 개수 1개 감소
if(S < 0) { // 사용 가능한 공유 자원이 없으므로 대기한다.
sleep()
}
}
signal() 함수는 임계 구역에서 작업을 마치고 빠져나올 때 호출하는 함수이다. 임계 구역에서 사용했던 공유 자원을 반납하기 때문에 S++을 호출한다. 이때, S ≤ 0 이라면 임계 구역에 접근하지 못하고 대기 상태에 있던 스레드가 있음을 의미한다. 따라서 대기 중인 스레드를 깨워서 임계 구역에 접근할 수 있도록 한다.
ex. S ≤ 0이라는 것은 S++ 호출하기 전에 S < 0 이었음을 의미한다. wait() 구현부를 보면 S < 0이면 대기 상태가 된다. 따라서 S++ 시에 S ≤ 0이라면 대기 중이던 스레드를 깨우고 반납된 공유 자원을 사용할 수 있도록 한다.
signal() {
S++;
if(S <= 0) {
wakeup();
}
}
3개의 스레드(P1, P2, P3)가 있고 2개의 공유 자원(S)이 존재한다.
- P1 스레드가 임계 구역에 접근하기 위해 wait()를 호출한다. 사용 가능한 공유 자원의 개수가 1개 감소한다. ⇒ 사용 가능한 공유 자원(S) 개수 : 2 → 1
- P2 스레드가 임계 구역에 접근하기 위해 wait()를 호출한다. 마찬가지로 사용 가능한 자원의 개수가 1개 감소한다. ⇒ 사용 가능한 공유 자원(S) 개수 : 1 → 0
- P3 스레드가 임계 구역에 접근하기 위해 wait()를 호출한다. 이때, 사용 가능한 공유 자원 개수가 0개에서 -1개가 되었으므로 S < 0 조건으로 인해 대기 상태가 된다. 다른 스레드에 의해 signal()이 호출될 때까지 대기한다.
- P1 스레드는 signal()을 호출하여 임계 구역에서 작업을 마치고 사용했던 공유 자원을 반납한다. 이때, S가 -1 → 0이 됨에 따라 임계 구역에 접근하지 못하고 대기 중이던 P3 스레드를 깨운다.
- 깨어난 P3 스레드는 임계 구역에 접근한다.
- P2 스레드는 signal()을 호출하여 임계 구역에서 작업을 마치고 사용했던 공유 자원을 반납한다. ⇒ 현재 사용 가능한 공유 자원(S) 개수 : 0 → 1
- P3 스레드도 signal()을 호출하여 임계 구역에서 작업을 마치고 사용했던 공유 자원을 반납한다. ⇒ 현재 사용 가능한 공유 자원(S) 개수 : 1 -> 2
3. 이진 세마포 (binary semaphore)
카운팅 세마포가 여러 개의 공유 자원이 존재할 때 동시에 여러 스레드가 접근하는 것을 관리한다면, 이진 세마포는 0과 1로만 이루어진 세마포 방식이다. 즉, 단일 자원에 대해서 사용되는 세마포 방식이다. 따라서 여러 스레드가 동시에 하나의 공유 자원에 접근하지 못하도록 한다.
공유 자원에 접근할 때 스레드가 세마포를 획득하면서 세마포 값이 1에서 0으로 바뀌고, 사용을 다했다면 세마포 값이 0에서 1로 바뀐다. 이는 마치 뮤텍스 락(mutex lock) 방식과 비슷하다.