본문 바로가기

시간 제한 join과 interrupt

시간 제한 join 뒤에 작업자가 정말 끝났는지 확인하는 방법과, 기한이 지난 작업을 interrupt로 정리하고 결과를 구분해 돌려주는 방법을 배웁니다.

앞 글에서 본 인자 없는 join()은 작업자가 끝날 때까지 기다립니다. 그런데 작업자가 끝나지 않는다면 어떻게 될까요? 응답 없는 서버를 기다리는 작업자나 버그로 무한 반복에 빠진 작업자를 join()하면, 호출한 스레드도 함께 영원히 멈춥니다. 사용자의 요청을 처리하는 스레드라면 그 요청은 끝내 응답을 받지 못합니다.

join(millis)는 이 문제를 풀려고 기다리는 시간에 상한을 둡니다. 예를 들어 join(500)은 작업자가 끝나면 바로 돌아오고, 끝나지 않더라도 500ms가 지나면 돌아옵니다. 하지만 돌아왔을 때 둘 중 어느 쪽인지는 알려 주지 않습니다. 반환 타입이 void이고, 시간이 다 됐다고 예외를 던지지도 않기 때문입니다. 그래서 시간 제한 join 뒤에는 작업자가 정말 끝났는지 직접 확인해야 하고, 아직 살아 있다면 무엇을 할지도 정해야 합니다. 이 글에서는 이 확인과 결정을 차례로 다룹니다.

시간이 다 돼서 돌아온 join을 완료로 착각하는 실수

아래 프로그램의 작업자는 래치가 열려야 결과 배열에 "ready"를 기록합니다. main은 작업자를 시작하고 join(20)으로 최대 20ms를 기다린 뒤, 작업자가 끝났는지와 결과를 출력합니다. 그다음 래치를 열고 인자 없는 join()으로 끝까지 기다린 뒤 결과를 다시 읽습니다. 앞 글과 마찬가지로 래치는 실수를 매번 똑같이 재현하려고 넣었습니다. 래치가 닫혀 있는 동안 작업자는 결과를 쓸 수 없으므로, 첫 출력이 어떻게 나올지는 CPU 속도와 상관없이 정해집니다.

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]);
    }
}

JDK 21에서 50번 넘게 실행해도 출력은 매번 같았습니다. 첫 줄의 wrong-complete= 뒤에는 join(20)이 돌아온 시점에 작업자가 끝났는지가 true나 false로 나옵니다.

wrong-complete=false, result=null
recovered=ready

join(20)은 20ms를 기다린 뒤 예외 없이 돌아왔지만, 작업자는 그때도 래치 앞에서 기다리는 중이었습니다. wrong-complete라는 이름은 'join이 돌아왔으니 끝났겠지'라는 잘못된 믿음을 가리킵니다. 실제로 출력해 보면 !worker.isAlive()는 false이고 결과는 아직 null입니다. main이 래치를 연 뒤에야 작업자가 "ready"를 기록하고 끝나며, 인자 없는 join()이 그 종료를 기다렸다가 돌아오므로 둘째 줄에서는 ready가 보입니다. 첫 줄은 닫힌 래치가, 둘째 줄은 인자 없는 join()이 결과를 정하므로 두 줄 모두 실행 순서에 따라 달라지지 않습니다. 20ms는 작업이 얼마나 빠른지 재는 값이 아니라 main이 기다리기로 한 시간일 뿐입니다.

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

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 뒤에 isAlive()가 true라면 결과 배열을 읽지 말고 '시간 초과'라는 별도의 결과를 만들어야 합니다. 반대로 isAlive()가 false라면 결과를 읽어도 안전합니다. 앞 글의 '더 깊이'에서 본 것처럼, 자바 메모리 모델은 isAlive()로 종료를 확인한 경우에도 join()과 같은 가시성을 약속하기 때문입니다.

join(millis)가 돌아오는 경우

join(millis)가 예외 없이 돌아왔다면 다음 셋 중 하나입니다. 대상 스레드가 끝났거나, 정한 시간이 지났거나, 대상 스레드가 애초에 시작하지 않았거나입니다. 기다리는 동안 호출한 스레드는 TIMED_WAITING 상태가 되고, 대상이 일찍 끝나면 정한 시간을 다 채우지 않고 바로 깨어납니다. 인자에 따라 동작은 다음과 같습니다.

