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

원문 URL : http://tutorials.jenkov.com/java-concurrency/blocking-queues.html



블로킹 큐란 특정 상황에 쓰레드를 대기하도록 하는 큐이다. 큐에서 엘레멘트를 빼려고 시도했는데(디큐) 큐가 비어있다거나, 큐에 엘레멘트를 넣으려 했는데(인큐) 큐에 넣을 수 있는 공간이 없다거나 할 때 디큐/인큐 호출 쓰레드를 대기하도록 한다. 비어있는 큐에서 엘레멘트를 빼려고 시도하는 쓰레드의 대기 상태는 다른 쓰레드가 이 큐에 엘레멘트를 넣을 때까지 지속된다. 꽉 찬 큐에 엘레멘트를 넣으려 시도하는 쓰레드의 대기 상태는 다른 쓰레드가 큐에서 엘레멘트를 빼거나 큐를 정리하여(clean) 큐의 공간이 확보될 때까지 지속된다. 


아래는 한 블로킹 큐를 둔 두 쓰레드의 상호 동작을 보여준다.


Java 5 는 java.util.concurrent 패키지에 블로킹 큐를 포함한다. 이 블로킹 큐 클래스에 대한 내용은 java.util.concurrent.BlockingQueue(원문) 튜토리얼에서 볼 수 있다. Java 5 가 블로킹 큐 구현체를 제공하긴 하지만, 역시 그 기반 이론을 아는 일은 가치가 있다.



블로킹 큐 구현


블로킹 큐의 구현은 바운디드 세마포어와 유사하다. 간단한 블로킹 큐 구현을 보자.


public class BlockingQueue {

  private List queue = new LinkedList();
  private int  limit = 10;

  public BlockingQueue(int limit){
    this.limit = limit;
  }


  public synchronized void enqueue(Object item)
  throws InterruptedException  {
    while(this.queue.size() == this.limit) {
      wait();
    }
    if(this.queue.size() == 0) {
      notifyAll();
    }
    this.queue.add(item);
  }


  public synchronized Object dequeue()
  throws InterruptedException{
    while(this.queue.size() == 0){
      wait();
    }
    if(this.queue.size() == this.limit){
      notifyAll();
    }

    return this.queue.remove(0);
  }

}
    


큐 크기가 크기 제한에 다다르면 enqueue() 와 dequeue() 에서 notifyAll() 이 호출된다. 큐 크기가 제한에 다다르지 않은 상태로 enqueue() 나 dequeue() 가 호출되면 쓰레드는 대기하지 않는다.

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

컴페어 스왑(Compare and Swap)  (0) 2017.05.24
쓰레드 풀(Thread Pools)  (0) 2017.05.22
세마포어(Semophores)  (0) 2017.05.22
재진입 락아웃(Reentrance Lockout)  (0) 2017.05.21
읽기/쓰기 락(Read/Write Locks in Java)  (0) 2017.05.16
이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

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



세마포어는 락과 마찬가지로 쓰레드간 신호 실종을 방지하기 위한 신호를 보내거나, 크리티컬 섹션을 보호하는 등의 목적을 위해 사용되는 쓰레드 동기화 구조이다. Java 5 는 java.util.concurrent 패키지에 세마포어 구현체를 포함하고 있기 때문에 직접 세마포어를 구현할 필요는 없다. 하지만 역시나 세마포어 구현의 기반 이론을 배우는 일은 충분히 가치있을 것이다.


Java 5 에는 Semaphore 클래스가 있다. 이 클래스에 대해서는 java.util.concurrent 튜토리얼의 java.util.concurrent.Semophore(원문) 을 읽어보기 바란다.



간단한 세마포어


아래는 간단한 Semaphore 구현이다.


public class Semaphore {
  private boolean signal = false;

  public synchronized void take() {
    this.signal = true;
    this.notify();
  }

  public synchronized void release() throws InterruptedException{
    while(!this.signal) wait();
    this.signal = false;
  }

}


take() 메소드는 Semaphore 내부에 신호 저장된 신호를 보낸다. release() 메소드는 신호를 기다린다. 신호가 도착하면 release() 메소드는 종료된다.


이러한 세마포어를 사용함으로써 신호 실종을 방지할 수 있다. notify() 대신 take() 를 호출하고, wait() 대신 release() 를 호출한다. release() 호출 전에 take() 호출이 발생하면, 신호는 내부적으로 signal 변수 안에 저장되기 때문에 release() 를 호출하는 쓰레드는 take() 이 호출되었었음을 알 수 있다. wait() 과 notify() 호출과는 다르다.


신호를 위해 세마포어를 사용할 때, take() 과 release() 라는 메소드 이름이 이상하게 보일 수 있다. 이 이름들은 세마포어를 락으로 사용함에서 나온 것이다. 이에 대한 설명은 후에 이어진다. 



신호를 위한 세마포어 사용


아래는 두 쓰레드 간의 신호를 위해 Semaphore 를 사용하는 간단한 예제이다.


Semaphore semaphore = new Semaphore();

SendingThread sender = new SendingThread(semaphore);

ReceivingThread receiver = new ReceivingThread(semaphore);

receiver.start();
sender.start();
public class SendingThread {
  Semaphore semaphore = null;

  public SendingThread(Semaphore semaphore){
    this.semaphore = semaphore;
  }

  public void run(){
    while(true){
      //do something, then signal
      this.semaphore.take();

    }
  }
}
public class RecevingThread {
  Semaphore semaphore = null;

  public ReceivingThread(Semaphore semaphore){
    this.semaphore = semaphore;
  }

  public void run(){
    while(true){
      this.semaphore.release();
      //receive signal, then do something...
    }
  }
}



카운팅 세마포어


앞서 선보인 Semaphore 구현에는 task() 메소드 호출로 발생한 신호의 수를 세지 않는다. 이 수를 세기 위해 코드를 조금 수정한다.이 클래스는 카운팅 세마포어라 부른다. 


public class CountingSemaphore {
  private int signals = 0;

  public synchronized void take() {
    this.signals++;
    this.notify();
  }

  public synchronized void release() throws InterruptedException{
    while(this.signals == 0) wait();
    this.signals--;
  }

}



바운디드 세마포어


CountingSemaphore 는 신호의 최대치에 대한 제한이 없다. 이 제한을 위해 코드를 수정한다.


public class BoundedSemaphore {
  private int signals = 0;
  private int bound   = 0;

  public BoundedSemaphore(int upperBound){
    this.bound = upperBound;
  }

  public synchronized void take() throws InterruptedException{
    while(this.signals == bound) wait();
    this.signals++;
    this.notify();
  }

  public synchronized void release() throws InterruptedException{
    while(this.signals == 0) wait();
    this.signals--;
    this.notify();
  }
}


take() 메소드를 보자. 저장된 신호의 수가 한도에 다다르면 호출 쓰레드는 블록된다. release() 가 호출될 때까지 take() 호출 쓰레드는 신호를 보낼 수 없다. 



세마포어를 락으로 사용하기


바운디드 세마포어를 락으로 사용할 수 있다. 이를 위해서는 신호 최대치를 1 으로 세팅해야 한다. 그리고 take() 와 release() 호출을 크리티컬 섹션에 사용한다.


BoundedSemaphore semaphore = new BoundedSemaphore(1);

...

semaphore.take();

try{
  //critical section
} finally {
  semaphore.release();
}


세마포어를 신호를 주고받기 위해 사용할 때와는 달리, take() 와 release() 메소드는 이제 같은 쓰레드에 의해 호출된다. 오직 한 쓰레드만이 세마포어를 획득할 수 있기 때문에, take() 를 호출하는 다른 모든 쓰레드들은 release() 가 호출될 때까지 블록된다. release() 호출은 절대 블록되지 않는데, 이는 언제나 take() 호출이 먼저 발생하기 때문이다.


바운디드 세마포어는 어떤 영역으로 동시에 진입 가능한 쓰레드의 수를 제한하는 데에 사용될 수 있다. 예를 들어, 위 예제에서 BoundedSemaphore 의 신호 최대치를 5 로 세팅하면 어떨까? 5 개의 쓰레드가 동시에 크리티컬 섹션으로 진입할 수 있다. 물론 이 경우에는 5 개 쓰레드가 크리티컬 섹션에 동시에 접근해서 어플리케이션에 문제가 발생하지 않아야 한다는 전제가 필요하다.


finally 절에서의 release() 메소드 호출은 크리티컬 섹션에서 예외가 던져지더라도 반드시 releas() 가 호출되도록 보장하기 위함이다.





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

