멀티스레드와 동시성

Ch08. 생산자 소비자 문제 - 예제3(wait, notify)

webmaster 2024. 8. 23. 22:12
728x90

앞서 설명한 synchronized를 사용한 임계 영역 안에서 락을 가지고 무한 대기하는 문제는 흥미롭게도 Object 클 래스에 해결 방안이 있다. Object 클래스는 이런 문제를 해결할 수 있는 wait() , notify()라는 메서드를 제공한다. Object는 모든 자바 객체의 부모이기 때문에, 여기 있는 기능들은 모두 자바 언어의 기본 기능이라 생각하면 된다.

 

wait(), notify() 설명

  •  Object.wait()
    • 현재 스레드가 가진 락을 반납하고 대기("WAITING")한다.
    • 현재 스레드를 대기("WAITING") 상태로 전환한다. 이 메서드는 현재 스레드가 synchronized 블록이나 메서드에서 락을 소유하고 있을 때만 호출할 수 있다. 호출한 스레드는 락을 반납하고, 다른 스레드가 해당 락을 획득할 수 있도록 한다. 이렇게 대기 상태로 전환된 스레드는 다른 스레드가 notify() 또는 notifyAll()을 호출할 때까지 대기 상태를 유지한다.
  • Object.notify()
    • 대기 중인 스레드 중 하나를 깨운다.
    • 이 메서드는 synchronized 블록이나 메서드에서 호출되어야 한다. 깨운 스레드는 락을 다시 획득할 기회를 얻게 된다. 만약 대기 중인 스레드가 여러 개라면, 그 중 하나만이 깨워지게 된다.
  • Object.notifyAll()
    • 대기 중인 모든 스레드를 깨운다.
    • 이 메서드 역시 synchronized 블록이나 메서드에서 호출되어야 하며, 모든 대기 중인 스레드가 락을 획득할 수 있는 기회를 얻게 된다. 이 방법은 모든 스레드를 깨워야 할 필요가 있는 경우에 유용하다.

wait() , notify() 메서드를 적절히 사용하면, 멀티스레드 환경에서 발생할 있는 문제를 효율적으로 해결할 있다.

실행 코드
실행 코드 - Main
실행 결과 - 생산자 먼저 실행
실행 결과 - 소비자 먼저 실행

  • sleep() 코드를 제거하고, Object.wait()를 사용했다.
    • Object는 모든 클래스의 부모이므로 자바의 모든 객체는 해당 기능을 사용할 수 있다.
  • put(data) - wait(), notify()
    • synchronized를 통해 임계 영역을 설정한다. 생산자 스레드는 락 획득을 시도한다.
    • 락을 획득한 생산자 스레드는 반복문을 사용해서 큐에 빈 공간이 생기는지 주기적으로 체크한다. 만약 빈 공간이 없다면 Object.wait()을 사용해서 대기한다. 참고로 대기할 때 락을 반납하고 대기한다. 그리고 대기 상태에서 깨어나면, 다시 반복문에서 큐의 빈 공간을 체크한다.
    • wait()를 호출해서 대기하는 경우 "RUNNABLE" -> "WAITING" 상태가 된다.
    • 생산자가 데이터를 큐에 저장하고 나면 notify()를 통해 저장된 데이터가 있다고 대기하는 스레드에 알려주어야 한다. 예를 들어서 큐에 데이터가 없어서 대기하는 소비자 스레드가 있다고 가정하자. 이때 notify()를 호출하면 소비자 스레드는 깨어나서 저장된 데이터를 획득할 수 있다.
  • take() - wait(), notify()
    • synchronized를 통해 임계 영역을 설정한다. 소비자 스레드는 락 획득을 시도한다.
    • 락을 획득한 소비자 스레드는 반복문을 사용해서 큐에 데이터가 있는지 주기적으로 체크한다. 만약 데이터가 없다면 Object.wait()을 사용해서 대기한다. 참고로 대기할 때 락을 반납하고 대기한다. 그리고 대기 상태에서 깨어나면, 다시 반복문에서 큐에 데이터가 있는지 체크한다.
    • 대기하는 경우 "RUNNABLE" -> "WAITING" 상태가 된다.
    • 소비자가 데이터를 획득하고 나면 notify()를 통해 큐에 저장할 여유 공간이 생겼다고, 대기하는 스레드에게 알려주어야 한다. 예를 들어서 큐에 데이터가 가득 차서 대기하는 생산자 스레드가 있다고 가정하자. 이때 notify()를 호출하면 생산자 스레드는 깨어나서 데이터를 큐에 저장할 있다.