호출동작
join(), join(0)대상이 끝날 때까지 시간 제한 없이 기다린다
join(500)처럼 양수대상이 끝나면 바로, 아니면 최대 500ms 뒤에 돌아온다
join(-1)처럼 음수IllegalArgumentException을 던진다
join(millis, nanos)nanos가 0부터 999999까지의 범위를 벗어나면 IllegalArgumentException을 던진다

가장 조심할 줄은 첫 줄입니다. join(0)은 '기다리지 말라'가 아니라 '끝날 때까지 기다리라'는 뜻입니다. 남은 시간을 계산해서 join에 넘기는 코드는 계산 결과가 0이 되는 순간 시간 제한이 사라지므로, 뒤에서 보듯이 따로 막아야 합니다. 시작하지 않은 스레드는 isAlive()도 false를 돌려준다는 점도 기억해 두세요. 그래서 'isAlive()가 false이면 끝났다'는 판단은 이미 start()를 호출한 스레드에만 쓸 수 있습니다.

시간이 다 돼서 join이 돌아와도 작업자에게는 아무 일도 일어나지 않습니다. 시간 제한은 작업자를 멈추는 명령이 아니라 호출한 스레드가 기다리기를 그만두는 시점일 뿐입니다. 작업자는 하던 일을 계속하므로, 그 순간 계산 중이라 RUNNABLE일 수도 있고, 잠금이나 다른 사건을 기다리느라 BLOCKED, WAITING, TIMED_WAITING일 수도 있습니다. 따라서 시간이 지난 뒤 작업자가 뒤에서 계속 일해도 되는지, 늦게 끝난 작업자가 공유 데이터에 결과를 써도 되는지를 미리 정해 둬야 합니다.

여러 작업자를 하나의 기한 안에서 기다리기

작업자 셋을 '모두 합쳐 80ms까지만' 기다리고 싶다고 해 봅시다. 작업자마다 join(80)을 호출하면 각 호출이 80ms씩 기다릴 수 있으므로, 최악에는 80ms의 세 배인 240ms를 기다리게 됩니다. 작업자 수가 늘수록 대기 시간도 늘어납니다. 전체 시간을 지키려면 기한(deadline)을 한 번만 정하고, 작업자를 기다릴 때마다 그 기한까지 남은 시간만 넘겨야 합니다.

아래 프로그램의 joinUntil이 그렇게 합니다. 작업자는 각각 10ms, 25ms, 200ms 동안 잠드는 fast, medium, slow이고, 전체 예산은 80ms입니다. joinUntil이 돌아오면 main은 아직 살아 있는 작업자에게 중단을 요청하고, 모든 작업자가 끝날 때까지 기다립니다.

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;
    }
}
all-within-budget=false

joinUntil은 시작할 때 System.nanoTime()으로 기한을 한 번 정합니다. nanoTime()은 경과 시간을 재는 용도의 시계로, 값을 나노초 단위로 돌려주며 시스템 시각을 바꿔도 영향을 받지 않습니다. 그다음 작업자마다 기한까지 남은 시간을 다시 계산해 그만큼만 기다리고, 기다린 뒤 작업자가 살아 있으면 곧바로 false를 돌려줍니다. 아래 표는 각 join 직후의 시각을 기록하도록 코드를 조금 고쳐 실행해 본 결과의 한 예입니다. 시각은 joinUntil이 기한을 정한 순간을 0으로 잡았고, 실행할 때마다 몇 ms씩 달라집니다.

기다린 작업자남은 시간요청한 대기join이 돌아온 시각돌아온 뒤 isAlive()
fast약 79.9msjoin(79)약 10msfalse
medium약 69.9msjoin(69)약 25msfalse
slow약 54.8msjoin(54)약 80mstrue이므로 false 반환

fast는 79ms까지 기다릴 수 있었지만 10ms 만에 끝났으므로 join도 그때 돌아옵니다. 다음 작업자의 남은 시간은 같은 기한에서 다시 계산하므로, fast가 쓰지 않은 시간은 뒤의 작업자들이 그대로 쓸 수 있습니다. 반대로 앞에서 오래 기다릴수록 뒤에 남는 시간은 줄어들어서, medium이 약 25ms에 끝난 뒤 slow에게 남은 시간은 약 54.8ms입니다. slow는 200ms를 자야 하므로 기한인 80ms 무렵 join이 돌아와도 살아 있고, joinUntil은 false를 돌려줍니다. 그 뒤 main이 slow에 중단을 요청하면, slow의 Thread.sleep이 InterruptedException을 던지면서 일찍 깨어나 run()을 마칩니다. 마지막 반복문의 join()은 이 종료를 기다립니다.

