본문 바로가기

시간 제한 join과 interrupt

시간 제한 join이 완료를 보장하지 않는 실패를 확인하고 기한·결과 폐기·interrupt 요청을 조합합니다.

인자 없는 join은 대상이 끝날 때까지 기다리지만 join(millis)는 최대 대기 시간만 제한합니다.

시간이 지나 정상 반환해도 작업자가 종료됐다는 뜻은 아닙니다.

반환 직후 isAlive()와 결과 상태를 확인하고, 시간 제한이면 계속 기다릴지 interrupt할지 결과를 버릴지 결정해야 합니다.


시간 제한 join의 성공 오판

작업자는 게이트가 열릴 때까지 기다립니다.

main은 20ms 시간 제한 join 뒤 결과를 읽으므로 실제로 wrong-complete=false, result=null이 출력됩니다.

join 메서드가 예외 없이 돌아왔다는 사실을 업무 완료로 오해한 것입니다.

lab/TimedJoinCompletionBug.java
import java.util.concurrent.CountDownLatch;

public final class TimedJoinCompletionBug {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch gate = new CountDownLatch(1);
        String[] result = new String[1];
        Thread worker =
                new Thread(
                        () -> {
                            try {
                                gate.await();
                                result[0] = "ready";
                            } catch (InterruptedException e) {
                                Thread.currentThread().interrupt();
                            }
                        },
                        "delayed-result");
        worker.start();
        worker.join(20);
        System.out.println("wrong-complete=" + !worker.isAlive() + ", result=" + result[0]);
        gate.countDown();
        worker.join();
        System.out.println("recovered=" + result[0]);
    }
}

게이트가 닫혀 있으면 시간 제한 뒤에도 결과는 없다

gate로 작업을 막은 timed join은 완료가 아니며, 해제 후 무기한 join이 결과를 회수합니다. 대기 요청·작업 종료·읽기 시점을 분리해 대기 반환과 완료의 차이를 확인할 수 있습니다.

게이트가 닫혀 있으면 시간 제한 뒤에도 결과는 없다

TimedJoinCompletionBug에서 게이트가 닫힌 첫 출력과 게이트 해제 뒤 종료를 기다린 출력을 구분합니다.

시간 제한 JOIN · 결과 읽기

게이트가 닫혀 있으면 시간 제한 뒤에도 결과는 없다

join(20)의 반환은 완료 신호가 아닙니다. 닫힌 게이트를 열고 무기한 join으로 회수해야 ready가 보입니다.

시간 제한 반환과 실제 종료 회수닫힌게이트의join20반환에서false/null을읽고,게이트를열어worker가ready를쓴뒤무기한join정상반환에서ready를읽습니다. 시간제한반환자체는종료신호가아닙니다.
게이트가 닫힘ready를 쓸 수 없음
연결 관계게이트가 닫힘 · ready를 쓸 수 없음 → join(20) 반환 · wrong-complete=false · result=null
join(20) 반환wrong-complete=falseresult=null
연결 관계join(20) 반환 · wrong-complete=false · result=null → countDown으로 해제 · 진행 가능 / 아직 종료 아님
countDown으로 해제진행 가능 / 아직 종료 아님
연결 관계countDown으로 해제 · 진행 가능 / 아직 종료 아님 → worker가 종료 · ready를 기록한 뒤 · 그 뒤에 종료를 확인
worker가 종료ready를 기록한 뒤그 뒤에 종료를 확인
연결 관계worker가 종료 · ready를 기록한 뒤 · 그 뒤에 종료를 확인 → 무기한 join 정상 반환 · recovered=ready
무기한 join 정상 반환recovered=ready

실선: 고정 게이트 조건과 완료 회수20ms는 대기 요청 / 실행속도 증거 아님

시간 제한 반환과 실제 종료 회수닫힌게이트의join20반환에서false/null을읽고,게이트를열어worker가ready를쓴뒤무기한join정상반환에서ready를읽습니다. 시간제한반환자체는종료신호가아닙니다.
게이트가 닫힘ready를 쓸 수 없음
연결 관계게이트가 닫힘 · ready를 쓸 수 없음 → join(20) 반환 · wrong-complete=false · result=null
join(20) 반환wrong-complete=falseresult=null
연결 관계join(20) 반환 · wrong-complete=false · result=null → countDown으로 해제 · 진행 가능 / 아직 종료 아님
countDown으로 해제진행 가능 / 아직 종료 아님
연결 관계countDown으로 해제 · 진행 가능 / 아직 종료 아님 → worker가 종료 · ready를 기록한 뒤 · 그 뒤에 종료를 확인
worker가 종료ready를 기록한 뒤그 뒤에 종료를 확인
연결 관계worker가 종료 · ready를 기록한 뒤 · 그 뒤에 종료를 확인 → 무기한 join 정상 반환 · recovered=ready
무기한 join 정상 반환recovered=ready

실선: 고정 게이트 조건과 완료회수20ms는 대기 요청 / 실행속도증거 아님

  1. join(20) 반환 뒤

    작업자의 쓰기 조건
    게이트가 닫혀 있어 ready를 쓸 수 없음
    main의 관찰
    wrong-complete=false · result=null
  2. countDown() 호출 뒤

    작업자의 쓰기 조건
    게이트가 열려 결과 쓰기를 진행할 수 있음
    main의 관찰
    이 호출만으로 작업자 종료를 뜻하지 않음
  3. 무기한 join() 반환 뒤

    작업자의 쓰기 조건
    작업자가 ready를 기록하고 종료함
    main의 관찰
    recovered=ready

20ms는 대기 요청값입니다. 첫 결과는 게이트로 고정되며 느린 CPU를 가정하지 않습니다. 작업자의 구체적인 Thread.State나 정확한 경과 시간은 출력하지 않습니다.

이 예의 조건

  • 게이트가 닫힌 동안 worker는 ready를 기록할 수 없습니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2닫힌 게이트의 join(20) 반환에서 false/null을 읽고, 게이트를 열어 worker가 ready를 쓴 뒤 무기한 join의 정상 반환에서 ready를 읽습니다. 시간 제한 반환 자체는 종료 신호가 아닙니다.
9진행 가능
10아직 종료 아님
1720ms는 대기 요청
18실행 속도 증거 아님
원 도표의 설명과 주석
구분내용
설명닫힌 게이트의 join(20) 반환에서 false/null을 읽고, 게이트를 열어 worker가 ready를 쓴 뒤 무기한 join의 정상 반환에서 ready를 읽습니다. 시간 제한 반환 자체는 종료 신호가 아닙니다.
전체 읽기 정보게이트가 닫혀 있으면 시간 제한 뒤에도 결과는 없다 게이트가 닫혀 있으면 시간 제한 뒤에도 결과는 없다 TimedJoinCompletionBug에서 게이트가 닫힌 첫 출력과 게이트 해제 뒤 종료를 기다린 출력을 구분합니다. 시간 제한 JOIN · 결과 읽기 게이트가 닫혀 있으면 시간 제한 뒤에도 결과는 없다 join(20)의 반환은 완료 신호가 아닙니다. 닫힌 게이트를 열고 무기한 join으로 회수해야 ready가 보입니다. join(20) 반환 뒤 작업자의 쓰기 조건 게이트가 닫혀 있어 ready 를 쓸 수 없음 main의 관찰 wrong-complete=false · result=null countDown() 호출 뒤 작업자의 쓰기 조건 게이트가 열려 결과 쓰기를 진행할 수 있음 main의 관찰 이 호출만으로 작업자 종료를 뜻하지 않음 무기한 join() 반환 뒤 작업자의 쓰기 조건 작업자가 ready 를 기록하고 종료함 main의 관찰 recovered=ready 20ms는 대기 요청값입니다. 첫 결과는 게이트로 고정되며 느린 CPU를 가정하지 않습니다. 작업자의 구체적인 Thread.State나 정확한 경과 시간은 출력하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
제한 대기의 반환

