안동민 개발노트

본문 시작

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는 대기 중인 park를 반환하게 하며 인터럽트 플래그를 설정합니다.

park는 검사 예외를 강제하지 않으므로 호출자가 조회를 빼먹어도 컴파일됩니다.

sleep은 interrupt 시 InterruptedException을 던지고 catch 진입 시 플래그가 false입니다.

인터럽트로 돌아온 park는 예외를 던지거나 플래그를 지우지 않습니다.

공통 도우미가 둘을 섞어 사용하면 각 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());
    }
}
인터럽트 플래그와 상태 관측은 보장 범위가 다르다

ParkInterruptFlagAssumption의 자기 인터럽트 결과와 ParkInterruptObservation의 세 출력 지점을 구분합니다.

인터럽트 플래그와 상태 관측은 보장 범위가 다르다
예제·조회 지점읽을 수 있는 결과그 결과의 범위
자기 interrupt 예제두 park 뒤에도 flag=true입니다.플래그를 지우지 않으므로 wrong-second-wait=false이며 대기 시간을 측정한 값은 아닙니다.
관측 예제의 beforeWAITING 폴링 뒤 상태를 새로 읽습니다.새 조회도 WAITING이라고 고정할 수 없습니다.
관측 예제의 작업자 로그park 뒤 isInterrupted()를 읽습니다.임의 반환이 앞서면 false일 수 있고 before 로그와의 순서도 바뀔 수 있습니다.
관측 예제의 after성공한 join 뒤 상태를 읽습니다.이 지점까지 완료했을 때는 TERMINATED입니다.
자기 interrupt 예제
읽을 수 있는 결과: 두 park 뒤에도 flag=true입니다.
그 결과의 범위: 플래그를 지우지 않으므로 wrong-second-wait=false이며 대기 시간을 측정한 값은 아닙니다.
관측 예제의 before
읽을 수 있는 결과: WAITING 폴링 뒤 상태를 새로 읽습니다.
그 결과의 범위: 새 조회도 WAITING이라고 고정할 수 없습니다.
관측 예제의 작업자 로그
읽을 수 있는 결과: park 뒤 isInterrupted()를 읽습니다.
그 결과의 범위: 임의 반환이 앞서면 false일 수 있고 before 로그와의 순서도 바뀔 수 있습니다.
관측 예제의 after
읽을 수 있는 결과: 성공한 join 뒤 상태를 읽습니다.
그 결과의 범위: 이 지점까지 완료했을 때는 TERMINATED입니다.

entered 래치는 park 호출보다 먼저 해제됩니다. main이 WAITING 순간을 놓치면, 작업자가 임의 반환 후 종료했어도 상태 폴링에서 계속 기다릴 수 있습니다.


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도 발생하지 않습니다.

WAITING은 Object.wait, Thread.join, LockSupport.park처럼 사건을 기다리는 상태입니다.

park는 unpark·interrupt·임의 반환, Object.wait는 notify·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());
    }
}
같은 interrupt라도 모니터 진입 대기를 해제하지 않는다

BlockedAndWaitingComparison의 main 모니터 보유 구간, blocked 인터럽트, 모니터 해제 뒤 정리를 두 작업자로 비교합니다.

같은 interrupt라도 모니터 진입 대기를 해제하지 않는다
원문의 경계blocked 작업자waiting 작업자
상태 관측 성공·main이 모니터 보유모니터를 얻지 못해 BLOCKED입니다.WAITING 관측은 순간값이며 이후 상태를 고정하지 않습니다.
같은 블록 안의 interrupt인터럽트 후 조회도 BLOCKED입니다.이 interrupt의 대상이 아닙니다.
모니터 해제 뒤 정리모니터를 얻어 monitor-acquired를 출력합니다.main이 unpark를 요청한 뒤 두 작업자를 join합니다.
상태 관측 성공·main이 모니터 보유
blocked 작업자: 모니터를 얻지 못해 BLOCKED입니다.
waiting 작업자: WAITING 관측은 순간값이며 이후 상태를 고정하지 않습니다.
같은 블록 안의 interrupt
blocked 작업자: 인터럽트 후 조회도 BLOCKED입니다.
waiting 작업자: 이 interrupt의 대상이 아닙니다.
모니터 해제 뒤 정리
blocked 작업자: 모니터를 얻어 monitor-acquired를 출력합니다.
waiting 작업자: main이 unpark를 요청한 뒤 두 작업자를 join합니다.

waiting의 park는 임의로 반환할 수 있습니다. 상태 관측 도우미가 실패하면 뒤의 unpark와 join을 건너뛰므로 대기자가 남아 JVM 종료를 막을 수도 있습니다.


직접 대기 루프의 네 조건

조건이 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해야 합니다.

정답과 반환 원인 판단
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();
    }
}
반환값은 각 반복의 검사 순서로 결정된다

ParkNanosDeadline과 TimedParkGateSolution의 조건, 인터럽트, 남은 시간 검사에 따른 반환값을 비교합니다.

반환값은 각 반복의 검사 순서로 결정된다
이번 반복의 검사30ms 대기 예제100ms 게이트 답안
업무 조건이 trueready 반환opened 반환
조건은 false·인터럽트는 trueinterrupted 반환interrupted 반환
조건·인터럽트가 false·남은 시간 ≤ 0timed-out 반환timed-out 반환
업무 조건이 true
30ms 대기 예제: ready 반환
100ms 게이트 답안: opened 반환
조건은 false·인터럽트는 true
30ms 대기 예제: interrupted 반환
100ms 게이트 답안: interrupted 반환
조건·인터럽트가 false·남은 시간 ≤ 0
30ms 대기 예제: timed-out 반환
100ms 게이트 답안: timed-out 반환

그 밖에는 parkNanos 뒤 다시 검사합니다. 30ms 원문은 timed-out이고 답안은 opened 또는 timed-out입니다. opened도 100ms 안에 열렸다는 증거는 아니며, 전체 실행 시간은 예산과 같지 않습니다.

이미 시간 초과로 반환한 호출의 결과는 나중의 open으로 바뀌지 않습니다.

park 계열을 사용할 때는 interrupt가 플래그를 지우지 않는다는 규칙, nanos 기한 재계산, 상태별 취소 가능성을 함께 다뤄야 합니다.

BLOCKED와 WAITING을 같은 “멈춤”으로 보면 적절한 해결 API를 고를 수 없습니다.