Liveness
병렬 애플리케이션에서 프로그램이 끝까지 실행되어 원하는 결과를 만들어 낼 수 있는 능력을 Liveness라고 한다.
Liveness에서는 대표적으로 DeadLock, Starvation, LiveLock 문제가 발생할 수 있다.
DeadLock
DeadLock은 동시에 두 개 이상의 스레드가 서로를 기다리며 영원히 차단(Blocked)되는 문제이다.
우선 아래와 같은 상황을 살펴보자.
public class Bower{
synchronized void bow(Bower bower){
bower.bowBack(this);
}
synchronized void bowBack(Bower bower){
System.out.println("bowBack");
}
}
public class Main{
public static void main(){
Bower a = new Bower();
Bower b = new Bower();
// thread n
new Thread(new Runnable(){
public void run(){ a.bow(b); }
}).start();
// thread m
new THread(new Runnable(){
public void run(){ b.bow(a); }
}).start();
}
}
이 코드는 DeadLock으로부터 안전하지 않다. 시나리오는 다음과 같다.
- Thread n 실행
- n에서 a에 대한 락을 얻음
- Thread m 실행
- m에서 b에 대한 락을 얻음
- DeadLock 발생
이 상황에서 n이 종료되기 위해서는 b의 락이 필요하고, m이 종료되기 위해서는 a의 락이 필요하기에 n과 m은 영원히 중단되는 데드락 문제가 발생한다.
DeadLock의 조건
DeadLock은 아래 4가지 조건이 모두 성립할 때 발생한다.
- 상호배제 : 스레드들이 자원에 대한 독점권(락)을 요구
- 점유대기 : 스레드가 어떤 락을 가진 상태에서 다른 락을 기다림
- 비선점 : 스레드가 락을 풀기 전까지 다른 스레드에서 락을 뺏을 수 없음
- 순환대기 : 각 스레드가 순환적으로 다른 스레드가 요구하는 자원을 갖는다
이러한 상황에서 위 데드락 예제는 4 가지에 모두 해당될 수 있고, 이는 데드락의 위험이 매우 크다는 것을 의미한다.
Starvation
Starvation(기아) 상태는 스레드가 공유 자원에 정기적으로 접근할 수 없는 상태이다.
보다 자세히 말하자면, greedy 스레드 등으로 인해 어떤 스레드가 끊임없이 필요한 자원을 가져오지 못하는 상태를 말한다.
예를 들어서 synchronized 메서드 실행 시 오래 걸리고, 여러 스레드가 자주 접근해야 한다면 스레드 간 starvation 문제가 발생할 수 있다.
LiveLock
DeadLock과 마찬가지로 두 개 이상의 스레드가 서로의 진행을 방해하는 문제이지만 차단이 되지는 않는다.
더 정확히 말해서 서로가 서로의 작업에 반응하는 것을 무한으로 반복할 때 발생한다.
대표적으로 아래와 같이 스레드가 실패한 작업을 동시에 재시도할 때 발생한다.
public void Main{
public static class Worker{
public boolean active = true;
public void doWorkWith(Worker other){
while(active){
if(other.active){
try{
Thread.sleep(100);
}catch(InterruptedException e){}
continue;
}
active = false;
}
}
}
public static void main(String[] args){
Worker w1 = new Worker();
Worker w2 = new Worker();
new Thread(() -> w1.doWorkWith(w2)).start();
new Thread(() -> w2.doWorkWith(w1)).start();
}
}
이 코드에서는 아래와 같이 LiveLock이 발생한다.
- w1이 w2가 active이므로 sleep
- w2가 w1이 active이므로 sleep
- w1이 sleep 종료 후 other.active이므로 sleep
- 무한 반복
이처럼 데드락을 피하기 위한 작동이나 실패한 작업을 동시에 계속 재시도하면서 두 개 이상의 스레드의 진행을 막는 것을 LiveLock이라고 한다.
Guarded Block
Guarded Block 기본 개념
스레드는 종종 자기 자신의 작업을 조정할 수 있어야 한다.
그리고 이러한 조정 방법 중 가장 일반적인 것이 guarded block이다.
이러한 블록은 코드가 진행되기 위해서는 참이어야 하는 상태를 주로 사용한다.
이번에는 guarded block 중 무한루프를 사용하는 방식과 wait을 사용하는 방식을 알아볼 예정이다.
Busy Waiting 방식의 Guarded Block
Busy Waiting 방식의 Guarded Block은 특정 상태가 true가 되기 전까지 무한 루프를 돌면서 기다리는 방식이다.
public void guardedBlock(){
while(isBlocked){}
System.out.println("release");
}
public void setBlockedFalse(){
this.isBlocked = false;
}
이러한 방식은 매우 간단하게 구현할 수 있지만, while문 내에서 루프를 낭비하기 때문에 CPU 사용량 측면에서 좋지 않다.
이러한 문제를 해결하기 위해서 wait를 사용한 방식을 채택할 수 있다.
Wait을 사용하는 방식의 Guarded Block
Wait 메서드는 Object 클래스의 메서드로, 모든 클래스들이 기본적으로 갖는 기능이다.
Wait 메서드를 호출하게 되면 notify나 notifyAll을 호출하기 전까지 대기 상태에서 돌아오지 않는다.
따라서 무한 루프 없이 효과적으로 자원을 사용하여 Guarded Block을 구현할 수 있다.
public synchronized void guardedBlock(){
while(isBlocked){
try{
wait();
}catch(IllegalMonitorStateException e){}
}
System.out.println("release");
}
public synchronized void notifyBlock(){
isBlocked = false;
notifyAll();
}
하지만, 위 코드에서처럼 notify 등을 호출했을 때 해당 호출이 우리가 기대하는 조건이 아닐 수 있으므로 while문 내에서 조건을 다시 확인해야 한다.
wait을 호출할 때, 해당 객체에 대해서 락을 갖고 있지 않다면 예외가 발생한다.
따라서 Synchronized 키워드를 사용하여 현재 객체에 대한 락을 습득한 후 wait을 호출하는 방식이 안전하다.
따라서 위 코드는 아래와 같은 순서로 동작한다.
- guardedBlock을 호출할 때 해당 객체의 락을 얻는다
- wait을 호출한 후 락을 해제한 후 대기 상대로 전환된다.
- notifyBlock을 호출한 후 wait이 대기 상태에서 돌아온 후 락을 습득한다.
- 조건을 재확인한 후, 적절한 조건이라면 다음 동작을 진행한다.
Wait을 사용한 Producer-Consumer 예제
아래는 Producer-Consumer 구조에서 사용할 수 있는 간단한 데이터 저장소에 대한 구현이다.
public class DataContainer {
private List<String> container;
int maxSize;
public DataContainer(int maxSize){
this.container= new ArrayList();
this.maxSize = maxSize;
}
public synchronized String take(){
while(container.isEmpty()){
try{
wait();
}catch(InterruptedException e){}
}
notifyAll();
return container.remote(0);
}
public synchronized void put(String data){
while(container.size>=maxSize){
try{
wait();
}catch(InterruptedException e){}
}
container.add(data);
notifyAll();
}
}
이 코드에서 take는 container가 비어있다면 데이터가 찰 때까지 기다린다.
또한, put은 container가 가득 찼다면 빈 공간이 생길 때까지 기다린다.
이러한 wait 방식은 기존의 busy wait 방식에 비해서 컴퓨팅 자원을 보다 효과적으로 사용할 수 있다.
즉 wait이 busy wait에 비해서 속도 측면에서 조금 떨어지지만, 자원 효율성은 더 높다고 볼 수 있다.
마무리
이번에는 liveness에서 발생할 수 있는 문제들에 대해서 알아보았다.
또한 무한 루프를 활용한 busy wait과 wait-notify 방식에 대해서 알아보았다.
기존에는 큰 생각 없이 busy wait 방식을 자주 사용하곤 했는데 wait-notify 방식을 사용하면 현재 결과물을 더욱 향상시킬 수 있으리라는 생각이 들었다.
'CS 및 기본 개념' 카테고리의 다른 글
| [Java 공식문서] 불변 객체 (4) | 2025.08.15 |
|---|---|
| [cs] Cache Control 헤더 (4) | 2025.08.13 |
| [cs] AWS lambda 기반의 Webhook (1) | 2025.08.07 |
| [ Java 공식문서 ] Java 동시성 프로그래밍 2 : Synchronized (4) | 2025.08.06 |
| [ Java 공식문서 ] Java 동시성 프로그래밍 1 : Thread (3) | 2025.08.04 |