wait()로 대기 상태에 빠진 스레드는 notify()를 사용해야 깨울 있다. 생산자는 생산을 완료하면 notify()로 대기하는 스레드를 깨워서 생산된 데이터를 가져가게 하고, 소비자는 소비를 완료하면 notify()로 대기하는 스레드를 깨워서 데이터를 생산하라고 하면 된다. 여기서 중요한 핵심은 wait()를 호출해서 대기 상태에 빠질 락을 반납하고 대기 상태에 빠진다는 것이다. 대기 상태에 빠지면어차피 아무 일도하지 않으므로 락도 필요하지 않다.

 

생산자 스레드 먼저 실행 분석

실행 결과 분석 - 초기 상태

22:26:40.571 [     main] == [생산자 먼저 실행] 시작, BoundedQueueV3 == 

22:26:40.575 [     main] 생산자 시작
  • 스레드 대기 집합(wait set)
  • synchronized 임계 영역 안에서 Object.wait()를 호출하면 스레드는 대기("WAITING") 상태에 들어간다. 이렇게 대기 상태에 들어간 스레드를 관리하는 것을 대기 집합(wait set)이라 한다. 참고로 모든 객체는 각자의 대기 집합을 가지고 있다.
  • 모든 객체는 락(모니터 락)과 대기 집합을 가지고 있다. 둘은 한 쌍으로 사용된다. 따라서 락을 획득한 객체의 대기 집합을 사용해야 한다. 여기서는 BoundedQueue(x001) 구현 인스턴스의 락과 대기 집합을 사용한다.
    • synchronized를 메서드에 적용하면 해당 인스턴스의 락을 사용한다여기서는 BoundedQueue(x001)의 구현체이다.
    • wait() 호출은 앞에 this를 생략할  있다. this는 해당 인스턴스를 뜻한다여기서는 BoundedQueue(x001)의 구현체이다.

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

22:26:40.604 [producer1] [생산 시도] data1 -> []
22:26:40.605 [producer1] [put] 생산자 데이터 저장, notify() 호출
  • p1 이 락을 획득하고 큐에 데이터를 저장한다.
  • 큐에 데이터가 추가되었기 때문에 스레드 대기 집합에 이 사실을 알려야 한다.
  • notify()를 호출하면 스레드 대기 집합에서 대기하는 스레드 중 하나를 깨운다.
  • 현재 대기 집합에 스레드가 없으므로 아무 일도 발생하지 않는다. 만약 소비자 스레드가 대기 집합에 있었다면 깨어나서 큐에 들어있는 데이터를 소비했을 것이다.

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

22:26:40.605 [producer1] [생산 완료] data1 -> [data1]

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

22:26:40.695 [producer2] [생산 시도] data2 -> [data1]
22:26:40.695 [producer2] [put] 생산자 데이터 저장, notify() 호출
22:26:40.695 [producer2] [생산 완료] data2 -> [data1, data2]

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

22:26:40.800 [producer3] [생산 시도] data3 -> [data1, data2]
22:26:40.800 [producer3] [put] 큐가 가득 참, 생산자 대기
  • P3가 데이터를 생산하려고 하는데, 큐가 가득 찼다. wait()를 호출한다.

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

22:26:40.906 [     main] 현재 상태 출력, 큐 데이터: [data1, data2]
22:26:40.907 [     main] producer1: TERMINATED
22:26:40.907 [     main] producer2: TERMINATED
22:26:40.908 [     main] producer3: WAITING
  • wait()를 호출하면 락을 반납한다.
    • 스레드의 상태가 "RUNNABLE" -> "WAITING"로 변경된다.
    • 스레드 대기 집합에서 관리된다.
  • 스레드 대기 집합에서 관리되는 스레드는 이후에 다른 스레드가 notify()를 통해 스레드 대기 집합에 신호를 주면 깨어날 있다.

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

22:26:40.908 [     main] 소비자 시작

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

