안동민 개발노트

본문 시작

ReentrantLock과 공정성

기본 비공정 설정과 재진입 횟수를 실행하고 synchronized보다 넓은 Lock 제어 기능의 비용을 판단합니다.

Lock 인터페이스는 획득과 해제를 메서드로 분리하고, 대표 구현인 ReentrantLock은 중단 가능 대기, 즉시·시간 제한 시도, 공정성 설정을 제공합니다.

기능이 많다는 이유로 synchronized를 모두 교체할 필요는 없습니다.

무한 대기와 순서 정책을 실제로 제어해야 할 때 선택합니다.


기본 ReentrantLock의 공정성 오해

인자 없는 생성자는 비공정 락을 만듭니다.

아래 프로그램의 실제 출력은 wrong-default-fair=true, isFair=false입니다.

이름에 큐가 있다고 FIFO가 자동으로 적용되는 것은 아닙니다.

lab/DefaultFairLockAssumption.java
import java.util.concurrent.locks.ReentrantLock;

public final class DefaultFairLockAssumption {
    public static void main(String[] args) {
        ReentrantLock lock = new ReentrantLock();
        System.out.println("wrong-default-fair=" + !lock.isFair() + ", isFair=" + lock.isFair());
    }
}

공정 모드는 new ReentrantLock(true)로 명시합니다.

공정성은 락 요청 순서를 더 존중하지만 OS 스케줄링과 스레드 시작 순서 전체를 보장하는 업무 FIFO는 아닙니다.

요청 자체가 큐에 들어간 순서가 스케줄러에 따라 달라질 수 있습니다.


Lock API의 획득 정책

획득 방법에 따라 대기와 중단 결과가 달라진다

ReentrantLock의 네 획득 API를 대기 조건과 반환 결과로 비교합니다. 특정 main의 출력이나 실제 대기 시간 표가 아닙니다.

획득 방법에 따라 대기와 중단 결과가 달라진다
획득 API락을 바로 얻을 수 없을 때반환과 중단
lock()획득할 때까지 대기획득 후 반환 · interrupt만으로 포기하지 않음
lockInterruptibly()획득 또는 interrupt까지 대기획득 후 반환 / InterruptedException
tryLock()기다리지 않음즉시 true 또는 false
tryLock(time, unit)획득·시간 만료·interrupt까지 대기true / false / InterruptedException
lock()
락을 바로 얻을 수 없을 때: 획득할 때까지 대기
반환과 중단: 획득 후 반환 · interrupt만으로 포기하지 않음
lockInterruptibly()
락을 바로 얻을 수 없을 때: 획득 또는 interrupt까지 대기
반환과 중단: 획득 후 반환 / InterruptedException
tryLock()
락을 바로 얻을 수 없을 때: 기다리지 않음
반환과 중단: 즉시 true 또는 false
tryLock(time, unit)
락을 바로 얻을 수 없을 때: 획득·시간 만료·interrupt까지 대기
반환과 중단: true / false / InterruptedException

처음 얻으려던 락을 획득하지 못한 예외 경로나 false 반환에는 unlock을 대응시키지 않습니다. 재진입 전에 이미 보유한 획득분의 해제 책임은 별도로 남습니다.

lock() 직후 try-finally를 열고 finally에서 해제하는 모양이 기본입니다.

newCondition()은 해당 락에 연결된 조건 큐를 만듭니다.

Object.wait·notify와 비슷한 대기·신호 기능을 제공하면서 여러 조건을 한 락에 둘 수 있습니다.

조건의 세부 동작은 생산자·소비자 설계에서 별도로 다룹니다.


재진입 락의 중첩 획득

같은 스레드의 중첩 메서드가 공통 락을 사용하는 예제를 살펴봅니다.

src/ReentrantHoldCount.java
import java.util.concurrent.locks.ReentrantLock;

public final class ReentrantHoldCount {
    private final ReentrantLock lock = new ReentrantLock();

    void outer() {
        lock.lock();
        try {
            System.out.println("outer-hold=" + lock.getHoldCount());
            inner();
        } finally {
            lock.unlock();
        }
    }

