동기화 - synchronized

이동건 (불꽃냥펀치)·2024년 12월 31일
0

출금 예제 - 1

멀티 스레드를 사용할 때 가장 주의해야 할 점은 같은 자원(리소스)에 여러 스레드가 동시에 접근할 때 발생하는 동시성 문제이다. 참고로 여러 스레드가 접근하는 자원을 공유 자원이라한다. 대표적인 공유자구언은 인스턴스의 필드(멤버변수)이다. 멀티스레드를 사용할 때는 이런 공유 자원에 대한 접근을 적절하게 동기화해서 동시성 문제가 발생하지 않게 방지하는 것이 중요하다.

동시성 문제가 무엇인지 이해하기 위해 은행 예제를 하나 만들어보겠다.

 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은 한 스레드가 값을 변경했을 때 다른 스레드에서 변경된 값을 즉시 볼수 있게하는 메모리 가시성의 문제를 해결할 뿐이다.


동시성 문제

문제 1: t1이 t2보다 빨리 실행되었을 때

  • t1이 약간 먼저 실행되면서 출금을 시도한다.
  • t1이 출금 코드에 있는 검증 로직을 실행한다.
  • t1은 출금 검증 로직을 통과해서 출금을 위해 잠시 대기중이다.
  • t2는 검증 로직을 실행하여 잔액이 출금 금액보다 많은지 확인한다. 이때 아직 t1에서 출금 되지 않았으므로 잔액은 1000원이다.
  • 이로 인해 t1은 출금을하여 잔액은 200원이 남고, t2역 검증 로직을 통과해 출금을 실행한다.
  • 그 결과 최종적으로 -600원이 잔액이 된다.

문제 2: t1이 t2가 동시에 실행되었을 때

  • t1,t2는 동시에 검증 로직을 실행한다. 잔액이 출금 금액보다 많은지 확인한다.
  • 결과적으로 t1,t2 모두 검증 로직을 통과하고, 출금을 위해 잠시 대기중이다.
  • t1,t2가 동시에 실행되기 때문에 둘다 잔액을 확인하는 시점에 잔액은 1000원이다.
  • t1은 800원을 출금하면서 잔액이 200원이 된다.
  • t2 역시 800원을 출금하면서 잔액이 200원이 된다.
  • 1600원이 빠져나갔는데 잔액은 200원이 남는 기묘한 상황이 된다.


임계영역

이런 문제점들이 발생하는 근본 원인은 여러 스레드가 함께 사용하는 공유 자원을 여러 단계로 나누어 사용하기 때문이다.

  • 검증단계: 잔액이 출금액보다 많은지 확인한다.
  • 출금단계: 잔액을 출금액 만큼 줄인다.

이 로직에는 하나의 가정이 있어야 성립할 수 있다. 그건 중간에 잔액이 바뀌지 않아야 한다는 점이다.
그런데 만약 중간에 다른 스레드가 잔액의 값을 변경한다면 큰 혼란이 발생한다. 1000원이라 생각한 잔액이 다른 값으로 변경되며 전혀 다른 값으로 계산될 수 있다.


공유 자원

잔액은 여러 스레드가 함께 사용하는 공유 자원이다. 따라서 출금 로직을 수행하는 중간에 다른 스레드에서 이값을 얼마든지 변경 할 수 있다.


임계 영역

  • 여러 스레드가 동시에 접근하면 데이터 불일치나 예상치 못한 동작이 발생할 수 있는 위험하고 중요한 코드 부분을 의미한다.
  • 여러 스레드가 동시에 접근해서는 안되는 공유 자원을 접근하거나 수정하는 부분을 의미한다.


출금 예제 - 2

  @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 키워드가 있는 메서드에 진입하려면 반드시 해당 인스턴스의 락이 있어야한다.

  • t1이 먼저 실행된다고 가정해보자.
  • 스레드 t1이 먼저 synchronized 키워드가 있는 withdraw()메서드를 호출한다.
  • synchronized 메서드를 호출하려면 먼저 해당 인스턴스의 락이 필요하다.
  • 락이 있으므로 스레드 t1BankAccount(x001) 인스턴스에 있는 락을 획득한다.

  • 스레드 t1 은 해당 인스턴스의 락을 획득했기 때문에 withdraw() 메서드에 진입할 수 있다.
  • 스레드 t2withdraw() 메서드 호출을 시도한다. synchronized 메서드를 호출하려면 먼저 해당 인스턴 스의 락이 필요하다.
  • 스레드 t2BankAccount(x001) 인스턴스에 있는 락 획득을 시도한다. 하지만 락이 없다. 이렇게 락이 없 으면 t2 스레드는 락을 획득할 때 까지 BLOCKED 상태로 대기한다.
  • t2 스레드의상태는 RUNNABLE BLOCKED 상태로변하고,락을획득할때까지무한정대기한다.

  • t1 : 메서드 호출이 끝나면 락을 반납한다.
  • t2 : 인스턴스에 락이 반납되면 락 획득을 대기하는 스레드는 자동으로 락을 획득한다.
    • 이때락을획득한스레드는 BLOCKED RUNNABLE 상태가되고,다시코드를실행한다.

락을 획득하는 순서는 보장되지 않는다.

출처: https://www.inflearn.com/course/%EA%B9%80%EC%98%81%ED%95%9C%EC%9D%98-%EC%8B%A4%EC%A0%84-%EC%9E%90%EB%B0%94-%EA%B3%A0%EA%B8%89-1/dashboard

profile
자바를 사랑합니다

0개의 댓글

관련 채용 정보