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

+ Recent posts