쓰레드 풀(Thread Pools)  (0) 2017.05.22
블로킹 큐(Blocking Queues)  (3) 2017.05.22
재진입 락아웃(Reentrance Lockout)  (0) 2017.05.21
읽기/쓰기 락(Read/Write Locks in Java)  (0) 2017.05.16
자바의 락(Locks in Java)  (0) 2017.05.09
이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

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



재진입 락아웃은 데드락중첩 모니터 락아웃과 유사한 현상이다. 자바의 락읽기/쓰기 락에서 다뤄지기도 했다.


재진입 락아웃은 한 쓰레드가 재진입이 불가능한 Lock 이나 ReadWriteLock, 이외 다른 동기화에 재진입 요청을 시도할 때 발생한다. 재진입이란 이미 락을 획득한 쓰레드가 자신이 보유한 락과 동일한 락을 다시 요청하는 동작을 의미한다. 자바의 synchronized 블록은 재진입 락이다. 때문에 아래의 코드는 문제 없이 수행된다.


public class Reentrant{

  public synchronized outer(){
    inner();
  }

  public synchronized inner(){
    //do something
  }
}


outer() 와 inner() 는 synchronized 로 선언되었다. 자바에서 메소드 선언부의 synchronized 는 synchronized(this) 블록과 동일한 의미를 가지므로, outer() 와 inner() 메소드는 같은 모니터 객체('this') 에 대한 동기화를 수행한다. 한 쓰레드가 모니터 객체의 락을 획득하면, 같은 모니터 객체에 대해 선언된 모든 synchronized 블록으로의 진입권을 갖는다. 이것이 재진입이다. 쓰레드는 자신이 소유한 락에 대한 모든 synchronized 블록으로 진입할 수 있다.


다음 Lock 구현은 비 재진입 락이다.


public class Lock{

  private boolean isLocked = false;

  public synchronized void lock()
  throws InterruptedException{
    while(isLocked){
      wait();
    }
    isLocked = true;
  }

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


한 쓰레드가 unlock() 호출 없이 lock() 을 두 번 호출하면, 두 번째 lock() 호출에 의해 쓰레드는 블록된다. 재진입 락아웃이 발생하는 것이다.


재진입 락아웃을 방지하는 방법은 두 가지가 있다.


  1. 재진입을 시도하지 않는다.
  2. 재진입 락을 사용한다.

두 방법 중 어느 쪽이 알맞느냐는 구체적인 상황에 달려있다. 재진입 락 종종 비 재진입 락만큼 제대로 동작하기 않기도 하고, 구현하기도 더 어렵지만, 꼭 필요한 것이 아닐 수도 있다. 재진입 락이 필요할지 필요하지 않을지는 구체적인 상황에 따라 결정되어야 한다.





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

블로킹 큐(Blocking Queues)  (3) 2017.05.22
세마포어(Semophores)  (0) 2017.05.22
읽기/쓰기 락(Read/Write Locks in Java)  (0) 2017.05.16
자바의 락(Locks in Java)  (0) 2017.05.09
슬립 상태(Slipped Conditions)  (0) 2017.04.23

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

원문 URL : http://tutorials.jenkov.com/java-concurrency/read-write-locks.html



읽기/쓰기 락은 자바의 락에서 선보인 락 구현보다 더욱 세련된 형태의 락이다. 이런 상황을 생각해보자. 자원에 대해 읽기/쓰기 작업을 수행하는 어플리케이션이 있다. 그런데 쓰기 작업은 읽기 작업만큼 자주 수행되지 않는다. 그리고 두 쓰레드가 동일한 자원에 읽기 작업을 수행해도 서로에게 어떤 문제도 발생하지 않기 때문에, 다수의 쓰레드가 동시에 자원을 읽는 작업에는 아무런 제한이 없다. 하지만 한 쓰레드가 자원에 대해 쓰기 작업을 수행하려 한다면, 이 때에는 다른 어떤 쓰레드도 이 자원에 읽기 또는 쓰기 작업을 수행중이어서는 안된다. 이처럼 다수의 읽기-하나의 쓰기를 가능케 하기 위해 읽기/쓰기 락이 필요하다.


자바5 부터 java.util.concurrent 패키지에 읽기/쓰기 락 구현체가 포함되었다. 때문에 직접 이 락을 구현할 필요는 없지만, 이 구현 뒤에 존재하는 배경 이론을 이해하는 일은 여전히 유용할 것이다.



자바 읽기/쓰기 락 구현


먼저 자원에 대한 읽기와 쓰기 접근을 획득하기 뒤한 조건을 정리해보자.


읽기 접근: 쓰기 작업을 수행중인 다른 쓰레드가 없고, 쓰기 접근을 요청한 다른 쓰레드도 없을 때

쓰기 접근: 쓰기와 읽기 작업을 수행중인 다른 쓰레드가 없을 때


한 쓰레드가 자원을 읽으려 할 때, 이 지원에 쓰기 작업을 수행중이거나 쓰기 작업을 수행하기 위한 접근을 요청한 다른 쓰레드가 존재하지 않는다면 이 읽기 작업에는 아무런 문제가 없다. 접근의 우선순위를 고려할 때, 쓰기 접근은 읽기 접근보다 더 중요하다고 가정한다. 또한, 대부분의 요청이 읽기 접근인 상황에서 쓰기 접근에 높은 우선순위를 부여하지 않는다면 기아 상태가 발생할 수 있다. 쓰기 접근을 시도하는 쓰레드는 모든 읽기 요청이 ReadWriteLock 을 해제할 때까지 블록된다. 만약 새로운 쓰레드들이 거듭 읽기 접근을 획득한다면, 쓰기 작업을 위해 대기중인 쓰레드는 무기한 블록 상태로 남게 될 것이다. 따라서 쓰기 접근을 위해 ReadWriteLock 에 락을 걸거나(이미 걸었거나) 락을 요청한 쓰레드가 없는 경우에만 읽기 접근이 허용된다.


자원에 대한 쓰기 접근을 요하는 쓰레드는 다른 쓰레드들이 이 자원에 읽기나 쓰기 작업중이지 않을 때 접근이 허용된다. 여기서 쓰기 접근 요청에 대해 공정성을 보장할 필요가 없다면, 쓰기 접근을 요청하는 쓰레드의 수나 순서는 문제가 되지 않는다.


이러한 규칙들을 전제로 아래와 같은 ReadWriteLock 을 구현할 수 있다.


public class ReadWriteLock{

  private int readers       = 0;
  private int writers       = 0;
  private int writeRequests = 0;

  public synchronized void lockRead() throws InterruptedException{
    while(writers > 0 || writeRequests > 0){
      wait();
    }
    readers++;
  }

  public synchronized void unlockRead(){
    readers--;
    notifyAll();
  }

  public synchronized void lockWrite() throws InterruptedException{
    writeRequests++;

    while(readers > 0 || writers > 0){
      wait();
    }
    writeRequests--;
    writers++;
  }

  public synchronized void unlockWrite() throws InterruptedException{
    writers--;
    notifyAll();
  }
}


ReadWriteLock 은  두 개의 락 메소드와 두 개의 언락 메소드를 가진다. 한 쌍의 락-언락 메소드는 읽기 접근에 대한 것이며, 다른 한 쌍의 락-언락 메소드는 쓰기 접근에 대한 것이다.


읽기 접근에 대한 규칙은 lockRead() 메소드에 구현되어 있다. 쓰기 접근중이거나 쓰기 접근을 요청한 쓰레드가 존재하지 않는다면 읽기 작업을 요청하는 모든 쓰레드들에게 접근이 허용된다.


쓰기 접근에 대한 규칙은 lockWrite() 메소드에 구현되어 있다. 쓰기 접근은 쓰기 요청에 의해 시작된다(writeRequest++). 다음으로 쓰기 접근이 가능한 상태인지 확인한다. 자원에 대해 읽기/쓰기 접근중인 쓰레드가 존재하지 않는다면 쓰기 접근은 허용된다. 쓰기 접근 요청을 하는 쓰레드의 수는 상관없다.


unlockRead() 와 unlockWrite() 메소드에서 notify() 가 아닌 notifyAll() 을 호출하는 이유는 알아둘 가치가 있다. 아래와 같은 경우를 생각해보자.


ReadWriteLock 안에서 쓰기/읽기 접근을 위해 대기중인 다수의 쓰레드들이 있다. 이 때 notify() 가 호출되어 읽기 접근 대기중이던 쓰레드가 깨어나면 이 쓰레드는 다시 대기 상태로 돌아갈 것이다. 왜냐하면 쓰기 접근으로 대기중인 쓰레드가 존재하기 때문이다. 하지만 여기서 쓰기 대기중인 쓰레드는 깨어나지 못한다. 그리고 이후로 아무 일도 일어나지 않는다. 읽기나 쓰기 접근 중 어느 쪽도 허용되지 못하게 된다. 이러한 현상을 방지하기 위해 일단 notifyAll() 호출을 통해 모든 대기중인 쓰레드를 깨운 뒤, 쓰레드들이 자신이 요청한 접근 권한을 획득할 수 있는지 확인하게끔 하는 것이다.


notifyAll() 호출에는 또다른 이점이 있다. 다수의 쓰레드들이 읽기 접근 대기중이고 쓰기 접근 요청이 없는 경우에, 한 번의 unlockWrite() 호출만으로 모든 대기중인 쓰레드를 깨워 읽기 접근을 허용할 수 있다. 쓰레드를 하나하나 깨울 필요가 없는 것이다.



읽기/쓰기 락 재진입


위의 ReadWriteLock 클래스는 재진입 락이 아니다. 이미 쓰기 접근을 획득한 쓰레드가 다시 락을 호출하면 이 쓰레드는 자기 자신이 소유한 락에 의해 블록(대기) 상태가 된다. 더 나아가서 다음의 경우를 생각해보자.