출력은 거의 언제나 false이고, 실제로 50번 넘게 실행했을 때도 모두 false였습니다. 다만 언어가 이 결과를 보장하지는 않습니다. joinUntil은 기한이 지났는지를 join을 호출하기 전에만 확인하고, 기다린 뒤에는 isAlive()로 작업자가 끝났는지만 봅니다. 그래서 main이 어딘가에서 오래 멈춰서 slow가 200ms 잠을 다 자고 끝난 뒤에야 마지막 확인을 하게 되면, 이론상 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입니다.

남은 시간이 0이 되지 않게 하는 이유

Math.max(1, remaining / 1_000_000)에서 Math.max는 남은 시간이 1ms보다 짧을 때를 대비한 장치입니다. 기한까지 0.4ms, 즉 400,000ns가 남았다고 해 봅시다. 정수 나눗셈 400_000 / 1_000_000은 0이고, 이 값을 그대로 넘기면 join(0)이 되어 기한을 지키려던 대기가 시간 제한 없는 대기로 바뀝니다. 그래서 0이 나오면 1로 올려 1ms를 기다리게 했습니다.

대가도 있습니다. 0.4ms만 남았는데 1ms를 기다리므로 기한을 조금 넘길 수 있습니다. 게다가 join이 시간에 맞춰 돌아오더라도 운영체제가 main을 곧바로 다시 실행해 준다는 보장이 없습니다. 이 기한은 '이때까지만 기다린다'는 대기 방침이지, 정확한 반환 시각을 약속하지 않습니다. joinUntil이 돌아온 뒤의 중단 요청과 정리용 join()에 걸리는 시간도 80ms에 들어가지 않습니다. 기다리는 데 쓸 예산과 기한이 지난 뒤 정리하는 데 쓸 예산은 따로 생각해야 합니다.

예산이 언제부터 흐르는지도 정해야 합니다. joinUntil은 자기 안에서 기한을 만들므로, 그 전에 스레드를 만들고 시작하는 데 쓴 시간은 80ms에 들어가지 않습니다. '요청을 받은 순간부터 80ms 안에 응답한다'는 식으로 전체 시간을 제한하려면, 요청을 받은 곳에서 기한을 하나 만들고 그 기한을 이후 단계에 넘겨줘야 합니다.

join(Duration)을 쓰면 0을 걱정하지 않아도 됩니다

자바 19부터는 Duration을 받는 join(Duration)을 쓸 수 있습니다. 이 메서드는 기다린 결과를 boolean으로 돌려주는데, 대상 스레드가 끝났으면 true, 시간이 다 됐는데도 살아 있으면 false입니다. 그래서 isAlive()를 따로 확인하지 않아도 됩니다. 넘긴 시간이 0 이하이면 기다리지 않고 지금 끝났는지만 확인하므로, join(0)처럼 시간 제한 없는 대기로 바뀌는 일도 없습니다. 앞 프로그램의 joinUntil을 다음처럼 바꿔도 출력은 똑같이 all-within-budget=false입니다.

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 (!worker.join(Duration.ofNanos(remaining))) return false;
    }
    return true;
}

남은 시간이 0 이하일 때의 동작은 조금 다릅니다. 원래 코드는 무조건 false를 돌려주지만, 이 코드는 그 작업자가 이미 끝났다면 true를 받고 다음 작업자로 넘어갑니다. 차이가 하나 더 있는데, join(Duration)은 아직 시작하지 않은 스레드에 호출하면 바로 돌아오지 않고 IllegalThreadStateException을 던집니다. 실수로 시작하지 않은 스레드를 기다리는 버그가 조용히 넘어가지 않고 드러나는 셈입니다.

interrupt로 작업자에게 그만두라고 요청하기

DeadlineJoinGroup의 main은 기한이 지난 뒤 slow에 interrupt()를 호출했습니다. worker.interrupt()는 대상 스레드의 interrupt 상태(interrupt status)를 표시합니다. 이 표시를 플래그라고도 부릅니다. 이 요청이 실제로 어떤 효과를 내는지는 대상이 그 순간 무엇을 하고 있느냐에 따라 달라집니다. sleep, wait, join처럼 중단에 반응하는 메서드에서 기다리는 중이라면, 대기가 풀리면서 InterruptedException이 발생합니다. 앞의 slow가 200ms를 다 자지 않고 깨어난 것이 이 경우입니다. 반면 반복문으로 계산하는 중이라면 아무 일도 일어나지 않고 플래그만 켜집니다. 그 코드가 직접 플래그를 확인하고 반복을 빠져나와야 비로소 멈춥니다.

