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

안동민 개발노트

본문 시작
19장 : 가시성과 동기화

park와 스레드 대기 상태

`interrupt` 플래그가 `park` 뒤 유지되는 동작과 시간 제한 대기, 모니터 진입 대기의 상태 차이를 재현합니다.

LockSupport 대기는 InterruptedException을 던지지 않습니다. interrupt 상태가 설정되면 park가 반환하고 플래그는 그대로 남습니다. 이 차이는 sleep·wait·join의 예외·플래그 소거 동작과 대비됩니다. 시간 대기에는 parkNanos가 있지만 반환 원인은 여전히 조건 재검사로 판단해야 합니다.

interrupt 뒤 park 동작

다음 작업자는 스스로 interrupt한 뒤 park를 두 번 호출합니다. 플래그가 지워지지 않아 두 호출 모두 즉시 반환하고 실제로 wrong-second-wait=false, flag=true가 출력됩니다.

lab/ParkInterruptFlagAssumption.java
import java.util.concurrent.locks.LockSupport;

public final class ParkInterruptFlagAssumption {
    public static void main(String[] args) throws InterruptedException {
        Thread worker =
                new Thread(
                        () -> {
                            Thread.currentThread().interrupt();
                            LockSupport.park();
                            boolean afterFirst = Thread.currentThread().isInterrupted();
                            LockSupport.park();
                            System.out.println(
                                    "wrong-second-wait="
                                            + !afterFirst
                                            + ", flag="
                                            + Thread.currentThread().isInterrupted());
                        },
                        "flagged-parker");
        worker.start();
        worker.join();
    }
}

interrupt를 취소로 처리한다면 park 반환 뒤 플래그를 보고 반복문을 종료합니다. 요청을 소비하고 계속 대기하기로 명시했다면 Thread.interrupted()로 플래그를 지울 수 있지만 상위 취소 신호를 잃지 않는지 검토해야 합니다.

park·interrupt 상태 전이

허가증이 없는 park에 들어가면 스레드는 WAITING이 될 수 있습니다. 다른 스레드가 interrupt하면 RUNNABLE로 돌아오며 상태는 true입니다. park는 검사 예외를 강제하지 않으므로 호출자가 조회를 빼먹어도 컴파일됩니다.

sleepinterruptInterruptedException을 던지고 catch 진입 시 플래그가 false입니다. park는 정상 반환처럼 보이고 플래그가 true입니다. 공통 도우미가 둘을 섞어 사용하면 각 API의 소거 규칙을 문서화해야 합니다.

src/ParkInterruptObservation.java
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.LockSupport;

public final class ParkInterruptObservation {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch entered = new CountDownLatch(1);
        Thread worker =
                new Thread(
                        () -> {
                            entered.countDown();
                            LockSupport.park("cancel-observation");
                            System.out.println(
                                    "returned flag=" + Thread.currentThread().isInterrupted());
                        },
                        "park-observer");
        worker.start();
        entered.await();
        while (worker.getState() != Thread.State.WAITING) {
            Thread.onSpinWait();
        }
        System.out.println("before=" + worker.getState());
        worker.interrupt();
        worker.join();
        System.out.println("after=" + worker.getState());
    }
}

before는 WAITING, 작업자 내부 플래그는 true, after는 TERMINATED입니다. 상태 폴링은 관찰용이고 실제 조정은 래치와 interrupt가 담당합니다.

parkNanos 최대 대기 예산

parkNanos(nanos)는 허가증이 없고 interrupt도 없다면 지정 시간까지 대기할 수 있습니다. 스케줄러 지연 때문에 정확히 그 시각에 실행된다는 보장은 없고 임의 return도 가능합니다. 기한 반복문에서 남은 나노초를 다시 계산합니다.

src/ParkNanosDeadline.java
import java.time.Duration;
import java.util.concurrent.locks.LockSupport;

public final class ParkNanosDeadline {
    private static final class ReadyFlag {
        volatile boolean ready;
    }

    public static void main(String[] args) throws InterruptedException {
        ReadyFlag flag = new ReadyFlag();
        Thread waiter =
                new Thread(
                        () -> System.out.println("outcome=" + await(flag, Duration.ofMillis(30))),
                        "timed-parker");
        waiter.start();
        waiter.join();
    }

    private static String await(ReadyFlag flag, Duration timeout) {
        long deadline = System.nanoTime() + timeout.toNanos();
        while (!flag.ready) {
            if (Thread.currentThread().isInterrupted()) return "interrupted";
            long remaining = deadline - System.nanoTime();
            if (remaining <= 0) return "timed-out";
            LockSupport.parkNanos(remaining);
        }
        return "ready";
    }
}

신호 없이 실행하므로 시간 초과를 출력합니다. 준비 상태를 true로 바꾸는 생산자는 flag.ready = true로 값을 공개하고, 대기자 Thread 참조를 보관해 unpark()도 호출해야 빠르게 깨어납니다. 여기서는 volatile 필드가 상태 공개를 담당합니다.

BLOCKED와 WAITING

BLOCKED는 내장 기능 모니터를 얻기 위해 기다리는 상태입니다. synchronized(lock) 소유자가 나가야 진입 후보가 됩니다. interrupt를 보내도 모니터 획득을 포기하지 않습니다. InterruptedException도 발생하지 않습니다.

