java notify 메서드와 deadlock

OS/이론 2020. 11. 24. 02:21

 안녕하세요. 조경완입니다. 이펙티브 자바를 보면 notify 대신에 notifyAll을 쓰라고 되어 있습니다. 사실 더 중요한 것은 Concurrent를 쓰라는 것이지만요. 왜 그런지는 아이템 81에 나와 있으니, deadlock이 발생하는 시나리오에 대해서만 짚고 넘어가 보도록 하겠습니다.

 


 먼저 notify 메서드에 대한 설명을 보겠습니다. 깨우는데, a single thread라 되어 있습니다. 하나의 스레드를 깨웁니다. Object의 모니터에 대해서? Common의 get이나 put에는 wait가 있었는데요. main 클래스에 보면 이런 것이 있었습니다.

 

 

 저는 Common 객체를 c 하나만 선언했고, put이나 get은 Common 클래스 안에 있었으니, c에 걸리는 무언가 정도로 보시면 됩니다. lock이 걸리는 대상이 c입니다. notifyAll은 아래와 같습니다.

 

 

  Wakes up all threads. all하고 a single은 차이가 있습니다. 전자는 c에 대해서 (특정 Object에 대해서) waiting 하고 있는 모든 스레드를 깨워서 run이 가능한 상태로 만드는 것이고, 후자는 하나를 택해서 run이 가능한 상태로 만드는 것입니다. 이전 글에서 썼던 Common 클래스를 살짝 바꿔보도록 하겠습니다.

 

 

 먼저, put 메서드입니다. list가 비어있지 않으면 기다리고, 그렇지 않으면 원소를 넣고 하나를 깨웁니다.

 

 

 get 메서드는 큰 변화는 없어 보입니다. 실행 결과가 어떻게 나올까요?

 

 

 잘 가다가 중간에 멈추어 버립니다. 왜 그럴까요? 결론부터 말하면, 랜덤한 1개만을 깨웠기 때문입니다. 하나의 시나리오를 들어보겠습니다.

 


 먼저, List는 처음에 비어 있습니다. 그리고 C1, C2, P1, P2가 생성되어서, 실행 가능한 상태라고 해 보겠습니다. 이 상태에서 Consumer 둘이 C1, C2 순서대로 들어온다고 해 봅시다.

 

 

 그러면 이들은 Common 클래스의 21번째 줄 wait에서 대기할 겁니다. List에 아이템이 없기 때문에, 아이템을 얻어가지 못하는 상황입니다. 이들이 깨어나려면, 아이템이 있어야 합니다. 이를 쉽게 '다시 실행되기 위한 조건' 이라고 해 보겠습니다. P1이 들어옵니다.

 

 

 list가 비어 있으므로, put 메서드가 실행되고 notify가 실행됩니다. 여기서, C2를 택해서 깨웠다고 해 보겠습니다. P1은 실행이 끝났습니다. 그러면, C2는 단지, 실행이 가능한 상태가 되었을 뿐, 실행 되고 있는 상태가 아닙니다. 경쟁해서 lock을 획득해야 하는 상태입니다. 이를 그림으로 도식화 시켜 보면 아래와 같습니다.

 

 

 비어 있지 않은 상태에서 P2가 Running 상태에 들어갔다고 해 보겠습니다.

 

 

   그러면 P2는 쭉 실행하다가 list가 비어 있지 않기 때문에 wait를 하게 될 겁니다.

 

 

그러면 이런 상황이 될 겁니다. 생산자 하나도 wait를 하고 있다. C1은 notify가 C2만 깨웠기 때문에 깨어나지 못하였습니다. 그리고 P2는 이미 생산자가 list에 원소 하나를 집어넣은 상황이였기 때문에 wait를 하게 됩니다. 다음에, 어떤 스레드가 Running 상태에 들어가면 좋을까요?

 

 

 P1이 또 획득해 보겠습니다.

 

 

 그렇게 되면, P1이 Running 상태에 도달하게 됩니다. 그런데, 여기서 중요한 점은 List는 비어 있는 상태가 아니기 때문에 P1 또한 대기를 하게 됩니다.

 

 

 이제, Runnable한 상태에 있는 스레드는 C2 하나 뿐입니다.

 

 

 그러므로, C2는 원소 하나를 가져온 다음에 notify를 호출할 것이고, P1, P2, C1 중에 하나를 깨울 겁니다. 원소가 하나 비어 있는 상태에서 Runnable한 것은 C2와 C2가 깨운 무언가일 겁니다.

 

 


 C2가 깨운 무언가가 C1이라면 어떨까요?

 

 원소가 빈 상태에서 Runnable한 것은 C1과 C2밖에 없습니다. 

 

 

 

 그림으로 표현해 보면, C1과 C2 역시 wait에 걸려버립니다. List가 비어 있기 때문입니다. P1과 P2는 C1나 C2가 깨워줄 때 까지 기다려야 합니다. 그런데, 원소가 비어 있는데, C1과 C2가 notify를 호출할 수 있을까요? 아닙니다. 비어 있으면 채워질 때 까지 대기를 해야 하는데, 그러려면 P1이나 P2가 깨워줘야 합니다. C1과 C2는 P1이나 P2가 깨워주기만을 바라고 있고, P1과 P2는 C1이나 C2가 깨워주기만을 바라고 있습니다. 익숙한 사이클이 나왔습니다. Deadlock이 걸려버린 셈입니다.

 

 설명한 시나리오를 6줄로 정리하면 아래와 같습니다.

 

 

 그러면, notifyAll은 상황이 조금 나을까요? 소비자던 생산자던 어느 한 스레드가 작업을 완전히 수행하고 notifyAll을 하면 모든 스레드가 Runnable한 상태가 됩니다.

 

 

 만약에 list가 비어 있었다면, C2, C1, P1 순서대로 실행하였다고 하더라도, P1이 C1, C2, P1, P2를 깨웁니다. 그러므로 한 사이클이 지난 후에, notify와 다르게 P1, P2, C1, C2가 모두 Runnable한 상태가 됩니다. list가 비어 있지 않다고 해도, C1이나 C2가 notifyAll을 호출하게 될 것이고, 이는 생산자와 소비자 모두를 깨우게 됩니다. notify만을 호출했을 때 보다 상황이 나아진 셈입니다.

 

 여기서 질문. 이펙티브 자바에서 언급된 'notify를 삼켜서 꼭 깨어났어야 하는 스레드가 대기해야 하는 상황이 발생한다'는 어떤 의미일까요? 이 글의 내용을 조금 더 응용하면 되니, 답은 생략하도록 하겠습니다.