이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/thread-signaling.html



쓰레드 시그널링의 목적은 쓰레드들이 서로 신호를 교환하도록 하기 위함이다. 쓰레드 시그널링은 쓰레드에게 다른 쓰레드로부터의 신호를 기다리도록 할 수 있다. 예를 들어, Thread B 는 Thread A 의 신호 -처리될 데이터가 준비되었다는- 가 올 때 까지 대기할 수 있다.



공유 객체를 이용한 시그널링


쓰레드들이 서로 신호를 보내도록 하는 간단한 방법은 공유 객체 변수에 신호값을 세팅하는 것이다. Thread A 는 동기화블록 안에서 boolean 타입 멤버변수인 hasDataToProcess 에 true 를 세팅하고, Thread B 는 동기화블록 안에서 이 변수의 값을 읽는다. 여기 시그널 변수를 가진 한 객체가 이 변수에 값을 세팅하고 확인하는 메소드를 제공하는 예제가 있다:


public class MySignal{

  protected boolean hasDataToProcess = false;

  public synchronized boolean hasDataToProcess(){
    return this.hasDataToProcess;
  }

  public synchronized void setHasDataToProcess(boolean hasData){
    this.hasDataToProcess = hasData;  
  }

}


Thread A 와 B 는 시그널링을 위해 MySignal 의 인스턴스를 공유해야 한다. 만일 이 두 쓰레드가 서로 다른 MySignal 인스턴스를 참조한다면, 이들은 서로의 신호를 감지할 수 없을 것이다. 시그널링을 위한 데이터는 MySignal 인스턴스로부터 분리된 공유 버퍼에 위치할 수 있다.



Busy Wait


데이터를 처리하는 쓰레드인 Thread B 는 데이터가 접근 가능해질 때까지 기다린다. 다시 말해서, 이 쓰레드는 Thread A 가 hasDataToProcess() 메소드에서 true 를 반환할 때까지 기다린다. Thread B 돌고 있는 루프는 다음과 같이 신호를 기다린다:


protected MySignal sharedSignal = ...

...

while(!sharedSignal.hasDataToProcess()){
  //do nothing... busy waiting
}


루프는 hasDatToProcess() 메소드가 true 를 호출할 때까지 그저 기다린다. 이를 busy waiting 이라 부른다. 이 쓰레드는 기다리기 바쁘다(The thread id busy while waiting).



wait(), notify() and notifyAll()


Busy waiting 은 대기(waiting)하는 쓰레드를 돌리기 위한 CPU의 활용으로써 그다지 효율적인 활용은 아니다. 평균 대기 시간이 아주 짧을 경우에는 괜찮겠지만, 그렇지 않다면 차라리 대기중인 쓰레드로 하여금 - 기다리는 신호가 도착할 때까지 - sleep 상태나 inactive 하도록 만드는 편이 현명하다.


자바에는 신호를 기다리는 동안 쓰레드의 활동을 중단하도록 하는 대기 메카니즘이 내장되어 있다. java.lang.Object 클래스에는 이를 위한 세 가지 메소드, wait(), notify() 그리고 notifyAll() 이 정의되어 있다.


wait() 메소드는 java.lang.Object 클래스에 정의되어 있기 때문에 어느 객체에든 이 메소드를 호출할 수 있는데, 어느 객체의 wait() 메소드를 호출하든, 이를 호출하는 쓰레드는 다른 쓰레드가 notify() 를 호출할 때까지 활동을 중단한다. wait() 이나 waiting 을 깨우는 메소드를 호출하기 위해서는 쓰레드는 먼저 반드시 해당 객체의 lock 을 획득하고 있어야 한다. 다시말해, wait() 이나 notify() 메소드 호출은 반드시 synchronized 블록 안에서 이루어져야 한다는 것이다. 


다음은 MySignal 클래스가 이를 활용하도록 수정한 버전이다:


public class MonitorObject{
}

public class MyWaitNotify{

  MonitorObject myMonitorObject = new MonitorObject();

  public void doWait(){
    synchronized(myMonitorObject){
      try{
        myMonitorObject.wait();
      } catch(InterruptedException e){...}
    }
  }

  public void doNotify(){
    synchronized(myMonitorObject){
      myMonitorObject.notify();
    }
  }
}