이렇게 대상이 협력해야만 멈추는 방식을 협력적 취소(cooperative cancellation)라고 합니다. 예전에는 Thread.stop()으로 다른 스레드를 강제로 끝낼 수 있었습니다. 하지만 강제 종료는 작업이 어디까지 진행됐든 그 자리에서 끊어 버립니다. 예를 들어 A 계좌에서 돈을 빼고 B 계좌에 넣기 직전에 멈추면, 돈이 사라진 상태가 다른 스레드에게 그대로 보입니다. 이렇게 객체가 늘 지켜야 할 조건, 즉 불변식(invariant)이 깨질 수 있어서 Thread.stop()은 오래전에 사용 중단(deprecated)으로 지정됐습니다. JDK 20부터는 호출하면 항상 UnsupportedOperationException을 던지고, 이 글의 기준인 JDK 25에서도 마찬가지입니다.

협력적 취소에서는 작업자가 멈출 시점과 방법을 스스로 고르므로, 멈추기 전에 뒷정리를 할 수 있습니다. 임시 파일을 지우거나, 트랜잭션을 되돌리거나, 큐 상태를 정리한 뒤 돌아오도록 만들면 됩니다. 결과를 돌려줄 때는 중단이 정상적인 취소였는지 업무상 실패였는지도 구분해 두는 편이 좋습니다.

아래 프로그램의 작업자는 중단 요청을 받을 때까지 숫자를 세는 계산 반복문입니다. sleep이나 wait가 없으므로 InterruptedException은 발생하지 않고, 반복문 조건에서 직접 플래그를 확인합니다.

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));
    }
}
terminated=true, count-positive=true

main은 started 래치로 작업자가 실행을 시작한 것을 확인한 뒤 5ms를 기다리고 중단을 요청합니다. 작업자는 다음 반복 조건에서 isInterrupted()가 true가 된 것을 보고 반복을 끝내며, 센 값을 count[0]에 기록한 뒤 종료합니다. main은 join()이 돌아온 뒤에 count[0]을 읽으므로 작업자가 기록한 값을 확실히 봅니다. isInterrupted()는 플래그를 읽기만 하고 지우지 않는데, 플래그를 읽으면서 지우는 Thread.interrupted()와의 차이는 interrupt 플래그와 InterruptedException 처리에서 다룹니다.

출력의 두 값 가운데 terminated=true는 항상 같습니다. join()이 정상적으로 돌아왔다면 작업자는 이미 끝났기 때문입니다. 반면 실제로 센 횟수는 실행마다 크게 달라서 출력하지 않았고, count-positive도 늘 true라고 장담할 수는 없습니다. 래치는 첫 증가보다 먼저 열리기 때문입니다. 작업자가 countDown() 직후 5ms 넘게 실행되지 못하다가 중단 요청이 온 뒤에야 첫 조건을 확인하면, 한 번도 세지 못하고 끝나 false가 나옵니다. 50번 넘게 실행했을 때는 모두 true였지만, 그것은 이런 지연이 드물다는 뜻일 뿐입니다.

시간이 지난 뒤 할 일은 작업의 종류가 정한다

시간 제한에 걸린 작업을 어떻게 처리할지는 그 작업이 바깥 세상에 무엇을 남기느냐에 달려 있습니다.

작업의 종류시간이 지난 뒤 할 일
읽기만 하는 계산(검색, 통계 계산)늦은 결과를 버린다. 작업은 뒤에서 마저 끝나게 두거나 interrupt로 멈춘다
바깥에 흔적을 남기는 작업(결제, 파일 갱신)이미 반영됐을 수 있으므로 상태를 조회한 뒤에 다시 시도한다
interrupt에 반응하는 작업중단을 요청하고, 정리가 끝날 때까지 기다린다
interrupt에 반응하지 않는 작업소켓 시간 제한, 별도 프로세스 같은 다른 수단으로 대기 시간을 제한한다

