IllegalMonitorStateException과 notify, wait

Su hwan Choi·2023년 6월 5일

최근에 Redisson 을 사용한 프로젝트 과제를 하면서 IllegalMonitorStateException이 발생하는 경우가 생겼다.
마침 이전에 궁금했던 wait,와 notify 그리고 Monitor에 관해 확인한 내용을 적어본다
wait()와 notify()는 동기화상태에서 스레드간 신호를 보내기 위해 사용된다. 여기서 신호란 스레드를 대기상태인 스레드를 실행상태로 바꾸거나(notify, notifyAll), 실행중인 스레드를 대기상태로 바꾸는것(wait)이다.

두개의 스레드가 BlockingQueue를통해 데이터를 주고 받는 코드를 구현할때, 두개의 스레드사이에서 신호의 전달이 필요하다.

public abstract class BaseBoundedBuffer<V> {
  /**
   * Queue에 데이터를 추가한다
   *
   */
  abstract void put(E e) throws InterruptedException;

  /**
   * Queue에서 데이터를 가져온다
   */
	abstract V take() throws InterruptedException;
}
  1. 선행조건 오류를 호출자에게 그대로 전달
    가장 간단한 방법이다. 여기서 선행조건이란 스레드가 실행가능한 조건을 의미한다. 갑자기 선행조건이라는 개념이 나오는 이유는 멀티스레드 환경에서 스레드의 실행여부가 특정 상태를 만족하는지로 결정되기 때문이다.
    예를들어 위의 간단한 BlockingQueue에서 put메소드를 실행하기 위해서는 Queue의 공간이 남아있어야 put메소드를 문제없이 실행할 수 있다. 즉 Queue의 공간이 남아있는가? 라는 것이 선행조건인 것이다.
    선행조건 오류를 전달한 다는 것은 다시말해 put 메소드를 호출하는 쪽에 여유공간없음 이라는 선행조건 오류를 전달한다는 의미다. 호출자가 이 오류를 받고 다시 시도할지, 그냥 무시할지는 구현하게될 BlockingQueue가 알바 아니다.
    이 방식을 적용한 BlockingQueue는 다음과 같다.
/**
 * 질나쁜(Grumpy) Bounded Buffer. 동기화 클래스 지만 예외가 발생하면, 그 예외를 던져서 호출자가 처리하게 한다.
 * @param <V>
 */
public class GrumpyBoundedBuffer<V> extends BaseBoundedBuffer<V> {
     /**
     *
     * @param v 추가값
     * @throws BufferFullException 버퍼가득찬 상태라 예외발생. 호출자에게 예외 처리를 넘긴다. 호출자는 예외처리를 해야한다.
     */
    public synchronized void put(V v) throws BufferFullException {
        if (isFull())
            throw new BufferFullException();
        doPut(v);
    }

    /**
     * take를 시도하되 버퍼가 비어있다면 예외를 던진다. 호출자는 이를 처리해야 한다.
     * @throws BufferEmptyException 버퍼가 비어있음 예외.
     */
    public synchronized V take() throws BufferEmptyException {
        if (isEmpty())
            throw new BufferEmptyException();
        return doTake();
    }
}

이 경우 코드에 나와있듯, 호출자가 예외를 처리해야한다.

while(true){
  try{
		V item = buffer.take();
		break;
  } catch(BufferEmptyException e){
		//버퍼가비었다. 1초 대기후 재실행,
		//호출하는 쪽에서는 예외처리 코드를 추가해야한다. 
		//만약 대기Thread.sleep 없이 다시 take를 호출하면 CPU자원 소모량이 증가한다(spin waiting)
		//그렇다고 대기시간을 길게 잡으면 필요이상으로 시간이 지연되여 응답성에 손해를 본다.
		Thread.sleep(1000);
  }
}
  1. 폴링과 대기를 반복하는 세련되지 못한 대기상태
    두번째 방법은 재시도 하는 반복문을 내부로 내장시키는 방법이다. 예외처리를 내장했다는것 말고는 사실 크게 달라진것은 없다.
/**
 * {@link concurrent.ex1.GrumpyBoundedBuffer} 에서 예외를 던졌다면, 여기서는 예외를 발생하지 않게, 직접 처리(Sleep)한다.
 * 하지만 Thread sleep 의 시간에 따라 CPU 사용량이 크게 올라가거나, 응답속도가 떨어진다.
 * @param v
 */
@Override
public void put(V v) throws InterruptedException {
    while (true) {
        synchronized (this) {
            if (!isFull()) {
                doPut(v);
                return;
            }
        }
        Thread.sleep(1000);
    }
}

@Override
public V take() throws InterruptedException {
    while (true) {
        synchronized (this) {
            if (!isEmpty()) {
                return doTake();
            }
        }
        Thread.sleep(1000);
    }
}

두가지 방법의 공통점은 put과 take의 호출과 선행조건이 연결되있지 않다는 것이다. 즉 put은 선행조건(여유공간이 있는가?)의 확인을 put메소드를 실행하고 나서야 체크한다. 만약 선행조건을 만족한다면 진행되지만, 그렇지 않다면 임의의 시간만큼 스레드를 멈추고(sleep) 다시 선행조건을 확인한다.

  1. 조건큐
    이 상황에서 wait, notifyAll 등을 사용할 수 있다. wait메소드는 현재 확보하고 있는 락을 해제하면서, 스레드를 멈춰달라고 요청한다. 락이 해제됬기 때문에 다른 스레드가 락을 확보할 수 있게 된다.
    메소드 설명에서 알겠지만 우선 락의 확보가 가능한 상황이어야 한다. 그리고 스레드의 실행과 멈추는데 기준이 되는 조건이 있어야 한다. 이 예제의 경우 여유공간이 있는지 가 해당될 것이다.
public class BoundedBuffer<V> extends BaseBoundedBuffer<V> {
    public BoundedBuffer(int capacity) {
        super(capacity);
    }

    @Override
    public synchronized void put(V e) throws InterruptedException {
		  //스레드가 멈추는 조건
        while (isFull()) {
            wait();
        }
        doPut(e);
        notifyAll();
    }

    @Override
    public synchronized V take() throws InterruptedException {
        while (isEmpty()) {
            wait();
        }
        V v = doTake();
        notifyAll();
        return v;
    }
}

코드로 나타내면 위와 같다.
1,2 와 3번의 가장 큰 차이는 쓰레드간에 신호를 주고받는지 여부일 것이다. 1,2는 신호를 주고받지 않고, 예외처리와 대기시간을 통해 처리한다. 반면 3은 wait와 notifyAll을 사용하여 스레드간의 신호를 주고받는다.

0개의 댓글