닫힌 게이트를 둔 채 join(20)이 반환합니다.

  • 게이트: 닫힘
  • 조회: wrong-complete=false, result=null
  • 20ms 대기 반환은 worker의 종료를 확인하지 않습니다.
실행을 허용

countDown이 worker의 게이트를 해제합니다.

  • 게이트: 열림
  • worker: ready 기록 가능
  • 해제된 worker는 ready를 기록한 뒤 종료합니다.
종료와 결과 회수

무기한 join이 정상 반환한 뒤 결과를 읽습니다.

  • worker: 종료
  • 결과: recovered=ready
  • 종료를 회수한 뒤 앞선 ready 기록을 읽습니다.

게이트가 닫혀 있으면 시간 제한 뒤에도 결과는 없다

gate로 작업을 막은 timed join은 완료가 아니며, 해제 후 무기한 join이 결과를 회수합니다. 대기 요청·작업 종료·읽기 시점을 분리해 대기 반환과 완료의 차이를 확인할 수 있습니다.

게이트가 닫혀 있으면 시간 제한 뒤에도 결과는 없다

TimedJoinCompletionBug에서 게이트가 닫힌 첫 출력과 게이트 해제 뒤 종료를 기다린 출력을 구분합니다.

시간 제한 JOIN · 결과 읽기

게이트가 닫혀 있으면 시간 제한 뒤에도 결과는 없다

join(20)의 반환은 완료 신호가 아닙니다. 닫힌 게이트를 열고 무기한 join으로 회수해야 ready가 보입니다.

시간 제한 반환과 실제 종료 회수닫힌게이트의join20반환에서false/null을읽고,게이트를열어worker가ready를쓴뒤무기한join정상반환에서ready를읽습니다. 시간제한반환자체는종료신호가아닙니다.
게이트가 닫힘ready를 쓸 수 없음
연결 관계게이트가 닫힘 · ready를 쓸 수 없음 → join(20) 반환 · wrong-complete=false · result=null
join(20) 반환wrong-complete=falseresult=null
연결 관계join(20) 반환 · wrong-complete=false · result=null → countDown으로 해제 · 진행 가능 / 아직 종료 아님
countDown으로 해제진행 가능 / 아직 종료 아님
연결 관계countDown으로 해제 · 진행 가능 / 아직 종료 아님 → worker가 종료 · ready를 기록한 뒤 · 그 뒤에 종료를 확인
worker가 종료ready를 기록한 뒤그 뒤에 종료를 확인
연결 관계worker가 종료 · ready를 기록한 뒤 · 그 뒤에 종료를 확인 → 무기한 join 정상 반환 · recovered=ready
무기한 join 정상 반환recovered=ready

실선: 고정 게이트 조건과 완료 회수20ms는 대기 요청 / 실행속도 증거 아님

시간 제한 반환과 실제 종료 회수닫힌게이트의join20반환에서false/null을읽고,게이트를열어worker가ready를쓴뒤무기한join정상반환에서ready를읽습니다. 시간제한반환자체는종료신호가아닙니다.
게이트가 닫힘ready를 쓸 수 없음
연결 관계게이트가 닫힘 · ready를 쓸 수 없음 → join(20) 반환 · wrong-complete=false · result=null
join(20) 반환wrong-complete=falseresult=null
연결 관계join(20) 반환 · wrong-complete=false · result=null → countDown으로 해제 · 진행 가능 / 아직 종료 아님
countDown으로 해제진행 가능 / 아직 종료 아님
연결 관계countDown으로 해제 · 진행 가능 / 아직 종료 아님 → worker가 종료 · ready를 기록한 뒤 · 그 뒤에 종료를 확인
worker가 종료ready를 기록한 뒤그 뒤에 종료를 확인
연결 관계worker가 종료 · ready를 기록한 뒤 · 그 뒤에 종료를 확인 → 무기한 join 정상 반환 · recovered=ready
무기한 join 정상 반환recovered=ready

실선: 고정 게이트 조건과 완료회수20ms는 대기 요청 / 실행속도증거 아님

  1. join(20) 반환 뒤

    작업자의 쓰기 조건
    게이트가 닫혀 있어 ready를 쓸 수 없음
    main의 관찰
    wrong-complete=false · result=null
  2. countDown() 호출 뒤

    작업자의 쓰기 조건
    게이트가 열려 결과 쓰기를 진행할 수 있음
    main의 관찰
    이 호출만으로 작업자 종료를 뜻하지 않음
  3. 무기한 join() 반환 뒤

    작업자의 쓰기 조건
    작업자가 ready를 기록하고 종료함
    main의 관찰
    recovered=ready

20ms는 대기 요청값입니다. 첫 결과는 게이트로 고정되며 느린 CPU를 가정하지 않습니다. 작업자의 구체적인 Thread.State나 정확한 경과 시간은 출력하지 않습니다.

이 예의 조건

  • 게이트가 닫힌 동안 worker는 ready를 기록할 수 없습니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2닫힌 게이트의 join(20) 반환에서 false/null을 읽고, 게이트를 열어 worker가 ready를 쓴 뒤 무기한 join의 정상 반환에서 ready를 읽습니다. 시간 제한 반환 자체는 종료 신호가 아닙니다.
9진행 가능
10아직 종료 아님
1720ms는 대기 요청
18실행 속도 증거 아님
원 도표의 설명과 주석
구분내용
설명닫힌 게이트의 join(20) 반환에서 false/null을 읽고, 게이트를 열어 worker가 ready를 쓴 뒤 무기한 join의 정상 반환에서 ready를 읽습니다. 시간 제한 반환 자체는 종료 신호가 아닙니다.
전체 읽기 정보게이트가 닫혀 있으면 시간 제한 뒤에도 결과는 없다 게이트가 닫혀 있으면 시간 제한 뒤에도 결과는 없다 TimedJoinCompletionBug에서 게이트가 닫힌 첫 출력과 게이트 해제 뒤 종료를 기다린 출력을 구분합니다. 시간 제한 JOIN · 결과 읽기 게이트가 닫혀 있으면 시간 제한 뒤에도 결과는 없다 join(20)의 반환은 완료 신호가 아닙니다. 닫힌 게이트를 열고 무기한 join으로 회수해야 ready가 보입니다. join(20) 반환 뒤 작업자의 쓰기 조건 게이트가 닫혀 있어 ready 를 쓸 수 없음 main의 관찰 wrong-complete=false · result=null countDown() 호출 뒤 작업자의 쓰기 조건 게이트가 열려 결과 쓰기를 진행할 수 있음 main의 관찰 이 호출만으로 작업자 종료를 뜻하지 않음 무기한 join() 반환 뒤 작업자의 쓰기 조건 작업자가 ready 를 기록하고 종료함 main의 관찰 recovered=ready 20ms는 대기 요청값입니다. 첫 결과는 게이트로 고정되며 느린 CPU를 가정하지 않습니다. 작업자의 구체적인 Thread.State나 정확한 경과 시간은 출력하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
제한 대기의 반환