    void inner() {
        lock.lock();
        try {
            System.out.println(
                    "inner-hold="
                            + lock.getHoldCount()
                            + ", owned="
                            + lock.isHeldByCurrentThread());
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        ReentrantHoldCount example = new ReentrantHoldCount();
        example.outer();
        System.out.println("locked-after=" + example.lock.isLocked());
    }
}
중첩 획득 두 번을 각각 해제해야 보류 횟수가 0이 된다

ReentrantHoldCount의 0, 1, 2, 1, 0 보류 횟수와 실제 출력이 있는 지점을 구분합니다.

중첩 획득 두 번을 각각 해제해야 보류 횟수가 0이 된다
코드 지점현재 스레드의 보류 횟수출력 또는 코드 근거
획득 전0새 락 · 아직 획득 호출 없음
outer()의 획득 뒤1outer-hold=1 출력
inner()의 재진입 뒤2inner-hold=2, owned=true 출력
inner()의 해제 뒤1안쪽 finally의 해제 1회 · 이 지점은 출력 없음
outer()의 해제 뒤0남은 획득분 해제 · 이후 locked-after=false 출력
획득 전
현재 스레드의 보류 횟수: 0
출력 또는 코드 근거: 새 락 · 아직 획득 호출 없음
outer()의 획득 뒤
현재 스레드의 보류 횟수: 1
출력 또는 코드 근거: outer-hold=1 출력
inner()의 재진입 뒤
현재 스레드의 보류 횟수: 2
출력 또는 코드 근거: inner-hold=2, owned=true 출력
inner()의 해제 뒤
현재 스레드의 보류 횟수: 1
출력 또는 코드 근거: 안쪽 finally의 해제 1회 · 이 지점은 출력 없음
outer()의 해제 뒤
현재 스레드의 보류 횟수: 0
출력 또는 코드 근거: 남은 획득분 해제 · 이후 locked-after=false 출력

이 예제는 한 스레드가 같은 락을 중첩 획득합니다. 다른 스레드의 대기 순서나 여러 락 사이의 교착 상태를 시험하지 않습니다.

진단 메서드를 동기화 조건으로 사용하면 관찰과 동작 사이에 경합이 생길 수 있으므로 락 소유권 판단은 API 결과와 코드 구조로 정합니다.


모니터와 ReentrantLock 대기

내장 모니터 진입 대기는 Thread.State.BLOCKED입니다.

ReentrantLock은 내부적으로 LockSupport.park와 대기 큐를 사용하므로 WAITING, 시간 제한 획득에서는 TIMED_WAITING으로 보일 수 있습니다.

스레드 덤프에서 상태 이름만 보고 같은 락이라고 결론 내리지 않습니다.

unlock은 락을 반납하고 큐의 대기자 하나를 깨울 수 있습니다.

깨어난 스레드는 RUNNABLE이 된 뒤 실제 획득을 다시 시도합니다.

비공정 모드에서는 새로 도착한 스레드가 먼저 잡을 여지가 있습니다.

공정 모드는 큐 선두를 우선해 기아 위험을 줄입니다.

src/FairnessConfigurationReport.java
import java.util.concurrent.locks.ReentrantLock;

public final class FairnessConfigurationReport {
    public static void main(String[] args) {
        report("throughput", new ReentrantLock());
        report("bounded-wait", new ReentrantLock(true));
    }

