티스토리 뷰
멀티 스레드를 사용할 때 가장 주의해야 할 점은, 같은 자원(리소스)에 여러 스레드가 동시에 접근할 때 발생하는 동시성 문제이다. 참고로 여러 스레드가 접근하는 자원을 공유 자원 이라고 한다. 대표적인 공유 자원은 인스턴스의 필드(멤버 변수)이다.
코드로 동시성 문제를 확인해보자.
가장 이해하기 쉬운 은행 출금 예제를 만들어보겠다.
public interface BankAccount {
boolean withdraw(int amount);
int getBalance();
}
```
- withdraw(int amount) : 계좌에 돈을 출금한다. 출금할 금액을 매개변수로 받는다.
- 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 구현체이다. 생성 시 출금할 계좌 (account)와 출금할 금액(amount)을 저장해 둔다.
run()을 통해 스레드가 출금을 진행한다.
public class BankMain {
public static void main(String[] args) throws InterruptedException {
BankAccount account = new BankAccountV1(1000);
Thread t1 = new Thread(new WithdrawTask(account, 800), "t1");
Thread t2 = new Thread(new WithdrawTask(account, 800), "t2");
t1.start();
t2.start();
sleep(500); // 검증 완료까지 잠시 대기
log("t1 state: " + t1.getState());
log("t2 state: " + t2.getState());
t1.join();
t2.join();
log("최종 잔액: " + account.getBalance());
}
}
메인 메서드다. 1000원을 저장하고, 두 개의 쓰레드가 계좌에서 800원씩 출금을 시도한다.
실행 전에 생각을 해보면 체크로직도 존재하니 별 문제가 생길것 같지는 않다.
실행결과를 확인해보자
결과는 -600원이 된다. 우리 코드는 현재 조건문을 체크로직을 만들었는데도, 우리 생각대로 작동하지않았다.
if (balance < amount) {
log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
return false;
}
이게 바로 동시성 문제이다.
왜 이런 문제가 발생할까?
쓰레드 t1,t2 순서로 실행됬다는 가정으로 분석해보자
t1원이 아주 약간 더 빠르게 실행되는 경우를 먼저 알아보자
t1이 약간 먼저 실행되면서, 출금을 시도한다.
t1이 출금 코드에 있는 검증 로직을 실행한다. 이 때 잔액이 출금 액수보다 많은지 확인한다.
잔액은 1000원 이므로 출금액 800원 보다 많으므로 검증 로직을 통과한다.
t1 출금 검증 로직을 통과해서 출금을 위해 잠시 대기중이다. (sleep(1000))
t2 검증 로직을 실행한다. 잔액이 출금 금액보다 많은지 확인한다.
잔액 1000원이 출금액 800원 보다 많으므로 통과한다
이러한 문제로 우리는 -600원의 결과를 받았던 것이다.
그럼 t1과 t2 스레드가 동시에 작동한다면 어떻게 될까?
그렇다면 매순간마다 같은 코드를 실행하므로 t1스레드가 800원 출금, t2스레드가 800원을 출금하더라도 잔액은 200원이 남았을 것이다.
공유 자원 (Shared Resource)
우리의 계좌 잔액은 여러 쓰레드가 접근하는 공유 자원이다.
따라서 출금 로직을 수행하는 중간에 다른 스레드에서 이 값을 언제든지 변경할 수 있다.
임계 영역 (Critical section)
여러 스레드가 동시에 접근하면 데이터의 불일치나 예상치 못한 동작이 발생할 수 있는 위험하고 중요한 코드 부분을 뜻한다. 위의 코드에서 임계영역은 어디일까?
우리가 살펴본 출금() 로직은 임계영역이다.
더 자세히는 출금을 진행할 때 잔액을 검증하는 단계부터 잔액의 계산을 완료 할 때 까지가 임계 영역이다.
해결방법
그럼 해결 방법은 뭘까?
간단하다. 공유 자원의 리소스를 변경하는 임계 영역을 하나의 스레드만 작업을 할 수 있게 변경하면 된다.
자바는 synchronzied 키워드를 통해 아주 간단하게 임계 영역을 보호 할 수 있다.
@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;
}
}
실행결과를 확인해보면 t1이 시작하고 종료 될 때 까지 t2는 BLOCKED 상태로 대기한다.
(t1의 TIMED_WAITING은 sleep(1000)에 의해 변경된 것)
그림을 통해 분석해보자
모든 객체(인스턴스)는 내부에 자신만의 락 (Lock)을 가지고 있다. (모니터 락 이라고 하기도한다.)
스레드가 synchronzied 키워드가 있는 메서드에 진입하려면 반드시 해당 인스턴스의 락이 있어야 한다.
여기서는 BankAccount 인스턴스의 synchronized withdraw() 메서드를 호출하므로 이 인스턴스의 락이 필요하다.
t1이 먼저 실행된다고 가정하겠다.
스레드 t1이 먼저 synchronized 키워드가 있는 withdraw() 메서드를 호추한다.
synchronized 메서드를 호출 하려면 먼저 해당 인스턴스의 락이 필요하다.
현재 인스턴스에 락이 존재하므로 스레드 t1은 BankAccount 인스턴스에 있는 락을 흭득한다.
스레드를 흭득한 t1은 withdraw() 메서드에 진입할 수 있다.
스레드 t2도 withdraw() 메서드 호출 시도하지만, 현재 BankAccount 인스턴스의 락은 t1이 가지고 있으므로,
t2스레드는 락을 흭득할 때까지 BLOCKED 상태로 대기한다. ( RUNNABLE -> BLOCKED )
참고: BLOCKED 상태가 되면 락을 다시 흭득하기 전까지는 계속 대기하고, CPU 실행 스케줄링에 들어가지 않는다.
스레드 t1이 로직을 종료하고 락을 반납하면 이제 t2이 락을 흭득한다. ( BLCOKED -> RUNNABLE )
t2는 검증로직을 만족하지 못하므로 false를 반환한다.
참고로 락을 흭득하는 순서는 보장되지 않는다. 즉 두 개의 스레드가 아니라 수 십개의 스레드가 존재하더라도 락을 흭득하는건 한 개의 스레드이며, 다른 스레드들은 BLOCKED 상태로 대기하다가 락을 흭득하고있던 스레드가 락을 반납하면 대기중이던 스레드들 중 하나의 스레드가 락을 흭득하고 작업을 수행한다. ( 오래 대기한 스레드가 흭득할 가능성은 큼)
또한 앞서 포스팅한 메모리 가시성문제를 synchronized 안에서 접근하는 변수의 메모리 가시성 문제는 해결된다.
그리고 앞서 메서드 전체를 synchronized 거는 것 뿐만아니라 특정 코드 영역만 synchronized 를 걸 수도있다.
@Override
public boolean withdraw(int amount) {
log("거래 시작: " + getClass().getSimpleName());
synchronized (this) {
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;
}
}
'Java' 카테고리의 다른 글
Java - 고급동기화 (ReentrantLock) (0) | 2024.09.04 |
---|---|
Java - 고급 동기화 (LockSupport) (3) | 2024.09.04 |
Java - 메모리 가시성 (0) | 2024.08.28 |
Java - Thread Join (0) | 2024.08.08 |
Java - Thread 제어와 생명 주기 (0) | 2024.08.08 |