닫힌 게이트를 둔 채 join(20)이 반환합니다.

  • 게이트: 닫힘
  • 조회: wrong-complete=false, result=null
  • 20ms 대기 반환은 worker의 종료를 확인하지 않습니다.
실행을 허용

countDown이 worker의 게이트를 해제합니다.

  • 게이트: 열림
  • worker: ready 기록 가능
  • 해제된 worker는 ready를 기록한 뒤 종료합니다.
종료와 결과 회수

무기한 join이 정상 반환한 뒤 결과를 읽습니다.

  • worker: 종료
  • 결과: recovered=ready
  • 종료를 회수한 뒤 앞선 ready 기록을 읽습니다.

첫 단계의 null은 가시성 문제를 volatile로 덮을 상황이 아닙니다.

시간 제한 join 뒤 생존이면 결과 컨테이너를 읽지 않고 시간 제한 결과를 만들어야 합니다.


시간 제한 join의 상태 해석

join(0)은 즉시 반환이 아니라 무기한 join과 같습니다.

양수 millis를 주면 호출자는 최대 그 시간 동안 TIMED_WAITING이 될 수 있습니다.

대상이 먼저 끝나면 일찍 깨어납니다.

허용 범위 밖 nanos나 음수 millis는 예외가 될 수 있으므로 기간 입력을 확인합니다.

시간 제한은 작업자 중단 명령이 아닙니다.

시간 제한 join이 반환된 뒤 살아 있는 대상은 RUNNABLE, BLOCKED, WAITING, TIMED_WAITING일 수 있습니다.

따라서 백그라운드에서 계속 작업해도 되는지, 공유 자원에 늦은 write를 해도 되는지 명시합니다.

여러 작업자 각각에 같은 시간 제한을 주면 총 대기가 작업자 수만큼 늘어납니다.

전체 예산 100ms라면 기한을 하나 정하고 join마다 남은 시간을 계산합니다.

예산이 언제 시작하는지도 정하세요. 아래 joinUntil은 호출 안에서 deadline을 만들므로, 그 전에 수행한 스레드 생성과 start 시간은 80ms 예산에 포함하지 않습니다. 생성부터 응답까지의 전체 시간을 제한하려면 더 앞에서 만든 하나의 기한을 이후 단계에 전달해야 합니다. 회수 대기 예산과 기한 뒤 정리 예산은 별도입니다.

src/DeadlineJoinGroup.java
import java.time.Duration;

public final class DeadlineJoinGroup {
    public static void main(String[] args) throws InterruptedException {
        Thread[] workers = {sleeping("fast", 10), sleeping("medium", 25), sleeping("slow", 200)};
        for (Thread worker : workers) {
            worker.start();
        }
        boolean complete = joinUntil(workers, Duration.ofMillis(80));
        System.out.println("all-within-budget=" + complete);
        for (Thread worker : workers) {
            if (worker.isAlive()) worker.interrupt();
        }
        for (Thread worker : workers) {
            worker.join();
        }
    }

    private static Thread sleeping(String name, long millis) {
        return new Thread(
                () -> {
                    try {
                        Thread.sleep(millis);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                },
                name);
    }

    private static boolean joinUntil(Thread[] workers, Duration budget)
            throws InterruptedException {
        long deadline = System.nanoTime() + budget.toNanos();
        for (Thread worker : workers) {
            long remaining = deadline - System.nanoTime();
            if (remaining <= 0) return false;
            worker.join(Math.max(1, remaining / 1_000_000));
            if (worker.isAlive()) return false;
        }
        return true;
    }
}

세 작업자의 회수에 하나의 80ms 기한을 쓴다

하나의 deadline을 기준으로 각 작업자 앞에서 remaining을 다시 계산하고 join 뒤 생존을 검사하는 실제 알고리즘입니다. 매번 새 예산이 아님을 반복 단계로 설명할 수 있습니다.

세 작업자의 회수에 하나의 80ms 기한을 쓴다

DeadlineJoinGroup의 실제 sleep 입력과 공유 기한 계산, 종료 후 정리의 범위를 구분합니다.

전체 기한 · 80ms 공유

세 작업자의 회수에 하나의 80ms 기한을 쓴다

세 join마다 새 80ms를 주지 않습니다. 하나의 deadline에서 이미 쓴 시간을 빼고 남은 시간만 기다립니다.

하나의 기한을 공유하는 남은 시간 deadline한번설정후매worker에서remaining을다시계산하고양수일때만join합니다. 검사에서생존이면false로반환하고,종료관측가지에서다음worker를확인한뒤모두종료일때만true를반환합니다. 10·25·200ms는실제완료시각이아닌sleep요청입니다. 단일 기한의 순회 기한 설정 budget80ms의 deadline 매번 새80ms를 주지 않음 배열 순서로 남은 시간 remaining=deadline−now 양수일 때만 join 음수·0이면 false 반환 각 join 뒤 판정 fast10 / medium25 slow200은 sleep 요청값 생존 여부 검사 살아 있으면 false 반환 종료 관측 다음 worker도 확인 모두 종료했으면 true 실선: 알고리즘의 조건·판정 관계 두 영역은 동일 알고리즘의 측면 sleep값은 실제 완료 시각이 아님
하나의 기한을 공유하는 남은 시간 deadline한번설정후매worker에서remaining을다시계산하고양수일때만join합니다. 검사에서생존이면false로반환하고,종료관측가지에서다음worker를확인한뒤모두종료일때만true를반환합니다. 10·25·200ms는실제완료시각이아닌sleep요청입니다. 단일 기한의 순회 기한 설정 budget80ms의 deadline 매번 새80ms를 주지 않음 배열 순서로 남은 시간 remaining=deadline−now 양수일 때만 join 음수·0이면 false 반환 각 join 뒤 판정 fast10 / medium25 slow200은 sleep 요청값 생존 여부 검사 살아 있으면 false 반환 종료 관측 다음 worker도 확인 모두 종료했으면 true 실선: 알고리즘의 조건·판정 관계 두 영역은 동일 알고리즘의 측면 sleep값은 실제 완료 시각이 아님

fast

실제 입력
10ms sleep 요청
기한을 쓰는 방법
같은 기한에서 남은 시간을 계산

medium

실제 입력
25ms sleep 요청
기한을 쓰는 방법
앞선 대기 뒤 남은 시간만 사용

slow

실제 입력
200ms sleep 요청
기한을 쓰는 방법
살아 있으면 완료로 판정하지 않음

호출자

실제 입력
Duration.ofMillis(80)
기한을 쓰는 방법
남은 시간이 없으면 false 반환

all-within-budget은 실행 지연에 따라 false 또는 true가 될 수 있습니다. 뒤의 interrupt와 무기한 join 정리까지 80ms 안에 끝난다는 보장은 없습니다.

이 예의 조건

