안동민 개발노트

본문 시작

잠든 스레드와 교착

synchronized 안의 sleep 재시도가 왜 진행 가능성을 파괴하는지 상태와 타임라인으로 밝히고 잠금 밖 재시도와 올바른 대기 조건을 구분합니다.

공유 큐를 보호하려고 synchronized를 붙인 다음, 가득 차면 빈자리가 생길 때까지 sleep으로 쉬면 될 것처럼 보입니다.

문제는 잠드는 위치입니다.

Thread.sleep은 실행만 잠시 멈출 뿐 현재 소유한 모니터를 반납하지 않습니다.

생산자가 큐 잠금을 쥔 채 잠들면 빈칸을 만들 소비자는 take에 들어갈 수 없고, 생산자는 소비를 기다리며 같은 조건을 영원히 다시 확인합니다.

이 현상은 서로 다른 두 락을 반대 순서로 획득하는 고전적 교착과 모양이 다릅니다.

단 하나의 락만 있어도 조건을 바꿀 주체를 임계 영역 밖에 세워 두면 진행이 멈춥니다.

따라서 안전성만 확인해서는 부족합니다.

용량 불변식이 지켜져도 어느 스레드도 작업을 완료하지 못한다면 버퍼는 사용할 수 없습니다.


빈칸을 만들 소비자가 입구에서 막히는 실험

다음 프로그램은 용량 1 큐에 첫 값을 넣은 뒤 두 번째 생산을 시작합니다.

두 번째 생산자는 모니터 안에서 잠들며 빈자리를 기다립니다.

이어서 소비자를 실행해도 take 호출은 객체 락을 얻지 못합니다.

각 join에 제한 시간을 두고 두 스레드의 순간 상태를 출력한 뒤 생산자에게 인터럽트를 요청합니다. 정리 뒤 종료를 다시 기다리는 코드는 없습니다.

bad/SleepWhileHoldingMonitor.java
import java.util.ArrayDeque;
import java.util.Queue;

public final class SleepWhileHoldingMonitor {
    private final Queue<String> queue = new ArrayDeque<>();

    public synchronized void put(String value) {
        while (queue.size() == 1) {
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
        }
        queue.add(value);
    }

    public synchronized String take() {
        return queue.remove();
    }

    public static void main(String[] args) throws InterruptedException {
        SleepWhileHoldingMonitor buffer = new SleepWhileHoldingMonitor();
        buffer.put("first");
        Thread producer = Thread.ofPlatform().name("stuck-producer").start(() -> buffer.put("second"));
        Thread.sleep(50);
        Thread consumer = Thread.ofPlatform().name("blocked-consumer").start(buffer::take);
        producer.join(150);
        consumer.join(150);
        System.out.println("producer=" + producer.getState());
        System.out.println("consumer=" + consumer.getState());
        producer.interrupt();
    }
}
생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다

SleepWhileHoldingMonitor에서 생산자가 먼저 가득 찬 큐의 모니터를 얻은 경로와 실제 상태 관측의 한계를 구분합니다.

생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다
원문의 지점생산자소비자
큐가 가득 찬 반복sleep 동안에도 buffer 모니터를 보유합니다.take에 진입하려면 같은 모니터가 필요합니다.
이번 실행의 조회TIMED_WAITINGBLOCKED
출력 뒤 interruptsleep에서 중단을 받으면 상태를 복구하고 put에서 반환합니다.생산자가 모니터를 놓은 뒤 first를 제거할 수 있습니다.
큐가 가득 찬 반복
생산자: sleep 동안에도 buffer 모니터를 보유합니다.
소비자: take에 진입하려면 같은 모니터가 필요합니다.
이번 실행의 조회
생산자: TIMED_WAITING
소비자: BLOCKED
출력 뒤 interrupt
생산자: sleep에서 중단을 받으면 상태를 복구하고 put에서 반환합니다.
소비자: 생산자가 모니터를 놓은 뒤 first를 제거할 수 있습니다.

main의 sleep(50)은 잠금 획득 순서를 보장하지 않습니다. 소비자가 먼저 제거하면 둘 다 끝날 수도 있으며 두 상태 조회도 하나의 원자적 스냅샷은 아닙니다.

이 종료 장치는 실패를 해결한 것이 아니라 실험을 끝내기 위한 안전핀입니다.


잠금의 목적과 조건 대기의 목적 분리

모니터는 크기 검사와 큐 변경을 하나의 원자적 구간으로 묶습니다.

반면 대기는 현재 스레드가 할 일이 없을 때 다른 주체가 조건을 바꾸도록 실행권을 넘기는 행위입니다.

