1. Thread.interrupt() と協調的キャンセル
実際のアプリケーションでは、処理が長時間に及んだり、ときにはネットワーク・ファイル・外部サービスの操作で「ハング」することもあります。ユーザーが操作を取り消すこともあれば、サーバーがリクエスト処理を中断することもありますし、共通のタイムアウトが満了する場合もあります。正しくキャンセルできないと、アプリは固まり、無駄にリソースを消費し、外部イベントへの応答が悪くなります。
キーアイデア: キャンセルは 協調的 であるべきです。すなわち、タスク自身が「終了を求められていないか」を確認し、リソースを正しく解放して終了する必要があります。
Thread.interrupt() の動作
各スレッドには「割り込み」フラグがあります。thread.interrupt() を呼ぶと、このフラグは true に設定されます。スレッド自体が「殺される」わけではなく、自分で状態を確認して終了します。すなわち、定期的に Thread.currentThread().isInterrupted() を呼び、適切に抜けます。
例:
Thread worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
// 作業中...
try {
Thread.sleep(100); // 割り込み可能
} catch (InterruptedException e) {
// フラグはクリアされるが、再度自分自身を割り込ませることができる
Thread.currentThread().interrupt();
break;
}
}
System.out.println("割り込みによりスレッドを終了しました。");
});
worker.start();
// ... 後で
worker.interrupt();
自動的に割り込みが効くのはどこか?
- ブロックしうるメソッド(sleep、wait、join、ブロッキング構造の操作)は、割り込み時に InterruptedException を投げます。
- それ以外(たとえば計算ループ)では、手動で isInterrupted() を確認する必要があります。
「フラグを立ててすぐに抜ける」パターン
- 呼び出し側で: thread.interrupt()
- タスク側で: 定期的に Thread.currentThread().isInterrupted() を確認する
- 必要に応じてリソースを正しく解放し、終了する。
典型的な誤り: interrupt() が即座にスレッドを「殺す」と期待すること。違います——これは単なるシグナルであり、タスク自身が反応する必要があります。
2. Future.cancel()、CancellationException とタスクのキャンセル
Future.cancel の動作
ExecutorService.submit() でタスクを起動すると、Future を受け取ります。これには cancel(boolean mayInterruptIfRunning) というメソッドがあります:
- タスクがまだ開始されていなければ、起動されません。
- タスクがすでに実行中で、mayInterruptIfRunning == true の場合、そのタスクを実行しているスレッドに対して interrupt() が呼ばれます。
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<?> future = executor.submit(() -> {
while (!Thread.currentThread().isInterrupted()) {
// 長い処理
}
System.out.println("キャンセルによりタスクを終了しました。");
});
// ... 後で
future.cancel(true); // タスクのキャンセルを要求する
実際にタスクに何が起こるか
Future によるキャンセルは、「スレッドを殺す」魔法のボタンではなく、実質的には丁寧な Thread.interrupt() です。タスクが割り込みフラグを正しく確認していれば、穏当に終了します。そうでなければ、自然終了まで動き続けます。
キャンセル後に future.get() を呼ぶと、CancellationException がスローされ、タスクが取り消されたことを知らせます。
3. CompletableFuture: キャンセル、タイムアウト、チェーン
CompletableFuture のキャンセル
CompletableFuture にも cancel(boolean) があります。タスクがまだ終わっていなければキャンセルされ、以降のハンドラ(thenApply、thenAccept など)は呼ばれません。
CompletableFuture<Void> cf = CompletableFuture.runAsync(() -> {
while (!Thread.currentThread().isInterrupted()) {
// 作業中...
}
System.out.println("キャンセルによりCFを終了しました。");
});
// ... 後で
cf.cancel(true);
タイムアウト: orTimeout と completeOnTimeout
- orTimeout(timeout, unit) — 規定時間内に完了しない場合、CompletableFuture を TimeoutException で終了させます。
- completeOnTimeout(value, timeout, unit) — 規定時間内に完了しない場合、指定した値で完了させます。
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
try { Thread.sleep(5000); } catch (InterruptedException e) {}
return "OK";
});
cf.orTimeout(2, TimeUnit.SECONDS)
.exceptionally(ex -> "TIMEOUT")
.thenAccept(System.out::println); // 2秒後: "TIMEOUT"
チェーンでのキャンセル伝播
「上位」の CompletableFuture をキャンセルすると、その後のステップは呼び出されません。しかし、内部の非同期処理を起動するために thenCompose を使う場合、キャンセルは自動的に「上位」に伝播しません——明示的に設計する必要があります(状態確認、子タスクのキャンセル、共通デッドラインの利用など)。
注意: thenCompose と カスタム Executor の組み合わせ! 内部タスクが割り込み/キャンセルに反応できること、または共通のタイムアウトを受け取れることを必ず確認してください。
4. StructuredTaskScope: タスク群のキャンセル
Structured Concurrency とキャンセル
StructuredTaskScope(Java 21+)は、タスクのグループを起動し、そのライフサイクルをひとまとまりとして管理できます。いずれかのタスクがエラーで終了したり、タイムアウトした場合——残りのタスクは自動的にキャンセルされます。
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> f1 = scope.fork(() -> fetchData1());
Future<String> f2 = scope.fork(() -> fetchData2());
scope.join(); // すべてのタスクの終了を待つ
scope.throwIfFailed(); // どれか1つでも失敗したら例外を送出
String result = f1.resultNow() + f2.resultNow();
System.out.println(result);
}
- いずれかのタスクがエラーで終了した場合、scope は他のすべてのタスクをキャンセルします。
- タイムアウトした場合(scope.joinUntil(deadline) を使用)も、scope はすべてのタスクをキャンセルします。
終了ポリシー
- ShutdownOnFailure — 最初のエラーで全タスクをキャンセルします。
- ShutdownOnSuccess — いずれかが成功したら、残りのタスクをキャンセルします。
5. 実践: 長時間処理を安全にキャンセルする
例: ブロッキングIOのキャンセル
タスクがファイルやネットワークの読み取りでブロックしている場合、スレッドの割り込みが常に有効とは限りません——一部の IO 操作は interrupt に反応しません。近年の API(NIO、AsynchronousFileChannel)では割り込みサポートが改善されていますが、まだ不十分な箇所もあります。
推奨事項:
- キャンセルが必要なら、ノンブロッキングIOを利用する。
- ブロッキングIOでは、API レベルのタイムアウト(例: Socket.setSoTimeout)を設定する。
- 非同期タスクでは、Future.cancel を使い、割り込みに正しく反応する。
例: キュー/バリア待機のキャンセル
多くのシンクロナイザ(BlockingQueue.take()、CountDownLatch.await()、CyclicBarrier.await())は、割り込み時に InterruptedException を投げます。ハンドラで例外を捕捉し、必要ならフラグを復元し、タスクを正しく終了させてください。
6. パターン「time‑budget」: 一連の処理に共通のデッドライン
複雑なアプリケーションでは、複数の処理に共通のタイムアウトを設定する必要がよくあります。たとえば、ユーザーが 2 秒以上待てないのに内部で 3 件のネットワーク呼び出しが必要な場合、すべてが共通のデッドライン内に収まらなければなりません。
デッドラインをスタックの下層に伝播させるには?
- デッドラインのオブジェクト(例: Instant の deadline)を、潜在的にブロックするすべてのメソッドに渡す。
- 各メソッドで残り時間を計算する: Duration.between(Instant.now(), deadline)。
- この時間をブロッキング操作のタイムアウトに使う(await(timeout)、poll(timeout)、orTimeout(timeout) など)。
Instant deadline = Instant.now().plusSeconds(2);
void doWork(Instant deadline) throws TimeoutException, InterruptedException {
Duration left = Duration.between(Instant.now(), deadline);
if (left.isNegative() || left.isZero()) throw new TimeoutException();
// 残り時間leftをタイムアウトに使う
queue.poll(left.toMillis(), TimeUnit.MILLISECONDS);
}
Scoped Values / コンテキスト
Java 21+ では、Scoped Values を使用してデッドラインを呼び出しスタック全体に渡すことができ、各メソッドに明示的に引数として渡す必要がなくなります。
7. Structured Concurrency: 障害/タイムアウト時にスコープ全体をキャンセル
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> f1 = scope.fork(() -> fetchData1());
Future<String> f2 = scope.fork(() -> fetchData2());
boolean completed = scope.joinUntil(Instant.now().plusSeconds(2));
if (!completed) {
scope.shutdown();
throw new TimeoutException("デッドラインが切れました!");
}
scope.throwIfFailed();
// ...
}
- デッドラインが切れた場合、scope はすべてのタスクをキャンセルします。
- いずれかのタスクが失敗した場合、他のタスクは自動的にキャンセルされます。
8. キャンセルとタイムアウト取り扱いの典型的な誤り
誤り 1: interrupt() が即座にスレッドを終了させると期待する。 実際には単なるシグナルであり、タスク自身が状態を確認して正しく終了する必要があります。
誤り 2: 長いループで isInterrupted() を確認しない。 フラグを確認しないと、終了要求があってもタスクは延々と動き続けます。
誤り 3: タスクが割り込みに反応しない場合、Future.cancel() では実際には止まらない。 タスクが「聞こえない」状態なら、cancel() は効きません。
誤り 4: タイムアウトをスタックの下層に伝播させない。 すべてのメソッドにデッドラインを渡さないと、内部の操作が想定より長く「詰まる」ことがあります。
誤り 5: thenCompose を使った CompletableFuture のチェーンで、キャンセルが自動伝播すると誤解する。 「上位」の future をキャンセルしても、内部タスクは動き続ける場合があります——キャンセルを明示的に扱ってください。
誤り 6: StructuredTaskScope を閉じない(try‑with‑resources を使わない)。 scope を閉じ忘れると、子タスクが「ぶら下がり」状態で残ることがあります。
GO TO FULL VERSION