멀티스레드를 사용할 때 가장 주의해야 할 점은, 같은 자원(리소스)에 여러 스레드가 동시에 접근할 때 발생하는 동시성 문제이다. 참고로 여러 스레드가 접근하는 자원을 공유 자원이라 한다. 대표적인 공유 자원은 인스턴스의 필드(멤버 변 수)이다.
멀티스레드를 사용할 때는 이런 공유 자원에 대한 접근을 적절하게 동기화(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 의 변경된 값을 즉시 확인해도 여전히 같은 문제가 발생한다. 이 문제는 메모리 가시성 문제를 해결해도 여전히 발생한다.
'멀티스레드와 동시성' 카테고리의 다른 글
| Ch06. 동기화 - Synchronized 정리 (0) | 2024.08.12 |
|---|---|
| Ch06. 동기화 - 문제와 풀이 (0) | 2024.08.12 |
| Ch06. 동기화 - synchronized (0) | 2024.08.11 |
| Ch06. 동기화 - 임계 영역 (0) | 2024.08.10 |
| Ch06. 동기화 - 동시성 문제 (0) | 2024.08.10 |