  • 80ms는 모든 worker가 공유하는 예산입니다. 10·25·200ms는 sleep 요청값입니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2deadline을 한 번 설정한 후 매 worker에서 remaining을 다시 계산하고 양수일 때만 join합니다. 검사에서 생존이면 false로 반환하고, 종료 관측 분기에서 다음 worker를 확인한 뒤 모두 종료일 때만 true를 반환합니다. 10·25·200ms는 실제 완료 시각이 아닌 sleep 요청입니다.
5budget 80ms의 deadline
6매번 새 80ms를 주지 않음
12fast10
13medium25
14slow 200은 sleep 요청값
22sleep 값은 실제 완료 시각이 아님
원 도표의 설명과 주석
구분내용
설명deadline을 한 번 설정한 후 매 worker에서 remaining을 다시 계산하고 양수일 때만 join합니다. 검사에서 생존이면 false로 반환하고, 종료 관측 분기에서 다음 worker를 확인한 뒤 모두 종료일 때만 true를 반환합니다. 10·25·200ms는 실제 완료 시각이 아닌 sleep 요청입니다.
전체 읽기 정보세 작업자의 회수에 하나의 80ms 기한을 쓴다 세 작업자의 회수에 하나의 80ms 기한을 쓴다 DeadlineJoinGroup의 실제 sleep 입력과 공유 기한 계산, 종료 후 정리의 범위를 구분합니다. 전체 기한 · 80ms 공유 세 작업자의 회수에 하나의 80ms 기한을 쓴다 세 join마다 새 80ms를 주지 않습니다. 하나의 deadline에서 이미 쓴 시간을 빼고 남은 시간만 기다립니다. fast 실제 입력 10ms sleep 요청 기한을 쓰는 방법 같은 기한에서 남은 시간을 계산 medium 실제 입력 25ms sleep 요청 기한을 쓰는 방법 앞선 대기 뒤 남은 시간만 사용 slow 실제 입력 200ms sleep 요청 기한을 쓰는 방법 살아 있으면 완료로 판정하지 않음 호출자 실제 입력 Duration.ofMillis(80) 기한을 쓰는 방법 남은 시간이 없으면 false 반환 all-within-budget 은 실행 지연에 따라 false 또는 true가 될 수 있습니다. 뒤의 interrupt와 무기한 join 정리까지 80ms 안에 끝난다는 보장은 없습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
단일 기한

순회 전에 deadline을 한 번 계산합니다.

  • 예산: 80ms
  • 대상: fast, medium, slow
  • 각 worker에게 새 80ms를 주지 않습니다.
남은 시간 검사

각 worker 앞에서 deadline−now를 다시 계산합니다.

  • remaining: 양수일 때만 join
  • 0 이하: false 반환
  • 앞 worker에 사용한 시간이 뒤 worker의 예산을 줄입니다.
종료 조건

join 뒤 살아 있으면 false, 종료했으면 다음 worker를 검사합니다.

  • 생존: 즉시 false
  • 모두 종료: true
  • 마지막 worker까지 종료를 확인해야 true입니다.

세 작업자의 회수에 하나의 80ms 기한을 쓴다

하나의 deadline을 기준으로 각 작업자 앞에서 remaining을 다시 계산하고 join 뒤 생존을 검사하는 실제 알고리즘입니다. 매번 새 예산이 아님을 반복 단계로 설명할 수 있습니다.

세 작업자의 회수에 하나의 80ms 기한을 쓴다

DeadlineJoinGroup의 실제 sleep 입력과 공유 기한 계산, 종료 후 정리의 범위를 구분합니다.

전체 기한 · 80ms 공유

세 작업자의 회수에 하나의 80ms 기한을 쓴다

세 join마다 새 80ms를 주지 않습니다. 하나의 deadline에서 이미 쓴 시간을 빼고 남은 시간만 기다립니다.

하나의 기한을 공유하는 남은 시간 deadline한번설정후매worker에서remaining을다시계산하고양수일때만join합니다. 검사에서생존이면false로반환하고,종료관측가지에서다음worker를확인한뒤모두종료일때만true를반환합니다. 10·25·200ms는실제완료시각이아닌sleep요청입니다. 단일 기한의 순회 기한 설정 budget80ms의 deadline 매번 새80ms를 주지 않음 배열 순서로 남은 시간 remaining=deadline−now 양수일 때만 join 음수·0이면 false 반환 각 join 뒤 판정 fast10 / medium25 slow200은 sleep 요청값 생존 여부 검사 살아 있으면 false 반환 종료 관측 다음 worker도 확인 모두 종료했으면 true 실선: 알고리즘의 조건·판정 관계 두 영역은 동일 알고리즘의 측면 sleep값은 실제 완료 시각이 아님
하나의 기한을 공유하는 남은 시간 deadline한번설정후매worker에서remaining을다시계산하고양수일때만join합니다. 검사에서생존이면false로반환하고,종료관측가지에서다음worker를확인한뒤모두종료일때만true를반환합니다. 10·25·200ms는실제완료시각이아닌sleep요청입니다. 단일 기한의 순회 기한 설정 budget80ms의 deadline 매번 새80ms를 주지 않음 배열 순서로 남은 시간 remaining=deadline−now 양수일 때만 join 음수·0이면 false 반환 각 join 뒤 판정 fast10 / medium25 slow200은 sleep 요청값 생존 여부 검사 살아 있으면 false 반환 종료 관측 다음 worker도 확인 모두 종료했으면 true 실선: 알고리즘의 조건·판정 관계 두 영역은 동일 알고리즘의 측면 sleep값은 실제 완료 시각이 아님

fast

실제 입력
10ms sleep 요청
기한을 쓰는 방법
같은 기한에서 남은 시간을 계산

medium

실제 입력
25ms sleep 요청
기한을 쓰는 방법
앞선 대기 뒤 남은 시간만 사용

slow

실제 입력
200ms sleep 요청
기한을 쓰는 방법
살아 있으면 완료로 판정하지 않음

호출자

실제 입력
Duration.ofMillis(80)
기한을 쓰는 방법
남은 시간이 없으면 false 반환

all-within-budget은 실행 지연에 따라 false 또는 true가 될 수 있습니다. 뒤의 interrupt와 무기한 join 정리까지 80ms 안에 끝난다는 보장은 없습니다.

이 예의 조건

