자바 동기화 이해하기 ( 2 ) - Lock Reentrance, lock & condition 잘 알고 있나요?

Chan Young Jeong·2023년 3월 8일
1

All About JAVA

목록 보기
5/10
post-thumbnail

이번에는 자바에서 lock 과 conditin을 이용한 동기화에 대해 알아보겠습니다.

지난 시간에 포스팅에서 wait & notify를 이용한 동기화 방법에는 쓰레드를 구분해서 통제하는 것이 불가능하다는 제약사항이 이었습니다.

이럴 때 java.util.concurrent.locks 에서 제공하는 lock을 사용할 수 있습니다. 해당 패키지에서는 LockReadWriteLock 인터페이스를 제공하고 있습니다. 대표적으로 ReentrantLock은 Lock을 구현하였고, ReentrantReadWriteLock은 ReadWriteLock을 구현하였습니다.


public class Counter{

  private int count = 0;

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

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;
  }
}

The lock() method locks the Lock instance so that all threads calling lock() are blocked until unlock() is executed.

간단한 Lock 구현은 다음과 같습니다.

public class Lock{

  private boolean isLocked = false;

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

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

Lock 종류

  • ReentrantLock : 재진입이 가능한 lock. 가장 일반적인 배타적인 lock.

  • ReentrantReadWriteLock : 읽기에는 공유적이고, 쓰기에는 배타적인 lock

  • StampedLock : ReentrantReadWriteLock에 낙관적 읽기(Optimistic Reading) lock의 기능을 추가

Re-Entrant

그렇다면 Re-Entrant 락이 언제 사용되는지 알아보겠습니다. 일단 먼저 공식 문서를 보면 다음과 같이 Re-Entrant 락과 관련된 메서드가 정의 되어 있습니다.

public void lock()

설명은 다음과 같습니다.

Acquires the lock. 기본적으로 lock을 획득합니다.

Acquires the lock if it is not held by another thread and returns immediately, setting the lock hold count to one. 다른 스레드가 락을 들고 있지 않다면 락을 얻고 return합니다. 그리고 lock hold count의 값을 1로 set 합니다.

If the current thread already holds the lock then the hold count is incremented by one and the method returns immediately. 만약 현재 스레드가 이미 락을 가졌다면 lock hold count를 1만큼 증가시키고 return합니다.

If the lock is held by another thread then the current thread becomes disabled for thread scheduling purposes and lies dormant until the lock has been acquired, at which time the lock hold count is set to one. 만약 락을 다른 스레드가 가지고 있으면 현재 스레드는 락을 얻을 때까지 대기 상태가 됩니다. 그리고 lock hold count를 1로 set합니다.

이게 무슨 소리인지 이해해봅시다.

Lock Retrance

public class Reentrant{

  public synchronized outer(){
    inner();
  }

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

기본적으로 자바에서 synchronized 블록은 재진입이 가능합니다.
이것이 의미하는 것은 outer메서드를 호출한 메서드가 해당 객체에 대한 락을 얻고 inner()메서드를 호출한다면 , inner()메서드 또한 synchronized블록이기 때문에 락을 얻어야 하지만 이미 해당 객체에 해당하는 락을 얻었기 때문에 block없이 inner()메서드를 실행할 수 있다는 것입니다. 즉 재진입이 가능합니다.

This means, that if a Java thread enters a synchronized block of code, and thereby take the lock on the monitor object the block is synchronized on, the thread can enter other Java code blocks synchronized on the same monitor object.

이번에는 Lock을 이용해서 위에 코드를 변경해보겠습니다. 과연 잘 작동할까요?
outer() 메서드를 호출하면 스레드는 문제없이 락을 얻을 수 있습니다. 그러나
inner() 메서드를 호출하면 해당 스레드는 블락될 것입니다. 왜냐하면 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() 메서드에서 첫 번째로 객체에 대한 락을 얻습니다. 그리고 inner() 메서드를 호출합니다. 하지만 위에서 보았듯이 간단한 Lock 구현에서 isLocked가 true이면 wait() 메서드가 호출되어 블락되게 됩니다.

그렇다면 이를 재진입이 가능한 락을 만들기 위해서는 어떻게 하면 될까요? 다음과 같이 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 loop안에 lockedBy != callingThread 조건이 추가 되었습니다. 즉 락을 획득한 이전 스레드일 때는 재진입이 가능하게 된다는 것입니다. 여기서 추가로 언제 notify()를 해야 할지를 알기 위해 해당 스레드가 몇번이나 인스턴스의 락을 획득했는지 count를 해야합니다. lockedCount에 그 값을 저장합니다. 예를 들어 같은 스레드가 lock()을 3번 했으면 unlock() 또한 3번 호출해야합니다.

Lock 사용할 때 주의사항

unlock()은 finally에서 호출하기!

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

Condition

Condition은 구분할 쓰레드 종류에 따라 각각의 Condition을 만들어서 각각의 waiting pool에서 따로 기다리도록 할 수 잇습니다.

사용방식은 이미 생선된 lock으로 부터 newCondition()을 호출하여 생성할 수 있습니다.

ReentrantLock lock = new ReentrantLock();
private Condition forCook = lock.newCondition();
private Condition forCust = lock.newCondition();

이후에는 wait()와 notify() 대신 await()와 signal()을 호출하면 됩니다.

  • signal() : Wakes up one waiting thread. If any threads are waiting on this condition then one is selected for waking up. That thread must then re-acquire the lock before returning from await.

  • await() throws InterruptedException
    Causes the current thread to wait until it is signalled or interrupted.

 public void add(String dish) {

      lock.lock();
      try {
         if (isFull()) {
            String name = Thread.currentThread().getName();
            System.out.println(name + " is waiting");
            try {
               //wait();
               forCook.await();
               Thread.sleep(500);
            } catch (InterruptedException e) {}
         }
         dishes.add(dish);

         //notify();
         forCust.signal();
         System.out.println("Dishes: " + dishes.toString());
      } finally {
         lock.unlock();
      }
   }

   public boolean remove(String dish) {
      lock.lock();
      try {
         while (isEmpty()) {
            System.out.println(Thread.currentThread().getName() + " is waiting");
            try {
               forCust.await();
               Thread.sleep(500);
            } catch (InterruptedException e) {}
         }

         while (true) {
            for (String dishName : dishes) {
               if (dishName.equals(dish)) {
                  dishes.remove(dish);
                  forCook.signal();
                  return true;
               }
            }

            try {
               System.out.println(Thread.currentThread().getName() + " is waiting");
               forCust.await();
               Thread.sleep(500);
            } catch (InterruptedException e) {
            }
         }
      } finally {
         lock.unlock();
      }
   }

출처
스택 오버플로우
jenkov.com
티스토리
[자바의 정석]

0개의 댓글