두 목적을 섞어서 “잠금 안에서 오래 쉬면 안전하다”고 해석하면 안전성은 얻을지 몰라도 생존성을 잃습니다.

  • sleep은 시간 경과만 기다리며 어떤 객체의 모니터도 자동으로 해제하지 않습니다.
  • yield는 스케줄러에 양보 힌트를 줄 뿐 잠금 소유권이나 조건 신호를 다루지 않습니다.
  • 잠금 밖 폴링은 다른 스레드의 진입을 허용하지만 확인 주기만큼 지연과 불필요한 깨어남을 만듭니다.
  • wait와 Condition.await는 정해진 잠금과 결합되어 대기 진입 시 소유권을 반납합니다.
  • 인터럽트는 취소 통로이므로 잡아서 버리지 말고 반환·전파·상태 복구 중 규칙에 맞는 처리를 해야 합니다.
  • 진행성 검증은 최종 값뿐 아니라 제한 시간 안에 각 역할이 종료되는지도 확인해야 합니다.

잠금 범위는 “공유 상태를 읽고 바꾸는 데 필요한 최소 구간”으로 설명하는 편이 정확합니다.

파일 쓰기, 네트워크 호출, 인위적 지연을 그 안에 두면 큐의 불변식과 관계없는 시간이 모든 참여자를 막습니다.

조건 확인부터 변경까지는 붙여 두되, 후속 처리와 느린 작업은 원소를 꺼낸 뒤 잠금 밖에서 수행합니다.


모니터 밖 재시도 기준

정식 해법은 뒤 절의 조건 대기입니다.

여기서는 잘못의 원인이 락 보유라는 사실을 분리해 확인하기 위해 폴링 버전을 만듭니다.

tryPut과 tryTake는 한 번만 검사하고 즉시 반환합니다.

재시도 루프는 메서드 밖에 있으므로 실패 사이에 다른 역할이 같은 모니터를 얻을 수 있습니다.

LockSupport.parkNanos는 CPU 독점을 줄이지만 조건 신호가 아니므로 학습용 비교 기준으로만 사용합니다.

src/OutsideMonitorRetryBuffer.java
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.Optional;
import java.util.Queue;
import java.util.concurrent.locks.LockSupport;

public final class OutsideMonitorRetryBuffer<E> {
    private final Queue<E> queue = new ArrayDeque<>();
    private final int capacity;

    public OutsideMonitorRetryBuffer(int capacity) {
        if (capacity < 1) throw new IllegalArgumentException("capacity");
        this.capacity = capacity;
    }

    public synchronized boolean tryPut(E value) {
        if (value == null) throw new NullPointerException("value");
        if (queue.size() == capacity) return false;
        queue.add(value);
        return true;
    }

    public synchronized Optional<E> tryTake() {
        return Optional.ofNullable(queue.poll());
    }

    public boolean putBefore(E value, Duration timeout) throws InterruptedException {
        long deadline = System.nanoTime() + timeout.toNanos();
        do {
            if (tryPut(value)) return true;
            if (Thread.interrupted()) throw new InterruptedException();
            LockSupport.parkNanos(200_000);
        } while (System.nanoTime() < deadline);
        return false;
    }

