멀티스레드와 동시성

Ch06. 동기화 - 출금

webmaster 2024. 8. 11. 23:18
728x90

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

출금 예제

BankAccount(인터페이스)

은행이 제공해야 하는 메서드(인터페이스)

  • BankAccount 인터페이스이다. 앞으로 이 인터페이스의 구현체를 점진적으로 발전시키면서 문제를 해결할 예정이다.
  • withdraw(amount) : 계좌의 돈을 출금한다. 출금할 금액을 매개변수로 받는다.
    • 계좌의 잔액이 출금할 금액보다 많다면 출금에 성공하고, true 를 반환한다.
    • 계좌의 잔액이 출금할 금액보다 적다면 출금에 실패하고, false 를 반환한다.
  • getBalance() : 계좌의 잔액을 반환한다.

BankAccountV1(동시성 문제가 발생하는 예제)

실행 코드

  • BankAccountV1은 BankAccount 인터페이스를 구현한다.
  • 생성자를 통해 계좌의 초기 잔액를 저장한다.
  • int balance : 계좌의 잔액 필드
  • withdraw(amount) : 검증과 출금 2가지 단계로 나누어진다.
    • 검증 단계: 출금액과 잔액을 비교한다. 만약 출금액이 잔액보다 많다면 문제가 있으므로 검증에 실패하고, false 를 반환한다.
    • 출금 단계: 검증에 통과하면 잔액이 출금액보다 많으므로 출금할 있다. 잔액에서 출금액을 빼고 출금을 완료하면, 성공이라는 의미의 true 반환한다.
  • getBalance() : 잔액을 반환한다.

출금을 담당하는 구현체(스레드의 작업 단위)

실행 코드

  • 출금을 담당하는 Runnable 구현체이다. 생성시 출금할 계좌( account )와 출금할 금액( amount )을 저장해 둔다.
  • run() 통해 스레드가 출금을 실행한다.

Main 코드

실행코드
실행 결과

  • new BankAccountV1(1000)을 통해 초기 잔액을 1000 원으로 설정한다.
  • main 스레드는 t1 , t2 스레드를 만든다. 만든 스레드들은 같은 계좌에 각각 800 원의 출금을 시도한다.
  • main 스레드는 join()을 사용해서 t1 , t2 스레드가 출금을 완료한 이후에 최종 잔액을 확인한다.

실행코드 분석 - 시간의 흐름

실행코드 분석 - 시간의 흐름1

  • 각각의 스레드의 스택에서 run() 이 실행된다.
  • t1  스레드는 WithdrawTask(x002) 인스턴스의 run()을 호출한다. 
  • t2  스레드는 WithdrawTask(x003) 인스턴스의 run() 을 호출한다.
  • 스택 프레임의 this 에는 호출한 메서드의 인스턴스 참조가 들어있다.
  • 스레드는 같은 계좌( x001 ) 대해서 출금을 시도한다.

실행코드 분석 - 시간의 흐름2

  • t1 스레드의 run()에서 withdraw()를 실행한다.
  • 거의 동시에 t2 스레드의 run() 에서 withdraw() 를 실행한다.
  • t1 스레드와 t2 스레드는 같은 BankAccount(x001) 인스턴스의 withdraw() 메서드를 호출한다.
  • 따라서 두 스레드는 같은 BankAccount(x001) 인스턴스에 접근하고 또 x001 인스턴스에 있는 잔액(balance) 필드도 함께 사용한다.

동시성 문제

  • 이 시나리오는 악의적인 사용자가 2대의 PC에서 동시에 같은 계좌의 돈을 출금한다고 가정한다.
  • t1 , t2 , 스레드는 거의 동시에 실행되지만, 아주 약간의 차이로 t1 스레드가 먼저 실행되고, t2 스레드가 그 다음에 실행된다고 가정하겠다.
  • 처음 계좌의 잔액은 1000원이다. t1 스레드가 800원을 출금하면 잔액은 200원이 남는다.
  • 이제 계좌의 잔액은 200원이다. t2 스레드가 800원을 출금하면 잔액보다 더 많은 돈을 출금하게 되므로 출금에 실패해야 한다.

-> 그런데 실행 결과를 보면 기대와는 다르게 t1 , t2는는 각각 800원씩 1600 출금에 성공한다. 계좌의 잔액는 -600 원이 되어있고, 계좌는 예상치 못하게 마이너스 금액이 되어버렸다. 악의적인 사용자는 2대의 PC 통해 자신의 계좌에 있는 1000 보다 많은 금액인 1600 출금에 성공한다. 분명히 계좌를 출금할 잔고를 체크하는 로직이 있는데도 불구하고, 이런 문제가 발생했을까?

 

참고: balance 값에 volatile을 도입하면 문제가 해결되지 않을까? 그렇지 않다. volatile 은 한 스레드가 값을 변경했을 때 다른 스레드에서 변경된 값을 즉시 볼 수 있게 하는 메모리 가시성의 문제를 해결할 뿐이다. 예를 들어 t1 스레드가 balance의 값을 변경했을 때, t2 스레드에서 balance 의 변경된 값을 즉시 확인해도 여전히 같은 문제가 발생한다. 문제는 메모리 가시성 문제를 해결해도 여전히 발생한다.

728x90