WAITINGObject.wait, Thread.join, LockSupport.park처럼 사건을 기다리는 상태입니다. parkunpark·interrupt·임의 반환, Object.waitnotify·interrupt, join은 대상 종료·interrupt로 재개합니다. ReentrantLock의 대기 큐도 내부에서 park를 사용하므로 WAITING으로 보일 수 있습니다.

src/BlockedAndWaitingComparison.java
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.LockSupport;

public final class BlockedAndWaitingComparison {
    public static void main(String[] args) throws InterruptedException {
        Object monitor = new Object();
        CountDownLatch enteredPark = new CountDownLatch(1);
        Thread waiting =
                new Thread(
                        () -> {
                            enteredPark.countDown();
                            LockSupport.park();
                        },
                        "plain-waiting");
        Thread blocked =
                new Thread(
                        () -> {
                            synchronized (monitor) {
                                System.out.println("monitor-acquired");
                            }
                        },
                        "monitor-blocked");
        waiting.start();
        enteredPark.await();
        synchronized (monitor) {
            blocked.start();
            awaitState(waiting, Thread.State.WAITING);
            awaitState(blocked, Thread.State.BLOCKED);
            System.out.println("states=" + waiting.getState() + "," + blocked.getState());
            blocked.interrupt();
            System.out.println("blocked-after-interrupt=" + blocked.getState());
        }
        LockSupport.unpark(waiting);
        waiting.join();
        blocked.join();
    }

    private static void awaitState(Thread t, Thread.State state) throws InterruptedException {
        for (int i = 0; i < 200 && t.getState() != state; i++) {
            Thread.sleep(1);
        }
        if (t.getState() != state) throw new IllegalStateException(t.getName());
    }
}

모니터를 main이 보유하는 동안 차단된 스레드는 interrupt 뒤에도 BLOCKED입니다. 블록을 나와 모니터가 풀리면 임계 영역에 진입한 뒤 종료합니다. park로 대기 중인 스레드는 unpark로 해제됩니다.

직접 대기 루프의 네 조건

조건이 true이면 성공, 인터럽트 상태면 취소, 기한이 지났으면 시간 초과이며 그 외에는 다시 park()합니다. 허가증은 신호 전달 수단일 뿐 대기 조건 자체가 아닙니다. 이 순서를 하나의 도우미에 모으면 호출자가 임의 반환을 잘못 처리할 가능성이 줄어듭니다.

parkUntil(epochMillis)는 wall clock 기반 절대 시간이고 clock 변경 영향을 받을 수 있습니다. elapsed 시간 제한에는 monotonic System.nanoTime()과 parkNanos 조합이 적합합니다. 매우 짧은 남은 시간에는 park 오버헤드와 스케줄러 granularity가 측정값을 지배할 수 있습니다.

API 문서의 상태별 취소 가능성

모니터 BLOCKED를 즉시 interrupt 취소해야 한다면 synchronized 대신 ReentrantLock.lockInterruptibly()를 검토합니다. 시간 제한 획득에는 시간 제한 tryLock이 있습니다. 단순한 락 보호에 취소 요구가 없다면 synchronized가 더 읽기 쉬울 수 있습니다.

park 기반 사용자 정의 동기화 도구는 대기자 큐 제거와 취소됨 node 정리를 직접 책임집니다. 스레드 하나만 대상으로 한 예제가 성공해도 운영 정확성을 증명하지 않습니다.

연습 문제

volatile boolean 조건과 대기자 Thread 참조를 사용해 최대 100ms 기다리는 게이트를 작성하세요. open은 조건을 먼저 true로 만든 뒤 unpark해야 합니다.

정답과 반환 원인 판단

park 반환 자체가 아니라 조건, interrupt, 남은 값 순서로 결과를 결정합니다.

exercise/TimedParkGateSolution.java
import java.util.concurrent.locks.LockSupport;

public final class TimedParkGateSolution {
    private volatile boolean open;
    private volatile Thread waiter;

    String await(long timeoutNanos) {
        waiter = Thread.currentThread();
        long deadline = System.nanoTime() + timeoutNanos;
        while (!open) {
            if (Thread.currentThread().isInterrupted()) return "interrupted";
            long left = deadline - System.nanoTime();
            if (left <= 0) return "timed-out";
            LockSupport.parkNanos(this, left);
        }
        return "opened";
    }

    void open() {
        open = true;
        Thread target = waiter;
        if (target != null) LockSupport.unpark(target);
    }

    public static void main(String[] args) throws InterruptedException {
        TimedParkGateSolution gate = new TimedParkGateSolution();
        Thread worker =
                new Thread(() -> System.out.println(gate.await(100_000_000L)), "timed-gate");
        worker.start();
        while (gate.waiter == null) {
            Thread.onSpinWait();
        }
        gate.open();
        worker.join();
    }
}

정상 출력은 opened입니다. open writeunpark보다 먼저라 대기자가 깨어난 뒤 최신 조건을 읽습니다.

park 계열을 사용할 때는 interrupt가 플래그를 지우지 않는다는 규칙, nanos 기한 재계산, 상태별 취소 가능성을 함께 다뤄야 합니다. BLOCKED와 WAITING을 같은 “멈춤”으로 보면 적절한 해결 API를 고를 수 없습니다.