waiting 쓰레드는 doWait() 을 호출하고, notifying 쓰레드는 doNotify() 를 호출할 것이다. 한 쓰레드가 한 객체의 notify() 를 호출하면, 이 객체의 wait() 메소드 호출을 통해 대기중인 쓰레드들 중 한 쓰레드가 대기 상태에서 깨어나고 실행 가능한 상태가 된다. notify() 대신 notifyAll() 메소드를 호출하면 이 객체를 두고 대기중인 모든 쓰레드가 깨어난다.


보다시피 wait() 와 notify() 메소드 호출은 synchronized 블록 안에서 호출된다. 이것은 필수이다. 쓰레드는 wait(), notify() notifyAll() 메소드들이 호출되는 객체에 대한 lock 을 가지고 있지 않은 상태에서 이 메소드들을 호출할 수 없다. lock 을 가지지 않고 이 메소드들을 호출하면 IllegalMonitorStateexception 이 던져진다.


그런데, 어떻게 이게 가능할까? waiting 쓰레드는 synchronized 블록 안의 코드를 실행하는 한, 모니터 객체(myMonitorObject)에 대한 lock 을 유지하고 있지 않은가? waiting 쓰레드는 notifying 쓰레드가 doNotify() 를 호출하며 synchronized 블록 안으로 진입하는 것을 막지 않는 것인가? 대답은 no 다. 쓰레드는 wait() 을 호출하면 자신이 가지고 있던 lock 을 풀어두게 된다. 그리고 이로 인해 다른 쓰레드들이 또 lock 을 획득하고, wait() 이나 notify() 메소드를 호출할 수 있게 되는 것이다. 


한 번 wait() 메소드 호출로 잠들었다가 notify() 호출에 의해 깨어난 쓰레드는 notify() 메소드를 호출한 쓰레드가 풀어놓은 lock 을 다시 획득해야만 wait() 호출을 벗어날 수 있다. 다시말해: wait() 호출은 synchronized 블록 안에서 이루어지기 때문에, notify() 에 의해 깨어난 쓰레드는 wait() 호출을 벗어나기 위해 lock을 재획득해야만 한다. notifyAll() 호출이 잠들어있던 다수의 쓰레드를 깨우면, 이 잠들어있던 쓰레드들은 차례대로 lock 을 재획득하기 때문에, 오직 한 시점에 한 쓰레드만이 wait() 메소드를 벗어날 수 있는 것이다.



Missed Signals


wait() 를 호출한 쓰레드가 없는 상태에서 notify(), notifyAll() 메소드를 호출하면, notify(), notifyAll() 의 신호는 그대로 분실된다. 이는 문제가 될 수도, 안 될 수도 있는데, 때때로 이런 상황은 영영 깨어나지 못하고 계속 잠들어 있는 쓰레드를 만들어 내기도 한다. 이 쓰레드를 꺠우기 위한 신호가 분실됐기 때문이다.


이렇게 신호를 분실하는 상황을 피하기 위해, 신호를 내부에 저장하는 클래스를 만들어야 한다. 위 예제의 MyWaitNotify 는 notify 신호를 MyWaitNotify 인스턴스의 멤버변수로 저장할 필요가 있다. 아래 예제는 그것을 적용한 버전이다:


public class MyWaitNotify2{

  MonitorObject myMonitorObject = new MonitorObject();
  boolean wasSignalled = false;

  public void doWait(){
    synchronized(myMonitorObject){
      if(!wasSignalled){
        try{
          myMonitorObject.wait();
         } catch(InterruptedException e){...}
      }
      //clear signal and continue running.
      wasSignalled = false;
    }
  }

  public void doNotify(){
    synchronized(myMonitorObject){
      wasSignalled = true;
      myMonitorObject.notify();
    }
  }
}


doNotify() 메소드가 notify() 를 호출하기 전에 wasSignalled 를 true 로 세팅하는 부분과, doWait() 메소드가 wait() 를 호출하기 전에 wasSignalled 를 체크하는 부분을 잘 봐야한다. wait() 메소드는 doNotify() 가 호출되지 않은 상태에서만 호출된다.



Spurious wakeups