    private static void report(String policy, ReentrantLock lock) {
        lock.lock();
        try {
            System.out.println(
                    policy + ": fair=" + lock.isFair() + ", queue=" + lock.getQueueLength());
        } finally {
            lock.unlock();
        }
    }
}
두 정책 이름은 설정값을 보여 주며 성능을 측정하지 않는다

FairnessConfigurationReport의 두 생성자와 실제 설정 출력을 비교하되 정책 이름을 벤치마크 결과로 읽지 않습니다.

두 정책 이름은 설정값을 보여 주며 성능을 측정하지 않는다
코드의 정책 이름생성자출력된 설정과 큐
throughputnew ReentrantLock()fair=false · queue=0
bounded-waitnew ReentrantLock(true)fair=true · queue=0
throughput
생성자: new ReentrantLock()
출력된 설정과 큐: fair=false · queue=0
bounded-wait
생성자: new ReentrantLock(true)
출력된 설정과 큐: fair=true · queue=0

두 락에는 다른 요청자가 없습니다. queue는 일반적으로 추정치이며, 이 출력은 처리량·대기 시간·FIFO 순서를 측정하지 않습니다.


공정 모드의 처리량·꼬리 지연 절충

비공정 락은 릴리스 직후 running 스레드가 다시 획득하거나 새 스레드가 barging해 문맥 switch를 줄일 수 있어 처리량에 유리할 수 있습니다.

대신 오래 기다리는 스레드가 생길 가능성이 있습니다.

공정 락은 큐 순서를 더 지켜 대기 편차를 줄이지만 스케줄러 handoff와 캐시 지역성 비용이 늘 수 있습니다.

공정성이 필요한지 평균 처리량만 보지 말고 최대값 wait, p99 지연 시간, 기아 허용 여부를 봅니다.

사용자별 업무 순서를 보장해야 한다면 락 공정성만으로 부족하고 작업 큐에서 키별 순서를 구현해야 합니다.

실험에서는 동일한 임계 영역과 경합 수준에서 공정 설정만 바꾸고 획득 지연 시간 분포를 수집합니다.

짧은 실행의 스레드 이름 순서는 스케줄러 잡음이 커서 공정성 증거가 아닙니다.

오래 기다린 요청의 최대 시간과 단위 시간당 완료 수를 함께 보아야 기아 감소와 처리량 비용을 동시에 판단할 수 있습니다.

주의할 점은 인자 없는 tryLock()이 공정 락에서도 큐를 우회해 즉시 획득할 수 있다는 API 사용 규칙입니다.

공정성을 엄격히 존중하는 시간 제한 시도는 tryLock(0, unit) 같은 중단 가능 시간 제한 형태를 검토하되 문서를 기준으로 사용합니다.


Lock을 도입할지 결정하는 질문

단순한 짧은 임계 영역이고 시간 제한·중단 가능 획득이 필요 없다면 synchronized가 누락 위험이 적습니다.

획득 실패를 업무 결과로 처리하거나 락 wait를 취소해야 하면 ReentrantLock이 적합합니다.

공정성은 실제 기아 요구가 있을 때만 켭니다.

Lock 필드는 private final로 두어 보호 대상과 식별 정보가 바뀌지 않게 합니다.

외부에 노출한 락을 호출자가 임의로 잡으면 class 내부 메서드가 예측 못 한 대기를 겪습니다.

여러 필드의 불변식은 같은 락 아래 일관되게 접근합니다.


연습 문제

첫 인수가 fair이면 공정 락, 그 외에는 기본 비공정 락을 만들고 설정과 보류 count를 출력하세요.

락은 반드시 finally에서 해제합니다.

정답과 설정 경계

생성자에서 한 번 결정한 공정성은 이후 바뀌지 않습니다.

실행 시점 전환이 필요하면 락을 교체하지 말고 상위 구성 요소를 재구성해야 합니다.

exercise/ConfiguredReentrantLockSolution.java
import java.util.concurrent.locks.ReentrantLock;

public final class ConfiguredReentrantLockSolution {
    public static void main(String[] args) {
        boolean fair = args.length > 0 && args[0].equalsIgnoreCase("fair");
        ReentrantLock lock = new ReentrantLock(fair);
        lock.lock();
        try {
            System.out.println("fair=" + lock.isFair() + ", hold=" + lock.getHoldCount());
        } finally {
            lock.unlock();
        }
        System.out.println("released=" + !lock.isLocked());
    }
}

기본 실행은 공정=false, 공정 인수 실행은 true입니다.

두 경우 모두 released=true여야 합니다.

ReentrantLock은 synchronized보다 우월한 대체품이 아니라 획득 정책을 더 많이 노출하는 도구입니다.

재진입 횟수, 큐 상태, 공정성 비용, 성공한 획득만 해제하는 규칙을 이해한 뒤 필요한 제어가 있을 때 선택합니다.