ReentrantLock, Condition

서버란·2024년 9월 19일

자바 궁금증

목록 보기
27/35

ReentrantLock - 공정모드와 비공정모드

ReentrantLock은 스레드가 재귀적으로 같은 락을 획득할 수 있는 락입니다. 여기서 락은 공정모드(fair mode)와 비공정모드(unfair mode) 두 가지로 나뉩니다.

  • 공정모드(Fair Mode): 락을 요청한 순서대로 스레드가 락을 획득하게 하는 방식입니다. 즉, 먼저 락을 요청한 스레드가 먼저 락을 획득합니다. 공정모드는 ReentrantLock 생성 시 new ReentrantLock(true)로 설정할 수 있습니다. 하지만 이 모드는 성능이 상대적으로 떨어질 수 있습니다. 왜냐하면, 스레드가 순서를 기다려야 하기 때문에 경쟁이 심화될 수 있기 때문입니다.

  • 비공정모드(Unfair Mode): 비공정모드는 락을 요청한 스레드들이 락을 획득하는 데 있어서 순서를 보장하지 않는 방식입니다. 락을 요청한 스레드가 락을 바로 획득할 수 있으면 락을 바로 잡고 실행을 시작합니다. 비공정모드는 기본 모드(new ReentrantLock(false) 또는 아무 인자 없이 생성)로 설정되며, 성능 면에서 공정모드보다 유리할 수 있습니다.

Condition과 await, signal 메서드

ReentrantLock은 synchronized와 비슷한 역할을 하지만, 더 유연하게 사용될 수 있습니다. 특히, Condition 객체를 이용해서 락과 대기 큐를 보다 세밀하게 제어할 수 있습니다.

Condition 객체 생성: ReentrantLock은 내부적으로 여러 Condition을 가질 수 있습니다. Condition은 lock.newCondition()을 통해 생성됩니다. 하나의 락에 여러 대기 조건을 가질 수 있다는 점에서 Object.wait()와 Object.notify()보다 더 유연합니다.

  • await(): Condition.await()는 현재 스레드가 대기하도록 만듭니다. 이때 스레드는 락을 해제한 상태에서 대기하게 되며, 다른 스레드가 signal()을 호출할 때까지 기다립니다. 이는 Object.wait()와 유사한 역할을 합니다.

  • signal(): Condition.signal()은 대기 중인 스레드 중 하나를 깨워 실행 상태로 전환합니다. 이 메서드는 Object.notify()와 비슷합니다. 대기 중인 모든 스레드를 깨우고 싶다면 signalAll()을 사용하면 됩니다.

notify와 signal의 차이점

notify (Object 클래스):

  • notify()는 객체의 모니터에 대기 중인 스레드 중 하나를 깨웁니다. notifyAll()은 모든 대기 중인 스레드를 깨웁니다.
  • notify()를 사용할 때는 synchronized 블록 안에서만 호출할 수 있습니다. 그 외에는 IllegalMonitorStateException이 발생합니다.
  • 단일 모니터에서 대기 큐를 제어할 수 있으므로 유연성이 낮고, 특정 조건별로 대기 큐를 구분하는 것이 불가능합니다.

signal (Condition 인터페이스):

  • signal()은 Condition 객체의 대기 큐에서 대기 중인 스레드 중 하나를 깨웁니다. signalAll()은 모든 대기 중인 스레드를 깨웁니다.
  • ReentrantLock에서만 사용할 수 있으며, 여러 개의 Condition을 통해 대기 조건을 더 세밀하게 제어할 수 있습니다.
  • 특정 조건에 따라 대기하는 스레드 그룹을 구분해서 관리할 수 있는 유연성이 있습니다.

synchronized에서 대기 상태