  1. 쓰레드1이 읽기 접근을 획득한다.
  2. 쓰레드2이 쓰기를 요청하지만 쓰레드1이 획득한 읽기 접근 권한에 의해 블록된다.
  3. 쓰레드1이 다시 읽기 접근을 요청한다(재진입). 그러나 쓰레드2의 쓰기 요청에 의해 블록된다.


이 상황에서 ReadWriteLock 클래스를 데드락과 유사한 상황에 놓인다. 읽기나 쓰기 접근을 요청하는 쓰레드 모두 권한을 획득하지 못한다.


ReadWriteLock 을 재진입 락으로 만들기 위해 몇가지 수정이 필요하다. 읽기와 쓰기 재진입을 각각 분리하여 다뤄본다.



읽기 재진입


ReadWriteLock 을 읽기 재진입 락으로 만들기 위해, 먼저 읽기 재진입 규칙을 세운다.


한 쓰레드는 읽기 재진입은 읽기 접근을 획득할 수 있거나(쓰기 접근중인 쓰레드가 쓰기 접근을 요청한 쓰레드가 없을 때), 혹은 이미 읽기 접근을 획득한 경우(이 때는 쓰기 요청 존재 유무는 상관없다)에 허용된다.


쓰레드의 읽기 접근 획득 여부를 판단하기 위해, 읽기 접근을 획득한 각 쓰레드로의 참조를 맵에 저장한다. 쓰레드가 획득한 읽기 접근 횟수를 맵의 값으로 둔다. 이 맵은 쓰레드가 읽기 접근을 획득할 수 있는지 판단할 때 사용될 것이다. lockRead() 와 unlockRead() 메소드가 어떻게 수정되었는지 보자.


public class ReadWriteLock{ private Map<Thread, Integer> readingThreads = new HashMap<Thread, Integer>(); private int writers = 0; private int writeRequests = 0; public synchronized void lockRead() throws InterruptedException{ Thread callingThread = Thread.currentThread(); while(! canGrantReadAccess(callingThread)){ wait(); } readingThreads.put(callingThread, (getReadAccessCount(callingThread) + 1)); } public synchronized void unlockRead(){ Thread callingThread = Thread.currentThread(); int accessCount = getAccessCount(callingThread); if(accessCount == 1){ readingThreads.remove(callingThread); } else { readingThreads.put(callingThread, (accessCount -1)); } notifyAll(); } private boolean canGrantReadAccess(Thread callingThread){ if(writers > 0) return false; if(isReader(callingThread) return true; if(writeRequests > 0) return false; return true; } private int getReadAccessCount(Thread callingThread){ Integer accessCount = readingThreads.get(callingThread); if(accessCount == null) return 0; return accessCount.intValue(); } private boolean isReader(Thread callingThread){ return readingThreads.get(callingThread) != null; } }


코드에서 보이듯, 읽기 재진입은 오직 쓰기 접근을 획득한 다른 쓰레드가 없을 때만 허용된다. 그리고 쓰기 요청에 대해서는, 이미 읽기 접근을 획득한 쓰레드가 더 높은 우선순위를 가지게 된다.



쓰기 재진입


쓰기 재진입은 오직 해당 쓰레드가 이미 쓰기 접근을 소유한 경우해만 허용된다. 여기서는 lockWrite() 와 unlockWrite() 메소드를 보자.


public class ReadWriteLock{

    private Map<Thread, Integer> readingThreads =
        new HashMap<Thread, Integer>();

    private int writeAccesses    = 0;
    private int writeRequests    = 0;
    private Thread writingThread = null;

  public synchronized void lockWrite() throws InterruptedException{
    writeRequests++;
    Thread callingThread = Thread.currentThread();
    while(! canGrantWriteAccess(callingThread)){
      wait();
    }
    writeRequests--;
    writeAccesses++;
    writingThread = callingThread;
  }

  public synchronized void unlockWrite() throws InterruptedException{
    writeAccesses--;
    if(writeAccesses == 0){
      writingThread = null;
    }
    notifyAll();
  }

  private boolean canGrantWriteAccess(Thread callingThread){
    if(hasReaders())             return false;
    if(writingThread == null)    return true;
    if(!isWriter(callingThread)) return false;
    return true;
  }

  private boolean hasReaders(){
    return readingThreads.size() > 0;
  }

  private boolean isWriter(Thread callingThread){
    return writingThread == callingThread;
  }
}


쓰레드의 쓰기 접근 보유 여부를 어떤 방식으로 등록하는지, 그리고 이것이 다른 쓰레드의 쓰기 접근 획득 여부를 판단할 때 어떻게 활용되는지 보자.



읽기에서 쓰기로의 재진입


때때로 읽기 접근 쓰레드가 쓰기 접근을 획득할 필요성이 생기기도 한다. 이 경우에도 단 하나의 읽기 쓰레드만이 쓰기 접근을 획득할 수 있도록 하여야 한다. 이 처리를 위해 writeLock() 메소드를 수정한다.


public class ReadWriteLock{

    private Map<Thread, Integer> readingThreads =
        new HashMap<Thread, Integer>();

    private int writeAccesses    = 0;
    private int writeRequests    = 0;
    private Thread writingThread = null;

  public synchronized void lockWrite() throws InterruptedException{
    writeRequests++;
    Thread callingThread = Thread.currentThread();
    while(! canGrantWriteAccess(callingThread)){
      wait();
    }
    writeRequests--;
    writeAccesses++;
    writingThread = callingThread;
  }

  public synchronized void unlockWrite() throws InterruptedException{
    writeAccesses--;
    if(writeAccesses == 0){
      writingThread = null;
    }
    notifyAll();
  }

  private boolean canGrantWriteAccess(Thread callingThread){
    if(isOnlyReader(callingThread))    return true;
    if(hasReaders())                   return false;
    if(writingThread == null)          return true;
    if(!isWriter(callingThread))       return false;
    return true;
  }

  private boolean hasReaders(){
    return readingThreads.size() > 0;
  }

  private boolean isWriter(Thread callingThread){
    return writingThread == callingThread;
  }

  private boolean isOnlyReader(Thread thread){
      return readers == 1 && readingThreads.get(callingThread) != null;
      }
  
}


이제 ReadWriteLock 클래스는 읽기-쓰기 재진입이 가능한다.



쓰기에서 읽기로의 재진입


위와 반대로 쓰기 접근 쓰레드가 읽기 접근을 필요로 하는 상황도 생긴다. 쓰기 접근을 보유한 쓰레드는 어느 때나 읽기 접근이 가능해야 한다. 안전한 구현을 위해, 한 쓰레드가 읽기 접근을 보유중이라면 다른 쓰레드들은 읽기나 쓰기 접근 모두 불가능하도록 한다. 수정된 canGrantReadAccess() 메소드를 보자.


public class ReadWriteLock{

    private boolean canGrantReadAccess(Thread callingThread){
      if(isWriter(callingThread)) return true;
      if(writingThread != null)   return false;
      if(isReader(callingThread)  return true;
      if(writeRequests > 0)       return false;
      return true;
    }

}



재진입 읽기/쓰기 락의 최종 버전


아래 코드가 최종적으로 완성된 ReadWriteLock 구현이다. 가독성과 쉬운 이해를 위해 리펙토링이 조금 들어갔다.


public class ReadWriteLock{

