왜 wait 메서드는 반복문 안에서 써야 할까요?

OS 2020. 11. 14. 02:11

 누군가에게 이 글에 대한 메일을 받았습니다. if문 안에 wait를 썼는데 문제가 없나요? 이펙티브 자바 3판 item 80에서는 반복문 안에 wait를 쓰라는데. 사실 item 80은 wait notify 대신에 동시성 유틸리티를 쓰라는 겁니다. 그런데, 이 아이템에 언급된 것들 중에 짚고 넘어가야 할 문장들이 꽤 많은데, 그 중 하나는 반복문 안에 wait를 써라. 였습니다. 그만큼 어려우니, 그냥 동시성 유틸리티를 쓰라는 그림이였을지도 모르지만요. 메일로도 언급되었으니 짚고 넘어가겠습니다.

 

 링크에 언급되었던 문제 상황을 바꾸어 보겠습니다. 2명의 생산자가 각각 10개의 item을 생산하고, 2명의 소비자가 각각 10개의 item을 소비합니다.

 


 먼저, worker1 스레드 2개와 worker2 스레드 2개를 생성하고, 돌립니다. 링크에서는 생산자 하나, 소비자 하나만을 가지고 돌렸는데, 이것은 생산자와 소비자가 2명 이상이라는 것이 다른 점입니다.

 

 

 worker1은 값 하나를 가져옵니다. 소비자 역할을 합니다.

 

 

 worker2는 값 하나를 넣습니다. '생산자' 역할을 합니다. 이제 Common 클래스의 get, put 메서드를 보겠습니다.

 

 

 생산자는 그냥 넣기만 합니다.

 

 

 소비자는 알아서 가져갑니다. 예외가 떨어지면 적절히 처리를 해야 하는데, 위 예제에서는 생략 하였습니다. 제대로 동작할까요?

 

 

 얼핏 보면 제대로 동작하는 것 처럼 보입니다.

 

 

 그런데, 예외가 발생합니다. NoSuchElement? 저는, get 메서드에서, 리스트가 비어 있으면, 채워질 때 까지 기다린 다음에 가져오는 로직을 작성하였습니다. 그러한 로직으로 실행이 됨을 기대했지만, 깔끔하게 실패하였습니다. 왜 그런지, 단순화 시켜서 보도록 하겠습니다.

 


 생산자와 소비자의 로직을 단순화 시키면 아래와 같습니다.

 

 오른쪽 그림을 보시면, isEmpty가 있습니다. 만약에 비어 있다면, 파란색 네모인 wait를 수행합니다. 그러면, 누군가 깨우기 전까지는 잠들게 되는데, 깨우는 역할을 하는 것은 notify입니다. notify는 문서를 보시면 waiting 하는 Object 중 하나를 깨운다고 되어 있습니다. 여기서 잠자는 것은 소비자들밖에 없을 것이니, 깨워지는 대상 또한 소비자들밖에 없을 겁니다.

 

 이런 시나리오를 생각해 보겠습니다. 처음에는 버퍼가 비어 있습니다.

 

 

 소비자1 (C1)이 먼저 들어옵니다. 그런데, 처음에 item이 없으므로 기다립니다. 생산자1이 아이템을 하나 넣고 자고 있는 Thread를 깨워서 실행 대기 상태로 만듭니다. 여기서 문제. 이 순간에 소비자1이 아이템을 가져오는 연산을 수행할 수 있을까요? 문서를 보면 그게 아님을 알 수 있습니다.

 

 자세히 보시면, Competing, other Thread, activity 키워드를 볼 수 있는데요. 이는 다른 스레드와 lock을 획득하기 위해서 경쟁을 해야 한다는 의미입니다. 생각나는 키워드는 racing condition이 있겠네요. 이 순간에 소비자 2 (C2)가 get 메서드를 호출하려 한다고 해 보겠습니다. 분명한 것은, C1도 깨어났고, P1이 notify를 날리고 put 메서드를 끝냈고, 아무 Thread도 동기화 메서드에 접근하지 않았을 때, 다음과 같은 일이 가능하다는 것입니다.

 

 C1, C2, P1, P2가 lock을 획득 가능하다. 이 상황에서 우리는 C2가 동기화 메서드인 get을 호출한 시나리오를 생각할 수 있습니다.

 

 

 C2가 isEmpty 조건을 비교했더니, 그렇지 않습니다. 그러므로, get을 할 수 있습니다. 

 

 

 그러면, item 1개가 빠질 거고, C2는 동기화 메서드를 빠져나올 겁니다. 이 순간에 깨어난 C1이 lock을 획득하면 어떻게 될까요?

 

 

 이미, wait() 메서드가 끝났고, 이것은 if 블록에 있으니, 더 이상 li.isEmpty() 조건문을 검사하지 않습니다. 그러므로, 16번째 줄로 오게 됩니다. 그런데, 이미 원소는 제거된 상태입니다.

 

 

 이 상태에서 C1이 get 연산을 수행한다고 하면 어떨까요? 없는데 꺼내려고 하니 Exception이 떨어질 겁니다.

 

 


 여기서 핵심은 이것입니다. wait를 한 상태에서 누군가 깨우면 최소한 하나의 원소는 있을 것이다. 문제는, 소비자는 하나가 아닌 둘이였다는 것입니다. 분명히 깨어났을 때에는 원소가 하나 이상 있었습니다.

 

 

 즉, 깨어나자마자 실행이 되었다면, 정상적으로 get 함수가 수행되었을 겁니다. 문제는 그럴 거라는 보장이 없다는 것입니다. 이는 생산자에 의해서 깨어난 시점에는 원소가 있었지만, lock을 획득한 시점에서는 누군가 원소를 가져가 버렸기 때문입니다. 그 사이에 객체의 상태가 변경된 셈입니다.

 

 이것을 조금 더 응용하면, p1이 1.txt를 읽기 위해서 1.txt가 있는 것을 확인한 순간에, 스케쥴러가 p2를 실행시켰습니다. p2가 1.txt를 지웠습니다. 그리고 다시 스케쥴러가 p1을 올렸습니다. 1.txt가 없으면 바로 빠져나오고, 아니라면 1.txt를 읽는 로직을 p1이 수행했다면, 문제가 될 수 있다는 것도 알 수 있습니다. 왜냐하면, p1이 1.txt가 있는지 검사한 시점에는 1.txt가 있었지만, 실제로 사용하려는 시점에는 1.txt가 없기 때문입니다. 

 

 

 이 문제는, 깨어났을 때 정말로 item을 가져갈 수 있는지 확인하면 해결이 될까요? 최소한 비어 있는데도 wait를 했다는 이유로 빠져나와서 원소를 제거하는 행동은 하지 않을 겁니다.

 

 

 실제로 이렇게 바꾼 다음에, Exception이 발생했을 때에만 trace를 찍게 하면, 아무 것도 찍히지 않습니다.

 

 

 이 정도만 정리하면 되겠네요. notify에 대한 것은 다음에 이어서 올려보도록 하겠습니다.

댓글을 달아 주세요