둘째 줄이 특히 위험합니다. 결제 요청이 시간 제한에 걸렸다고 해서 결제가 안 된 것은 아닙니다. 호출한 쪽이 기다리기를 그만둔 사이에 결제가 이미 처리됐을 수 있고, 그 상태에서 그냥 다시 시도하면 두 번 결제됩니다. 그래서 이런 작업은 요청마다 고유한 키를 붙여 같은 요청이 두 번 와도 한 번만 처리되게 만들고, 다시 시도하기 전에 이전 요청이 어떻게 됐는지 조회합니다. 여러 번 실행해도 한 번 실행한 것과 결과가 같은 성질을 멱등성(idempotency)이라고 하고, 이런 키를 멱등성 키라고 부릅니다.

셋째 줄의 작업이라면, 작업자가 쓰는 블로킹 API가 모두 interrupt에 반응하는지 확인하고, 오래 걸리는 계산 구간에서도 주기적으로 플래그를 확인하게 만듭니다. 넷째 줄에는 interrupt로 깨울 수 없는 네이티브 호출이나 일부 입출력 대기가 해당합니다. 이런 작업에는 소켓 자체에 시간 제한을 걸거나, 작업을 별도 프로세스로 분리하거나, 계속 실패하는 서비스는 한동안 호출하지 않는 서킷 브레이커(circuit breaker)를 둡니다. 어느 경우든 바깥에서 건 join의 시간 제한이 작업자 안쪽 호출의 시간 제한을 대신해 주지는 않습니다.

결과를 세 가지로 나눠 돌려주기

지금까지 본 판단을 메서드 하나로 묶어 보겠습니다. 아래 run은 작업을 새 스레드에서 실행하고 정한 시간만큼 기다린 뒤, 결과를 다음 세 상태 가운데 하나로 돌려줍니다.

  • DONE: 시간 안에 작업자가 끝났습니다. 작업자가 기록한 값을 돌려줍니다.
  • TIMED_OUT: 시간이 지나도 작업자가 살아 있었습니다. 중단을 요청하고 작업자가 끝날 때까지 기다린 뒤, 값 없이 돌려줍니다.
  • CALLER_INTERRUPTED: 기다리던 호출자 자신이 중단 요청을 받았습니다. 작업자에게 중단을 요청하고 호출자의 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[status=DONE, value=indexed]
Outcome[status=TIMED_OUT, value=null]

첫 호출의 작업은 문자열 하나를 돌려주는 것이 전부라서 100ms 안에 끝나고, run은 DONE과 indexed를 돌려줍니다. 둘째 호출의 작업은 200ms를 자야 하는데 10ms만 기다리므로, join(10)이 돌아왔을 때 작업자는 살아 있습니다. 그래서 run은 중단을 요청하고 작업자가 끝나기를 기다린 뒤 TIMED_OUT을 돌려줍니다. 수십 번 실행해도 출력은 같았지만, 두 줄 모두 시간에 기대고 있다는 점은 알아 두세요. 컴퓨터가 극도로 바빠서 첫 작업자가 100ms 안에 실행되지 못하면 첫 줄이 TIMED_OUT이 될 수 있습니다. 반대로 main이 200ms 넘게 멈춰 있다가 돌아오면 둘째 줄이 DONE이 될 수도 있습니다. 앞의 래치 예제와 달리 이 결과를 고정해 주는 장치는 없습니다. main은 CALLER_INTERRUPTED 경로를 만들지 않으므로, 이 상태는 출력에 나오지 않습니다.

둘째 호출에서 눈여겨볼 부분이 있습니다. 중단 요청을 받은 작업자는 sleep에서 깨어나 "late"를 돌려주므로, value[0]에는 late가 기록된 채로 작업자가 끝납니다. 그래도 run은 이 값을 버리고 null을 돌려줍니다. 살아 있는 작업자를 확인하고 시간 초과로 판정한 이상, 정리하는 사이에 도착한 값으로 그 판정을 되돌리지 않는다는 방침입니다. 작업자가 끝났다는 사실과 그 결과를 이번 요청에 쓸지는 따로 정하는 문제입니다.

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 종료 미회수
  • 세 반환 상태는 같은 작업이 차례로 거치는 상태가 아닙니다.