알 수 없는 이유로, notify() 나 notifyAll() 이 호출되지 않았음에도 불구하고 쓰레드가 깨어나는 현상이 있다. 이는 spurious wakeups 라 불리는데, 어떤 분명한 이유없이 쓰레드가 깨어난다.


만일 MyWaitNotify2 클래스의 doWait() 메소드에 spurious wakeup 이 발생한다면, 필요할 때 doNotify() 메소드 호출을 통해 깨어났어야 할 waiting 쓰레드가 제멋대로 깨어나 동작을 재개하는 현상이 발생할 수 있고, 이는 어플리케이션에 심각한 문제가 될 수 있다.


spurious wakeups 현상을 막기 위해서 신호를 저장하는 멤버변수 체크를 if 대신 while 루프에서 수행하는 방법이 있다. 이 역할을 하는 루프를 spin lock 이라 부른다. spin lock 안의 조건이 false 가 될 때가지 쓰레드는 루프를 계속 돈다.


MyWaitNotify2 에 spin lock 을 적용한 예제는 다음과 같다:


public class MyWaitNotify3{

  MonitorObject myMonitorObject = new MonitorObject();
  boolean wasSignalled = false;

  public void doWait(){
    synchronized(myMonitorObject){
      while(!wasSignalled){
        try{
          myMonitorObject.wait();
         } catch(InterruptedException e){...}
      }
      //clear signal and continue running.
      wasSignalled = false;
    }
  }

  public void doNotify(){
    synchronized(myMonitorObject){
      wasSignalled = true;
      myMonitorObject.notify();
    }
  }
}


wait() 호출은 if 가 아닌 while 루프 안에 있다. 이제 doNotify() 메소드가 호출되지 않는 한 wasSignalled 변수는 false 를 유지하게 되기 때문에, waiting 쓰레드가 깨어나도 wasSignalled 를 반복 체크하여 다시 wai() 메소드를 호출하고 대기 상태로 돌아갈 것이다.



같은 신호를 기다리는 멀티플 쓰레드


만약 다수의 waiting 쓰레드들이 있고, 이 쓰레드들이 모두 notifyAll() 호출을 통해 깨워지지만, 이들 중 한 쓰레드만 작업이 허용되는 상황에서라면, while 루프는 좋은 해결책이 된다. 


한 시점에 오직 한 쓰레드만 lock 을 획득할 것이고, 이는 이 한 쓰레드만 wait() 호출을 벗어나 wasSignalled 플레그를 초기화 할 수 있다는 것을 의미한다. 이 쓰레드가 wait() 호출을 벗어나고 synchronized 블록을 벗어나면, 대기중이던 다른 쓰레드들도 lock 을 획득하여 wait() 호출을 벗어나고 while 루프의 wasSignalled 변수를 체크한다. 그런데 여기서 이 wasSignalled 플레그 변수는 첫 번째로 루프를 빠져나간 쓰레드에 의해 false 로 초기화되었기 때문에, 나머지 다른 쓰레드들은 다시 루프를 반복하여 대기상태로 놓이게 된다.



글로벌 객체나 문자열 상수에 wait() 을 호출하지 말 것


이 글의 예전 버전에서는 MyWaitNotify 예제 클래스의 수정판이 있었는데, 이 클래스는 상수 문자열 ("") 을 모니터 객체로 사용했었다:


public class MyWaitNotify{

  String myMonitorObject = "";
  boolean wasSignalled = false;

  public void doWait(){
    synchronized(myMonitorObject){
      while(!wasSignalled){
        try{
          myMonitorObject.wait();
         } catch(InterruptedException e){...}
      }
      //clear signal and continue running.
      wasSignalled = false;
    }
  }

  public void doNotify(){
    synchronized(myMonitorObject){
      wasSignalled = true;
      myMonitorObject.notify();
    }
  }
}


빈 문자열이나 다른 문자열 상수에 대해 wait() 과 notify() 를 사용할 때의 문제점은, JVM/컴파일러는 내부적으로 상수 문자열을 같은 객체로 변환한다는 것이다. 같은 문자로 구성된 문자열 상수는 하나의 인스턴스가 되어 참조되는데, 때문에 빈 문자열을 모니터 객체로 사용하게 되면, MyWaitNotify 의 인스턴스가 여러개일 때, 모두 같은 하나의 인스턴스 - 문자열 인스턴스, ("") - 를 모니터 객체로 공유하게 되는 것이다.


