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

원문 URL : http://tutorials.jenkov.com/java-concurrency/nested-monitor-lockout.html



중첩 모니터 락아웃의 발생


중첩 모니터 락아웃은 데드락과 유사하다. 이 현상은 다음과 같이 발생한다.


쓰레드1 은 A 의 락을 획득한다.

쓰레드1 은 B 의 락을 획득한다.

쓰레드1 은 B.wait() 을 호출하여 B 의 락을 해제한다. (A 의 락은 해제하지 않는다.)


쓰레드2 쓰레드1 에게 신호를 보내기 위해 A 와 B 의 락 모두 필요하다. (같은 순서로)

쓰레드2 은 A 의 락을 획득하려 시도하지만 획득할 수 없다. 이 락은 쓰레드1 이 소유하고 있다.

쓰레드2 은 쓰레드1 이 A 의 락을 해제하기를 기대리며 무한정 블록 상태에 빠진다.


쓰레드1 은 쓰레드2 의 신호를 기다리며 무한정 블록(대기) 상태로 남는다.


이렇게 쓰레드1 은 A 의 락을 절대 해제하지 않으며, 이로 인해 쓰레드2 는 쓰레드1 에게 신호를 보낼 수 없게 된다.


둘은 서로를 기다리며 영원히 대기한다.


이것은 상당히 이론적인 상황으로 들린다. 하지만 신중하게 구현하지 않은 아래의 Lock 클래스를 보자.


// 중첩 모니터 락아웃을 야기하는 락 구현 public class Lock{ protected MonitorObject monitorObject = new MonitorObject(); protected boolean isLocked = false; public void lock() throws InterruptedException{ synchronized(this){ while(isLocked){ synchronized(this.monitorObject){ this.monitorObject.wait(); } } isLocked = true; } } public void unlock(){ synchronized(this){ this.isLocked = false; synchronized(this.monitorObject){ this.monitorObject.notify(); } } } }


lock() 메소드가 어떻게 'this' 와 멤버변수 monitorObject 의 락을 획득하는지 보자. isLocked 이 false 일 때는 아무런 문제가 없다. 쓰레드는 monitorObject.wait() 을 호출하지 않는다. 하지만 isLocked 이 true 이면 lock() 을 호출하는 쓰레드는 monitorObejct.wait() 을 호출하여 대기 상태에 빠진다.


문제는, monitorObject.wait() 은 오직 monitorObject 의 락만을 해제한다는 것이다. 'this' 의 락은 여전히 보유 상태다.


처음 lock() 메소드를 호출한 쓰레드는 unlock() 메소드를 호출하여 자신이 건 락을 해제하려 한다. 이 쓰레드는 unlock() 메소드의 synchronized(this) 으로의 진입을 시도하며 블록 상태에 빠진다. 이 블록은 lock() 메소드에서 대기중인 쓰레드가 lock() 메소드 안의 synchronized(this) 를 빠져나갈 때까지 해제되지 않는다. 하지만 lock() 에서 대기중인 쓰레드는 monitorObject.notify() 가 호출될 때가지, 그리고 isLock() 이 false 가 될 때까지 synchronized(this) 를 떠나지 않는다. 


간단히 말해서, lock() 에서 대기중인 쓰레드는 lock() 을 빠져나가기 위해 unlock() 메소드가 호출되어야 하지만, unlock() 을 호출해줄 다른 쓰레드들은 lock() 에서 대기중인 쓰레드로 인해 블록 상태가 되어 unlock() 을 실행할 수 없게 된다.


이렇게 lock(), unlock() 을 호출하는 쓰레드 모두 무한정 블록 상태에 되고, 이런 현상을 중첩 모니터 락아웃이라 한다.



보다 현실적인 예제