synchronized를 사용할 때 스레드가 두 가지 주요 대기 상태로 나뉩니다.

  1. 모니터 락 획득 대기 (Blocked 상태):
  • 다른 스레드가 모니터 락을 이미 소유하고 있을 때, 새로운 스레드는 모니터 락을 획득할 때까지 Blocked 상태로 대기합니다.
  • 이 상태에서는 인터럽트를 통해 스레드를 깨울 수 없습니다.
  1. wait() 대기 (Waiting 상태):
  • synchronized 블록 안에서 Object.wait() 메서드를 호출하면 해당 스레드는 모니터 락을 해제하고 Waiting 상태가 됩니다.
  • 다른 스레드가 notify() 또는 notifyAll()을 호출할 때까지 기다립니다. 이 상태에서는 인터럽트를 통해 대기 중인 스레드를 깨울 수 있습니다.

ReentrantLock에서 대기 상태

  1. ReentrantLock 락 획득 대기:
  • ReentrantLock.lock()을 호출할 때, 이미 다른 스레드가 락을 소유하고 있다면, 새로운 스레드는 락을 획득할 때까지 Waiting 상태로 대기합니다. 이 상태에서는 인터럽트가 가능하며, lockInterruptibly()를 사용하면 대기 중에 인터럽트를 처리할 수 있습니다.
  1. await() 대기:
  • Condition.await()를 호출한 스레드는 락을 해제한 상태에서 Waiting 상태가 됩니다. 다른 스레드가 signal() 또는 signalAll()을 호출하여 해당 스레드를 깨울 때까지 대기합니다.

BlockingQueue 대기 전략

BlockingQueue는 여러 스레드가 안전하게 데이터를 추가하거나 제거할 수 있는 큐입니다. 이때 스레드가 큐에 대해 다양한 방식으로 대기할 수 있습니다.

  1. 대기 시 예외 발생:
  • 큐가 꽉 찼거나 비어 있을 때, 예외를 던지는 방식입니다. add() 메서드는 큐가 꽉 차 있으면 IllegalStateException을 던집니다. remove() 메서드는 큐가 비어 있을 때 NoSuchElementException을 던집니다.
  1. 대기하지 않고 false 반환:
  • 즉시 반환하되, 큐가 꽉 차 있거나 비어 있으면 false를 반환하는 방식입니다. offer() 메서드는 큐가 꽉 차 있으면 false를 반환합니다. poll() 메서드는 큐가 비어 있으면 null을 반환합니다.
  1. 대기한다:
  • 큐가 꽉 차 있거나 비어 있을 때, 스레드가 대기 상태로 들어가고, 공간이 생기거나 값이 추가될 때까지 기다리는 방식입니다. put() 메서드는 큐가 꽉 차 있으면 대기합니다. take() 메서드는 큐가 비어 있으면 대기합니다.
  1. 특정 시간만큼만 대기한다:
  • 큐가 꽉 차거나 비어 있을 때, 특정 시간 동안만 대기하고, 시간이 초과되면 false 또는 null을 반환하는 방식입니다. offer()와 poll() 메서드에 타임아웃 값을 설정하여 사용할 수 있습니다.

Q1. ReentrantLock의 공정모드와 비공정모드를 사용할 때 성능 차이가 발생하는 이유는 무엇인가요?

  • 공정모드(Fair Mode)는 락을 요청한 순서대로 스레드가 락을 획득할 수 있도록 보장합니다. 이로 인해 락을 요청한 순서대로 락을 스레드가 차례로 받게 되므로 컨텍스트 스위칭이 더 자주 발생할 수 있습니다. 여러 스레드가 동일한 락을 기다리고 있을 때, 스레드 스케줄러가 공정하게 락을 배분하기 위해 추가적인 작업을 수행하게 되면서 성능이 떨어질 수 있습니다.

  • 비공정모드(Unfair Mode)는 락을 바로 사용할 수 있는 스레드가 즉시 락을 획득하게 됩니다. 이를 통해 불필요한 컨텍스트 스위칭이 줄어들고, 락을 빠르게 잡고 작업을 수행할 수 있어 성능이 상대적으로 향상될 수 있습니다. 이 방식은 시스템이 최적화되어 락 경쟁이 적거나 특정 순서가 중요하지 않은 경우 유리합니다.