이 상황을 다이어그램으로 나타내자면 다음과 같다:



MyWaitNotify1 인스턴스과 MyWaitNotify2 인스턴스는 같은 문자열 인스턴스를 모니터 객체로 공유한다. doWait(), doNotify() 의 신호 변수는 각각의 인스턴스에 개별적으로 저장되어 있는데, 같은 모니터 객체 하나("")를 공유해서 사용하기 때문에 이럴 경우 MyWaitNotify1 의 doNotify() 호출이 MyWaitNotify2 를 깨우고, 그 신호는 MyWaitNotify1 에 저장하는 문제가 발생할 수 있다.


이 현상은 큰 문제가 될 수 있는데, 일단 MyWaitNotify2 인스턴스에 대한 doNotify() 호출이 Thread A 와 Thread B 를 의도치 않게 깨울 수 있다. 이렇게 깨어난 쓰레드들은 while 루프에서 자신의 신호(signal) 변수를 체크하고 다시 대기상태로 돌아갈 것이다. 왜냐하면 doNotify() 는 Thread A/B 가 대기중인 MyWaitNotify 인스턴스에 대해 호출된 것이 아니기 때문이다. 이 상황은 spurious wakeup 이 발생한 것과 같은 상황이다. Thread A 나 B 는 의도치 않게 깨워졌다. 하지만 신호 변수가 바뀌지 않았기에 이 쓰레드들은 다시 대기상태로 돌아가고, 아직까진 괜찮은 상황이다.


문제는 doNotify() 메소드는 notifyAll() 이 아닌, notify() 를 호출한다는 것인데, 같은 모니터 객체("") 를 두고 4개의 쓰레드가 대기중에 있지만 오직 한 쓰레드만 깨어난다. 때문에, 만약 원래 깨우려고 했던 쓰레드가 C 나 D 였지만 Thread A 나 B 중 하나가 깨어난 상황이 발생하면, 이 깨어난 쓰레드는 자신의 신호 변수를 체크하고는 자신에게 주어진 신호가 없음을 알게된다. 그리고 다시 대기상태로 돌아간다. Thread C 나 D 중 어느쪽도 - 깨워지지 못했기 때문에 - 자신에게 주어진 신호를 체크하지 않는다. 신호가 분실된 것이다. 이 상황은 위에서 설명된, 신호 분실(missed signals) 문제와 같다. Thread c 와 D 는 신호를 받았지만, 응답에는 실패했다.


doNotify() 메소드가 notify() 가 아닌 notifyAll() 을 호출한다면, 대기중이던 모든 쓰레드들이 깨어나서 차례대로 자신들의 신호 변수를 체크할 것이다. 그리고 Thread A 와 B 는 자신들에게 온 신호가 없기에 다시 대기상태로 돌아가고, C 와 D 중 하나는 신호를 체크하고 doWait() 메소드 호출을 벗어나게 된다.

이 설명을 읽은 당신은 어쩌면 언제나 notify() 대신 notifyAll() 을 호출하려 할 수도 있다. 하지만 이는 성능을 고려할 때 바람직하지 못하다. 신호를 받고 응답하는 쓰레드가 단 하나뿐인데 모든 쓰레드를 깨울 이유는 없다.


결론: wait()/notify() 를 글로벌한 객체나, 문자열 상수같은 곳에 사용하지 말아야 한다. 유니크하게 사용될 수 있는 객체를 모니터로 사용하자. 예를 들자면 위 예제들 중 MyWaitNotify3 인스턴스가 자신의 MonitorObject 인스턴스를 가지고 있는 것처럼.



'Java > Concurrency' 카테고리의 다른 글

데드락 방지(Deadlock Prevention)  (0) 2017.04.09
데드락(Deadlock)  (0) 2017.04.09
자바 쓰레드로컬(ThreadLocal)  (1) 2017.04.09
자바 volatile 키워드  (2) 2017.04.09
자바 동기화 블록(Java Synchronized Blocks)  (0) 2017.04.09

+ Recent posts