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

원문 URL : http://tutorials.jenkov.com/java-concurrency/slipped-conditions.html



슬립 상태란?


슬립 상태란 한 쓰레드가 특정 상태를 확인하고 이 상태에 대해 어떤 동작을 수행하는 사이에 다른 쓰레드가 상태를 변경하여 처음 쓰레드가 잘못된 동작을 수행하게 되는 현상을 의미한다. 


예제를 보자.


public class Lock {

    private boolean isLocked = true;

    public void lock(){
      synchronized(this){
        while(isLocked){
          try{
            this.wait();
          } catch(InterruptedException e){
            //do nothing, keep waiting
          }
        }
      }

      synchronized(this){
        isLocked = true;
      }
    }

    public synchronized void unlock(){
      isLocked = false;
      this.notify();
    }

}


lock() 메소드가 동기화를 수행하는 방식을 보자. 첫 synchronized 블록은 isLocked 가 false 일 때까지 기다린다. 두 번째 블록은 isLocked 를 true 로 세팅하여 Lock 인스턴스를 다른 쓰레드로부터 잠근다(락을 건다).


isLocked 가 false 이고 두 쓰레드가 동시에 lock() 메소드를 호출하는 경우를 생각해보자. 첫 번째 쓰레드는 첫 synchronized 블록에 진입하자마자 isLocked 가 false 인 것을 확인한다. 그리고 두 번째 쓰레드도 똑같이 synchronized 블록에 진입하고 isLocked 가 false 임을 확인한다. 두 쓰레드 모두 두 번째 synchronized 블록으로 진입하여 isLocked 를 true 로 세팅하고 자신의 동작을 


이 상황은 슬립 상태의 예시이다. 두 쓰레드는 상태를 확인하고, synchronized 블록을 벗어나고, 상태를 변경한다(isLocked=true). 이런 동작은 처음 synchronized 로 진입한 쓰레드가 isLocked 에 true 를 세팅하기 전에 다른 쓰레드들이 상태를 확인하는 것을 허용하게 된다. 다시 말해서, '상태' 는 쓰레드의 연이은 확인-변경 동작 사이에 다른 쓰레드들에게로 '탈선'된다.


슬립 상태를 피하기 위해서는 상태의 확인-변경 동작이 원자적으로 이루어져야 한다. 한 쓰레드가 상태 확인-변경 동작을 수행하는 동안 다른 쓰레드들이 상태를 확인할 수 없게끔 하여야 한다는 말이다.


위 예제에 대한 해결책은 간단하다. isLocked=true; 를 처음 synchronized 블록 안, while 루프 바로 다음으로 옮긴다.


public class Lock {

    private boolean isLocked = true;

    public void lock(){
      synchronized(this){
        while(isLocked){
          try{
            this.wait();
          } catch(InterruptedException e){
            //do nothing, keep waiting
          }
        }
        isLocked = true;
      }
    }

    public synchronized void unlock(){
      isLocked = false;
      this.notify();
    }

}


이제 isLocked 상태의 확인-변경 동작은 한 synchronized 블록 안에서 원자적으로 수행된다.



더 현실적인 예제


당신은 Lock 클래스를 위와 같이 구현하지 않을 수 있고, 때문에 슬립 상태는 상당히 이론적인 문제일 뿐이라고 이야기할 수 있다. 첫 번째 예제는 슬립 상태의 개념을 보다 잘 전달하기 위해 다소 단순하게 작성되었다.


더 현실적인 예제는 기아상태와 공정성에서 다룬, 공정한 락 구현 중에 등장한다. 슬립 상태는 중첩 모니터 락아웃에서의 잘못된 구현을 제거하려 할 때 발생하기 쉽다. 아래는 중첩 모니터 락아웃의 예제이다.


// 중첩 모니터 락아웃을 야기하는 공정한 락 구현 public class FairLock { private boolean isLocked = false; private Thread lockingThread = null; private List<QueueObject> waitingThreads = new ArrayList<QueueObject>(); public void lock() throws InterruptedException{ QueueObject queueObject = new QueueObject(); synchronized(this){ waitingThreads.add(queueObject); while(isLocked || waitingThreads.get(0) != queueObject){ synchronized(queueObject){ try{ queueObject.wait(); }catch(InterruptedException e){ waitingThreads.remove(queueObject); throw e; } } } waitingThreads.remove(queueObject); isLocked = true; lockingThread = Thread.currentThread(); } } public synchronized void unlock(){ if(this.lockingThread != Thread.currentThread()){ throw new IllegalMonitorStateException( "Calling thread has not locked this lock"); } isLocked = false; lockingThread = null; if(waitingThreads.size() > 0){ QueueObject queueObject = waitingThread.get(0); synchronized(queueObject){ queueObject.notify(); } } } }

public class QueueObject {}


synchronized(this) 블록 안에서 synchronized(queueObject) 와 queueObject.wait() 이 중첩되어 있는 형태를 보자. 여기서 중첩 모니터 락아웃 문제가 발생한다. 이 문제를 피하기 위해서 synchronized(queueObject) 블록은 synchronized(this) 블록 밖으로 옮겨져야 한다. 


