바로 이 부분이 문제인데, t1이 아직 잔액(balance)을 줄이지 못했기 때문에 t2는 검증 로직에서 현재 잔액을 1000원으로 확인하고 검증로직을 통과하게 됨
t1이 검증 로직을 통과하고 바로 잔액을 줄였다면 이런 문제가 발생하기 않았겠지만 t1이 검증 로직을 통과하고 잔액을 줄이기도 전에 먼저 t2가 검증 로직을 확인한 것임
sleep(1000) 코드를 빼면 정상적으로 동작한다고 예상할 수 있지만 t1이 검증 로직을 통과하고 잔액을 계산하기 직전에 t2가 실행되면서 검증 로직을 통과할 수 있음
sleep(1000) 코드는 단지 이런 문제를 쉽게 확인하기 위해 넣었을 뿐이지 이 코드를 빼도 여전히 동일한 문제는 발생하게 됨
결과적으로 t1, t2 모두 검증로직을 통과한 후 t1이 먼저 800원을 출금하면 계좌의 잔액이 200원이 되고 t2가 이 200원에서 800원을 출금하게 되고 잔액이 -600원이 됨
(2) t1, t2가 동시에 실행된다는 가정
t1, t2가 동시에 검증 로직을 실행하고 둘 다 잔액이 출금액보다 많으므로 통과하여 두 스레드 모두 출금 로직을 수행함
두 스레드가 완전히 동시에 실행되기 때문에 잔액(balance)을 확인하는 시점의 잔액은 1000원임
두 스레드가 출금로직을 실행하면 1000원 - 800원을 계산하고 계산결과를 balance에 반영하여 최종 잔액은 200원이 됨
은행의 입장에서는 t1도 800원을 출금하고, t2도 800원을 출금하여 총 1600원이 출금되었지만 계좌의 원금이 1000원이었고 최종 잔액은 200원이 되어 800원만 줄어들었으므로 800원이 사라지는 결과가 됨
3. 임계 영역
1) 임계 영역
(1) 문제 원인
이 문제가 발생한 근본 원인은 여러 스레드가 함께 사용하는 공유 자원을 여러 단계로 나누어서 사용하기 때문임
출금 로직에서는 검증 단계에서 잔액이 출금액보다 많은지 확인하고 출금 단계에서는 잔액을 출금액만큼 줄이는데 이 로직에는 하나의 큰 가정이 있음
스레드 하나의 관점에서 출금메서드를 보면 검증 단계에서 확인한 잔액은 출금 단계에서 계산을 끝마칠 때까지 같은 금액으로 유지되어야 검증 단계에서 확인한 금액으로 출금 단계에서 정확한 잔액을 계산할 수 있음
결국 여기서는 내가 사용하는 값이 중간에 변경되지 않을 것이라는 가정이 있음
그러나 중간에 다른 스레드가 잔액의 값을 변경하면 큰 혼란이 발생하게 됨
1000원이라 생각한 잔액이 다른 값으로 변경되면 잔액이 전혀 다른 값으로 계산될 수 있음
(2) 공유 자원
잔액(balance)은 여러 스레드가 함께 사용하는 공유 자원이므로 출금 로직을 수행하는 중간에 다른 스레드에서 이 값을 얼마든지 변경할 수 있음
예제에서는 출금 메서드를 호출할 때 잔액의 값이 변경되는데 다른 스레드가 출금 메서드를 호출하면서 사용 중인 출금 값을 중간에 변경해 버릴 수 있음
(3) 한 번에 하나의 스레드만 실행
만약 출금이라는 메서드를 한 번에 하나의 스레드만 실행할 수 있게 제한할 수 있다면 t1, t2 스레드가 함께 출금 메서드를 호출해도 t1 스레드가 먼저 처음부터 끝까지 메서드를 완료하고 그다음 t2 스레드가 처음부터 끝까지 메서드를 완료하게 될 것임
이렇게 하면 공유 자원인 balance를 한 번에 하나의 스레드만 변경할 수 있게 되어 계산 중간에 다른 스레드가 balance의 값을 변경하는 부분을 걱정하지 않아도 됨
즉, 출금을 진행할 때 검증하는 단계에서부터 출금을 완료하는 단계까지 잔액의 값은 중간에 변하면 안 됨
이 두 단계는 한 번에 하나의 스레드만 실행해야 잔액이 중간에 변하지 않고 안전하게 계산을 수행할 수 있음
(4) 임계 영역(critical section)
여러 스레드가 동시에 접근하면 데이터 불일치나 예상치 못한 동작이 발생할 수 있는 위험하고 중요한 코드 부분을 뜻함
여러 스레드가 동시에 접근해서는 안 되는 공유 자원을 접근하거나 수정하는 부분을 의미함
예제에서의 balance 변수는 여러 스레드가 동시에 접근해서는 안되는 공유 자원이며 출금 메서드의 검증 코드에서부터 출금이 완료할 때까지가 임계 영역임
임계 영역은 한 번에 하나의 스레드만 접근할 수 있도록 안전하게 보호해야 하는데 자바는 synchronized 키워드를 통해 아주 간단하게 임계 영역을 보호할 수 있음
4. synchronized 메서드
1) Synchronized 메서드
(1) BankAccountV1
자바의 synchronized 키워드를 사용하면 한 번에 하나의 스레드만 실행할 수 있는 코드 구간을 만들 수 있음
withdraw()와 getBalance() 메서드의 코드에 synchronized 키워드를 추가
package thread.sync;
public class BankAccountV2 implements BankAccount {
// ... 코드 동일 생략
@Override
public synchronized boolean withdraw(int amount) {
// ... 코드 동일 생략
}
@Override
public synchronized int getBalance() {
return balance;
}
}
(2) BankMain 수정
BankMain에서 BankAccountV2를 실행하도록 코드를 변경하고 실행해 보면 먼저 실행된 t1 스레드는 출금이 정상적으로 완료되지만 t2 스레드의 결과는 검증이 실패되어 출금이 되지 않는 것을 확인할 수 있음
로그 출력도 보면 t1 -> t2의 순서대로 차례대로 실행되고 있으며 t2 스레드의 상태가 BLOCKED인 부분도 확인할 수 있음
물론 환경에 따라 t2가 먼저 실행될 수 있으며 이 경우에도 t2가 모두 실행하고 나서 t1이 실행됨
BankAccount account = new BankAccountV2(1000);
/* 적용 후 실행 결과
16:07:36.381 [ t1] 거래 시작: BankAccountV2
16:07:36.386 [ t1] [검증 시작] 출금액: 800, 잔액: 1000
16:07:36.386 [ t1] [검증 완료] 출금액: 800, 잔액: 1000
16:07:36.875 [ main] t1 state: TIMED_WAITING
16:07:36.875 [ main] t2 state: BLOCKED
16:07:37.392 [ t1] [출금 완료] 출금액: 800, 잔액: 200
16:07:37.392 [ t1] 거래 종료
16:07:37.395 [ t2] 거래 시작: BankAccountV2
16:07:37.396 [ t2] [검증 시작] 출금액: 800, 잔액: 200
16:07:37.396 [ t2] [검증 실패] 출금액: 800, 잔액: 200
16:07:37.397 [ main] 최종 잔액: 200
*/
(3) 실행 분석
모든 객체(인스턴스)는 내부에 자신만의 락(lock)을 가지고 있음, 모니터 락(monitor lock)이라고도 부르며 직접 확인하기는 어려움
스레드가 synchronized 키워드가 있는 메서드에 진입하려면 반드시 해당 인스턴스의 락이 있어야 함
스레드 t1이 먼저 실행된다고 가정하면, t1이 먼저 synchronized 키워드가 있는 withdraw() 메서드를 호출하기 때문에 해당 인스턴스의 락을 얻어야 함
현재 BankAccount는 락을 가지고 있으므로 t1은 해당 인스턴스의 락을 획득하고 withdraw() 메서드에 진입할 수 있음
스레드 t2도 withdraw() 메서드를 호출하려고 하면 락이 필요하므로 락 획득을 시도하지만 이미 해당 인스턴스의 락은 t1이 사용하고 있기 때문에 락이 없음
이렇게 락이 없으면 t2 스레드는 락을 획득할 때까지 BLOCKED 상태로 대기하고 락을 획득할 때 까지 무한정 대기함
BLOCKED 상태가 되면 락을 다시 획득하기 전까지 계속 대기하고 CPU 실행 스케줄링에 들어가지 않음
t1 스레드가 검증로직을 통과하고 출금로직을 실행하여 1000원에서 800원을 출금하고 계산 결과인 200원을 잔액에 반영한 뒤에 메서드 호출이 끝나면 락을 반납함
인스턴스에 락이 반납되면 락 획득을 대기하는 스레드는 자동으로 락을 획득하므로 t2스레드는 BLOCKED -> RUNNABLE 상태가 되어 다시 코드를 실행함
t2 스레드가 withdraw() 메서드에 진입하여 검증로직을 수행하면 조건을 만족하지 않으므로 false를 반환하고 락을 반납하면서 return 함
결과적으로 t1은 800원 출금이 완료되고 t2는 잔액 부족으로 출금실패하여 결과적으로 원금 1000원에 최종 잔액이 200원이 되어 기대하는 결과로 동작함
이렇게 자바의 synchronized를 사용하면 한 번에 하나의 스레드만 실행하는 안전한 임계 영역 구간을 편리하게 만들 수 있음
** 참고 - 락을 획득하는 순서는 보장되지 않음
만약 BankAccount 인스턴스의 withdraw()를 수많은 스레드가 동시에 호출한다면 1개의 스레드만 락을 획득하고 나머지는 모두 BLOCKED 상태가 됨
그 이후 인스턴스에 락을 반납하면 해당 인스턴스의 락을 기다리는 수 많은 스레드 중 하나의 스레드만 락을 획득하고 락을 획득한 스레드만 BLOCKED -> RUNNABLE 상태가 됨
이때 어떤 순서로 락을 획득하는지는 자바 표준에 정의되어있지 않으므로 순서를 보장하지 않고 환경에 따라서 순서가 달라질 수 있음
참고로 volatile를 사용하지 않아도 synchronized 안에서 접근하는 변수의 메모리 가시성 문제는 자동으로 해결됨
5. synchronized 코드 블럭
1) synchronized 코드 블럭
(1) 특정 부분에 synchronized 적용
synchronized의 가장 큰 장점이자 단점은 한 번에 하나의 스레드만 실행할 수 있다는 점인데 여러 스레드가 동시에 실행하지 못하므로 전체로 보면 성능이 떨어질 수 있음
따라서 synchronized를 통해 여러 스레드를 동시에 실행할 수 없는 코드 구간은 꼭! 필요한 곳으로 한정해서 설정해야 함
withdraw() 메서드를 보면 처음 로그를 출력하는 거래 시작 부분과 마지막 로그를 출력하는 거래 종료 부분은 공유 자원을 전혀 사용하지 않으므로 이런 부분은 동시에 실행해도 아무런 문제가 발생하지 않음
즉, 실제 임계 영역이 필요한 아래 코드의 주석으로 표시한 부분이지만 메서드 앞에 적용하면 synchronized의 적용 범위가 메서드 전체에 적용됨
자바는 이런 문제를 해결하기 위해서 synchronized를 메서드 단위가 아니라 특정 코드 블럭에 최적화할 수 있는 기능을 제공함
public synchronized boolean withdraw(int amount) {
log("거래 시작: " + getClass().getSimpleName());
// == 임계 영역 시작 ==
log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
if (balance < amount) {
log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
return false;
}
log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
sleep(1000); // 출금에 걸리는 시간
balance -= amount;
log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
// == 임계 영역 끝 ==
log("거래 종료");
return true;
}
(2) BankAccountV3
withdraw() 메서드 앞에 사용하던 synchronized를 제거하고 임계영역을 지정하고 싶은 곳에 synchronized (this) { }로 코드 블럭을 지정하면 꼭 필요한 코드만 안전한 임계 영역으로 만들 수 있음
synchronized (this)에서 괄호 안에 들어가는 값은 획득할 인스턴스의 참조임
getBalance()의 경우에는 코드가 한 줄이므로 메서드에 설정하나 코드 블럭으로 설정하나 같음