  • 80ms는 모든 worker가 공유하는 예산입니다. 10·25·200ms는 sleep 요청값입니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2deadline을 한 번 설정한 후 매 worker에서 remaining을 다시 계산하고 양수일 때만 join합니다. 검사에서 생존이면 false로 반환하고, 종료 관측 분기에서 다음 worker를 확인한 뒤 모두 종료일 때만 true를 반환합니다. 10·25·200ms는 실제 완료 시각이 아닌 sleep 요청입니다.
5budget 80ms의 deadline
6매번 새 80ms를 주지 않음
12fast10
13medium25
14slow 200은 sleep 요청값
22sleep 값은 실제 완료 시각이 아님
원 도표의 설명과 주석
구분내용
설명deadline을 한 번 설정한 후 매 worker에서 remaining을 다시 계산하고 양수일 때만 join합니다. 검사에서 생존이면 false로 반환하고, 종료 관측 분기에서 다음 worker를 확인한 뒤 모두 종료일 때만 true를 반환합니다. 10·25·200ms는 실제 완료 시각이 아닌 sleep 요청입니다.
전체 읽기 정보세 작업자의 회수에 하나의 80ms 기한을 쓴다 세 작업자의 회수에 하나의 80ms 기한을 쓴다 DeadlineJoinGroup의 실제 sleep 입력과 공유 기한 계산, 종료 후 정리의 범위를 구분합니다. 전체 기한 · 80ms 공유 세 작업자의 회수에 하나의 80ms 기한을 쓴다 세 join마다 새 80ms를 주지 않습니다. 하나의 deadline에서 이미 쓴 시간을 빼고 남은 시간만 기다립니다. fast 실제 입력 10ms sleep 요청 기한을 쓰는 방법 같은 기한에서 남은 시간을 계산 medium 실제 입력 25ms sleep 요청 기한을 쓰는 방법 앞선 대기 뒤 남은 시간만 사용 slow 실제 입력 200ms sleep 요청 기한을 쓰는 방법 살아 있으면 완료로 판정하지 않음 호출자 실제 입력 Duration.ofMillis(80) 기한을 쓰는 방법 남은 시간이 없으면 false 반환 all-within-budget 은 실행 지연에 따라 false 또는 true가 될 수 있습니다. 뒤의 interrupt와 무기한 join 정리까지 80ms 안에 끝난다는 보장은 없습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
단일 기한

순회 전에 deadline을 한 번 계산합니다.

  • 예산: 80ms
  • 대상: fast, medium, slow
  • 각 worker에게 새 80ms를 주지 않습니다.
남은 시간 검사

각 worker 앞에서 deadline−now를 다시 계산합니다.

  • remaining: 양수일 때만 join
  • 0 이하: false 반환
  • 앞 worker에 사용한 시간이 뒤 worker의 예산을 줄입니다.
종료 조건

join 뒤 살아 있으면 false, 종료했으면 다음 worker를 검사합니다.

  • 생존: 즉시 false
  • 모두 종료: true
  • 마지막 worker까지 종료를 확인해야 true입니다.

이후 살아 있는 작업자에 interrupt를 요청하고 모두 join합니다.

밀리초 단위 절삭으로 0을 전달하면 무한 대기가 되므로 최소 1ms로 올렸습니다.

예를 들어 남은 0.4ms를 정수 millisecond로 바꾸면 0이 됩니다. 이를 join에 그대로 주면 기한 대기가 무기한 대기로 바뀝니다. 원 코드는 1ms를 요청해 이 의미 오류를 막지만, 남은 0.4ms보다 긴 대기를 요청할 수 있습니다. 스케줄러가 호출자를 바로 실행시킨다는 보장도 없으므로 이 기한은 대기 정책이지 정확한 복귀 시각이나 이후 interrupt·정리 시간의 상한이 아닙니다. Thread의 시간 제한 join 계약에서 0의 별도 의미를 확인할 수 있습니다.

더 정밀한 API가 필요하면 Future의 시간 제한 get이나 구조화된 동시성 정책을 검토합니다.


interrupt의 협력적 취소

worker.interrupt()는 대상의 interrupt 상태를 설정합니다.

sleep, wait, join 같은 중단 가능 블로킹 연산에서 대기 중이면 InterruptedException을 발생시켜 일찍 깨웁니다.

일반 계산 반복문은 플래그를 확인하고 스스로 빠져나와야 합니다.

과거 Thread.stop의 강제 종료는 불변식을 깨뜨릴 수 있어 폐기됐습니다. Java 25의 Thread.stop은 항상 UnsupportedOperationException을 던집니다.

interrupt를 받는 코드가 임시 파일 삭제, 트랜잭션 롤백, 큐 상태 정리 후 반환하도록 프로토콜을 구성합니다.

interrupt가 업무 실패인지 정상 취소인지도 결과에서 구분합니다.

src/InterruptibleCounter.java
import java.util.concurrent.CountDownLatch;

public final class InterruptibleCounter {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch started = new CountDownLatch(1);
        long[] count = new long[1];
        Thread worker =
                new Thread(
                        () -> {
                            started.countDown();
                            long local = 0;
                            while (!Thread.currentThread().isInterrupted()) {
                                local++;
                            }
                            count[0] = local;
                        },
                        "count-until-cancel");
        worker.start();
        started.await();
        Thread.sleep(5);
        worker.interrupt();
        worker.join();
        System.out.println(
                "terminated=" + !worker.isAlive() + ", count-positive=" + (count[0] > 0));
    }
}

반복문은 플래그를 소비하지 않는 isInterrupted()로 관찰합니다.

main이 join한 뒤 count를 읽어 write 완료를 보장합니다.

실제 반복 횟수는 실행 상황에 따라 달라 검증에 사용하지 않습니다. 래치 해제가 첫 증가보다 앞서므로 count-positive도 항상 true인 것은 아닙니다.


시간 제한 이후 부수 효과별 선택

읽기 전용 계산은 늦은 결과를 버리고 백그라운드 완료를 허용할 수 있습니다.

외부 결제나 파일 갱신은 호출자의 대기 시간이 초과됐어도 이미 커밋됐을 수 있어 단순 재시도가 중복 부작용을 만듭니다.

멱등성 키와 상태 조회가 필요합니다.

취소 가능한 작업에서는 사용하는 모든 블로킹 API가 interrupt에 응답하는지 확인하고 계산 구간도 주기적으로 플래그를 봅니다.

취소 불가능한 네이티브 호출이나 외부 서비스 대기는 별도 프로세스 격리, 소켓 시간 제한, circuit breaker를 검토합니다.

join 시간 제한 하나로 하위 작업의 시간 제한이 생기지는 않습니다.


완료 결과를 나타내는 Outcome

다음 작은 실행기는 시간 제한 join 뒤 종료를 확인하면 DONE, 살아 있으면 interrupt와 무기한 join 후 TIMED_OUT, 기다리는 호출자가 중단되면 CALLER_INTERRUPTED를 반환합니다.

문자열 결과를 읽는 시점은 DONE일 때뿐입니다.

늦게 도착한 값은 시간 제한 판정을 되돌리지 않습니다. 이 예제는 생존을 확인해 시간 제한 경로를 택하면, interrupt와 정리 중 작업자가 문자열을 기록하더라도 그 값을 반환하지 않습니다. 이는 늦은 결과를 버리는 정책입니다. 작업자가 종료했다는 관찰과 그 결과를 이번 요청에 사용할 것인지는 구분하세요.

app/TimedDocumentTask.java
public final class TimedDocumentTask {
    private enum Status {
        DONE,
        TIMED_OUT,
        CALLER_INTERRUPTED
    }

    private record Outcome(Status status, String value) {}