  private Map<Thread, Integer> readingThreads =
       new HashMap<Thread, Integer>();

   private int writeAccesses    = 0;
   private int writeRequests    = 0;
   private Thread writingThread = null;


  public synchronized void lockRead() throws InterruptedException{
    Thread callingThread = Thread.currentThread();
    while(! canGrantReadAccess(callingThread)){
      wait();
    }

    readingThreads.put(callingThread,
     (getReadAccessCount(callingThread) + 1));
  }

  private boolean canGrantReadAccess(Thread callingThread){
    if( isWriter(callingThread) ) return true;
    if( hasWriter()             ) return false;
    if( isReader(callingThread) ) return true;
    if( hasWriteRequests()      ) return false;
    return true;
  }


  public synchronized void unlockRead(){
    Thread callingThread = Thread.currentThread();
    if(!isReader(callingThread)){
      throw new IllegalMonitorStateException("Calling Thread does not" +
        " hold a read lock on this ReadWriteLock");
    }
    int accessCount = getReadAccessCount(callingThread);
    if(accessCount == 1){ readingThreads.remove(callingThread); }
    else { readingThreads.put(callingThread, (accessCount -1)); }
    notifyAll();
  }

  public synchronized void lockWrite() throws InterruptedException{
    writeRequests++;
    Thread callingThread = Thread.currentThread();
    while(! canGrantWriteAccess(callingThread)){
      wait();
    }
    writeRequests--;
    writeAccesses++;
    writingThread = callingThread;
  }

  public synchronized void unlockWrite() throws InterruptedException{
    if(!isWriter(Thread.currentThread()){
      throw new IllegalMonitorStateException("Calling Thread does not" +
        " hold the write lock on this ReadWriteLock");
    }
    writeAccesses--;
    if(writeAccesses == 0){
      writingThread = null;
    }
    notifyAll();
  }

  private boolean canGrantWriteAccess(Thread callingThread){
    if(isOnlyReader(callingThread))    return true;
    if(hasReaders())                   return false;
    if(writingThread == null)          return true;
    if(!isWriter(callingThread))       return false;
    return true;
  }


  private int getReadAccessCount(Thread callingThread){
    Integer accessCount = readingThreads.get(callingThread);
    if(accessCount == null) return 0;
    return accessCount.intValue();
  }


  private boolean hasReaders(){
    return readingThreads.size() > 0;
  }

  private boolean isReader(Thread callingThread){
    return readingThreads.get(callingThread) != null;
  }

  private boolean isOnlyReader(Thread callingThread){
    return readingThreads.size() == 1 &&
           readingThreads.get(callingThread) != null;
  }

  private boolean hasWriter(){
    return writingThread != null;
  }

  private boolean isWriter(Thread callingThread){
    return writingThread == callingThread;
  }

  private boolean hasWriteRequests(){
      return this.writeRequests > 0;
  }

}



finally 절에서의 unlock() 호출


ReadWriteLock 을 이용해 크리티컬 섹션을 보호하고 이 크리티컬 섹션에서 예외가 던져질 수 있는 경우에 finally 절에서 readUnlock 과 writeUnlock() 메소드를 호출하도록 해야한다. 


lock.lockWrite();
try{
  //do critical section code, which may throw exception
} finally {
  lock.unlockWrite();
}


이 사소해 보이는 구조가 크리티컬 섹션에서 예외가 던져지더라도 ReadWriteLock 의 락이 반드시 풀리도록 보장한다. finally 절에서 unlockWrite() 을 호출하지 않는 상태로 크리티컬 섹션에서 예외가 던져지면 ReadWriteLock 은 영원히 쓰기 접근이 획득된 상태로 남게 되고, lockRead() 나 lockWrite() 를 호출하는 쓰레드들은 무기한 중단 상태가 된다. 이 상태를 벗어나는 유일한 방법이 있기는 하다. 쓰기 접근을 보유했었던 쓰레드가 다시 이 코드에 접근하여 쓰기 접근을 다시 획득하고, 크리티컬 섹션의 코드를 무사히(예외 없이) 수행한 뒤, unlockWriter() 를 호출한다면 중단된 쓰레드들은 정상적인 상태로 돌아올 수 있다. 하지만 프로그램을 이런식으로 처리할 이유는 없을 것이다. 훨씬 간단하고 안전하게 finally 절에서 반드시 unlockWrite() 를 호출하도록 하자.








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

세마포어(Semophores)  (0) 2017.05.22
재진입 락아웃(Reentrance Lockout)  (0) 2017.05.21
자바의 락(Locks in Java)  (0) 2017.05.09
슬립 상태(Slipped Conditions)  (0) 2017.04.23
중첩 모니터 락아웃(Nested Monitor Lockout)  (0) 2017.04.16

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

자바 컨커런시와 자바 메모리 모델에 관한 자료를 찾던 중 발견한 이 튜토리얼의 깔금한 이미지와 예제, 명료한 설명에 반하여 번역-소개한다. 자바 컨커런시 외에도 유익한 자료가 많으니 관심이 있다면 꼭 들러보길 바란다(특히 자바 아키텍처 기반 대용량 웹서비스와 관련이 있다면).

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

!! 쉬운 이해를 위한 의역이 다소 포함되었다.



락은 synchrnonized 블록과 유사한 쓰레드 동기화 메카니즘이다. 락은 synchronized 블록보다 더 정교하고 세련된 방식의 동기화를 가능하게 한다. 락은 synchronized 블록을 이용해 구현되며, 때문에 락을 사용한다고 해서 synchronized 블록이 완전히 사라지는 것은 아니다.

Java 5 부터 추가된 java.util.concurrent.locks 패키지는 몇 가지 락 구현을 포함하고 있기 때문에 락을 스스로 구현할 필요는 없어졌다. 하지만 락을 어떻게 사용하는지는 알아야 하고, 락 구현이 어떤 이론을 바탕으로 하는지 안다면 도움이 될 것이다. 더 자세한 정보를 원한다면 여기를 보는 것이 좋다.



간단한 락

synchronized 블록을 이용한 락에서 시작해보자.

public class Counter{

  private int count = 0;

  public int inc(){
    synchronized(this){
      return ++count;
    }
  }
}


inc() 메소드 안의 synchronized 블록을 보자. 이 블록은 코드 return ++count; 의 실행이 한 시점에 오직 한 쓰레드에 의해서만 수행될 수 있음을 보장한다. synchronized 블록 안에는 더 나은 코드가 있었을 수도 있겠지만, 여기서 synchronized 블록의 의미를 이해하기 위해서는 ++count 만으로 충분하다.

Counter 클래스는 synchronized 블록 대신 Lock 을 이용하여 다음과 같이 작성되었다.

public class Counter{

  private Lock lock = new Lock();
  private int count = 0;

  public int inc(){
    lock.lock();
    int newCount = ++count;
    lock.unlock();
    return newCount;
  }
}


lock() 메소드는 Lock 인스턴스에 락을 걸어 lock() 을 호출하는 모든 쓰레드가 unlock() 메소드가 호출될 때까지 블록되도록 한다.

다음은 간단한 Lock 구현이다.

public class Lock{

  private boolean isLocked = false;

  public synchronized void lock()
  throws InterruptedException{
    while(isLocked){
      wait();
    }
    isLocked = true;
  }

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

여기서 while(isLocked) 루프는 '스핀 락' 이라 불린다. 스핀 락과 wait(), notify() 메소드에 대해서는 쓰레드 시그널링에서 보다 자세히 다뤄진다. isLocked 이 true 인 동안은 lock() 을 호출하는 쓰레드는 wait() 메소드 호출을 통해 대기 상태가 된다. 쓰레드가 notify() 가 호출되지 않았음에도 예상치 못하게 깨어나는 경우, isLocked 의 값으로 쓰레드가 다시 대기 상태로 들어가야 할지, 아니면 다음 동작을 수행해야 할지 결정하도록 한다. isLocked 이 false 라면 쓰레드는 while(isLocked) 루프에서 벗어나 isLocked 를 true 로 세팅하여 Lock 인스턴스에 락이 걸렸음을 표시한다.

크리티컬 섹션(lock() 과 unlock() 사이)의 코드 실행을 완료한 쓰레드는 unlock() 을 호출하여 isLocked 을 false 로 세팅하고 lock() 메소드에서 대기중인 쓰레드를 깨운다.


락 재진입

자바의 synchronized 블록은 재진입이 허용된다. 한 쓰레드가 synchronized 블록에 진입하며 모니터 객체의 락을 획득하면, 쓰레드는 자신이 획득한 모니터 객체에 대한 다른 synchronized 블록으로도 진입할 수 있다. 

public class Reentrant{

