본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
20장 : 생산자·소비자와 조건 대기

ReentrantLock과 Condition

명시적 락에서 await·신호를 호출할 수 있는 범위를 확인하고 `try`-`finally` 해제, 인터럽트, 공정성 선택을 포함한 대기 큐를 구현합니다.

ReentrantLocksynchronized의 대체 철자에 그치지 않습니다. 잠금 획득을 메서드 호출로 드러내고, 시간 제한 획득과 인터럽트 가능한 획득, 여러 조건 대기실을 제공합니다. 그만큼 해제 책임도 코드가 직접 집니다. 예외가 발생해도 반드시 unlock이 실행되도록 lock() 직후 try-finally 골격을 세워야 합니다.

Condition은 자신을 만든 락과 결합됩니다. await()는 해당 락을 보유한 스레드만 호출할 수 있고, 대기에 들어가며 소유권을 내놓습니다. signal() 역시 같은 락 안에서 상태를 바꾼 뒤 실행해야 합니다. 모니터 규칙이 객체 메서드에서 명시적 타입으로 옮겨 왔을 뿐, 조건 검사와 신호의 원리는 그대로입니다.

락 미소유 상태의 await 호출

아래 예제는 newCondition()까지는 성공하지만 락을 얻지 않고 대기합니다. 실행하면 IllegalMonitorStateException이 발생합니다. Condition이라는 별도 객체가 있으니 독립적으로 기다릴 수 있을 것이라는 오해를 바로 드러내는 실패입니다.