당신은 절대로 위와 같은 락을 구현하지 않는다고 주장할지 모른다. 내부 모니터 객체에 대한 wait() 과 notify() 를 호출하지 않는다는 것이다. 그 말은 아마 사실일 것이다. 하지만 실제로 위와 같은 구현이 발생할 수 있는 상황이 있다. 예를 들어, 락에 공정성을 구현할 때다. 한 번에 한 쓰레드를 깨우기 위해 각 쓰레드가 자신의 큐 객체의 wait() 을 호출하게 하는 것이다.


아래는 신중하지 못한 공정한 락 구현이다.


// 중첩 모니터 락아웃을 야기하는 공정한 락 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 {}


이 구현은 언뜻 보기에는 괜찮아 보인다. 그러나 lock() 메소드가 queueObject.wait() 을 호출하는 방식을 보자. 이 호출은 두 synchronized 블럭 안에서 이루어진다. 하나는 'this' 이다. 다른 하나는 'this' 안의 queueObject 이다. 한 쓰레드가 queueObject.wait() 을 호출하면 QueueObject 인스턴스에 대한 락을 해제한다. 하지만 역시 'this' 의 락을 해제하지는 않는다.


unlock() 메소드는 synchronized 로 선언되어 있다. 이 선언은 synchronized(this) 와 같다. 이는 lock() 메소드를 호출하여 대기 상태에 놓인 쓰레드가 있다면 'this' 의 락을 획득하려는 쓰레드들은 블록 상태가 된다는 것을 의미한다. unlock() 을 호출하는 모든 쓰레드들은 무한정 블록된다. 'this' 의 락을 소유한 쓰레드가 락을 해제해주기를 기다리지만, 이 락을 절대 해제되지 않는다. 왜냐하면 이 해제는 오직 lock() 에서 대기중인 쓰레드를 깨워야만 가능하고, 이 쓰레드를 깨우는 일은 unlock() 메소드를 호출하는 방법 외에는 없기 때문이다.


결국 위의 공정한 락 구현은 중첩 모니터 락아웃을 일으킨다. 더 나은 공정한 락 구현은 여기에 있다.



중첩 모니터 락아웃 vs 데드락


중첩 모니터 락아웃과 데드락의 결과는 같다. 관련된 쓰레드들은 결국 영원히 블록되어 서로를 기다리는 것이다.


하지만 이 둘이 완전히 같은 것은 아니자. 데드락에서 설명했듯, 데드락은 두 쓰레드가 서로 다른 순서로 락을 획득할 때 발생한다. 쓰레드1 이 A 의 락을 획득한 상태로 B 를 기다리고, 쓰레드2 는 B 의 락을 획득한 상태로 A 를 기다린다. 그리고 데드락 방지에서 설명했듯, 데드락은 언제나 락을 같은 순서로 거는 방법으로 회피할 수 있다. (Lock Ordering) 그러나 중첩 모니터 락아웃은 두 쓰레드가 완벽히 같은 순서로 락을 획득하는 상황에서 발생한다. 쓰레드1 은 A 와 B 의 락을 획득한 후 B 의 락을 해제하고 쓰레드2 의 신호를 기다린다. 쓰레드2 는 쓰레드1 에게 신호를 보내기 위해 A 와 B 의 락이 필요하다. 그리하여 한 쓰레드는 신호를 기다리고, 다른 쓰레드는 락이 해제되기를 기다린다.


아래는 요약.


데드락:

두 쓰레드가 서로의 락이 해제되기를 기다린다.


중첩 모니터 락아웃:

쓰레드1 은 A 의 락을 가지고 쓰레드2 로부터의 신호를 기다리며 대기한다.

쓰레드2 는 쓰레드1 에게 신호를 보내기 위해 쓰레드1 이 가진 A 의 락을 얻으려 대기한다.









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

자바의 락(Locks in Java)  (0) 2017.05.09
슬립 상태(Slipped Conditions)  (0) 2017.04.23
기아상태와 공정성(Starvation and Fairness)  (0) 2017.04.09
데드락 방지(Deadlock Prevention)  (0) 2017.04.09
데드락(Deadlock)  (0) 2017.04.09

+ Recent posts