멀티 스레드를 사용할 때 가장 주의해야 할 점은 같은 자원(리소스)에 여러 스레드가 동시에 접근할 때 발생하는 동시성 문제이다. 참고로 여러 스레드가 접근하는 자원을 공유 자원이라한다. 대표적인 공유자구언은 인스턴스의 필드(멤버변수)이다. 멀티스레드를 사용할 때는 이런 공유 자원에 대한 접근을 적절하게 동기화해서 동시성 문제가 발생하지 않게 방지하는 것이 중요하다.
동시성 문제가 무엇인지 이해하기 위해 은행 예제를 하나 만들어보겠다.
public interface BankAccount {
boolean withdraw(int amount);
int getBalance();
}
withdraw(amount)
: 계좌의 돈을 출금한다.true
를 반환받음false
를 반환받음getBalance()
: 계좌의 잔액을 반환한다.public class BankAccountV1 implements BankAccount {
private int balance;
//volatile private int balance;
public BankAccountV1(int initialBalance) {
this.balance = initialBalance;
}
@Override
public boolean withdraw(int amount) {
log("거래 시작: " + getClass().getSimpleName());
log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
if (balance < amount) {
log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
return false;
}
log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
sleep(1000); // 출금에 걸리는 시간으로 가정
balance = balance - amount;
log("[출금 완료] 출금액: " + amount + ", 변경 잔액: " + balance);
log("거래 종료");
return true;
}
@Override
public int getBalance() {
return balance;
}
}
public class WithdrawTask implements Runnable {
private BankAccount account;
private int amount;
public WithdrawTask(BankAccount account, int amount) {
this.account = account;
this.amount = amount;
}
@Override
public void run() {
account.withdraw(amount);
}
}
Runnable
구현체이다. run()
을 통해 스레드가 출금을 실행한다.이 스레드를 실행해보면 출금 금액이 부족한 상황인데도 돈이 빠져나가 -xxx
원이 최종 결과값으로 나온다. 분명히 계좌를 출금할 때 잔고를 체크라는 로직이 읶는데도 불구하고 왜 이런 문제가 발생했을까?
참고로 balance
값에 volatile
을 도입하면 문제가 해결될거 같은 데, 실제로는 그렇지 않다.
volatile
은 한 스레드가 값을 변경했을 때 다른 스레드에서 변경된 값을 즉시 볼수 있게하는 메모리 가시성의 문제를 해결할 뿐이다.
이런 문제점들이 발생하는 근본 원인은 여러 스레드가 함께 사용하는 공유 자원을 여러 단계로 나누어 사용하기 때문이다.
검증단계:
잔액이 출금액보다 많은지 확인한다.출금단계:
잔액을 출금액 만큼 줄인다.이 로직에는 하나의 가정이 있어야 성립할 수 있다. 그건 중간에 잔액이 바뀌지 않아야 한다는 점이다.
그런데 만약 중간에 다른 스레드가 잔액의 값을 변경한다면 큰 혼란이 발생한다. 1000원이라 생각한 잔액이 다른 값으로 변경되며 전혀 다른 값으로 계산될 수 있다.
공유 자원
잔액은 여러 스레드가 함께 사용하는 공유 자원이다. 따라서 출금 로직을 수행하는 중간에 다른 스레드에서 이값을 얼마든지 변경 할 수 있다.
임계 영역
@Override
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 = balance - amount;
log("[출금 완료] 출금액: " + amount + ", 변경 잔액: " + balance);
log("거래 종료");
return true;
}
@Override
public synchronized int getBalance() {
return balance;
}
BankAccountV1
와 같은데, withdraw()
, getBalance()
코드에 synchronized
키워드가 추가되었 다.withdraw()
, getBalance()
메서드는 한 번에 하나의 스레드만 실행할 수 있다.synchronized
키워드가 있는 메서드에 진입하려면 반드시 해당 인스턴스의 락이 있어야한다.synchronized
키워드가 있는 withdraw()
메서드를 호출한다.synchronized
메서드를 호출하려면 먼저 해당 인스턴스의 락이 필요하다.t1
은 BankAccount(x001)
인스턴스에 있는 락을 획득한다.t1
은 해당 인스턴스의 락을 획득했기 때문에 withdraw()
메서드에 진입할 수 있다.t2
도 withdraw()
메서드 호출을 시도한다. synchronized
메서드를 호출하려면 먼저 해당 인스턴 스의 락이 필요하다.t2
는 BankAccount(x001)
인스턴스에 있는 락 획득을 시도한다. 하지만 락이 없다. 이렇게 락이 없 으면 t2
스레드는 락을 획득할 때 까지 BLOCKED
상태로 대기한다.t2
스레드의상태는 RUNNABLE
BLOCKED
상태로변하고,락을획득할때까지무한정대기한다.t1
: 메서드 호출이 끝나면 락을 반납한다.t2
: 인스턴스에 락이 반납되면 락 획득을 대기하는 스레드는 자동으로 락을 획득한다. BLOCKED
RUNNABLE
상태가되고,다시코드를실행한다.락을 획득하는 순서는 보장되지 않는다.