22:26:40.913 [consumer1] [소비 시도]     ? <- [data1, data2]
22:26:40.914 [consumer1] [take] 소비자 데이터 획득, notify() 호출
  • 소비자 스레드가 데이터를 획득했기 때문에 큐에 데이터를 보관할 빈자리가 생겼다.
  • 소비자 스레드는 notify()를 호출해서 스레드 대기 집합에 이 사실을 알려준다.

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

  • 스레드 대기 집합은 notify() 신호를 받으면 대기 집합에 있는 스레드 중 하나를 깨운다.
  • 그런데 대기 집합에 있는 스레드가 깨어난다고 바로 작동하는 것은 아니다. 깨어난 스레드는 여전히 임계 영역 안에 있다.
  • 임계 영역에 있는 코드를 실행하려면 먼저 락이 필요하다. p3는 대기 집합에서는 나가지만 여전히 임계 영역에 있으므로 락을 획득하기 위해 "BLOCKED" 상태로 대기한다.
    • 당연한 이야기지만 임계 영역 안에서 2개의 스레드가 실행되면 큰 문제가 발생한다! 임계 영역 안에서는 락을 가지고 있는 하나의 스레드만 실행되어야 한다.
    • p3 : "WAITING" -> "BLOCKED"
  • 참고로 이때 임계 영역의 코드를 처음으로 돌아가서 실행하는 것은 아니다. 대기 집합에 들어오게 wait()를 호출한 부분부터 실행된다. 락을 획득하면 wait() 이후의 코드를 실행한다.

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

22:26:40.915 [consumer1] [소비 완료] data1 <- [data2]
  • c1 은 데이터 소비를 완료하고 락을 반납하고 임계 영역을 빠져나간다.

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

22:26:40.915 [producer3] [put] 생산자 깨어남
22:26:40.916 [producer3] [put] 생산자 데이터 저장, notify() 호출
  • p3 가 락을 획득한다.
    • "BLOCKED" -> "RUNNABLE"
    • wait() 코드에서 대기했기 때문에 이후의 코드를 실행한다.
    • data3을 큐에 저장한다.
    • notify()를 호출한다. 데이터를 저장했기 때문에 혹시 스레드 대기 집합에 소비자가 대기하고 있다면 소비자를 하나 깨워야 한다. 물론 지금은 대기 집합에 스레드가 없기 때문에 아무 일도 일어나지 않는다

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

22:26:40.916 [producer3] [생산 완료] data3 -> [data2, data3]

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

22:26:41.018 [consumer2] [소비 시도]     ? <- [data2, data3]
22:26:41.019 [consumer2] [take] 소비자 데이터 획득, notify() 호출
22:26:41.019 [consumer2] [소비 완료] data2 <- [data3]
22:26:41.124 [consumer3] [소비 시도]     ? <- [data3]
22:26:41.124 [consumer3] [take] 소비자 데이터 획득, notify() 호출
22:26:41.124 [consumer3] [소비 완료] data3 <- []
  • c2 , c3를 실행한다. 데이터가 있으므로 둘 다 데이터를 소비하고 완료한다.
  • 둘 다 notify()를 호출하지만 대기 집합에 스레드가 없으므로 아무 일도 발생하지 않는다.
22:26:41.229 [     main] 현재 상태 출력, 큐 데이터: []
22:26:41.229 [     main] producer1: TERMINATED
22:26:41.230 [     main] producer2: TERMINATED
22:26:41.230 [     main] producer3: TERMINATED
22:26:41.231 [     main] consumer1: TERMINATED
22:26:41.231 [     main] consumer2: TERMINATED
22:26:41.232 [     main] consumer3: TERMINATED
22:26:41.232 [     main] == [생산자 먼저 실행] 종료, BoundedQueueV3 ==

wait(), notify() 덕분에 스레드가 락을 놓고 대기하고, 또 대기하는 스레드를 필요한 시점에 깨울 수 있었다.

생산자 스레드가 큐가 가득 차서 대기해도, 소비자 스레드가 큐의 데이터를 소비하고 나면 알려주기 때문에, 최적의 타이밍에 깨어나서 데이터를 생산할 수 있었다. 덕분에 최종 결과를 보면 p1, p2, p3는 모두 데이터를 정상 생산하고, c1, c2, c3는 모두 데이터를 정상 소비할 수 있었다.

 

소비자 스레드 먼저 실행 분석

실행 결과 분석 - 초기 상태

22:51:08.258 [     main] == [소비자 먼저 실행] 시작, BoundedQueueV3 == 

22:51:08.264 [     main] 소비자 시작

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

22:51:08.276 [consumer1] [소비 시도]     ? <- []
22:51:08.276 [consumer1] [take] 큐에 데이터가 없음, 소비자 대기

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

22:51:08.381 [consumer2] [소비 시도]     ? <- []
22:51:08.381 [consumer2] [take] 큐에 데이터가 없음, 소비자 대기
22:51:08.486 [consumer3] [소비 시도]     ? <- []
22:51:08.487 [consumer3] [take] 큐에 데이터가 없음, 소비자 대기