세 상태는 정리를 어디까지 하고 돌아오는지도 다릅니다. TIMED_OUT은 인자 없는 join()까지 마친 뒤에 돌아오므로, 작업자가 중단 요청을 무시한다면 run도 계속 기다립니다. 시간 제한을 걸었는데도 호출이 끝나지 않을 수 있는 것입니다. CALLER_INTERRUPTED는 작업자에게 중단을 요청하고 호출자의 interrupt 상태를 복원한 뒤 더 기다리지 않고 돌아오므로, 돌아온 순간 작업자가 아직 살아 있을 수 있습니다. 호출자의 중단 요청은 처음의 join(timeoutMillis)에서 올 수도 있고, 시간 초과 뒤 정리하는 join()에서 올 수도 있습니다. 두 호출이 같은 try 안에 있으므로 어느 쪽이든 CALLER_INTERRUPTED로 갑니다. 실제 서비스라면 각 경로에서 남은 작업자와 자원을 누가 마저 정리할지도 정해야 합니다.

DONE이 '작업 성공'을 뜻하지 않는다는 점도 짚어 두겠습니다. 작업이 제한 시간 안에 예외를 던지면 작업자는 처리되지 않은 예외로 끝나므로, isAlive()는 false이고 value[0]은 null로 남습니다. 이때 run은 DONE과 null을 돌려줍니다. 이 코드의 DONE은 '시간 안에 끝난 것을 확인했다'는 뜻일 뿐입니다. 실제 제품 코드라면 실패도 별도의 결과로 나누고, 호출할 때마다 스레드를 새로 만들지도 않습니다. 그런 일은 22장의 실행기(ExecutorService)와 Future가 맡으며, Future로 시간 제한과 실패를 다루는 방법은 Future 취소와 시간 초과, 실패 구분하기에서 봅니다.

작업자의 실패 원인까지 모으기

작업자가 예외로 끝났을 때 그 원인을 받으려면 잡히지 않은 예외의 처리기(UncaughtExceptionHandler)를 쓸 수 있습니다. 아래 예제는 앞의 run에서 시간 제한을 빼고, 작업자가 끝나면 값과 실패 원인을 함께 돌려줍니다.

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

두 번째 작업은 IllegalStateException을 던지므로 값을 기록하지 못하고, 처리기가 그 예외를 failure[0]에 기록합니다. 처리기는 작업자 스레드 안에서, 그 스레드가 완전히 끝나기 전에 실행됩니다. 그래서 join()이 돌아온 뒤 main이 읽는 failure[0]에는 처리기가 기록한 예외가 확실히 보이고, 출력도 실행할 때마다 같습니다. 처리기를 지정했으므로 처리되지 않은 예외가 났을 때 기본으로 출력되는 스택 트레이스도 표준 오류에 나오지 않습니다.

이 예제는 실패 원인을 모으는 방법만 보여 주고, 앞의 run이 하던 시간 제한과 호출자 중단 처리는 하지 않습니다. 두 가지를 합친다면 시간 초과 뒤 작업자를 정리하는 일과 늦은 결과를 버리는 방침도 함께 유지해야 합니다.

정리

  • join(millis)는 기다리는 시간에 상한을 둘 뿐, 돌아왔다고 해서 작업자가 끝났다는 뜻은 아닙니다. 돌아온 뒤 isAlive()로 확인하고, 살아 있다면 결과를 읽지 말고 시간 초과로 처리합니다.
  • join(0)은 시간 제한 없는 대기입니다. 남은 시간을 밀리초로 바꿔 넘길 때는 0이 되지 않게 막거나, 0 이하이면 기다리지 않는 join(Duration)을 씁니다.
  • 여러 작업자를 기다릴 때는 기한을 한 번만 정하고, 작업자마다 그 기한까지 남은 시간만 기다립니다. 기한 뒤의 정리 시간은 이 예산과 따로 생각합니다.
  • interrupt()는 중단을 요청할 뿐입니다. sleep·wait·join에서 기다리던 작업자는 InterruptedException으로 깨어나고, 계산 중이던 작업자는 플래그를 직접 확인해야 멈춥니다.
  • 시간이 지난 뒤 할 일은 작업의 부수 효과에 따라 다릅니다. 바깥에 흔적을 남기는 작업은 시간 초과 뒤에도 이미 반영됐을 수 있습니다.
  • 결과는 '끝남', '시간 초과', '호출자 중단'처럼 나눠서 돌려주고, 작업이 끝났다는 사실과 작업이 성공했다는 사실을 구분합니다.