intentional-runtime/AwaitWithoutLock.java
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public final class AwaitWithoutLock {
    public static void main(String[] args) {
        ReentrantLock lock = new ReentrantLock();
        Condition changed = lock.newCondition();
        try {
            changed.await();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

락을 얻었다가 해제한 뒤 signal하는 것도 같은 오류입니다. 공유 상태의 변경과 알림 사이를 다른 스레드가 관찰하면 신호와 조건이 어긋날 수 있습니다. 올바른 순서는 잠금, 조건 재검사, 상태 전이, 신호, finally 해제입니다. 대기자는 await 내부에서 잠시 해제하지만 메서드가 반환될 때는 다시 락을 가진 상태라는 점도 중요합니다.

명시적 락을 사용할 때 검토할 네 경계

첫째, 모든 접근 경로가 같은 ReentrantLock을 사용하는지 확인합니다. 일부 메서드만 synchronized를 쓰면 두 잠금은 서로를 배제하지 않습니다. 둘째, unlock은 락 획득이 성공한 뒤의 finally에서 한 번만 실행합니다. 셋째, 조건은 술어를 반복 확인합니다. 넷째, 인터럽트 가능한 메서드가 취소 신호를 삼키지 않도록 예외를 전달합니다.

  • isLocked()를 보고 나서 행동하는 코드는 검사 직후 상태가 달라질 수 있으므로 동기화 수단이 아닙니다.
  • tryLock 실패는 임계 영역을 실행하지 않았다는 정상 분기이며 무조건 unlock해서는 안 됩니다.
  • 공정 락은 대기 시간이 긴 스레드의 선행 가능성을 높이지만 처리량 저하와 완전한 순서 보장의 부재를 감수합니다.
  • await 복귀는 신호 수신과 락 재획득을 모두 거친 뒤이므로 반환 시 공유 상태를 안전하게 읽을 수 있습니다.
  • 진단 메서드의 대기자 수는 순간 추정치이므로 정확성 조건으로 사용하지 않고 운영 관찰에만 씁니다.
  • 한 Condition에 서로 다른 술어가 섞이면 단일 모니터와 같은 대상 선택 문제가 다시 생깁니다.

명시적 잠금의 장점은 기능이 필요할 때만 생깁니다. 단순 상호 배제만 필요하면 synchronized가 해제 누락을 구조적으로 막아 더 읽기 쉽습니다. 시간 제한 락 획득, 인터럽트 가능한 획득, 복수 조건 중 하나가 실제 요구일 때 ReentrantLock의 추가 복잡도를 정당화할 수 있습니다.

단일 변경 조건으로 동작하는 메시지 저장소

이 구현은 아직 Condition을 하나만 둡니다. 생산자와 소비자가 같은 대기실을 공유하므로 상태 전이 뒤 signalAll을 사용합니다. 앞 절의 모니터 버전과 동등한 정확성을 유지하면서 명시적 락 골격을 익히는 것이 목표입니다. 용량과 큐 상태는 오직 lock이 보호합니다.

src/SingleConditionBuffer.java
import java.util.ArrayDeque;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public final class SingleConditionBuffer<E> {
    private final Queue<E> queue = new ArrayDeque<>();
    private final int capacity;
    private final ReentrantLock lock;
    private final Condition changed;

    public SingleConditionBuffer(int capacity, boolean fair) {
        if (capacity < 1) throw new IllegalArgumentException("capacity");
        this.capacity = capacity;
        this.lock = new ReentrantLock(fair);
        this.changed = lock.newCondition();
    }

    public void put(E value) throws InterruptedException {
        if (value == null) throw new NullPointerException("value");
        lock.lockInterruptibly();
        try {
            while (queue.size() == capacity) changed.await();
            queue.add(value);
            changed.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public E take() throws InterruptedException {
        lock.lockInterruptibly();
        try {
            while (queue.isEmpty()) changed.await();
            E value = queue.remove();
            changed.signalAll();
            return value;
        } finally {
            lock.unlock();
        }
    }

    public int size() {
        lock.lock();
        try {
            return queue.size();
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        SingleConditionBuffer<String> buffer = new SingleConditionBuffer<>(1, false);
        Thread consumer = Thread.ofPlatform().start(() -> {
            try { System.out.println("got=" + buffer.take()); }
            catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        });
        Thread producer = Thread.ofPlatform().start(() -> {
            try { buffer.put("condition"); }
            catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        });
        producer.join();
        consumer.join();
        System.out.println("size=" + buffer.size());
    }
}

lockInterruptibly를 사용하면 락 입구에서 기다리는 동안에도 취소에 반응합니다. 일반 lock은 획득 전 인터럽트가 즉시 예외로 나오지 않습니다. 버퍼 작업이 요청 취소와 연결되어 있다면 입구 대기와 조건 대기 모두 중단 가능해야 일관된 API가 됩니다. 반드시 획득에 성공한 뒤 try로 들어가므로 finally의 해제도 안전합니다.

락 획득 제한 시간과 조건 대기 구분

락을 얻는 시간과 버퍼 자리가 생기는 시간은 서로 다른 지연입니다. 다음 카운터는 락 획득에 100밀리초 제한을 두고, 값 변경 조건에는 남은 전체 시간을 사용합니다. awaitNanos가 반환한 잔여 시간을 다음 반복에 전달해야 임의 복귀나 경쟁 후에도 총 제한이 늘어나지 않습니다.

src/TimedConditionCounter.java
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public final class TimedConditionCounter {
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition advanced = lock.newCondition();
    private long value;

    void increment() {
        lock.lock();
        try {
            value++;
            advanced.signalAll();
        } finally {
            lock.unlock();
        }
    }

    boolean awaitAtLeast(long target, Duration timeout) throws InterruptedException {
        if (!lock.tryLock(100, TimeUnit.MILLISECONDS)) return false;
        try {
            long remaining = timeout.toNanos();
            while (value < target) {
                if (remaining <= 0) return false;
                remaining = advanced.awaitNanos(remaining);
            }
            return true;
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        TimedConditionCounter counter = new TimedConditionCounter();
        Thread updater = Thread.ofPlatform().start(() -> {
            counter.increment();
            counter.increment();
        });
        System.out.println("reached=" + counter.awaitAtLeast(2, Duration.ofSeconds(1)));
        updater.join();
    }
}

기대 결과는 reached=true입니다. 나노초 잔여값은 0 이하가 될 수 있으므로 반복 시작에서 검사합니다. 절대 시각을 직접 빼는 방법도 가능하지만 System.currentTimeMillis는 시계 조정 영향을 받으므로 경과 시간에는 단조 증가하는 System.nanoTime이나 Condition의 잔여값을 사용합니다.

ReentrantLock 도입 여부 판단표

필요한 기능synchronized 가능성결론
단순한 짧은 상호 배제충분함내장 모니터 유지
락 입구에서 취소직접 지원 부족lockInterruptibly 고려
획득 시간 제한직접 표현 어려움tryLock 사용
조건을 역할별로 분리대기 집합 하나여러 Condition 채택

공정성 플래그를 켜면 모든 지연 문제가 사라진다고 생각해서는 안 됩니다. 공정 락도 재진입과 스케줄러 영향이 있으며, 조건에서 깨어난 뒤의 정확한 실행 순서를 약속하지 않습니다. 기아가 실제로 관찰되고 잠금 획득 편향이 원인일 때 처리량과 함께 비교합니다.

연습 문제

창고 재고 카운터에 awaitStock(int minimum, Duration timeout)을 구현하세요. 입고는 음수를 허용하지 않고 조건을 알립니다. 대기 메서드는 락 입구에서도 인터럽트 가능해야 하며, 남은 시간을 반복해서 사용해 전체 대기 상한을 지켜야 합니다. 제한 시간이 지나면 현재 재고를 바꾸지 않고 false를 반환합니다.

정답과 해설

재고 필드와 조건은 같은 명시적 락이 보호합니다. 입고가 끝난 후 신호를 보내고, 대기자는 stock < minimum인 동안 awaitNanos를 호출합니다. 제한 시간 판단과 조건 검사가 잠금 안에 있으므로 접점에서 입고와 타임아웃이 경쟁해도 하나의 일관된 상태를 봅니다.

exercise/InventoryThresholdSolution.java
import java.time.Duration;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public final class InventoryThresholdSolution {
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition stockChanged = lock.newCondition();
    private int stock;

    void receive(int quantity) {
        if (quantity <= 0) throw new IllegalArgumentException("quantity");
        lock.lock();
        try {
            stock = Math.addExact(stock, quantity);
            stockChanged.signalAll();
        } finally {
            lock.unlock();
        }
    }

    boolean awaitStock(int minimum, Duration timeout) throws InterruptedException {
        if (minimum < 0) throw new IllegalArgumentException("minimum");
        lock.lockInterruptibly();
        try {
            long nanos = timeout.toNanos();
            while (stock < minimum) {
                if (nanos <= 0) return false;
                nanos = stockChanged.awaitNanos(nanos);
            }
            return true;
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        InventoryThresholdSolution inventory = new InventoryThresholdSolution();
        Thread delivery = Thread.ofPlatform().start(() -> inventory.receive(12));
        boolean ready = inventory.awaitStock(10, Duration.ofMillis(500));
        delivery.join();
        System.out.println("ready=" + ready);
    }
}

결과는 ready=true입니다. 여러 종류의 재고나 출고 가능 조건이 필요해지면 하나의 Condition에 복잡한 역할을 모두 섞지 말고 도메인 술어와 신호 범위를 다시 설계합니다. 필요 수량이 서로 다른 대기자는 signal 하나로 올바른 대상 선택을 보장할 수 없으므로 전체 알림이 더 안전합니다.

명시적 락의 능력과 책임

ReentrantLock을 택했다면 모든 종료 경로의 해제를 눈으로 추적할 수 있어야 합니다. 획득 직후 try, 해제는 finally, 상태 검사와 신호는 그 사이에 둡니다. Condition 호출이 소유권 밖으로 새면 즉시 런타임 실패하거나 더 미묘한 신호 손실로 이어집니다.

단일 조건 버전은 모니터 모델과 연결하기 위한 중간 단계입니다. 다음 절에서는 생산 가능과 소비 가능 대기실을 분리해 전체 각성을 줄이되, 반복 검사와 인터럽트 규칙은 그대로 유지합니다.