  public synchronized outer(){
    inner();
  }

  public synchronized inner(){
    //do something
  }
}

outer(), inner() 메소드 모두 synchronized 로 선언되어 있다. 한 쓰레드가 outer() 를 호출했다면, outer() 메소드 안에서는 inner() 마음대로 호출할 수 있다. 두 메소드의 synchronized 블록은 같은 모니터 객체, 'this' 에 대한 동기화를 의미하기 때문이다. 한 쓰레드가 한 모니터 객체에 대한 락을 획득하면 이 쓰레드는 자신이 획득한 모니터 객체에 대한 모든 synchronized 블록으로 진입할 수 있다. 이를 재진입이라 한다. 

먼저 선보인 락 구현은 재진입이 불가능하다. Reentrant 클래스를 아래와 같이 다시 작성하면, outer() 를 호출하는 쓰레드는 inner() 메소드 안의 lock.lock() 에서 블록된다.

public class Reentrant2{

  Lock lock = new Lock();

  public outer(){
    lock.lock();
    inner();
    lock.unlock();
  }

  public synchronized inner(){
    lock.lock();
    //do something
    lock.unlock();
  }
}


outer 를 호출하는 쓰레드는 먼저 Lock 인스턴스에 락을 걸고 inner() 를 호출한다. inner() 메소드 안에서 쓰레드는 다시 Lock 인스턴스에 락을 시도하는데, 이 시도는 실패하며 쓰레드는 블록된다. Lock 인스턴스는 이미 outer() 메소드에서 락이 걸렸기 때문이다.

쓰레드가 두 번째 lock() 을 호출하면서 블록되는 이유는 lock() 메소드 구현을 보면 간단히 알 수 있다.

public class Lock{

  boolean isLocked = false;

  public synchronized void lock()
  throws InterruptedException{
    while(isLocked){
      wait();
    }
    isLocked = true;
  }

  ...
}

이유는 쓰레드가 lock() 메소드를 벗어날지 말지를 결정하는 while 루프(스핀 락) 내부의 조건에 있다. 현재의 조건은 어떤 쓰레드가 락을 걸었느냐와 관계없이 lock() 을 벗어나기 위해서는 isLocked 은 반드시 false 여야 한다.

Lock 클래스를 재진입 가능한 락으로 만들기 위해 약간의 수정이 필요하다.

public class Lock{

  boolean isLocked = false;
  Thread  lockedBy = null;
  int     lockedCount = 0;

  public synchronized void lock()
  throws InterruptedException{
    Thread callingThread = Thread.currentThread();
    while(isLocked && lockedBy != callingThread){
      wait();
    }
    isLocked = true;
    lockedCount++;
    lockedBy = callingThread;
  }


  public synchronized void unlock(){
    if(Thread.curentThread() == this.lockedBy){
      lockedCount--;

      if(lockedCount == 0){
        isLocked = false;
        notify();
      }
    }
  }

  ...
}

이제 while 루프(스핀 락)가 Lock 인스턴스에 락을 건 쓰레드를 가지고 어떻게 동작하는지 보자. 락이 걸리지 않았거나(isLocked = false), 쓰레드가 락을 건 쓰레드와 동일하다면 while 루프는 실행되지 않는다. 그리고 쓰레드는 메소드를 종료한다. 


이에 더하여, 같은 쓰레드에 의해 락이 걸린 횟수를 저장해둘 필요가 있다. 그렇지 않으면 락을 여러번 호출하더라도 한 번의 unlock() 호출로 모든 락이 풀려버리게 된다. 락을 해제하려는 쓰레드가 lock() 호출과 동일한 횟수의 unlock() 을 호출하기 전까지는 락은 풀리지 않아야 한다. 

이제 Lock 클래스는 재진입 가능한 락이 되었다. 


락 공정성

자바의 synchronized 블록은 쓰레드들의 진입 순서에 대한 어떠한 보장도 하지 않는다. 때문에 다수의 쓰레드들이 동일한 synchronized 블록에의 접근을 계속 시도한다면, 하나 이상의 쓰레드가 영영 접근 권한을 부여받지 못하게 될 위험이 있다. 이 경우, 접근 권한은 언제나 다른 쓰레드들에게만 부여되는데, 이러한 현상을 기아상태라 한다. 이 현상을 피하기 위해 Lock 은 공정성을 가져야 한다. 이 글에서 등장한 Lock 구현은 내부적으로 synchronized 블록을 사용하고, 때문에 공정성을 보장하지 않는다. 기아상태와 공정성은 기아상태와 공정성에서 보다 자세히 다뤄진다. 


finally 절에서의 unlock() 호출

Lock 을 이용해 크리티컬 섹션을 보호할 때는 크리티컬 섹션에서 예외가 발생할 수 있음을 기억해야 한다. finally 절에서 unlock() 을 호출하는 일은 중요하다. 다른 쓰레드가 락을 걸 수 있도록 Lock 인스턴스의 락은 반드시 해제되도록 한다. 

lock.lock();
try{
  //do critical section code, which may throw exception
} finally {
  lock.unlock();
}

이 구조는 크리티컬 섹션의 코드에서 예외가 발생할 경우에도 락은 해제되도록 한다. finally 절에서 unlock() 을 호출하지 않는 상태로 크리티컬 섹션에서 예외가 발생한다면 락은 영원히 유지되어 lock() 을 호출하는 모든 쓰레드를 무한정 멈춰버릴 것이다. 








이 글은 원 저자 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() 메소드 호출보다 먼저 발생하는 상황에서도 신호는 실종되지 않는다.













이 글은 원 저자 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

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

원문 URL : http://tutorials.jenkov.com/java-concurrency/starvation-and-fairness.html



어떤 쓰레드가 다른 쓰레드들이 CPU시간을 모두 잡고 있어 CPU시간을 사용할 수 없게 되는 현상을 "기아상태(starvation)" 라 한다. 이 기아 쓰레드는 글자 그대로 "굶어 죽게" 된다. 다른 쓰레드들이 CPU시간을 사용하도록 되어 있기 때문이다. 기아상태의 해결책은 "공정성(fairness)" 이다. 공정성이란, 모든 쓰레드들이 자신의 작업을 수행할 기회를 공정하게 갖는 것을 의미한다.



자바에서의 기아상태의 원인


다음 세 가지는 자바에서 쓰레드의 기아상태를 일으키는 일반적인 원인들이다.