연습 문제

  1. 첫 작업자는 15ms, 둘째 작업자는 100ms 동안 잠듭니다. 두 작업자를 함께 시작한 뒤 전체 60ms의 기한 안에서 기다리고, 기한이 지난 뒤에도 살아 있는 작업자는 interrupt()와 join()으로 정리하는 프로그램을 작성하세요. 마지막에 두 작업자가 모두 끝났는지 출력합니다.
  2. 본문의 joinUntil에 80ms 예산을 줍니다. 기한을 정한 순간의 시각을 0으로 두고, 아래 시각은 실제 측정값이 아니라 계산용으로 가정한 값입니다.
    • 첫 작업자가 끝난 뒤 다음 작업자 앞에서 잰 시각이 15ms입니다. 남은 시간과 join에 넘기는 값은 얼마인가요? 작업자마다 새로 80ms를 주면 왜 같은 예산이 아닌가요?
    • 다음 작업자 앞에서 잰 시각이 79.6ms라면, 그리고 80.1ms라면 각각 어떻게 되나요? 79.6ms일 때 1ms를 요청하면 80ms 안에 돌아온다고 장담할 수 있나요?
  3. TimedDocumentTask의 둘째 호출처럼, run이 살아 있는 작업자를 보고 시간 초과 경로를 택했습니다. 작업자는 중단 요청을 받은 뒤 "late"를 돌려주고 정상적으로 끝났고, 호출자에게 다른 중단 요청은 없었습니다. run은 무엇을 돌려주나요? 만약 정리하는 join() 도중 호출자가 중단 요청을 받아 CALLER_INTERRUPTED가 반환됐다면, 작업자가 끝난 것까지 확인한 것인가요?
해설 보기

1번. 기한은 두 작업자를 시작한 뒤 한 번만 정하고, 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

보통은 첫 작업자가 약 15ms에 끝나고, 둘째 작업자는 60ms 기한에 아직 잠들어 있다가 중단 요청을 받고 깨어나 끝납니다. 다만 main이 늦게 실행되면 둘째 작업자가 중단 요청을 받기 전에 100ms를 다 자고 스스로 끝날 수도 있으므로, 둘째 작업자가 어떻게 끝났는지는 출력하지 않았습니다. 마지막 출력은 실행할 때마다 cleaned=true입니다. 마지막 반복문의 join()이 두 작업자 모두에 대해 정상적으로 돌아왔다면, 그 시점에 두 작업자는 이미 끝났기 때문입니다.

2번. 기한은 80ms입니다. 15ms 시점에는 65ms가 남으므로 join(65)를 호출합니다. 작업자마다 새로 80ms를 주면 이미 쓴 15ms가 빠지지 않아서, 전체 대기가 80ms를 넘을 수 있습니다.

79.6ms 시점에는 0.4ms, 즉 400,000ns가 남습니다. 정수 나눗셈 결과는 0이지만 Math.max가 1로 올려서 join(1)을 호출합니다. 80.1ms 시점에는 남은 시간이 -0.1ms라서 join을 호출하지 않고 바로 false를 돌려줍니다. 79.6ms에 1ms를 요청하면 남은 0.4ms보다 길게 기다릴 수 있고, join이 돌아온 뒤 main이 곧바로 실행된다는 보장도 없으므로 80ms 안에 돌아온다고 장담할 수 없습니다. 그렇다고 Math.max를 빼고 0을 넘기면 시간 제한 없는 대기가 되므로 더 나쁩니다.

3번. 정리용 join()이 정상적으로 돌아온 뒤 TIMED_OUT과 null을 돌려줍니다. value[0]에 기록된 late는 시간 초과로 판정한 뒤에 도착한 값이므로, 이 코드의 방침에 따라 버립니다. CALLER_INTERRUPTED가 반환됐다면 작업자가 끝난 것은 확인하지 못한 것입니다. 정리용 join()이 예외로 중단됐으므로 이 경로에서 확인한 것은 작업자에게 중단을 요청했다는 것과 호출자의 interrupt 상태를 복원했다는 것뿐입니다.

다음 글

이 글에서는 interrupt()를 중단 요청으로만 썼습니다. interrupt 플래그와 InterruptedException 처리에서는 그 플래그가 언제 켜지고 언제 지워지는지, Thread.interrupted()와 isInterrupted()가 어떻게 다른지, InterruptedException을 잡았을 때 플래그를 왜 복원해야 하는지를 자세히 살펴봅니다.