멀티스레드와 동시성

Ch06. 동기화 - 동시성 문제

webmaster 2024. 8. 10. 15:53
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의 변경된 값을 즉시 확인해도 여전히 같은 문제가 발생한다. 문제는 메모리 가시성 문제를 해결해도 여전히 발생한다.

 

동시성 문제

t1, t2 순서로 실행 가정

t1  아주 약간 빠르게 실행되는 경우를 먼저 알아보자

실행 결과 분석 - 시간의 흐름1

  • "t1"이 약간 먼저 실행되면서, 출금을 시도한다.
  • "t1"이 출금 코드에 있는 검증 로직을 실행한다. 이때 잔액이 출금 액수보다 많은지 확인한다.
    • 잔액[1000]이 출금액[800] 보다 많으므로 검증 로직을 통과한다.

실행 결과 분석 - 시간의 흐름2

  • t1  : 출금 검증 로직을 통과해서 출금을 위해 잠시 대기 중이다. 출금에 걸리는 시간으로 생각하자.
  • t2  : 검증 로직을 실행한다. 잔액이 출금 금액보다 많은지 확인한다. 잔액[1000] 출금액[800] 보다 많으므로 통과한다
  • 바로 이 부분이 문제다! t1이 아직 잔액(balance)을 줄이지 못했기 때문에 t2는 검증 로직에서 현재 잔액을 1000원으로 확인한다.
    • t1 검증 로직을 통과하고 바로 잔액을 줄였다면 이런 문제가 발생하지 않겠지만, t1 검증 로직을 통과하고 잔액을 줄이기도 전에 먼저 t2가 검증 로직을 확인한 것이다.
    • 그렇다면 sleep(1000) 코드를 빼면 되지 않을까? 이렇게 하면 t1 검증 로직을 통과하고 바로 잔액을 줄일 있을 것 같다.
      • 하지만 t1 검증 로직을 통과하고 balance = balance - amount를계산하기 직전에 t2 실행 되면서 검증 로직을 통과할 수도 있다. sleep(1000) 단지 이런 문제를 쉽게 확인하기 위해 넣었을 뿐이다.

실행 결과 분석 - 시간의 흐름3

  • 결과적으로 t1 , t2 모두 검증 로직을 통과하고, 출금을 위해 잠시 대기 중이다. 출금에 걸리는 시간으로 생각하자

실행 결과 분석 - 시간의 흐름4

  • t1 은 800원을 출금하면서, 잔액을 1000원에서 출금 액수인 800원만큼 차감한다. -> 이제 계좌의 잔액은 200원 이 된다.

실행 결과 분석 - 시간의 흐름5

  • t2는 800원을 출금하면서, 잔액을 200원에서 출금 액수인 800원만큼 차감한다. -> 이제 잔액은 -600원이 된다.

실행 결과 분석 - 시간의 흐름6

  • t1: 800원 출금 완료, t2 : 800원 출금 완료
  • 처음 원금은 1000원이었는데, 최종잔액은 -600원이 된다.
  • 은행 입장에서 마이너스 잔액이 있으면 안 된다!

t1, t2가 동시에 실행 가정

실행 결과 분석 - 시간의 흐름1

  • t1 , t2는 동시에 검증 로직을 실행한다. 잔액이 출금 금액보다 많은지 확인한다.
    • 잔액[1000]이 출금액[800] 보다 많으므로 둘 다 통과한다.

실행 결과 분석 - 시간의 흐름2

  • 결과적으로 t1 , t2 모두 검증 로직을 통과하고, 출금을 위해 잠시 대기 중이다. 출금에 걸리는 시간으로 생각하자.

실행 결과 분석 - 시간의 흐름3

  • t1  은 800원을 출금하면서, 잔액을 1000원에서 출금 액수인 800원 만큼 차감한다. -> 이제 잔액은 200원이 된다.
  • t2  은 800원을 출금하면서, 잔액을 1000원에서 출금 액수인 800원 만큼 차감한다. -> 이제 잔액은 200원이 된다.
  • t1 , t2 동시에 실행되기 때문에 둘 다 잔액(balance) 확인하는 시점에 잔액은 1000원이다!
  • t1 , t2 둘다 동시에 계산된 결과를 잔액에 반영하는데, 둘다 계산 결과인 200원을 반영하므로 최종 잔액은200원이 된다.
balance = balance - amount;
  • 계산의 위해 balanace 값을 조회할 때, t1, t2 두 스레드가 동시에 x001.balance의 필드 값을 읽는다.
    • 이때, 값은 "1000"이다. 따라서 두 스레드는 모두 잔액을 1000원으로 인식한다.
  • 두 값을 계산하기 위해 두 스레드 모두 1000 - 800을 계산해서 200이라는 결과를 만든다.
  • 계산 결과를 왼쪽의 balance 변수에 저장하기 위해 두 스레드 모두 "balance = 200"을 대입한다.

실행 결과 분석 - 시간의 흐름4

  • t1  : 800원 출금완료, t2  : 800원 출금완료 
  • 원래 원금이 1000원이었는데, 최종 잔액은 200원이 된다.
  • 은행 입장에서 보면 총 1600원이 빠져나갔는데, 잔액은 800원만 줄어들었다. 800원이 감쪽같이 어디론가 사라 진 것이다!
728x90