  1. 높은 우선순위를 가진 쓰레드들의 CPU시간 독점
  2. 동기화 영역 진입을 위한 무한한 대기 중 블록 상태
  3. 객체에 대한 무한한 대기 상태(wait() 호출로 인한)

높은 우선순위를 가진 쓰레드들의 CPU시간 독점

자바에서는 쓰레드들 각각에 대한 우선순위를 설정할 수 있다. 이 우선순위가 높은 쓰레드는 더 많은 CPU시간을 획득한다. 우선순위는 1 에서 10 까지 지정할 수 있으며, 지정된 우선순위를 어떻게 해석하고 작업을 수행하게 할 것인지는 어플리케이션이 작동하는 OS에 달려있다. 대부분의 어플리케이션에 있어서 이 우선순위는 지정하지 않고 그냥 두는 편이 낫다.


동기화 영역 진입을 위한 무한한 대기 중 블록 상태

자바의 동기화는 기아상태의 또다른 원인이다. 자바의 동기화는 대기 중인 쓰레드의 순서를 전혀 보장하지 않는다. 이것은 이론적으로 쓰레드가 동기화 영역으로의 진입을 계속 시도하기만 하며 영원히 블록된 상태로 놓일 수 있음을 의미한다. 왜냐하면 이 동기화 영역으로의 진입권을 다른 쓰레드들이 계속, 거듭 획득하기 때문이다. 이 문제를 "기아상태" 라 부르고, 이 현상의 피해자 쓰레드는 "굶어 죽게" 된다. 구체적인 이유는 동기화 영역으로의 진입권을 계속 획득하고 있는 다른 쓰레드들이 CPU시간을 계속 할당받기 때문이다.


객체에 대한 무한한 대기 상태(wait() 호출로 인한)

둘 이상의 쓰레드가 동일한 객체의 wait() 메서드 호출로 대기 상태에 놓였을 때, nofity() 메소드는 이들 중 어떤 쓰레드를 깨울 것인지 전혀 알 수 없다. 즉 어떤 쓰레드 깨어날지 아무도 모른다. 때문에 여기에는 깨어나지 못하고 계속 대기중인 쓰레드가 존재하게 될 위험성이 존재한다. nofity() 메소드가 계속 다른 쓰레드만 깨울 수 있기 때문이다.


자바에서의 공정성 구현

자바에서 100%의 공정성을 구현하기란 불가능하지만 우리는 쓰레드들 사이의 공정성을 높이기 위한 동기화 구성을 구현할 수 있다.

먼저 아래 간단한 코드를 보자.

public class Synchronizer{ public synchronized void doSynchronized(){ // 오랜 시간이 소요되는 작업을 수행한다. } }


다수의 쓰레드가 위의 doSynchronized() 메소드를 호출하게 되면 처음 메소드 안으로 진입한-코드를 수행하는-쓰레드를 제외한 나머지 쓰레드들은 모두 블록된다. 메소드 안으로 진입한 쓰레드가 메소드 수행을 마치고 메소드 밖으로 빠져나가게 되면 대기 중이던 쓰레드들 중 하나가 메소드로의 진입권을 획득하게 되는데, 이 쓰레드가 어떤 쓰레드일지에 대한 보장은 전혀 없다.


Synchronized 대신 Lock 사용하기

공정성을 높이기 위한 다른 방법으로 synchronized 를 버리고 Lock 객체를 사용해보자.

public class Synchronizer{ Lock lock = new Lock(); public void doSynchronized() throws InterruptedException{ this.lock.lock(); // 크리티컬 섹션. 오랜 시간이 소요되는 작업을 수행한다. this.lock.unlock(); } }


이제 synchronized 선언은 없다. 크리티컬 섹션은 Lock.lock(), Lock.unlock() 이 보호한다.

간단한 Lock 클래스 구현은 다음과 같이 이루어질 수 있다.

public class Lock{ private boolean isLocked = false; private Thread lockingThread = null; public synchronized void lock() throws InterruptedException{ while(isLocked){ wait(); } isLocked = true; lockingThread = Thread.currentThread(); } public synchronized void unlock(){ if(this.lockingThread != Thread.currentThread()){ throw new IllegalMonitorStateException( "호출 쓰레드가 이 락을 소유하지 않음."); } isLocked = false; lockingThread = null; notify(); } }


Synchronizer 클래스와 Lock 구현 클래스를 보자. 둘 이상의 쓰레드가 lock() 을 동시에 호출하게 되면 lock() 메소드로의 진입에서 블록된다는 것을 알 수 있다. 그리고 lock이 잠기면(isLocked=true) 쓰레드들은 while 안에서 wait() 을 호출하여 다시 블록 상태에 놓인다(여기서 wait() 메소드 호출은 synchronized 메소드로 진입하며 획득한 락을 해제한다는 사실을 기억하자). 결국 처음 lock() 을 수행한 쓰레드를 제외한 나머지 쓰레드들은 lock() 안에서의 wait() 호출로 모두 블록 상태에 놓이게 된다.

doSynchronized() 메소드의 lock() 과 unlock() 사이의 주석은 lock() 과 unlock() 사이의 코드가 수행에 오랜 시간이 소요됨을 명시하고 있다. 이 코드의 수행이 lock() 메소드로의 진입과 wait() 메소드 호출에 비해 오랜 시간이 소요된다고 가정해보자. 이것은 크리티컬 섹션으로의 진입을 위해 대기한 시간의 대부분이 lock() 메소드로의 진입을 위해서가 아닌, lock() 메소드 안의 wait() 메소드 호출로 인해 블록 상태에 되어 소요됨을 의미한다.

위에서 언급되었듯, synchronized 는 다수의 쓰레드가 경합 중에 있을 때 어떤 쓰레드가 진입권을 획득할지에 대해 보장하지 않는다. wait() 메소드도 마찬가지로 notify() 가 호출될 때 어떤 쓰레드가 깨어날지 보장하지 않는다. 고로, 이 Lock 클래스는 synchronized 로 동기화된 처음 버전의 doSynchronized() 메소드와 그 공정성에 있어 아무런 차이가 없다.

현재의 Lock 클래스는 자신의(this) wait() 메소드를 호출한다. 만약 쓰레드들이 각각 다른 객체의 wait() 을 호출하게끔 하여 한 객체에 대해 wait() 을 호출한 쓰레드를 하나로 만들어 쓰레드와 wait() 객체를 1:1 관계로 구현할 수 있다면, Lock 클래스는 어떤 객체에 대해 notify() 를 호출할지 선택할 수 있고, 즉 정확히 어떤 쓰레드를 깨울지 효과적으로 선택할 수 있을 것이다.


공정한 락

다음은 Lock 클래스를 공정한 락이 구현된, FairLock 으로 바꾼 것이다. 동기화와 wait() / notify() 메소드 활용에 있어 아까의 Lock 클래스와 비교해보면 구현이 다소 바뀐 것을 알 수 있을 것이다.

코드의 디자인이 이전의 Lock 클래스에서 여기에 도달하기까지에는 늘어나는 디자인 과정과 관련된 더 긴 이야기가 있는데, 이전 과정의 문제점을 수정했다: 중첩 모니터 락아웃, 이탈 상태, 신호 소실. 이 논의를 여기에 가져오기에는 이 글이 너무 길어지므로 생략하기로 한다. 각 과정에 대한 내용은 각각에 알맞는 별도의 글에 포함되어 있다(링크를 따라가면 됨).

중요한 것은 이제 lock() 을 호출하는 쓰레드는 큐에 담기게 되고 오직 이 큐에서 처음에 위치한 쓰레드만이 FairLock 인스턴스에 락을 걸 수 있게 된다(물론 아직 락이 걸리지 않을 상태일 경우). 다른 모든 쓰레드들은 큐의 최상위 쓰레드가 될 때 까지 대기 상태로 놓인다.

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();
    boolean     isLockedForThisThread = true;
    synchronized(this){
        waitingThreads.add(queueObject);
    }

    while(isLockedForThisThread){
      synchronized(this){
        isLockedForThisThread =
            isLocked || waitingThreads.get(0) != queueObject;
        if(!isLockedForThisThread){
          isLocked = true;
           waitingThreads.remove(queueObject);
           lockingThread = Thread.currentThread();
           return;
         }
      }
      try{
        queueObject.doWait();
      }catch(InterruptedException e){
        synchronized(this) { waitingThreads.remove(queueObject); }
        throw e;
      }
    }
  }

  public synchronized void unlock(){
    if(this.lockingThread != Thread.currentThread()){
      throw new IllegalMonitorStateException(
        "호출 쓰레드가 이 락을 소유하지 않음.");
    }
    isLocked      = false;
    lockingThread = null;
    if(waitingThreads.size() > 0){
      waitingThreads.get(0).doNotify();
    }
  }
}
public class QueueObject {

  private boolean isNotified = false;

  public synchronized void doWait() throws InterruptedException {
    while(!isNotified){
        this.wait();
    }
    this.isNotified = false;
  }

  public synchronized void doNotify() {
    this.isNotified = true;
    this.notify();
  }