    public static void main(String[] args) throws Exception {
        OutsideMonitorRetryBuffer<String> buffer = new OutsideMonitorRetryBuffer<>(1);
        buffer.tryPut("first");
        Thread producer = Thread.ofPlatform().start(() -> {
            try {
                System.out.println("inserted=" + buffer.putBefore("second", Duration.ofSeconds(1)));
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        Thread consumer = Thread.ofPlatform().start(() -> {
            LockSupport.parkNanos(Duration.ofMillis(30).toNanos());
            System.out.println("removed=" + buffer.tryTake().orElseThrow());
        });
        producer.join();
        consumer.join();
        System.out.println("tail=" + buffer.tryTake().orElseThrow());
    }
}
재시도는 시도·중단·대기·기한 순서로 검사한다

OutsideMonitorRetryBuffer.putBefore의 do/while을 실제 호출 순서와 모니터 범위로 읽습니다.

재시도는 시도·중단·대기·기한 순서로 검사한다
실행 순서원문의 검사·호출결과와 범위
1 · 즉시 시도tryPut(value)검사·추가는 synchronized 메서드 안입니다. 성공하면 즉시 true를 반환합니다.
2 · 실패 뒤 중단 검사Thread.interrupted()true이면 플래그를 지우고 예외를 던집니다. 첫 시도보다 뒤입니다.
3 · 모니터 밖 대기parkNanos(200_000)소비자는 같은 모니터에 진입할 수 있습니다. 0.2ms는 대기 요청값입니다.
4 · 기한 비교현재 시간이 deadline보다 이른가이르면 다음 시도로, 아니면 false로 끝납니다. 시도 횟수 제한은 없습니다.
1 · 즉시 시도
원문의 검사·호출: tryPut(value)
결과와 범위: 검사·추가는 synchronized 메서드 안입니다. 성공하면 즉시 true를 반환합니다.
2 · 실패 뒤 중단 검사
원문의 검사·호출: Thread.interrupted()
결과와 범위: true이면 플래그를 지우고 예외를 던집니다. 첫 시도보다 뒤입니다.
3 · 모니터 밖 대기
원문의 검사·호출: parkNanos(200_000)
결과와 범위: 소비자는 같은 모니터에 진입할 수 있습니다. 0.2ms는 대기 요청값입니다.
4 · 기한 비교
원문의 검사·호출: 현재 시간이 deadline보다 이른가
결과와 범위: 이르면 다음 시도로, 아니면 false로 끝납니다. 시도 횟수 제한은 없습니다.

첫 시도는 기한 검사보다 먼저입니다. 기한 전 검사 뒤 스케줄이 늦어져 기한 후 삽입할 수도 있으므로 true를 정확한 1초 내 완료의 증거로 볼 수 없습니다.

이제 소비자는 생산자가 재시도 사이에 잠금을 반납하므로 first를 제거할 수 있습니다.

이번 실행은 removed=first, inserted=true, tail=second 순으로 끝났습니다. 성공 경로의 앞 두 줄 순서는 바뀔 수 있습니다. 기한이 먼저 끝나면 inserted=false 뒤 마지막 꺼내기에서 NoSuchElementException이 날 수 있습니다.

반복 조회 비용은 남으며 실제 재검사 간격과 추가 지연은 스케줄링과 모니터 획득 지연의 영향도 받습니다.


테스트의 제한 시간 관측

생존성 오류를 다루는 테스트가 join()을 제한 없이 호출하면 실패했을 때 테스트 러너도 끝나지 않습니다.

다음 감시기는 제한 시간 후 살아 있는 스레드의 상태를 기록하고 인터럽트합니다.

검사 순간의 종료 여부를 불리언으로 반환하지만, 인터럽트 요청 뒤 실제 종료를 기다리지는 않습니다.

src/ThreadCompletionProbe.java
import java.time.Duration;
import java.util.List;

public final class ThreadCompletionProbe {
    static boolean awaitAll(List<Thread> workers, Duration timeout) throws InterruptedException {
        long deadline = System.nanoTime() + timeout.toNanos();
        for (Thread worker : workers) {
            long remaining = deadline - System.nanoTime();
            if (remaining > 0) worker.join(Duration.ofNanos(remaining));
        }
        boolean completed = workers.stream().noneMatch(Thread::isAlive);
        if (!completed) {
            workers.stream()
                    .filter(Thread::isAlive)
                    .forEach(worker -> System.err.println(worker.getName() + ":" + worker.getState()));
            workers.forEach(Thread::interrupt);
        }
        return completed;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread shortJob = Thread.ofPlatform().name("short-job").start(() -> {
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        System.out.println("completed=" + awaitAll(List.of(shortJob), Duration.ofSeconds(1)));
    }
}

상태 출력은 원인 그 자체가 아니라 단서입니다.

BLOCKED는 모니터 입장을 기다린다는 뜻이고 TIMED_WAITING은 시간 제한 동작 안에 있다는 뜻입니다.

스레드 덤프에서 잠금 소유자와 대기자를 함께 연결해야 “생산자가 모니터를 가진 채 잠들어 소비자가 막혔다”는 인과를 확정할 수 있습니다.


어떤 대기 도구를 고를지 결정하는 표

상황락 반납 여부판단
임계 영역과 무관한 지연먼저 잠금 해제밖에서 sleep 가능
공유 조건이 바뀔 때까지 대기반드시 반납wait 또는 Condition
단 한 번의 비차단 시도즉시 반환try 메서드와 결과값
제한 시간 안의 수용반납하며 시간 계산시간 제한 offer 계열

대기 시간을 짧게 줄인다고 구조적 정지가 해결되지는 않습니다.

1초 대신 1밀리초로 바꾸면 잠금 소유자가 더 자주 깨어날 뿐, 소비자가 들어올 기회는 여전히 없습니다.

반대로 잠금 밖 폴링은 진행은 가능하지만 지연과 CPU 사용이 확인 주기에 묶입니다.

버퍼 조건이 바뀌는 정확한 순간을 알고 있으므로 신호 기반 대기가 더 직접적인 모델입니다.


연습 문제

큐에서 메시지를 꺼내 JSON 문자열로 직렬화하는 소비자가 있습니다.

기존 코드는 synchronized drainOne() 안에서 40밀리초 직렬화를 수행하여 생산자를 계속 막습니다.

큐 제거만 보호하고 변환은 밖에서 실행하도록 리팩터링하세요.

동시에 두 소비자가 같은 메시지를 처리하지 않는지도 설명합니다.

정답과 해설

잠금 안에서는 poll로 소유권을 한 소비자에게 이전합니다.

제거된 큐 항목은 한 소비자에게만 넘어갑니다.

그 다음 느린 변환은 지역 변수만 사용하므로 모니터가 필요하지 않습니다.

큐가 비었을 때는 Optional.empty()로 즉시 알려 줍니다.

exercise/SerializeOutsideLockSolution.java
import java.util.ArrayDeque;
import java.util.Optional;
import java.util.Queue;

public final class SerializeOutsideLockSolution {
    record Message(long id, String body) {}

    private final Queue<Message> pending = new ArrayDeque<>();

    synchronized void submit(Message message) {
        pending.add(message);
    }

    private synchronized Optional<Message> claimOne() {
        return Optional.ofNullable(pending.poll());
    }

    Optional<String> drainOne() {
        Optional<Message> claimed = claimOne();
        return claimed.map(this::slowSerialize);
    }

    private String slowSerialize(Message message) {
        try {
            Thread.sleep(40);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("serialization interrupted", e);
        }
        return "{\"id\":" + message.id() + ",\"body\":\"" + message.body() + "\"}";
    }

    public static void main(String[] args) throws InterruptedException {
        SerializeOutsideLockSolution queue = new SerializeOutsideLockSolution();
        queue.submit(new Message(7, "ready"));
        Thread consumer = Thread.ofPlatform().start(() -> System.out.println(queue.drainOne().orElseThrow()));
        Thread producer = Thread.ofPlatform().start(() -> queue.submit(new Message(8, "next")));
        consumer.join();
        producer.join();
        System.out.println(queue.drainOne().isPresent());
    }
}
큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다

SerializeOutsideLockSolution에서 submit과 claimOne은 큐를 보호하고 slowSerialize는 꺼낸 값을 변환합니다.

큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다
원문의 구간소유 범위수행하는 일
submitthis 모니터 안큐에 항목을 추가합니다.
claimOne같은 this 모니터 안poll로 항목 하나를 제거하여 한 호출자에게 넘깁니다.
slowSerialize모니터 밖꺼낸 Message를 사용해 40ms sleep과 문자열 결합을 수행합니다.
submit
소유 범위: this 모니터 안
수행하는 일: 큐에 항목을 추가합니다.
claimOne
소유 범위: 같은 this 모니터 안
수행하는 일: poll로 항목 하나를 제거하여 한 호출자에게 넘깁니다.
slowSerialize
소유 범위: 모니터 밖
수행하는 일: 꺼낸 Message를 사용해 40ms sleep과 문자열 결합을 수행합니다.

같은 참조를 여러 번 submit하면 큐 항목도 여러 개입니다. 이 답안의 JSON 문자열 결합은 따옴표 등을 이스케이프하지 않으므로 임의의 body를 안전하게 직렬화하는 구현은 아닙니다.

직렬화 중에도 생산자는 두 번째 메시지를 넣을 수 있습니다.

claimOne이 반환하는 순간 첫 메시지는 한 소비자에게만 귀속됩니다.

처리 실패 때 재큐잉이 필요하다면 별도의 실패 정책과 시도 횟수를 추가해야 하며, 단순히 긴 작업을 다시 잠금 안으로 넣어 원자성을 흉내 내서는 안 됩니다.


정지 원인의 명시

synchronized는 스레드를 안전하게 해 주는 마법 문구가 아니라 한 번에 한 주체만 상태 전이를 수행하게 하는 문입니다.

문 안에서 조건이 저절로 바뀌기를 기다리면, 조건 변경자를 문 밖에 세워 두는 셈입니다.

어떤 코드가 잠금을 소유하고 어떤 코드가 조건을 바꾸는지 타임라인으로 그리면 이 오류를 빠르게 찾을 수 있습니다.

완성된 버퍼는 안전성, 진행성, 취소 가능성을 따로 확인합니다.

크기가 용량을 넘지 않는지, 제한 시간 안에 생산과 소비가 모두 끝나는지, 인터럽트가 작업 중단으로 이어지는지를 각각 관찰해야 합니다.

다음 절의 모니터 대기는 이 세 요구를 한 프로토콜 안에서 다룹니다.