    public static void main(String[] args) {
        System.out.println(run(() -> "indexed", 100));
        System.out.println(
                run(
                        () -> {
                            try {
                                Thread.sleep(200);
                            } catch (InterruptedException e) {
                                Thread.currentThread().interrupt();
                            }
                            return "late";
                        },
                        10));
    }

    private static Outcome run(Task task, long timeoutMillis) {
        String[] value = new String[1];
        Thread worker = new Thread(() -> value[0] = task.execute(), "timed-document");
        worker.start();
        try {
            worker.join(timeoutMillis);
            if (!worker.isAlive()) return new Outcome(Status.DONE, value[0]);
            worker.interrupt();
            worker.join();
            return new Outcome(Status.TIMED_OUT, null);
        } catch (InterruptedException e) {
            worker.interrupt();
            Thread.currentThread().interrupt();
            return new Outcome(Status.CALLER_INTERRUPTED, null);
        }
    }

    @FunctionalInterface
    private interface Task {
        String execute();
    }
}

Outcome은 종료 관찰과 취소 요청을 구분한다

timed join 뒤 종료/생존/호출자 중단에 따라 정리와 반환이 다릅니다. 관측→정리 요청·완료→Outcome 반환의 실행 경계를 단계화합니다.

Outcome은 종료 관찰과 취소 요청을 구분한다

TimedDocumentTask.run의 DONE, TIMED_OUT, CALLER_INTERRUPTED 조건과 결과 사용·정리 범위를 비교합니다.

OUTCOME · 종료 / 기한 / 중단

Outcome은 종료 관찰과 취소 요청을 구분한다

상태마다 값 사용과 정리 범위를 구분합니다. 호출자 중단 경로는 작업자에 요청한 뒤 추가 join 없이 반환합니다.

종료 관찰과 기한과 호출자 중단의 분기 세영역은DONE/TIMED_OUT/CALLER_INTERRUPTED의조건과정리계약입니다. DONE의종료는업무성공을수집하지않고,호출자중단은추가join없이반환합니다. DONE 경로 timed join 뒤 종료 isAlive=false 기록된 value를 반환 작업 실패를 별도 수집 안 함 TIMED_OUT 경로 timed join 뒤 생존 isAlive=true 기한 뒤 정리 interrupt 뒤 무기한 join value 버림 / 별도 정리 시간 호출자 중단 경로 join이 중단 예외 호출자의 대기 중단 중단 요청·복원 worker에 interrupt 요청 호출자 플래그 복원 추가 join 없이 반환 실선: 조건과 해당 처리의 관계 DONE은 원 코드의 종료 관찰 업무 성공 상태를 따로 수집해야 함
종료 관찰과 기한과 호출자 중단의 분기 세영역은DONE/TIMED_OUT/CALLER_INTERRUPTED의조건과정리계약입니다. DONE의종료는업무성공을수집하지않고,호출자중단은추가join없이반환합니다. DONE 경로 timed join 뒤 종료 isAlive=false 기록된 value를 반환 작업 실패를 별도 수집 안 함 TIMED_OUT 경로 timed join 뒤 생존 isAlive=true 기한 뒤 정리 interrupt 뒤 무기한 join value 버림 / 별도 정리 시간 호출자 중단 경로 join이 중단 예외 호출자의 대기 중단 중단 요청·복원 worker에 interrupt 요청 호출자 플래그 복원 추가 join 없이 반환 실선: 조건과 해당 처리의 관계 DONE은 원 코드의 종료 관찰 업무 성공 상태를 따로 수집해야 함

DONE

이 코드가 택하는 조건
시간 제한 join 뒤 작업자가 종료됨
결과와 정리
기록된 값을 반환

TIMED_OUT

이 코드가 택하는 조건
확인 시 작업자가 아직 살아 있음
결과와 정리
interrupt와 무기한 join 뒤 값은 버림

CALLER_INTERRUPTED

이 코드가 택하는 조건
기다리는 호출자가 중단됨
결과와 정리
작업자에 interrupt · 호출자 플래그 복원 · 값은 버림

첫 호출의 100ms와 둘째 호출의 10ms는 DONE·TIMED_OUT을 고정하지 않습니다. 원문 main은 호출자 중단 경로를 만들지 않습니다. 그 경로에는 추가 join이 없고, 이 작은 예제는 작업자 예외를 별도 실패 상태로 수집하지 않습니다.

이 예의 조건

  • DONE·TIMED_OUT·CALLER_INTERRUPTED는 서로 다른 조건에서 반환하는 대안입니다.
  • CALLER_INTERRUPTED는 최초 timed join 또는 기한 뒤 정리의 무기한 join 중 InterruptedException을 받는 경로입니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2세 영역은 DONE/TIMED_OUT/CALLER_INTERRUPTED의 조건과 정리 계약입니다. DONE의 종료는 업무 성공을 수집하지 않고, 호출자 중단은 추가 join 없이 반환합니다.
13value 버림
14별도 정리 시간
원 도표의 설명과 주석
구분내용
설명세 영역은 DONE/TIMED_OUT/CALLER_INTERRUPTED의 조건과 정리 계약입니다. DONE의 종료는 업무 성공을 수집하지 않고, 호출자 중단은 추가 join 없이 반환합니다.
전체 읽기 정보Outcome은 종료 관찰과 취소 요청을 구분한다 Outcome은 종료 관찰과 취소 요청을 구분한다 TimedDocumentTask.run의 DONE, TIMED_OUT, CALLER_INTERRUPTED 조건과 결과 사용·정리 범위를 비교합니다. OUTCOME · 종료 / 기한 / 중단 Outcome은 종료 관찰과 취소 요청을 구분한다 상태마다 값 사용과 정리 범위를 구분합니다. 호출자 중단 경로는 작업자에 요청한 뒤 추가 join 없이 반환합니다. DONE 이 코드가 택하는 조건 시간 제한 join 뒤 작업자가 종료됨 결과와 정리 기록된 값을 반환 TIMED_OUT 이 코드가 택하는 조건 확인 시 작업자가 아직 살아 있음 결과와 정리 interrupt와 무기한 join 뒤 값은 버림 CALLER_INTERRUPTED 이 코드가 택하는 조건 기다리는 호출자가 중단됨 결과와 정리 작업자에 interrupt · 호출자 플래그 복원 · 값은 버림 첫 호출의 100ms와 둘째 호출의 10ms는 DONE·TIMED_OUT을 고정하지 않습니다. 원문 main은 호출자 중단 경로를 만들지 않습니다. 그 경로에는 추가 join이 없고, 이 작은 예제는 작업자 예외를 별도 실패 상태로 수집하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
대기와 조건 판정

대기 결과를 종료·생존·호출자 중단으로 나누어 관찰합니다.

  • 정상 timed join 뒤: isAlive=false 또는 true
  • 대기 중 예외: timed join·정리 join의 InterruptedException
  • 호출자 중단 예외는 어느 join에서 받았는지와 무관하게 별도 반환 경로로 이동합니다.
조건별 조치