22:51:08.591 [     main] 현재 상태 출력, 큐 데이터: []
22:51:08.609 [     main] consumer1: WAITING
22:51:08.609 [     main] consumer2: WAITING
22:51:08.610 [     main] consumer3: WAITING
  • 큐에 데이터가 없기 때문에 c1 , c2 , c3 모두 스레드 대기 집합에서 대기한다.
  • 이후에 생산자가 큐에 데이터를 생산하면 notify()통해 스레드들을 하나씩 깨워서 데이터를 소비할 있을 것이다.

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

22:51:08.611 [     main] 생산자 시작
22:51:08.614 [producer1] [생산 시도] data1 -> []
22:51:08.616 [producer1] [put] 생산자 데이터 저장, notify() 호출
  • p1 은 락을 획득하고, 큐에 데이터를 생산한다. 큐에 데이터가 있기 때문에 소비자를 하나 깨울 수 있다.
  • notify()를 통해 스레드 대기 집합에 이 사실을 알려준다.

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

  • notify() 를 받은 스레드 대기 집합은 스레드 중에 하나를 깨운다.
  • 여기서 c1 , c2 , c3 중에 어떤 스레드가 깨어날지 예측할 수 없다
    • 어떤 스레드가 깨워질지는 JVM 스펙에 명시되어 있지 않다. 따라서 JVM 버전 환경 등에 따라서 달라진다.
  • 그런데 대기 집합에 있는 스레드가 깨어난다고 바로 작동하는 것은 아니다. 깨어난 스레드는 여전히 임계 영역 안에 있다.
  • 임계 영역에 있는 코드를 실행하려면 먼저 락이 필요하다. 대기 집합에서는 나가지만 여전히 임계 영역에 있으므로 락을 획득하기 위해 "BLOCKED" 상태로 대기한다.
    • c1 : "WAITING" -> "BLOCKED"

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

22:51:08.618 [producer1] [생산 완료] data1 -> [data1]

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

22:51:08.618 [consumer1] [take] 소비자 깨어남
22:51:08.619 [consumer1] [take] 소비자 데이터 획득, notify() 호출
  • c1 은 락을 획득하고, 임계 영역 안에서 실행되며 데이터를 획득한다.
  • c1 이 데이터를 획득했으므로 큐에 데이터를 넣을 공간이 있다는 것을 대기 집합에 알려준다. 만약 대기 집합에 생산자 스레드가 대기하고 있다면 큐에 데이터를 넣을 수 있을 것이다.

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

  • c1 이 notify()로 스레드 대기 집합에 알렸지만, 생산자 스레드가 아니라 소비자 스레드만 있다.
  • 따라서 의도 와는 다르게 소비자 스레드인 c2 가 대기 상태에서 깨어난다.
  • 물론 대기 집합에 있는 어떤 스레드가 깨어날지는 알 수 없다. 여기서는 c2 가 깨어난다고 가정한다. 심지어 생산자와 소비자 스레드가 함께 대기 집합에 있어도 어떤 스레드가 깨어날지는 알 수 없다.

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

22:51:08.621 [consumer1] [소비 완료] data1 <- []
  • c1은 작업을 완료한다.
  • c1이 c2를 깨웠지만, 문제가 하나 있다. 바로 큐에 데이터가 없다는 점이다.

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

22:51:08.620 [consumer2] [take] 소비자 깨어남
22:51:08.622 [consumer2] [take] 큐에 데이터가 없음, 소비자 대기
  • c2는 락을 획득하고, 큐에 데이터를 소비하려고 시도 한다. 그런데 큐에는 데이터가 없다.
  • 큐에 데이터가 없기 때문에, c2 는 결국 wait()를 호출해서 대기 상태로 변하며 다시 대기 집합에 들어간다.

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

  • 이처럼 소비자인 c1 이 같은 소비자인 c2를 깨우는 것은 상당히 비효율적이다.
  • c1 입장에서 c2를 깨우게 되면 아무 일도 하지 않고, 그냥 다시 스레드 대기 집합에 들어갈 수 있다. 결과적으로 CPU 사용하고, 아무 일도 하지 않은 상태로 다시 대기 상태가 되어버린다.
  • 그렇다고 c1 스레드 대기 집합에 있는 어떤 스레드를 깨울지 선택할 수는 없다. notify() 스레드 대기 합에 있는 스레드 임의의 하나를 깨울 뿐이다.
  • 물론 이것이 비효율적이라는 것이지 문제가 되는 것은 아니다. 결과에는 문제가 없다. 가끔씩 약간 돌아서 뿐이.

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

