시간 제한 join과 interrupt
시간 제한 join이 완료를 보장하지 않는 실패를 확인하고 기한·결과 폐기·interrupt 요청을 조합합니다.
인자 없는 join은 대상이 끝날 때까지 기다리지만 join(millis)는 최대 대기 시간만 제한합니다.
시간이 지나 정상 반환해도 작업자가 종료됐다는 뜻은 아닙니다.
반환 직후 isAlive()와 결과 상태를 확인하고, 시간 제한이면 계속 기다릴지 interrupt할지 결과를 버릴지 결정해야 합니다.
시간 제한 join의 성공 오판
작업자는 게이트가 열릴 때까지 기다립니다.
main은 20ms 시간 제한 join 뒤 결과를 읽으므로 실제로 wrong-complete=false, result=null이 출력됩니다.
join 메서드가 예외 없이 돌아왔다는 사실을 업무 완료로 오해한 것입니다.
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]);
}
}TimedJoinCompletionBug에서 게이트가 닫힌 첫 출력과 게이트 해제 뒤 종료를 기다린 출력을 구분합니다.
| 확인 시점 | 작업자의 쓰기 조건 | main의 관찰 |
|---|---|---|
join(20) 반환 뒤 | 게이트가 닫혀 있어 ready를 쓸 수 없음 | wrong-complete=false · result=null |
countDown() 호출 뒤 | 게이트가 열려 결과 쓰기를 진행할 수 있음 | 이 호출만으로 작업자 종료를 뜻하지 않음 |
무기한 join() 반환 뒤 | 작업자가 ready를 기록하고 종료함 | recovered=ready |
join(20)반환 뒤- 작업자의 쓰기 조건: 게이트가 닫혀 있어
ready를 쓸 수 없음main의 관찰:wrong-complete=false·result=null countDown()호출 뒤- 작업자의 쓰기 조건: 게이트가 열려 결과 쓰기를 진행할 수 있음main의 관찰: 이 호출만으로 작업자 종료를 뜻하지 않음
- 무기한
join()반환 뒤 - 작업자의 쓰기 조건: 작업자가
ready를 기록하고 종료함main의 관찰:recovered=ready
20ms는 대기 요청값입니다. 첫 결과는 게이트로 고정되며 느린 CPU를 가정하지 않습니다. 작업자의 구체적인 Thread.State나 정확한 경과 시간은 출력하지 않습니다.
첫 단계의 null은 가시성 문제를 volatile로 덮을 상황이 아닙니다.
시간 제한 join 뒤 생존이면 결과 컨테이너를 읽지 않고 시간 제한 결과를 만들어야 합니다.
시간 제한 join의 상태 해석
join(0)은 즉시 반환이 아니라 무기한 join과 같습니다.
양수 millis를 주면 호출자는 최대 그 시간 동안 TIMED_WAITING이 될 수 있습니다.
대상이 먼저 끝나면 일찍 깨어납니다.
허용 범위 밖 nanos나 음수 millis는 예외가 될 수 있으므로 기간 입력을 확인합니다.
시간 제한은 작업자 중단 명령이 아닙니다.
시간 제한 join이 반환된 뒤 살아 있는 대상은 RUNNABLE, BLOCKED, WAITING, TIMED_WAITING일 수 있습니다.
따라서 백그라운드에서 계속 작업해도 되는지, 공유 자원에 늦은 write를 해도 되는지 명시합니다.
여러 작업자 각각에 같은 시간 제한을 주면 총 대기가 작업자 수만큼 늘어납니다.
전체 예산 100ms라면 기한을 하나 정하고 join마다 남은 시간을 계산합니다.
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;
}
}DeadlineJoinGroup의 실제 sleep 입력과 공유 기한 계산, 종료 후 정리의 범위를 구분합니다.
| 대상 | 실제 입력 | 기한을 쓰는 방법 |
|---|---|---|
fast | 10ms sleep 요청 | 같은 기한에서 남은 시간을 계산 |
medium | 25ms sleep 요청 | 앞선 대기 뒤 남은 시간만 사용 |
slow | 200ms sleep 요청 | 살아 있으면 완료로 판정하지 않음 |
| 호출자 | Duration.ofMillis(80) | 남은 시간이 없으면 false 반환 |
fast- 실제 입력: 10ms
sleep요청기한을 쓰는 방법: 같은 기한에서 남은 시간을 계산 medium- 실제 입력: 25ms
sleep요청기한을 쓰는 방법: 앞선 대기 뒤 남은 시간만 사용 slow- 실제 입력: 200ms
sleep요청기한을 쓰는 방법: 살아 있으면 완료로 판정하지 않음 - 호출자
- 실제 입력:
Duration.ofMillis(80)기한을 쓰는 방법: 남은 시간이 없으면false반환
all-within-budget은 실행 지연에 따라 false 또는 true가 될 수 있습니다. 뒤의 interrupt와 무기한 join 정리까지 80ms 안에 끝난다는 보장은 없습니다.
이후 살아 있는 작업자에 interrupt를 요청하고 모두 join합니다.
millisecond 절삭으로 0을 전달하면 무한 대기가 되므로 최소 1ms로 올렸습니다.
더 정밀한 API가 필요하면 Future의 시간 제한 get이나 structured 동시성 정책을 검토합니다.
interrupt의 협력적 취소
worker.interrupt()는 대상의 interrupt 상태를 설정합니다.
sleep, wait, join 같은 중단 가능 블로킹 연산에서 대기 중이면 InterruptedException을 발생시켜 일찍 깨웁니다.
일반 계산 반복문은 플래그를 확인하고 스스로 빠져나와야 합니다.
과거 Thread.stop의 강제 종료는 불변식을 깨뜨릴 수 있어 폐기됐습니다. Java 25의 Thread.stop은 항상 UnsupportedOperationException을 던집니다.
interrupt를 받는 코드가 임시 파일 삭제, 트랜잭션 롤백, 큐 상태 정리 후 반환하도록 프로토콜을 구성합니다.
interrupt가 업무 실패인지 정상 취소인지도 결과에서 구분합니다.
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인 것은 아닙니다.
시간 제한 이후 부수 효과별 선택
읽기 전용 계산은 늦은 결과를 버리고 백그라운드 완료를 허용할 수 있습니다.
외부 결제나 파일 갱신은 호출자가 시간 제한됐어도 이미 커밋됐을 수 있어 단순 재시도가 중복 부작용을 만듭니다.
멱등성 키와 상태 조회가 필요합니다.
취소 가능한 작업은 interrupt에 응답하는 모든 블로킹 API를 사용하고 계산 구간도 주기적으로 플래그를 봅니다.
취소 불가능한 네이티브 호출이나 외부 서비스 대기는 별도 프로세스 격리, 소켓 시간 제한, circuit breaker를 검토합니다.
join 시간 제한 하나로 하위 수집기 시간 제한이 생기지는 않습니다.
완료 결과를 나타내는 Outcome
다음 작은 실행기는 시간 제한 join 뒤 종료를 확인하면 DONE, 살아 있으면 interrupt와 무기한 join 후 TIMED_OUT, 기다리는 호출자가 중단되면 CALLER_INTERRUPTED를 반환합니다.
문자열 결과를 읽는 시점은 DONE일 때뿐입니다.
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();
}
}TimedDocumentTask.run의 DONE, TIMED_OUT, CALLER_INTERRUPTED 조건과 결과 사용·정리 범위를 비교합니다.
| 반환 상태 | 이 코드가 택하는 조건 | 결과와 정리 |
|---|---|---|
DONE | 시간 제한 join 뒤 작업자가 종료됨 | 기록된 값을 반환 |
TIMED_OUT | 확인 시 작업자가 아직 살아 있음 | interrupt와 무기한 join 뒤 값은 버림 |
CALLER_INTERRUPTED | 기다리는 호출자가 중단됨 | 작업자에 interrupt · 호출자 플래그 복원 · 값은 버림 |
DONE- 이 코드가 택하는 조건: 시간 제한 join 뒤 작업자가 종료됨결과와 정리: 기록된 값을 반환
TIMED_OUT- 이 코드가 택하는 조건: 확인 시 작업자가 아직 살아 있음결과와 정리: interrupt와 무기한 join 뒤 값은 버림
CALLER_INTERRUPTED- 이 코드가 택하는 조건: 기다리는 호출자가 중단됨결과와 정리: 작업자에 interrupt · 호출자 플래그 복원 · 값은 버림
첫 호출의 100ms와 둘째 호출의 10ms는 DONE·TIMED_OUT을 고정하지 않습니다. 원문 main은 호출자 중단 경로를 만들지 않습니다. 그 경로에는 추가 join이 없고, 이 작은 예제는 작업자 예외를 별도 실패 상태로 수집하지 않습니다.
제품 구현에서는 예외 결과도 분리하고 스레드를 매 호출마다 직접 만들지 않습니다.
여기서는 join 반환 뒤의 결과 정책에 초점을 둡니다.
연습 문제
첫 작업자는 15ms, 둘째는 100ms sleep합니다.
둘을 동시에 start한 뒤 전체 기한 60ms 안에 회수하고, 살아 있는 작업자는 interrupt와 join으로 정리하세요.
정답과 시간 예산 계산
각 join 전에 같은 기한에서 현재 시간을 빼야 합니다.
두 번째 작업자에 새 60ms를 주지 않습니다.
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 가능한 지점을 함께 설계해야 안전한 시간 제한이 됩니다.