멀티스레드를 사용할 때 가장 주의해야 할 점은, 같은 자원(리소스)에 여러 스레드가 동시에 접근할 때 발생하는 동시성 문제이다. 참고로 여러 스레드가 접근하는 자원을 공유 자원이라 한다. 대표적인 공유 자원은 인스턴스의 필드(멤버 변 수)이다.
멀티스레드를 사용할 때는 이런 공유 자원에 대한 접근을 적절하게 동기화(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 스레드가 출금을 완료한 이후에 최종 잔액을 확인한다.
실행코드 분석 - 시간의 흐름

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

- 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 이 아주 약간 빠르게 실행되는 경우를 먼저 알아보자

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

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

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

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

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

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

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

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

- 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"을 대입한다.

- t1 : 800원 출금완료, t2 : 800원 출금완료
- 원래 원금이 1000원이었는데, 최종 잔액은 200원이 된다.
- 은행 입장에서 보면 총 1600원이 빠져나갔는데, 잔액은 800원만 줄어들었다. 800원이 감쪽같이 어디론가 사라 진 것이다!
'멀티스레드와 동시성' 카테고리의 다른 글
| Ch06. 동기화 - synchronized (0) | 2024.08.11 |
|---|---|
| Ch06. 동기화 - 임계 영역 (0) | 2024.08.10 |
| Ch05. 메모리 가시성 - 자바 메모리 모델 (0) | 2024.08.08 |
| Ch05. 메모리 가시성 - volatile 예시 (0) | 2024.08.08 |
| Ch05. 메모리 가시성 - volatile, 메모리 가시성 (0) | 2024.08.08 |