22:51:08.719 [producer2] [생산 시도] data2 -> []
22:51:08.720 [producer2] [put] 생산자 데이터 저장, notify() 호출
  • p2 가 락을 획득하고 데이터를 저장한 다음에 notify()를 호출한다. 데이터가 있으므로 소비자 스레드가 깨어난다면 데이터를 소비할 수 있다.

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

  • 스레드 대기 집합에 있는 c3가 깨어난다. 참고로 어떤 스레드가 깨어날지는 알 수 없다.
  • c3는 임계 영역 안에 있으므로 락을 획득하기 위해 대기("BLOCKED")한다.

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

22:51:08.721 [producer2] [생산 완료] data2 -> [data2]

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

22:51:08.721 [consumer3] [take] 소비자 깨어남
22:51:08.721 [consumer3] [take] 소비자 데이터 획득, notify() 호출
  • c3는 락을 획득하고 "BLOCKED" -> "RUNNABLE" 상태가 된다.
  • c3 데이터를 획득한 다음에notify()를 통해 스레드 대기 집합에 알린다. 큐에 여유 공간이 생겼기 때문에 생산자 스레드가 대기 중이라면 데이터를 생산할 있다.

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

  • 생산자 스레드를 깨울 것으로 기대하고, notify()를 호출했지만 스레드 대기 집합에는 소비자인 c2 만 존재한다.
  • c2 깨어나지만 임계 영역 안에 있으므로 락을 기다리는 "BLOCKED" 상태가 된다.

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

22:51:08.721 [consumer3] [소비 완료] data2 <- []

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

22:51:08.721 [consumer2] [take] 소비자 깨어남
22:51:08.721 [consumer2] [take] 큐에 데이터가 없음, 소비자 대기
  • c2 가 락을 획득하고, 큐에서 데이터를 획득하려 하지만 데이터가 없다.
  • c2는 다시 wait() 호출해서 대기("WAITING") 상태에 들어가고, 다시 대기 집합에서 관리된다.

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

  • 물론 c2의 지금 이 사이클은 CPU 자원만 소모하고 다시 대기 집합에 들어갔기 때문에 비효율적이다.
  • 만약 소비자인 c3 입장에서 생산자, 소비자 스레드를 선택해서 깨울 수 있다면, 소비자인 c2를 깨우지는 않았을 것이다. 하지만 notify()는 이런 선택을 할 수 없다.

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

22:51:08.824 [producer3] [생산 시도] data3 -> []
22:51:08.825 [producer3] [put] 생산자 데이터 저장, notify() 호출
  • p3 가 데이터를 저장하고 notify()를 통해 스레드 대기 집합에 알린다.
  • 스레드 대기 집합에는 소비자 c2 가 있으므로 생산한 데이터를 잘 소비할 수 있다.

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

22:51:08.825 [producer3] [생산 완료] data3 -> [data3]

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

22:51:08.825 [consumer2] [take] 소비자 깨어남
22:51:08.826 [consumer2] [take] 소비자 데이터 획득, notify() 호출

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

22:51:08.826 [consumer2] [소비 완료] data3 <- []

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

22:51:08.930 [     main] 현재 상태 출력, 큐 데이터: []
22:51:08.931 [     main] consumer1: TERMINATED
22:51:08.932 [     main] consumer2: TERMINATED
22:51:08.932 [     main] consumer3: TERMINATED
22:51:08.932 [     main] producer1: TERMINATED
22:51:08.933 [     main] producer2: TERMINATED
22:51:08.933 [     main] producer3: TERMINATED
22:51:08.933 [     main] == [소비자 먼저 실행] 종료, BoundedQueueV3 ==

 

 

최종 결과를 보면 p1, p2, p3는 모두 데이터를 정상 생산하고, c1, c2, c3는 모두 데이터를 정상 소비할 수 있었다.

 

하지만 소비자인 c1이 같은 소비자인 c2 , c3를 깨울 수 있었다. 이 경우 큐에 데이터가 없을 가능성이 있다. 이때는 깨어난 소비자 스레드가 CPU 자원만 소모하고 다시 대기 집합에 들어갔기 때문에 비효율적이다.

 

만약 소비자인 c1 입장에서 생산자, 소비자 스레드를 선택해서 깨울 수 있다면, 소비자인 c2를 깨우지는 않았을 것이다. 예를 들어서 소비자는 생산자만 깨우고, 생산자는 소비자만 깨울 수 있다면 더 효율적으로 작동할 수 있을 것 같다. 하지만 notify()는 이런 선택을 할 수 없다. 물론 이것이 비효율적이라는 것이지 결과에는 아무런 문제가 없다. 약간 돌아서 뿐이다.

728x90