synchronized는 키워드 선언으로 편리하게 동시성 문제에 대해 객체의 Lock을 획득하는 방법으로 임계 영역에 대한 동기화 문제를 해결해주었다.
하지만 Lock을 기다리는 다른 쓰레드의 관점에서 BLOCKED 상태로 대기하기에 락이 풀릴 때 까지 무한정 기다려야한다는 단점이 존재했다. 타임아웃이 존재하지도, 인터럽트를 걸 수도 없다.(BLOCKED이기에)
결국 더 유연하고, 더 세밀한 동기화 작업이 필요해졌고 자바는 Java 1.5 부터 동시성 문제 해결을 위한java.util.concurrent 라이브러리를 추가한다.
LockSupport는 스레드를 WAITING 상태로 변경한다.
WAITING 상태는 누가 깨워주기 전까지는 계속 대기를 유지하고 CPU 실행 스케줄링(RUNNABLE)에 들어가지 않는다.
park() : 스레드를 WAITING 상태로 변경parkNanos() : 스레드를 나노초 동안만 TIMED_WAITING 상태로 변경unpark(thread) : WAITING 상태의 스레드 RUNNABLE로 변경synchronized가 너무 획일화된 기능을 제공하고 제약이 많은 상황을 타개하기 위해 좀 더 커스터마이징 된 기능을 사용하고자 한다.
그러기 위해서는 대기 상황을 BLOCKED 로 두면 안된다.
BLOCKED VS WAITING
BLOCKED가 깨는 조건은 락을 다시 획득할 수 있을 상황으로 단 하나이다. 즉 BLOCKED는 LOCK에 관련한, 동기화를 위한 상태이다. 그렇기에 명확하다. BLOCKED 상태를 인지한다면 우리는 이것이 "아 이 쓰레드는 락을 얻기 위해 대기중이구나"를 바로 파악할 수 있다. 반면 WAITING은 다양한 방면에서 사용된다. 더욱 자유롭다. 인터럽트를 걸어 RUNNABLE이 되도록 할 수 있다. BLOCKED, WAITING 둘 다 대기상태를 말하지만, BLOCKED는 완전히 LOCK의 유무에 종속되어있고 WAITING은 그렇지 않다. 그렇기에 더욱 세부적인 대기상태 탈출을 위해 WAITING을 사용할 수 있다.
쓰레드가 lock을 획득하고 다른 쓰레드는 lock이 획득할 수 없어 대기해야 하는 상황을 구현해야한다. 하지만 대기 상태가 BLOCKED가 아닌 WAITING이어야 한다.
쓰레드를 WAITING 상태로 만들기 위해 우리는 LockSupport.park()와 같은 기능을 사용할 수 있다.
하나의 쓰레드가 락을 얻어 열심히 임계영역을 실행중이고 N개의 쓰레드가 이 락을 얻기 위해 어디선가 대기를 취하며 WAITING 상태로 존재한다. 그리고 락이 다시 인스턴스로 돌아왔을때 WAITING된 여러 스레드중 하나를 깨워 RUNNABLE로 만드는 것, 이 논리를 직접 구현하기는 매우 어려울 것이다.
그래서 자바는 Lock 인터페이스와 ReentrantLock 구현체(주로 사용)를 제공한다.
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
기존 synchronized의 문제들 중 하나는 공정성에 관한 것이었다.
어떤 공정성 문제가 존재할까?
이것을 Lock 인터페이스를 활용하면 쉽게 해결할 수 있는데,
public class ReentrantLockEx {
// 비공정 모드 락
private final Lock nonFairLock = new ReentrantLock();
// 공정 모드 락
private final Lock fairLock = new ReentrantLock(true);
구현체 Lock에 생성자로 true를 넣어주면 공정 모드가 된다.
이는 먼저 들어온 쓰레드부터 처리하도록 한다. 비공정 모드가 디폴트이다. 다만 true로 하여금 공정 모드로 사용한다면 성능이 저하될 수 있다.
사실 보통은 디폴트인 비공정 모드로 사용한다. 그 이유는 공정성을 해치는 방향의 쓰레드 실행 스케줄링이 그렇게 자주 일어나지 않기 때문이다. 스케줄링 자체가 큐와 같은 로직으로 작동하기에 순서성을 크게 해치지 않는다. 다만 순서을 강력하게 지켜야한다면 공정모드를 사용할 수 있을 것이다.
무한 대기문제는 tryLock(시간 정보)이나 lockInterruptibly()로 하여금 시간 제한과 인터럽트를 활용해서 다양한 선택권을 준다.
@Override
private final Lock lock = new ReentrantLock();
public boolean withdraw(int amount) {
log("거래 시작: " + getClass().getSimpleName());
lock.lock(); // ReentrantLock 이용 lock 걸기
try {
// 잔고가 출금액 보다 적으면, 진행하면 안됨
log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
if (balance < amount) {
log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
return false;
}
// 잔고가 출금액 보다 많으면, 진행
log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
sleep(1000); // 출금에 걸리는 시간으로 가정
balance = balance - amount;
log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
} finally {
lock.unlock();
}
log("거래 종료");
return true;
}
이전의 synchronized를 활용했던 withdraw로직을 위와 같이 수정할 수 있다. Lock 구현체를 하나 생성해서 임계영역을 lock.lock(),lock.unlock()으로 둘러 싸주면 된다. try문을 활용한 이유는 lock.lock() 호출시 어떤 일이 있어도(finally) lock.unlock()을 호출해야하기 때문이다.
이 로직은 개발자가 간단하게 lock.lock()과 lock.unlock()을 작성하면 되지만 내부에서는 락을 얻지 못할 경우 WAITING으로 만드는 LockSupport.park()와 락을 반납하고 대기 큐의 스레드를 하나 깨우는 unpark(thread)등이 모두 진행된다.
@Override
public boolean withdraw(int amount) {
log("거래 시작: " + getClass().getSimpleName());
try {
if (!lock.tryLock(500, TimeUnit.MILLISECONDS)) {
log("[진입 실패] 이미 처리중인 작업이 있습니다.");
return false;
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
try {
// 잔고가 출금액 보다 적으면, 진행하면 안됨
log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
if (balance < amount) {
log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
return false;
}
// 잔고가 출금액 보다 많으면, 진행
log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
sleep(1000); // 출금에 걸리는 시간으로 가정
balance = balance - amount;
log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
} finally {
lock.unlock();
}
log("거래 종료");
return true;
}
위와 같이 tryLock은 시간제한(타임아웃)을 걸 수 있고 시간제한을 걸지 않았다면 Lock의 존재성을 확인하고 없다면 대기상태에 들어가지 않고 해당 메서드를 바로 빠져나온다.
스레드는 시간제한에 의해 대기상태에 들어갈 경우 TIME_WAITING 상태로 존재한다. 그리고 메서드를 빠져나오면 RUNNABLE이 된다.