// 슬립 상태를 야기하는 락 구현 public class FairLock { private boolean isLocked = false; private Thread lockingThread = null; private List<QueueObject> waitingThreads = new ArrayList<QueueObject>(); public void lock() throws InterruptedException{ QueueObject queueObject = new QueueObject(); synchronized(this){ waitingThreads.add(queueObject); } boolean mustWait = true; while(mustWait){ synchronized(this){ mustWait = isLocked } synchronized(queueObject){ if(mustWait){ try{ queueObject.wait(); }catch(InterruptedException e){ waitingThreads.remove(queueObject); throw e; } } } } synchronized(this){ waitingThreads.remove(queueObject); isLocked = true; lockingThread = Thread.currentThread(); } } }


lock() 메소드만 수정되었기 때문에 여기서는 unlock() 메소드는 등장하지 않는다.


이 lock() 메소드에는 세 번의 synchronized 블록이 등장한다. 


두 번째 synchronized(this) 블록은 mustWait=isLocked 을 세팅하여 락 상태를 확인한다.


synchronized(queueObject) 블록은 쓰레드가 대기 상태(wait() 호출)로 갈 것인지 아닌지를 체크한다. 다른 쓰레드가 이미 락을 건 상태가 아니라면 이 synchronized(queueObject) 블록을 바로 빠져나간다.


while(mustWait) 으로 인해 세 번째 synchronized(this) 블록은 mustWait 이 false 일 때만 실행된다. 이 블록에서는 isLocked 을 다시 true 로 세팅하고 그외 작업을 수행한다. 그리고 lock() 메소드 수행은 완료된다.


이제 이 FairLock 인스턴스에 락이 걸리지 않은 상황에서 두 쓰레드가 동시에 lock() 메소드를 호출했을 때 어떤 상황이 발생할지 생각해보자. 쓰레드1 은 isLocked 상태를 확인한다. 값은 락이 걸리지 않은 상태이므로 기본값인 false 이다. 다음으로 쓰레드2 도 같은 동작을 수행한다. 그리고 두 쓰레드 모두 대기 상태로 가지 않고 isLocked 를 true 로 세팅한다. 슬립 상태의 전형적인 예이다.



슬립 상태 제거


위 예제에서 슬립 상태를 제거하기 위해서는 마지막 synchronized(this) 블록의 내용이 처음 synchronize(this) 블록 안으로 옮겨져야 한다. 그리고 코드 이동을 위해 자연스럽게 다른 코드들도 조금씩 변경된다.


// 중첩 모니터 락아웃이 없지만 // 신호 실종 문제를 가진 공정한 락 구현 public class FairLock { private boolean isLocked = false; private Thread lockingThread = null; private List<QueueObject> waitingThreads = new ArrayList<QueueObject>(); public void lock() throws InterruptedException{ QueueObject queueObject = new QueueObject(); synchronized(this){ waitingThreads.add(queueObject); } boolean mustWait = true; while(mustWait){ synchronized(this){ mustWait = isLocked || waitingThreads.get(0) != queueObject; if(!mustWait){ waitingThreads.remove(queueObject); isLocked = true; lockingThread = Thread.currentThread(); return; } } synchronized(queueObject){ if(mustWait){ try{ queueObject.wait(); }catch(InterruptedException e){ waitingThreads.remove(queueObject); throw e; } } } } } }


지역변수 mustWait 에 대한 확인-변경 동작은 이제 한 synchronized 블록 안에서 이루어진다. 또한 만일 지역변수 mustWait 를 synchronized(this) 블록 외부에서 검사하더라도 while(mustWait) 안에서 mustWait 은 절대로 synchronized(this) 밖에서 변경되지 않는다. mustWait 을 false 로 판단하는 쓰레드는 내부 조건(isLocked) 를 원자적으로 설정하여, 상태를 확인하는 다른 쓰레드들이 이를 true 로 판단하도록 한다.


synchronized(this) 블록 안의 return 문은 꼭 필요한 것은 아니다. 이것은 작은 최적화의 일환이다. mustWait 이 false 인, 대기 상태로 들어가지 않을 쓰레드가 synchronized(queueObject) 블록으로 진입하여 if(mustWait) 을 실행할 필요는 없다.


관찰력 있는 독자는 위의 공정한 락 구현은 여전히 신호 실종 문제를 가지고 있음을 알 수 있을 것이다. FairLock 인스턴스가 잠겨 있을 때 lock() 메소드가 호출된 상황을 생각해보자. while(mustWait) 안의 synchronized(this) 블록에서 mustWait 은 true 이다. lock() 을 호출하는 쓰레드는 . 그리고 처음 락을 건 쓰레드가 unlock() 을 호출한다. unlock() 메소드 안에서는 queueObject.notify() 가 호출되는데, 대기하는 쓰레드는 아직 queueObject.wait() 을 호출하지 않았다. queueObject.nofity() 는 무효하게 된다. 신호가 실종된 것이다. lock() 메소드를 호출한 쓰레드는 queueObject.wait() 를 호출하여 블록 상태가 된다. unlock() 이 호출되어야 이 블록 상태를 벗어날 수 있지만, unlock() 은 절대 호출되지 않는다.


이 신호 실종 문제는 기아상태와 공정성에서 QueueObject 클래스가 두 메소드와 함께 세마포어로 수정된 이유이다. (doWait(), doNotify()) 이 메소드들은 QueueObject 내부에서 신호를 저장하고, 신호에 반응한다. 이 방법으로 doNotify() 메소드 호출이 doWait() 메소드 호출보다 먼저 발생하는 상황에서도 신호는 실종되지 않는다.













+ Recent posts