각 대안에서 필요한 처리만 수행합니다.

  • 종료 관찰: 기록된 value 채택, 업무 성공 별도
  • 생존 관찰: interrupt 뒤 무기한 정리 join, value 버림
  • 호출자 중단: worker interrupt 요청·호출자 플래그 복원, 추가 join 없음
  • 기한 뒤 정리 join도 중단될 수 있으므로 종료 회수와 중단 반환을 구별합니다.
각 반환 경계

종료 경로·정리 완료 경로·중단 경로의 반환 의미를 대조합니다.

  • DONE: 종료 관찰값 반환, 업무 성공 수집 안 함
  • TIMED_OUT: 기한 뒤 별도 정리 시간
  • CALLER_INTERRUPTED: 추가 join 없이 반환, worker 종료 미회수
  • 세 반환 상태는 같은 작업이 차례로 거치는 상태가 아닙니다.

Outcome은 종료 관찰과 취소 요청을 구분한다

timed join 뒤 종료/생존/호출자 중단에 따라 정리와 반환이 다릅니다. 관측→정리 요청·완료→Outcome 반환의 실행 경계를 단계화합니다.

Outcome은 종료 관찰과 취소 요청을 구분한다

TimedDocumentTask.run의 DONE, TIMED_OUT, CALLER_INTERRUPTED 조건과 결과 사용·정리 범위를 비교합니다.

OUTCOME · 종료 / 기한 / 중단

Outcome은 종료 관찰과 취소 요청을 구분한다

상태마다 값 사용과 정리 범위를 구분합니다. 호출자 중단 경로는 작업자에 요청한 뒤 추가 join 없이 반환합니다.

종료 관찰과 기한과 호출자 중단의 분기 세영역은DONE/TIMED_OUT/CALLER_INTERRUPTED의조건과정리계약입니다. DONE의종료는업무성공을수집하지않고,호출자중단은추가join없이반환합니다. DONE 경로 timed join 뒤 종료 isAlive=false 기록된 value를 반환 작업 실패를 별도 수집 안 함 TIMED_OUT 경로 timed join 뒤 생존 isAlive=true 기한 뒤 정리 interrupt 뒤 무기한 join value 버림 / 별도 정리 시간 호출자 중단 경로 join이 중단 예외 호출자의 대기 중단 중단 요청·복원 worker에 interrupt 요청 호출자 플래그 복원 추가 join 없이 반환 실선: 조건과 해당 처리의 관계 DONE은 원 코드의 종료 관찰 업무 성공 상태를 따로 수집해야 함
종료 관찰과 기한과 호출자 중단의 분기 세영역은DONE/TIMED_OUT/CALLER_INTERRUPTED의조건과정리계약입니다. DONE의종료는업무성공을수집하지않고,호출자중단은추가join없이반환합니다. DONE 경로 timed join 뒤 종료 isAlive=false 기록된 value를 반환 작업 실패를 별도 수집 안 함 TIMED_OUT 경로 timed join 뒤 생존 isAlive=true 기한 뒤 정리 interrupt 뒤 무기한 join value 버림 / 별도 정리 시간 호출자 중단 경로 join이 중단 예외 호출자의 대기 중단 중단 요청·복원 worker에 interrupt 요청 호출자 플래그 복원 추가 join 없이 반환 실선: 조건과 해당 처리의 관계 DONE은 원 코드의 종료 관찰 업무 성공 상태를 따로 수집해야 함

DONE

이 코드가 택하는 조건
시간 제한 join 뒤 작업자가 종료됨
결과와 정리
기록된 값을 반환

TIMED_OUT

이 코드가 택하는 조건
확인 시 작업자가 아직 살아 있음
결과와 정리
interrupt와 무기한 join 뒤 값은 버림

CALLER_INTERRUPTED

이 코드가 택하는 조건
기다리는 호출자가 중단됨
결과와 정리
작업자에 interrupt · 호출자 플래그 복원 · 값은 버림

첫 호출의 100ms와 둘째 호출의 10ms는 DONE·TIMED_OUT을 고정하지 않습니다. 원문 main은 호출자 중단 경로를 만들지 않습니다. 그 경로에는 추가 join이 없고, 이 작은 예제는 작업자 예외를 별도 실패 상태로 수집하지 않습니다.

이 예의 조건

  • DONE·TIMED_OUT·CALLER_INTERRUPTED는 서로 다른 조건에서 반환하는 대안입니다.
  • CALLER_INTERRUPTED는 최초 timed join 또는 기한 뒤 정리의 무기한 join 중 InterruptedException을 받는 경로입니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2세 영역은 DONE/TIMED_OUT/CALLER_INTERRUPTED의 조건과 정리 계약입니다. DONE의 종료는 업무 성공을 수집하지 않고, 호출자 중단은 추가 join 없이 반환합니다.
13value 버림
14별도 정리 시간
원 도표의 설명과 주석
구분내용
설명세 영역은 DONE/TIMED_OUT/CALLER_INTERRUPTED의 조건과 정리 계약입니다. DONE의 종료는 업무 성공을 수집하지 않고, 호출자 중단은 추가 join 없이 반환합니다.
전체 읽기 정보Outcome은 종료 관찰과 취소 요청을 구분한다 Outcome은 종료 관찰과 취소 요청을 구분한다 TimedDocumentTask.run의 DONE, TIMED_OUT, CALLER_INTERRUPTED 조건과 결과 사용·정리 범위를 비교합니다. OUTCOME · 종료 / 기한 / 중단 Outcome은 종료 관찰과 취소 요청을 구분한다 상태마다 값 사용과 정리 범위를 구분합니다. 호출자 중단 경로는 작업자에 요청한 뒤 추가 join 없이 반환합니다. DONE 이 코드가 택하는 조건 시간 제한 join 뒤 작업자가 종료됨 결과와 정리 기록된 값을 반환 TIMED_OUT 이 코드가 택하는 조건 확인 시 작업자가 아직 살아 있음 결과와 정리 interrupt와 무기한 join 뒤 값은 버림 CALLER_INTERRUPTED 이 코드가 택하는 조건 기다리는 호출자가 중단됨 결과와 정리 작업자에 interrupt · 호출자 플래그 복원 · 값은 버림 첫 호출의 100ms와 둘째 호출의 10ms는 DONE·TIMED_OUT을 고정하지 않습니다. 원문 main은 호출자 중단 경로를 만들지 않습니다. 그 경로에는 추가 join이 없고, 이 작은 예제는 작업자 예외를 별도 실패 상태로 수집하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
대기와 조건 판정

대기 결과를 종료·생존·호출자 중단으로 나누어 관찰합니다.

  • 정상 timed join 뒤: isAlive=false 또는 true
  • 대기 중 예외: timed join·정리 join의 InterruptedException
  • 호출자 중단 예외는 어느 join에서 받았는지와 무관하게 별도 반환 경로로 이동합니다.
조건별 조치

각 대안에서 필요한 처리만 수행합니다.

  • 종료 관찰: 기록된 value 채택, 업무 성공 별도
  • 생존 관찰: interrupt 뒤 무기한 정리 join, value 버림
  • 호출자 중단: worker interrupt 요청·호출자 플래그 복원, 추가 join 없음
  • 기한 뒤 정리 join도 중단될 수 있으므로 종료 회수와 중단 반환을 구별합니다.