  public boolean equals(Object o) {
    return this == o;
  }
}

먼저 lock() 메소드 선언에는 더이상 synchronized를 사용하지 않는다. synchronized는 메소드 실행 코드에서 필요한 곳에 한하여 사용된다.

FairLock 은 lock() 메소드가 호출되면 새로운 QueueObject 인스턴스를 생성하여 이것을 대기 목록을 의미하는 waitingThreads에 넣는다. unlock() 을 호출하는 쓰레드는 대기 목록에서 가장 상위에 위치한 QueueObject 를 가져와 doNotify() 를 호출하여 대기 중인 쓰레드를 깨운다. 이런 방법으로 쓰레드는 한 번에 오직 하나의 쓰레드를 깨우게 된다. 이것이 FairLock 에서 공정성을 관리하는 부분이다.

여기서 이탈 상태를 피하기 위해 같은 synchronized 블록 안의 상태를 어떻게 체크하고 세팅하는지를 알아야 한다.

또한 QueueObject 는 하나의 세마포어이다. doWait() 과 doNotify() 메소드는 QueueObject 내부에 신호를 저장한다. unlock() 을 호출하는 다른 쓰레드에 의해 수행되는 이 작업은 queueObject.doWait() 호출 직전에 선점되는 쓰레드로 인해 발생하는 신호 소실 문제를 피하기 위함이다. 중첩 모니터 락아웃을 방지하기 위해 queueObject.doWait() 호출은 synchronized(this) 밖에서 이루어진다. 이렇게 함으로써 lock() 메소드 안의 synchronized(this)에 진입한 쓰레드가 하나도 없을 때 다른 쓰레드가 unlock() 을 호출하는 일이 가능해진다.

마지막으로 queueObject.doWait() 이 try-catch 블록 안에서 호출된다는 점을 보자. 이는 InterruptedException 이 발생하면 쓰레드는 lock() 메소드를 벗어나게 되는데, 이 때 queueObject 를 대기 목록(waitingThreads)에서 제거해야 하기 때문이다.


성능

Lock 과 FairLock 클래스를 비교해보면 FairLock 클래스 쪽이 lock() 과 unlock() 메소드에서 뭔가 더 많은 것을 한다는 것을 알 수 있다. 이런 코드들은 FairLock 의 동기화 성능을 Lock 보다 조금 떨어지게 만드는 원인이 된다. 이것이 어플리케이션에 얼마나 영향을 미치느냐는 FairLock 으로 보호되는 크리티컬 섹션 코드의 수행 시간에 달려 있다. 크리티컬 섹션 안의 코드 수행 시간이 길 수록 동기화의 오버헤드는 줄어든다. 물론 이것은 이 코드의 호출 빈도와도 관련이 있다.











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

슬립 상태(Slipped Conditions)  (0) 2017.04.23
중첩 모니터 락아웃(Nested Monitor Lockout)  (0) 2017.04.16
데드락 방지(Deadlock Prevention)  (0) 2017.04.09
데드락(Deadlock)  (0) 2017.04.09
쓰레드 시그널링  (0) 2017.04.09


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

원문 URL : http://tutorials.jenkov.com/java-concurrency/deadlock-prevention.html



상황에 따라 데드락을 방지하는 것이 가능하다. 여기서는 세 가지 기술을 소개한다.


  1. 락 정렬
  2. 락 타임아웃
  3. 데드락 감지



락 정렬


데드락은 다수의 쓰레드들이 같은 락들을 필요로 하면서 서로 다른 순서로 서로의 락을 획득하려 할 때 발생한다.

만약 모든 락이 항상 같은 순서로 획득된다는 보장이 있다면, 데드락은 발생할 수 없다. 다음 예제를 보자:


Thread 1:

  lock A 
  lock B


Thread 2:

   wait for A
   lock C (when A locked)


Thread 3:

   wait for A
   wait for B
   wait for C


한 쓰레드가 여기 Thread 3 처럼 다수의 락을 필요로 한다면, 이 쓰레드는 반드시 정해진 순서로 락을 획득하여야 한다. 이 쓰레드의 락 획득은 정해진 순서 안에서 이전 락을 획득할 때까지 다음 락을 획득할 수는 없다.


예를 들어서, Thread 2 나 Thread 3 은 먼저 A 의 락을 획득해야 C 의 락을 획득할 수 있다. Thread 1 이 A 의 락을 잡고 있기 때문에, Thread 2 와 Thread 3 은 일단 A 의 락이 풀릴 때까지 기다려야 하고, B 나 C 의 락을 시도하기 전에 반드시 A 의 락을 계속 시도하게 된다.


락 정렬은 아직까지 효과적인 데드락 방지 기술이다. 하지만, 이 방법은 획득하려는 락의 순서를 알고 있는 상태에서만 사용될 수 있다. 언제나 사용할 수 있는 방법은 아니다.



락 타임아웃


다른 데드락 방지 기술로는 락 시도에 타임아웃을 거는 방법이 있다. 이 타임아웃이란 쓰레드가 락을 획득하기 위해 기다리는 시간을 정해놓는다는 것을 의미한다. 락 타임아웃을 건 쓰레드가 주어진 시간을 소진하기까지 락을 획득하지 못하면 쓰레드는 이 락을 포기한다. 만약 쓰레드가 반드시 획득해야 하는 락에서 타임아웃이 걸리면, 이 쓰레드가 잡고 있는 모든 락을 해제하고 '얼마간의' 시간을 기다린 뒤 다시 락을 시도한다. 이 얼마간의 시간이란, 같은 락(타임아웃이 걸린)을 획득하려는 다른 쓰레드에게 모든 락을 획득할 기회를 주는 것이다. 때문에 이런 경우에는 어플리케이션이 어디에도 락을 걸지 못하고 계속 돌아가는 경우가 생길 수도 있다.


다음 예제는 두 쓰레드가 다른 순서로 같은 두 락을 획득하려 하다가 재시도를 하게 되는 경우이다:


Thread 1 locks A
Thread 2 locks B

Thread 1 attempts to lock B but is blocked
Thread 2 attempts to lock A but is blocked

Thread 1's lock attempt on B times out
Thread 1 backs up and releases A as well
Thread 1 waits randomly (e.g. 257 millis) before retrying.

Thread 2's lock attempt on A times out
Thread 2 backs up and releases B as well
Thread 2 waits randomly (e.g. 43 millis) before retrying.

위 예제에서 Thread 2 는 Thread 1 보다 약 200 밀리초 먼저 락 획득을 시도하게 될 것이다. 그리고 Thread 1 는  락 A 획득을 위해 기다리다가 Thread 2 의 작업이 끝나면 락을 획득할 수 있게 된다(그 사이 Thread 2 나 다른 쓰레드가 끼어들어 락을 가져가지 않는다면).


여기서 기억할만한 점은, 락 타임아웃은 꼭 데드락에 대해서만 발생하지 않는다는 것이다. 락 타임아웃은 데드락 뿐만 아니라, 단순히 작업을 처리하기 위해 락을 오래 잡고 있게 되면 얼마든지 발생할 수 있다.


이에 더하여, 만약 충분한 수의 쓰레드들이 공유 자원에 대한 작업을 완료한다 해도 이 쓰레드들은 여전히 동시에 자원에 접근한다는 위험부담을 감수해야 하고, 이 사실은 타임아웃과 백업을 적용하더라도 마찬가지다. 타임아웃은 두 쓰레드간의 대기시간이 0 에서 500 밀리초 사이인 경우는 발생하지 않을 수 있지만, 쓰레드의 수가 10이나 20 정도로 많아지면 상황은 달라진다. 두 쓰레드간의 동기 대기에 있어서 발생할 공산이 훨씬 높다.


락 타임아웃 메카니즘의 문제점은 동기화블록에의 진입에 대해서는 타임아웃을 설정할 수 없다는 것이다. 이를 위해서는 커스턴 락 클래스를 만들거나 java.util.concurrency 패키지의 컨커런시 구조를 사용해야 한다. 커스텀 락 클래스 작성은 그리 어렵지는 않지만 이 글의 범위를 벗어나는 일이다. 자바 컨커런시의 다음 글에서 이를 다뤄보기로 한다.



데드락 감지


데드락 감지는 더 무거운 데드락 방지 메카니즘이다. 이것은 락 정렬이나 락 타임아웃으로 커버할 수 없는 상황에 적용될 수 있다.


한 쓰레드가 락을 획들하면 이는 쓰레드와 락의 데이터 스트럭처(맵, 그래프 기타등등) 에 알려진다. 쓰레드가 락을 요청할 때는 언제나 이 데이터 스트럭처에 알려진다.


한 쓰레드가 락을 요청했는데 이 요청이 거부된다면, 이 쓰레드는 데드락을 체크하기 위해 락 그래프를 가로지르며 횡단한다. 예를 들어, Thread A 가 7번 락을 요청했는데 7번 락이 Thread B 에 잡혀 있다면, Thread A 는 Thread B 가 자신(Thread A)이 가진 락들 요청하지 않았는지 체크한다. 만약 thread B 가 욫어을 했엇다면, 데드락이 발생한다.


물론 실제 데드락 시나리오는 두 쓰레드간에 발생하는 방황보단 훨씬 복잡할 수 있다. Thread A 는 Thread B 를 기다리고, Thread B 는 Thread A 를 기다릴 수 있다. Thread A 가 데드락을 감지하기 위해서는 Thread B 에 의해 요청된 락을 수동적으로 확인해야 한다. Thread B 의 락 요청에서부터, Thread A 는 Thread C 로, Thread C 는 Thread D 로, Thread A 가 가진 락을 찾아나가게 된다. 그리고 락을 찾으면 데드락이 발생한다.


아래는 네 개의 쓰레드로부터 요청된 락들의 그래프이다. 이건 데이터 구조가 데드락 감지를 위해 쓰인다.



그래서, 데드락이 감지되면 쓰레드는 무엇을 하는걸까?


한가지 가능한 일은 모든 락을 해제하고, 백업하고, 무작위의 시간을 대기한 뒤 재시도하는 것이다. 이것은 락 타임아웃 메카니즘과 비슷한데, 락 타임아웃에서의 쓰레드는 백업까지만 수행한다. 하지만 백업-대기 를 수행하더라도 만약 많은 수의 쓰레드가 같은 락을 요청한다면 쓰레드들은 결국 반복적으로 데드락에 빠질 수 있다.


여기서 더 나은 선택은, 쓰레드의 우선순위를 정하여 한 번에 한 쓰레드(혹은 적은 수, 몇 개의 쓰레드)만 백업을 하도록 하는 것이다. 남은 쓰레드들은 마치 데드락이 발생하지 않은 것처럼, 필요한 락 획득을 계속해서 시도할 것이다. 만약 쓰레드의 우선순위가 고정적이라면, 동일한 쓰레드들은 언제나 높은 우선순위를 가지게 될 것이다. 이를 방지하기 위해 데드락이 감지될 때마다 우선순위 할당을 랜덤하게 하는 방법이 있다.



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

중첩 모니터 락아웃(Nested Monitor Lockout)  (0) 2017.04.16
기아상태와 공정성(Starvation and Fairness)  (0) 2017.04.09
데드락(Deadlock)  (0) 2017.04.09
쓰레드 시그널링  (0) 2017.04.09
자바 쓰레드로컬(ThreadLocal)  (1) 2017.04.09


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

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



쓰레드 데드락


데드락이란, 둘 이상의 쓰레드가 lock 을 획득하기 위해 기다리는데, 이 lock 을 잡고 있는 쓰레드도 똑같이 다른 lock 을 기다리며 서로 블록 상태에 놓이는 것을 말한다. 데드락은 다수의 쓰레드가 같은 lock 을, 동시에, 다른 명령에 의해, 획득하려 할 때 발생할 수 있다.


예를 들자면, thread1 이 A 의 lock 을 가지고 있는 상태에서 B 의 lock 을 획득하려 한다. 그리고 thread2 는 B 의 lock 을 가진 상태에서 A 의 lock 을 획득하려 한다. 데드락이 생긴다. thread1 은 절대 B 의 lock 을 얻을 수 없고, 마찬가지로 thread2 는 절대 A 의 lock 을 얻을 수 없다. 두 쓰레드 중 어느 쪽도 이 사실을 모를 것이며, 쓰레드들은 각자의 lock - A 와 B 의 - 을 가진 채로 영원히 블록 상태에 빠진다. 이러한 상황이 데드락이다.


아래와 같이 표현된다:


Thread 1  locks A, waits for B
Thread 2  locks B, waits for A


그리고 다음은 서로 다른 인스턴스가 동기화된 메소드를 호출하는, TreeNode 클래스의 예제이다.


public class TreeNode {
 
