이 글은 원 저자 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() 을 호출하는 모든 쓰레드를 무한정 멈춰버릴 것이다. 








+ Recent posts