각 반환 경계

종료 경로·정리 완료 경로·중단 경로의 반환 의미를 대조합니다.

  • DONE: 종료 관찰값 반환, 업무 성공 수집 안 함
  • TIMED_OUT: 기한 뒤 별도 정리 시간
  • CALLER_INTERRUPTED: 추가 join 없이 반환, worker 종료 미회수
  • 세 반환 상태는 같은 작업이 차례로 거치는 상태가 아닙니다.

제품 구현에서는 예외 결과도 분리하고 스레드를 매 호출마다 직접 만들지 않습니다.

여기서는 join 반환 뒤의 결과 정책에 초점을 둡니다.

취소 요청과 정리 완료의 경계가 경로마다 다릅니다. TIMED_OUT은 무기한 join까지 마친 뒤 반환하므로 작업자가 interrupt를 무시하면 호출도 계속 기다릴 수 있습니다. CALLER_INTERRUPTED는 작업자에게 요청하고 호출자의 플래그를 복원한 뒤 추가 join 없이 반환하므로, 반환 시 작업자가 아직 살아 있을 수 있습니다. 운영에서는 어느 경로의 남은 작업·자원을 누가 회수하는지도 정해야 합니다.

원 TimedDocumentTask의 작업이 실행 중 예외를 던져 종료하면 isAlive는 false이지만 value[0]은 null로 남을 수 있습니다. 그래서 이 버전의 DONE은 “종료를 관측했다”라는 분류이며 업무 성공을 증명하지 않습니다. 다음 별도 예제는 종료와 실패 원인을 함께 수집합니다.

예시

src/TerminationAndFailureResults.java
public final class TerminationAndFailureResults {
    private record Result(String value, Throwable failure) {}
    @FunctionalInterface private interface Task { String execute(); }

    private static Result run(Task task) throws InterruptedException {
        String[] value = new String[1];
        Throwable[] failure = new Throwable[1];
        Thread worker = new Thread(() -> value[0] = task.execute());
        worker.setUncaughtExceptionHandler((thread, error) -> failure[0] = error);
        worker.start();
        worker.join();
        return new Result(value[0], failure[0]);
    }

    public static void main(String[] args) throws InterruptedException {
        Result first = run(() -> "indexed");
        Result second = run(() -> { throw new IllegalStateException("bad-task"); });
        System.out.println("first=" + first.value() + ", failed=" + (first.failure() != null));
        System.out.println("second=" + second.value() + ", cause=" + second.failure().getMessage());
    }
}

출력은 first=indexed, failed=false와 second=null, cause=bad-task입니다. 실패 원인은 작업자의 처리기에 남고, 정상 join 뒤 main이 그 기록을 읽습니다. 이 작은 예제는 원 코드의 시간 제한·호출자 중단 정책을 대신하지 않습니다. 시간 제한 경로에 추가한다면 종료 회수와 결과 폐기 계약도 함께 유지해야 합니다.


연습 문제

첫 작업자는 15ms, 둘째는 100ms sleep합니다.

둘을 동시에 start한 뒤 전체 기한 60ms 안에 회수하고, 살아 있는 작업자는 interrupt와 join으로 정리하세요.

정답과 시간 예산 계산

각 join 전에 같은 기한에서 현재 시간을 빼야 합니다.

두 번째 작업자에 새 60ms를 주지 않습니다.

exercise/SharedDeadlineJoinSolution.java
public final class SharedDeadlineJoinSolution {
    public static void main(String[] args) throws InterruptedException {
        Thread first = task("first", 15), second = task("second", 100);
        Thread[] all = {first, second};
        for (Thread t : all) {
            t.start();
        }
        long deadline = System.nanoTime() + 60_000_000L;
        for (Thread t : all) {
            long left = deadline - System.nanoTime();
            if (left > 0) t.join(Math.max(1, left / 1_000_000));
        }
        for (Thread t : all) {
            if (t.isAlive()) {
                t.interrupt();
            }
        }
        for (Thread t : all) {
            t.join();
        }
        System.out.println("cleaned=" + (!first.isAlive() && !second.isAlive()));
    }

    private static Thread task(String name, long millis) {
        return new Thread(
                () -> {
                    try {
                        Thread.sleep(millis);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                },
                name);
    }
}

최종 출력은 cleaned=true입니다.

둘째의 정상 완료 여부는 스케줄러 지연에 따라 달라질 수 있어 규칙에 넣지 않고, 기한 이후 살아 있는 스레드가 없다는 정리만 확인합니다.

시간 제한 join은 성공 신호가 아니라 관찰 기회입니다.

반환 뒤 생존 여부를 확인하고 기한을 공유하며, 시간 제한 부작용과 interrupt 가능한 지점을 함께 설계해야 안전한 시간 제한이 됩니다.


기한과 늦은 결과 계산하기

아래 시간 값은 실제 타이머 측정이 아니라 계산 연습용입니다. nanoTime 차이의 시작값을 0으로 두고, joinUntil에 80ms를 부여합니다. 기존 코드의 Math.max(1, remaining / 1_000_000) 정책을 그대로 적용합니다.

  1. 첫 작업자 회수 뒤 now가 15ms입니다. 다음 join의 remaining과 요청 millis는 얼마인가요? 각 작업자마다 새 80ms를 주면 왜 같은 전체 예산이 아닌가요?
  2. 다음 확인 시 now가 79.6ms, 그 뒤 확인 시 80.1ms라고 가정합니다. 각 remaining과 join 요청 여부는 무엇인가요? 79.6ms 시점의 최소 1ms 요청은 정확히 80ms 안에 복귀함을 보장하나요?
  3. TimedDocumentTask가 작업자 생존을 확인해 시간 제한 경로를 택했습니다. 작업자는 interrupt를 받은 뒤 "late"를 반환하고 정상 종료했으며, 호출자에게 추가 interrupt는 없었습니다. 최종 상태·반환 값은 무엇인가요? 대신 join 중 호출자가 중단돼 CALLER_INTERRUPTED로 반환했다면 작업자 종료까지 확인한 것인가요?
기한과 결과 폐기의 해설
  1. deadline은 80ms, remaining은 65ms이고 요청값은 65입니다. 새 80ms를 주면 이미 쓴 15ms를 빼지 않아 전체 대기 예산이 늘어납니다.
  2. 79.6ms에서는 0.4ms, 즉 400,000ns가 남습니다. 정수 나눗셈 결과 0을 최솟값으로 보정해 join(1)을 요청합니다. 80.1ms에서는 -0.1ms이므로 join하지 않고 false를 반환합니다. 최소 1ms 요청은 남은 0.4ms보다 길고 스케줄러 복귀 지연도 있으므로 정확한 복귀 시각을 보장하지 않습니다. join(0)으로 바꾸면 무기한 대기가 된다는 별도 의미도 주의해야 합니다.
  3. 정리 join이 정상 반환한 뒤 TIMED_OUT과 null을 반환합니다. 기록된 late는 이 경로의 정책에 따라 버립니다. CALLER_INTERRUPTED 경로는 추가 join이 없으므로 요청과 호출자 플래그 복원만 확인했으며 작업자 종료를 확인한 것은 아닙니다.