  TreeNode parent   = null;  
  List     children = new ArrayList();

  public synchronized void addChild(TreeNode child){
    if(!this.children.contains(child)) {
      this.children.add(child);
      child.setParentOnly(this);
    }
  }
  
  public synchronized void addChildOnly(TreeNode child){
    if(!this.children.contains(child){
      this.children.add(child);
    }
  }
  
  public synchronized void setParent(TreeNode parent){
    this.parent = parent;
    parent.addChildOnly(this);
  }

  public synchronized void setParentOnly(TreeNode parent){
    this.parent = parent;
  }
}


Thread1 이 parent.addChild(child) 메소드를, Thread 2 는 child.setParent(parent) 메소드를 각각 동시에, 같은 parent 와 child 인스턴스에 호출한다면,데드락이 발생할 수 있다.


이에 대한 유사코드가 있다:


Thread 1: parent.addChild(child); //locks parent
          --> child.setParentOnly(parent);

Thread 2: child.setParent(parent); //locks child
          --> parent.addChildOnly()


먼저, Thread 1 이 parent.addChild(child) 를 호출한다. addChild() 는 동기화된 메소드이기 때문에 Thread 1 은 parent 객체를 다른 쓰레드로부터의 접근을 막도록 lock 을 건다.


다음으로 Thread 2 은 child.setParent(parent) 를 호출한다. setParent() 역시 동기화된 메소드이고, Thread 2 는 child 객체를 다른 쓰레드로부터의 접근을 막도록 lock 을 건다.


이렇게 child 와 parent 객체들은 두 개의 다른 쓰레드에 의해 lock 이 걸린다. 그리고 Thread 1 은 child.setParentOnly() 메소드 호출을 시도하는데, 이 child 객체는 Thread 2 에 의해 lock 이 걸린 상태이고,Thread 1 은 블록 상태가 된다. Thread 2 는 또한 parent.addChildOnly() 호출을 시도하지만, parent 객체는 Thread 1 에 의해 lock 이 걸린 상태이고 Thread 2 역시 블록 상태가 된다. 이제 두 쓰레드 모두 서로가 잡고 있는 lock 을 기다리며 블록 상태가 된다.


주목할 점: 위 설명에 나와있듯, 이 두 쓰레드의 parent.addChild(child) 와 child.setParent(parent) 호출은 반드시 동시에, 서로의 parent, child 인스턴스에 대해 호출하여야만 데드락이 발생할 수 있다. 위 코드는 갑자기 데드락이 발생하기 전 까지는 정상적으로 작동할 수 있다.


쓰레드들은 정말로 lock 을 '동시에' 획득해야만 한다. 예를 들어, Thread 1 이 Thread 2 보다 약간 앞선다면, 그래서 child 와 parent 양쪽에 모두 lock 을 걸어버린다면, Thread 2 는 child 의 lock 을 획득하려 할 때 이미 블록 상태에 빠질 것이다. 이렇게 되면 데드락은 없다. 쓰레드의 스케쥴링은 예측할 수 없는 경우가 많기 때문에, 데드락이 '언제' 발생할지는 알 수 없다. 그저 '발생할 수 있을' 뿐이다.



더 복잡한 데드락


데드락은 셋 이상의 쓰레드에서도 발생할 수 있다. 이런 현상은 감지되기 더 어렵다.


다음은 네 쓰레드가 데드락에 빠진 예제이다.


Thread 1  locks A, waits for B
Thread 2  locks B, waits for C
Thread 3  locks C, waits for D
Thread 4  locks D, waits for A


Thread 1 은 Thread 2 를 기다리고, Thread 2 는 Thread 3 을, Thread 3 은 Thread 4 를 기다린다. 그리고 Thread 4 는 Thread 1 을 기다린다.



데이터베이스 데드락


데드락이 발생할 수 있는 더 복잡한 상황은, 데이터베이스 트랜젝션이다. 데이터베이스 트랜젝션은 많은 SQL 업데이트 요청으로 구성되곤 한다. 한 트랜잭션에서 어떤 레코드에 대해 업데이트가 수행될 때, 이 레코드는 업데이트 수행을 위해 다른 트랜잭션의 접근을 막도록 lock 이 걸린다. 이 lock 은 업데이트를 수행하는 트랜젝션이 끝날 때까지 지속된다. 같은 트랜잭션 안에서의 각 업데이트는 데이터베이스의 레코드들에 lock 을 걸 수 있다.


다수의 트랜젝션이 동시에 같은 레코드들을 업데이트한다면, 이는 데드락에 빠질 위험성이 있다.


예제:


Transaction 1, request 1, locks record 1 for update
Transaction 2, request 1, locks record 2 for update
Transaction 1, request 2, tries to lock record 2 for update.
Transaction 2, request 2, tries to lock record 1 for update.


여기서 lock 은 서로 다른 요청에 의해 잡혀있고, 어느 쪽도 먼저 알려져 있지 않기 때문에, 데이터베이스 트랜젝션에서의 데드락은 감지하거나 방지하기가 어렵다.



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

기아상태와 공정성(Starvation and Fairness)  (0) 2017.04.09
데드락 방지(Deadlock Prevention)  (0) 2017.04.09
쓰레드 시그널링  (0) 2017.04.09
자바 쓰레드로컬(ThreadLocal)  (1) 2017.04.09
자바 volatile 키워드  (2) 2017.04.09

+ Recent posts