따라서, 공정모드는 스레드 간의 공평성을 보장하지만, 성능 손실이 있을 수 있고, 비공정모드는 성능 최적화 측면에서 더 유리하지만, 스레드 간의 순서 보장은 하지 못합니다.

Q2. await()와 signal()을 사용하는 상황에서, 여러 개의 Condition을 사용할 때의 장점은 무엇일까요?

ReentrantLock의 여러 Condition을 사용할 수 있는 기능은 다음과 같은 장점을 제공합니다:

  1. 세분화된 대기 조건 관리: 여러 개의 Condition을 통해 서로 다른 조건에 대해 스레드들이 대기할 수 있습니다. 예를 들어, 생산자-소비자 패턴에서 생산자가 특정 조건에 따라 대기하고, 소비자는 다른 조건에 따라 대기할 수 있습니다. 이처럼 대기 조건을 세분화하면, 한 조건이 만족되었을 때 다른 대기 중인 스레드를 깨우지 않게 되어 불필요한 깨어남을 방지할 수 있습니다.

  2. 성능 최적화: 모든 스레드가 하나의 모니터 대기 큐에서 관리되는 notify() 방식과 달리, Condition은 다양한 대기 큐를 사용할 수 있습니다. 필요에 따라 해당 조건을 만족하는 대기 중인 스레드만 선택적으로 깨울 수 있기 때문에, 전체 성능을 향상시킬 수 있습니다.

  3. 직관적 코드 구조: 서로 다른 대기 조건을 가진 Condition을 사용하면 코드의 가독성과 유지보수성이 높아집니다. 각각의 Condition을 명확하게 구분해 사용할 수 있기 때문에, 대기 중인 스레드가 어떤 조건에서 대기하고 있는지 더 명확하게 이해할 수 있습니다.

Q3. BlockingQueue를 활용해 대기하지 않고 처리해야 하는 경우, 적절한 예외 처리 전략은 무엇인가요?

BlockingQueue에서 대기하지 않고 작업을 처리해야 할 때 사용할 수 있는 주요 전략은 다음과 같습니다:

  1. 대기 없이 즉시 실패 처리:
  • offer() 메서드는 큐가 꽉 차 있으면 즉시 false를 반환합니다. 이 방법을 사용하면 대기하지 않고 큐에 작업을 시도하고, 실패 시 즉각적인 후속 처리나 대체 작업을 수행할 수 있습니다.
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);
boolean success = queue.offer(5);
if (!success) {
    // 큐가 꽉 찬 경우의 예외 처리
    System.out.println("큐가 가득 차서 작업을 추가할 수 없습니다.");
}
  1. 명시적인 예외 처리:
  • 큐가 가득 찼을 때 예외를 던지도록 add() 메서드를 사용할 수도 있습니다. 예외 발생 시 이를 캐치해서 처리하는 방식으로, 시스템에 명시적인 경고를 줄 수 있습니다.
try {
    queue.add(5); // 큐가 꽉 찬 경우 예외 발생
} catch (IllegalStateException e) {
    // 예외 처리
    System.out.println("큐가 가득 찼습니다: " + e.getMessage());
}
  1. 대기 없이 null 반환:
  • poll() 메서드를 사용하여 큐가 비어 있을 때 null을 반환하도록 할 수 있습니다. 이를 통해 대기 없이 큐에서 데이터를 꺼낼 수 있으며, 꺼낼 데이터가 없으면 null 처리에 대한 예외 처리 로직을 작성할 수 있습니다.
Integer item = queue.poll();
if (item == null) {
    // 큐가 비어 있을 때 처리
    System.out.println("큐에 데이터가 없습니다.");
}

이러한 방식들은 모두 대기하지 않고 즉시 결과를 처리할 수 있는 방법들로, 각 방법을 상황에 맞게 선택할 수 있습니다.

profile
백엔드에서 서버엔지니어가 된 사람

0개의 댓글