Future 취소·시간 초과·실패
Future 취소와 인터럽트의 협력 관계를 이해하고 시간 제한·실행 실패·취소 예외를 구분합니다.
Future는 진행 중이거나 정상·예외·취소로 완료될 수 있습니다.
정상·예외·취소 완료는 모두 isDone이 true인 상태입니다. TimeoutException은 Future의 완료 상태가 아니라 get 호출자의 대기 시간이 만료됐다는 뜻입니다.
이런 결과를 하나의 null이나 일반 오류로 합치면 재시도와 사용자 메시지를 올바르게 선택할 수 없습니다.
cancel(true)는 실행 중 스레드에 인터럽트를 요청할 수 있지만 코드를 강제로 죽이지 않습니다.
작업이 인터럽트 상태를 무시하면 계속 실행될 수 있습니다.
취소는 호출자와 작업자가 함께 지키는 협력 규칙입니다.
인터럽트를 삼켜 취소 뒤에도 계속되는 작업
import java.util.concurrent.Executors;
public final class UncooperativeCancellation {
public static void main(String[] args) throws Exception {
var executor = Executors.newSingleThreadExecutor();
var future = executor.submit(() -> {
for (int i = 0; i < 5; i++) {
try { Thread.sleep(50); }
catch (InterruptedException ignored) { }
System.out.println("step=" + i);
}
});
Thread.sleep(20);
System.out.println("cancelled=" + future.cancel(true));
executor.shutdown();
executor.awaitTermination(1, java.util.concurrent.TimeUnit.SECONDS);
}
}UncooperativeCancellation의 FutureTask 취소와 원문 람다의 실행을 세 경우로 나누며, 취소가 작업 본문의 완료를 기다리지 않는다는 점을 보여줍니다.
| 경합 상황 | main의 cancel(true) 결과 | 람다의 step 출력 |
|---|---|---|
| 본문 진입을 막은 취소 | cancelled=true | step 출력 없음 |
| 본문이 실행된 상태에서 취소 성공 | cancelled=true | step=0부터 step=4까지 출력 · 인터럽트에 중단하는 분기 없음 |
| 정상 완료가 취소보다 앞섬 | cancelled=false | step=0부터 step=4 뒤 취소 결과 출력 |
- 본문 진입을 막은 취소
- main의 cancel(true) 결과:
cancelled=true람다의 step 출력: step 출력 없음 - 본문이 실행된 상태에서 취소 성공
- main의 cancel(true) 결과:
cancelled=true람다의 step 출력: step=0부터 step=4까지 출력 · 인터럽트에 중단하는 분기 없음 - 정상 완료가 취소보다 앞섬
- main의 cancel(true) 결과:
cancelled=false람다의 step 출력: step=0부터 step=4 뒤 취소 결과 출력
이번 실행은 step=0, cancelled=true, step=1부터 step=4 순으로 출력했습니다. 다른 실행의 cancelled=true 줄은 step 줄 앞·사이·뒤에 올 수 있고, sleep(20)은 본문 시작을 보장하지 않습니다. 표의 시작 전 취소와 완료 후 취소는 미관측 경로입니다.
awaitTermination(1초)의 반환값은 검사하지 않습니다. false를 반환했다면 main이 끝나도 작업자가 아직 실행 중일 수 있습니다. 이 실행기의 기본 작업자는 non-daemon이므로 정상 프로세스 종료와 main 반환도 구분해야 합니다.
인터럽트에 협력하는 검색 작업
import java.time.Duration;
import java.util.concurrent.CancellationException;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class CooperativeSearch {
static int search(int target) throws InterruptedException {
for (int candidate = 0; candidate < Integer.MAX_VALUE; candidate++) {
if ((candidate & 0x3fff) == 0 && Thread.interrupted()) {
throw new InterruptedException("search cancelled");
}
if (candidate == target) return candidate;
}
return -1;
}
public static void main(String[] args) throws Exception {
var executor = Executors.newSingleThreadExecutor();
var future = executor.submit(() -> search(Integer.MAX_VALUE - 1));
try {
System.out.println(future.get(Duration.ofMillis(5).toNanos(), TimeUnit.NANOSECONDS));
} catch (TimeoutException e) {
future.cancel(true);
try { future.get(); }
catch (CancellationException cancelled) { System.out.println("cancelled"); }
} finally {
executor.shutdownNow();
}
}
}CooperativeSearch.main의 첫 timed get과 timeout 이후 cancel 및 두 번째 get이 만드는 세 출력 경로를 비교합니다.
| 첫 get과 취소의 경로 | 후속 처리 | main이 출력하는 값 |
|---|---|---|
| 첫 get이 정상 반환 | 그 값을 바로 println | 2147483646 |
| 시간 초과 뒤 취소 성공 | 두 번째 get의 CancellationException을 catch | cancelled |
| 시간 초과 뒤 정상 완료가 취소에 앞섬 | cancel은 false · 두 번째 get의 정상값을 버림 | 출력 없음 · 정상 종료 가능 |
- 첫 get이 정상 반환
- 후속 처리: 그 값을 바로 printlnmain이 출력하는 값:
2147483646 - 시간 초과 뒤 취소 성공
- 후속 처리: 두 번째 get의 CancellationException을 catchmain이 출력하는 값:
cancelled - 시간 초과 뒤 정상 완료가 취소에 앞섬
- 후속 처리: cancel은 false · 두 번째 get의 정상값을 버림main이 출력하는 값: 출력 없음 · 정상 종료 가능
이번 실행은 cancelled를 출력했습니다. 표의 정상값 출력과 무출력 정상 종료는 미관측 가능 경로입니다. 원문은 cancel 반환값을 검사하지 않으며, finally의 shutdownNow도 작업자 종료를 기다리지는 않습니다.
search는 candidate가 16,384의 배수일 때 Thread.interrupted()로 인터럽트 상태를 읽고 지웁니다. 취소를 확인하면 InterruptedException을 던지지만, 5ms 대기 예산이 작업자 종료까지의 시간 상한은 아닙니다.
CPU 루프는 적절한 간격으로 검사해 오버헤드와 취소 지연을 균형 잡습니다.
실패 원인의 도메인 결과 분류
import java.time.Duration;
import java.util.concurrent.*;
public final class FutureOutcomeClassifier {
sealed interface Outcome<T> permits Success, Failed, TimedOut, Cancelled {}
record Success<T>(T value) implements Outcome<T> {}
record Failed<T>(Throwable cause) implements Outcome<T> {}
record TimedOut<T>() implements Outcome<T> {}
record Cancelled<T>() implements Outcome<T> {}
static <T> Outcome<T> await(Future<T> future, Duration timeout) throws InterruptedException {
try {
return new Success<>(future.get(timeout.toNanos(), TimeUnit.NANOSECONDS));
} catch (TimeoutException e) {
return new TimedOut<>();
} catch (CancellationException e) {
return new Cancelled<>();
} catch (ExecutionException e) {
return new Failed<>(e.getCause());
}
}
public static void main(String[] args) throws Exception {
var executor = Executors.newSingleThreadExecutor();
try {
Future<Integer> failed = executor.submit(() -> {
throw new IllegalStateException("boom");
});
System.out.println(await(failed, Duration.ofSeconds(1)));
} finally {
executor.shutdownNow();
}
}
}FutureOutcomeClassifier.await가 get의 정상값과 네 예외를 처리하는 방법을 비교하며 Future의 완료 상태와 호출자의 대기 사건을 구분합니다.
| get에서 관찰한 결과 | await의 처리 | Future·작업에 대한 의미 |
|---|---|---|
| 정상 반환 value | Success(value) 반환 | Future 정상 완료 |
ExecutionException | Failed(e.getCause()) 반환 | Future 예외 완료 · 원인 객체 보존 |
CancellationException | Cancelled() 반환 | Future 취소 완료 · 작업자 종료와 별개 |
TimeoutException | TimedOut() 반환 | 이번 대기만 만료 · 자동 취소 안 함 |
InterruptedException | catch하지 않고 상위로 전파 | 대기 호출자의 인터럽트 · 자동 취소 안 함 |
- 정상 반환 value
- await의 처리:
Success(value)반환Future·작업에 대한 의미: Future 정상 완료 ExecutionException- await의 처리:
Failed(e.getCause())반환Future·작업에 대한 의미: Future 예외 완료 · 원인 객체 보존 CancellationException- await의 처리:
Cancelled()반환Future·작업에 대한 의미: Future 취소 완료 · 작업자 종료와 별개 TimeoutException- await의 처리:
TimedOut()반환Future·작업에 대한 의미: 이번 대기만 만료 · 자동 취소 안 함 InterruptedException- await의 처리: catch하지 않고 상위로 전파Future·작업에 대한 의미: 대기 호출자의 인터럽트 · 자동 취소 안 함
이번 main은 Failed 결과에 IllegalStateException: boom을 담아 출력했습니다. 1초 대기가 먼저 만료되면 TimedOut 경로도 가능합니다. 그 경로와 나머지 행은 이번에 관측하지 않은 await 코드·API의 처리 범위입니다.
취소와 외부 효과의 복구
이미 전송한 외부 요청이나 기록한 변경은 Future를 취소한다고 되돌아가지 않습니다. 그런 효과가 있는 작업에서는 부분 완료와 보상 기준을 따로 정합니다. 이 장의 검색·해시 원문에는 업무 데이터를 저장하는 단계가 없습니다.
연습 문제
바이트 배열을 여러 번 해시하는 Callable을 만들고 get의 20밀리초 대기 시간이 초과되면 취소를 요청하세요.
반복 사이 인터럽트를 확인하고 최종 상태를 문자열로 출력합니다.
정답과 해설
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class TimedHashCancellationSolution {
static String hash(byte[] input) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] value = input;
for (int i = 0; i < 1_000_000; i++) {
if (Thread.interrupted()) throw new InterruptedException();
value = digest.digest(value);
}
return HexFormat.of().formatHex(value);
}
public static void main(String[] args) throws Exception {
var executor = Executors.newSingleThreadExecutor();
var future = executor.submit(() -> hash("java".getBytes(java.nio.charset.StandardCharsets.UTF_8)));
try {
System.out.println(future.get(20, TimeUnit.MILLISECONDS));
} catch (TimeoutException e) {
future.cancel(true);
System.out.println("timed-out-and-cancelled=" + future.isCancelled());
} finally {
executor.shutdownNow();
}
}
}이번 출력은 timed-out-and-cancelled=true였습니다. 첫 get이 정상 완료하면 해시 문자열을 출력합니다. 시간이 초과되면 취소를 시도한 뒤 true 또는 false를 출력할 수 있으며, 완료가 취소보다 먼저 확정되면 false가 됩니다. 정상 해시와 취소 false 경로는 이번 실행에서 관측하지 않았습니다.
hash는 각 digest 호출 전에 Thread.interrupted()를 확인합니다. 한 번의 digest 계산 도중에 즉시 중단하는 것은 아니며, 취소 요청 뒤의 실제 종료를 기다리는 코드는 main에 없습니다.
Future 중단 규칙의 최종 점검
시간 초과는 관찰자가 기다리기를 멈춘 사건이고 취소는 작업 중단 요청입니다.
둘을 연결할지 호출자가 결정합니다.
작업자는 인터럽트에 협력하고, 실패 원인은 ExecutionException의 원인에서 잃지 않아야 합니다.