[Java] 동기화 기법에 대하여(뮤텍스, 세마포어) - 1
들어가기 전
동기화를 하지 않으면 동시성 이슈로 인해 예상치 못한 문제를 겪을 수 있어 동기화를 하여 이와 같은 문제를 방지해야 합니다.
그래서 이번 포스팅에서는 동시성 이슈를 방지하기 위한 동기화 기법에 대해 알아보겠습니다.
동시성 이슈로 인한 문제는 아래 포스팅을 참고하시는 것을 추천드립니다.
https://hoestory.tistory.com/83
동기화란?
동기화는 여러 스레드가 동시에 임계 영역*에 접근을 하려고 할 때 하나의 스레드만 접근할 수 있도록 해주는 방식입니다.
만약 동기화를 하지 않으면 임계 영역*에 여러 스레드가 동시에 접근하여 경쟁 상태*를 방지를 하지 못해 동시성 이슈가 발생할 수 있습니다.
경쟁 상태(Race Condition)* : 공유 자원에 대해 여러 스레드가 동시 접근으로 인하여 결과값에 영향을 줄 수 있는 상태
임계 영역* : 여러 스레드가 공유 자원에 접근하려고 할 때 오직 하나의 스레드만 접근할 수 있는 영역
동기화 기법
- 뮤텍스(Mutex)
- 세마포어(Semaphore)
- 모니터(monitor)
- 스핀락(Spin Lock)
뮤텍스(Mutex)
뮤텍스 또는 상호배제는 공유 자원에 대한 경쟁상태를 방지하고 동시성 제어를 위한 락 메커니즘입니다.
임계 영역에는 하나의 스레드만 락을 획득하여 접근이 가능하고 락을 획득하지 못한 스레드는 대기상태에 있습니다.
그리고 락 획득한 스레드만 락을 해제할 수 있고 다른 스레드가 해당 락을 해제를 하지 못합니다. 즉 하나의 스레드만 임계 영역에 대한 락을 획득하고 해제를 할 수 있습니다.
뮤텍스 예제
public class Mutex {
private boolean lock = false;
public synchronized void acquired() {
while (lock) {
try {
wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
lock = true;
}
public synchronized void release() {
lock = false;
notify();
}
}
- synchronized : acquired 메서드에 하나의 스레드만 접근할 수 있는 기능 제공
- wait() : lock의 값이 true일 경우 이미 임계영역에 접근하고 있는 스레드가 존재하기 때문에 다른 스레드들은 대기 상태에 있습니다.
- notify() : 락을 획득했던 스레드가 락을 해제하고 대기상태에 있는 스레드를 깨워 실행대기 상태로 변경시켜 줍니다.
@Getter
class MutexTestService {
private int cnt = 0;
private Mutex mutex;
public MutexTestService(Mutex mutex) {
this.mutex = mutex;
}
public void increase() {
try {
mutex.acquired();
cnt++; //임계영역
}finally {
mutex.release();
}
}
}
- mutex.acquired() : 해당 부분에서 하나의 스레드가 락을 얻습니다. 만약 이미 락을 얻은 스레드가 있다면 다른 스레드들은 대기상태에 존재합니다.
- cnt++ : mutex.acquried() : 메서드에서 락을 획득한 스레드가 임계영역에 접근하여 값을 변경합니다.
- mutex.release() : 락을 얻었던 스레드가 락을 해제합니다.
해당 코드에서 finally 구문에 mutex.release() 메서드를 통해서 락을 해제해주고 있습니다. 이렇게 하는 이유는 만약 finally가 없고 try 구문에서 문제가 생기게 되면 락을 획득한 스레드는 락을 해제하지 못하고 대기하고 있던 스레드들도 락을 획득하지 못하는 문제가 발생할 수 있습니다. 그래서 이와 같이 문제가 발생을 하든 정상동작하든 무조건 실행할 수 있게 finally구문에 락 해제 코드를 넣었습니다.
class MutexTestServiceTest {
@Test
@DisplayName("동기화 기법 뮤텍스를 활용하여 원하는 결과 획득")
public void success() throws InterruptedException {
Mutex mutex = new Mutex();
MutexTestService sharedData = new MutexTestService(mutex);
Thread firstThread = new Thread(() -> {
for (int i = 0; i < 10000000; i++) {
sharedData.increase();
}
}, "첫번째 스레드");
Thread secondThread = new Thread(() -> {
for (int i = 0; i < 10000000; i++) {
sharedData.increase();
}
}, "두번째 스레드");
firstThread.start();
secondThread.start();
firstThread.join();
secondThread.join();
Assertions.assertThat(sharedData.getCnt()).isEqualTo(10000000 * 2);
}
}
테스트 코드를 통해 두 개의 스레드에서 각각 1000만 데이터를 더하는 로직에 뮤텍스를 활용하였더니 원하는 결과를 얻은 것을 확인할 수 있습니다.
뮤텍스의 문제점
- 데드락 : 데드락은 두 개 이상의 스레드가 서로가 가진 락을 기다리면서 상호적으로 블로킹되어 아무 작업도 수행할 수 없는 상태를 의미하며 잘못된 뮤텍스 사용으로 인해 데드락이 발생할 수 있습니다.
- 우선순위 역전 : 높은 우선순위를 가진 스레드가 낮은 우선순위를 가진 스레드가 보유한 락을 기다리는 동안 블록되는 현상으로 높은 우선순위를 가진 스레드의 작업이 지연될 수 있습니다.
- 오버헤드 : 뮤텍스를 사용하면 여러 스레드가 경합하면서 락을 얻기 위해 스레드 스케줄링이 발생하여 오버헤드가 발생하여 성능이 저하될 수 있습니다.
- 성능 저하 : 락을 얻기 위해 스레드가 대기하게 되고 스레드의 실행 시간이 블록 되면서 성능 저하가 발생할 수 있습니다.
세마포어(Semaphore)
세마 포어는 임계 영역에 대한 접근을 제어하기 위해 정수형 변수 S와 P, V의 두 가지 원자적 함수로 구성된 신호전달 메커니즘 동기화 도구입니다.
P는 임계 영역을 사용하려는 스레드의 진입 여부를 결정하는 연산으로 Wait 연산이라고도 하고 V는 대기 중인 프로세스를 깨우는 신호로 Signal연산이라고 합니다.
스레드가 임계영역에 진입하지 못할 경우 자발적으로 대기 상태에 들어가고 임계영역으로 빠져나오는 스레드가 대기 상태의 스레드를 실행 대기 상태로 깨워줍니다.
자바에서 세마포어 구현체를 포함하고 있습니다.
세마포어의 종류에는 이진 세마포어(Binary Semaphore), 카운팅 세마포어(Counting Semaphore)가 있습니다.
이진 세마포어(Binary Semaphore)
이진 세마포어는 카운트 변수 S의 값이 0과 1로만 이루어진 세마포어입니다. 뮤텍스와 동일하게 동작합니다.
public class BinarySemaphore {
private int S = 1;
public synchronized void P() {
while(S == 0) {
try {
wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
S = 0;
}
public synchronized void V() {
S = 1;
notify();
}
}
- S : 세마포어의 정수형 변수를 나타냅니다.
- P() : Wait 연산으로 S의 값으로 스레드 진입여부를 결정합니다. 정수형 변수 S가 1일 경우에 임계영역에 접근할 수 있고 0일 경우 다른 스레드는 임계 영역에 접근하지 못하고 대기상태에 있습니다.
- V() : Signal 연산으로 락을 획득했던 스레드가 락을 해제하면서 대기 상태에 있는 다른 스레드를 깨워 실행대기 상태로 변경합니다.
@Getter
public class BinarySemaphoreExecutor {
private int count = 0;
private BinarySemaphore binarySemaphore;
public BinarySemaphoreExecutor(BinarySemaphore binarySemaphore) {
this.binarySemaphore = binarySemaphore;
}
public void increase() {
try {
binarySemaphore.P();
count++; //임계 영역
}finally {
binarySemaphore.V();
}
}
}
class BinarySemaphoreExecutorTest {
@Test
void binarySemaphore() throws InterruptedException {
BinarySemaphore binarySemaphore = new BinarySemaphore();
BinarySemaphoreExecutor binarySemaphoreExecutor = new BinarySemaphoreExecutor(binarySemaphore);
Thread firstThread = new Thread(() -> {
for (int i = 0; i < 10000000; i++) {
binarySemaphoreExecutor.increase();
}
}, "첫번째 스레드");
Thread secondThread = new Thread(() -> {
for (int i = 0; i < 10000000; i++) {
binarySemaphoreExecutor.increase();
}
}, "두번째 스레드");
firstThread.start();
secondThread.start();
firstThread.join();
secondThread.join();
Assertions.assertThat(binarySemaphoreExecutor.getCount()).isEqualTo(10000000 * 2);
}
}
이진 세마포어는 뮤텍스와 유사하게 동작하여 뮤텍스와 동일한 결과를 얻을 수 있습니다. 해당 테스트 코드에서도 두 개의 스레드에서 각각 1000만 데이터를 더하는 로직에 이진 세마포어를 활용하였더니 원하는 결과를 얻은 것을 확인할 수 있습니다.
동작 방식
1번 과정
- Thread-1이 임계 영역에 접근합니다.
- Thread-2는 Thread-1이 임계 영역에 접근하고 있기 때문에 대기 상태에 있습니다.
2번 과정
- Thread-1이 임계 영역에서 해제 후 대기 상태에 있는 Thread-2를 깨워 실행대기 상태로 만듭니다.
3번 과정
- Thread-2는 대기상태에서 빠져나와 임계 영역에 접근합니다.
카운팅 세마포어(Counting Semaphore)
이진 세마포어와 달리 정수형 변수 S가 2 이상인 양수값을 가진 세마포어를 카운팅 세마포어라고 합니다.
정수형 변수 S 값에 따라 동시에 접근할 수 있는 스레드 수를 정할 수 있습니다.
public class CountingSemaphore {
private int signal;
private int perimits; // 동시에 접근할 수 있는 스레드 수
public CountingSemaphore(int perimits) {
this.perimits = perimits;
this.signal = perimits;
}
public synchronized void P() {
while (signal == 0) {
try {
System.out.println(Thread.currentThread().getName() + " 대기 상태");
wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
signal -= 1;
System.out.println(Thread.currentThread().getName() + " 스레드가 접근 후 접근할 수 있는 스레드 수 : " + signal);
}
public synchronized void V() {
if(signal < perimits) {
signal += 1;
System.out.println(Thread.currentThread().getName() + "스레드가 해제 후 접근할 수 있는 스레드 수 : " + signal);
notify();
}
}
}
- perimits : 동시에 접근할 수 있는 스레드 수
- P() : Wait 연산으로 signal의 값이 0 일 경우 다른 스레드는 임계영역에 접근할 수 없어 대기 상태에 있습니다. signal이 0이 아닐 경우 스레드는 임계영역에 접근할 수 있고 접근하게 되면 singal의 값은 -1이 됩니다.
- V() : Signal 연산으로 임계 영역에서의 작업이 끝난 스레드가 락을 해제하고 대기상태에 있는 스레드를 깨웁니다.
public class CountingExecutor {
int count = 0;
public void execute() throws InterruptedException {
int perimits = 5;
int threadCount = 10;
Thread[] threads = new Thread[threadCount];
CountingSemaphore countingSemaphore = new CountingSemaphore(perimits);
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
try {
countingSemaphore.P();
Thread.sleep(1000); //임계영역
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
countingSemaphore.V();
}
});
threads[i].start();
}
for (int i = 0; i < threads.length; i++) {
threads[i].join();
}
}
}
동시에 접근할 수 있는 스레드 수는 5이고 스레드의 개수는 10으로 설정을 하였습니다. 해당 코드를 실행시키면 아래 이미지와 같은 결과를 볼 수 있습니다.
접근할 수 있는 스레드 수를 5개로 설정을 하였더니 5개의 스레드가 임계 영역에 접근하는 것을 확인할 수 있습니다.
이미 제한된 수를 지난 상태에서 다른 스레드가 접근하려고 하자 해당 스레드는 대기 상태가 됩니다.
다른 스레드가 수행을 마치고 대기상태에 있는 스레드를 깨워서 대기 상태에 있는 스레드가 임계 영역에 접근하여 실행하는 것을 확인할 수 있습니다.
동작 방식
1번 과정
- 정수형 변수 S를 3으로 설정하여 동시에 3개의 스레드가 접근할 수 있습니다.
- Thread-1이 접근을 하여 변수 S는 -1이 되어 2개의 스레드가 접근할 수 있습니다.
2번 과정
- Thread-2가 접근하여 변수 S는 1이 됩니다.
3번 과정
- Thread-3이 접근하여 변수 S는 0이 됩니다.
4번 과정
- Thread-4가 접근하려고 하였으나 변수 S의 값이 0이 되어 임계 영역에 접근하지 못하고 대기상태에 있습니다.
5번 과정
- Thread-1의 작업이 완료되어 해제되고 변수 S의 값이 +1이 되고 대기 상태에 있는 Thread-4를 깨워 실행 대기 상태로 만듭니다.
6번 과정
- Thread-4는 실행대기 상태에 있다가 스케줄러에 의해 실행이 되고 임계 영역에 접근을 합니다. 그리고 변수 S의 값은 0이 됩니다.
지금까지 세마포어, 뮤텍스를 직접 구현해 보면서 알아보았습니다. 뮤텍스는 자바에서 제공해 주는 객체가 없지만 세마포어는 자바에서 제공해 주는 객체가 있습니다. 자바에서 제공해 주는 세마포어 객체를 활용하는 법과 예제에서 사용하는 메서드가 무엇이 있는지 알아보겠습니다.
import java.util.concurrent.Semaphore;
public class JavaSemaphore {
public static void main(String[] args) throws InterruptedException {
int threadCount = 4;
int permits = 3;
Thread[] threads = new Thread[threadCount];
Semaphore semaphore = new Semaphore(permits);
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
try {
semaphore.acquire();
System.out.println(Thread.currentThread().getName() + " 획득 ");
Thread.sleep(1000);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
semaphore.release();
System.out.println(Thread.currentThread().getName() + " 해제 ");
}
});
threads[i].start();
}
for (int i = 0; i < threads.length; i++) {
threads[i].join();
}
}
}
- 자바에서 제공하는 세마포어 객체는 java.util.concurrent 패키지 내부에 있습니다.
자바에서 제공해 주는 세마포어 객체의 메서드
public Semaphore(int permits) {
sync = new NonfairSync(permits);
}
- 생성자에서 접근할 수 있는 스레드 수를 설정할 수 있습니다.
public Semaphore(int permits, boolean fair) {}
- 생성자에서 접근할 수 있는 스레드와 공정성 여부에 대한 값을 설정할 수 있습니다.
public void acquire() throws InterruptedException{}
public void acquire(int permits) throws InterruptedException {
- 위에서 설명한 세마포어 P연산 즉 Wait 연산을 하는 메서드입니다.
- 스레드의 진입여부를 파악하여 진입 또는 대기하게 하는 메서드입니다.
- 매개변수 있는 메서드는 진입할 때 스레드의 진입 허용 개수를 정합니다.
public void release() {}
public void release(int permits) {}
- 위에서 설명한 세마포어 V연산 즉 Signal 연산을 하는 메서드입니다.
- 스레드의 수행이 끝나면 해제하고 대기 상태에 있는 스레드를 실행 대기 상태로 만들어주는 메서드입니다.
- 매개변수가 있는 메서드는 해제할 때 스레드의 허용 개수를 설정할 수 있습니다.
뮤텍스와 세마포어
동작 방식
- 뮤텍스는 공유 자원에 하나의 스레드만 접근할 수 있습니다.
- 이진 세마포어는 변수 S가 0과 1로만 이루어져 있어 뮤텍스와 동일하게 공유 자원에 하나의 스레드만 접근할 수 있습니다.
- 카운팅 세마포어는 변수 S가 양수 2 이상으로 이루어져 있어 뮤텍스와 이진 세마포어와 다르게 여러 스레드가 동시에 공유자원에 접근할 수 있습니다.
소유권
- 뮤텍스는 락을 획득한 스레드에게 소유권이 있습니다. 소유권 있는 스레드 외에는 락을 해제할 수 없습니다.
- 세마포어는 소유권이 존재하지 않습니다. 특정 개수의 스레드가 동시에 접근을 허용하는 카운팅 기법으로 작동합니다. 세마포어를 사용하는 스레드들이 모두 세마포어를 해제할 수 있습니다.
초기값
- 뮤텍스의 초기값은 잠겨있는 상태로 시작합니다. 하나의 스레드가 락을 획득하여 공유자원에 접근할 경우 다른 스레드는 블로킹이 됩니다.
- 세마포어는 초기값을 설정할 수 있고 초기값에 따라 동시에 접근할 수 있는 스레드를 정할 수 있습니다.
사용 목적
- 뮤텍스는 주로 상호 배제를 위해 사용되며 하나의 자원에 하나의 스레드만 접근하도록 보장해야 하는 경우에 사용됩니다.
- 세마포어는 주로 리소스의 한정적인 사용을 제어하는 데 사용되며 특정 개수의 스레드만이 동시에 자원에 접근하도록 제한하고자 할 때 사용됩니다.
참고 강의
'Java' 카테고리의 다른 글
[Java] 동시성 이슈 개념과 발생하는 동작 과정 (1) | 2024.07.26 |
---|---|
[Java] 스레드의 상태와 생명주기 (0) | 2024.07.23 |
[Java] 멀티 스레드 모델에 대해서 (1) | 2024.07.14 |
[Java] Static Block 실행 시점 (2) | 2024.03.15 |
[Java] ConcurrentModificationException 원인 및 해결 방법 (0) | 2022.11.22 |
댓글
이 글 공유하기
다른 글
-
[Java] 동시성 이슈 개념과 발생하는 동작 과정
[Java] 동시성 이슈 개념과 발생하는 동작 과정
2024.07.26 -
[Java] 스레드의 상태와 생명주기
[Java] 스레드의 상태와 생명주기
2024.07.23 -
[Java] 멀티 스레드 모델에 대해서
[Java] 멀티 스레드 모델에 대해서
2024.07.14 -
[Java] Static Block 실행 시점
[Java] Static Block 실행 시점
2024.03.15